# Java - Spring Boot Notes ## New Project * JDK 跟 Java版本須保持一致 * Spring Boot版本若為2開頭,則JDK需要選擇舊版(11),若為3開頭則可選擇JDK17 * Spring Boot 3多為Library更新,後續再提 --- ## Change Version Edit pom.xml file ``` <parent> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-parent</artifactId> <version>2.3.7.RELEASE</version> <relativePath/> <!-- lookup parent from repository --> </parent> ``` Edit "version" will do the trick --- ## @SpringBootApplication * @ is an annotation, meaning adding a new function or feature to the class * classes with a @SpringBootApplication means that the class can be run under Spring Boot --- ## First Spring Boot Application MyController ```java import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RestController; @RestController public class MyController { @RequestMapping("/test") public String test() { System.out.println("Hi"); return "Hello, world!"; } } ``` Then open a browser, type in "http://localhost:8080/test" in URL, and Hello world will appear! --- ## Spring IoC & Dependency Injection(DI) ### 1. IoC & DI **IoC = Inversion of Control 控制反轉** **意義:將object的控制權交給了外部的Spring容器來管理** 以往的做法是需要在每一個class內單獨new一個新的object來使用,但如果要修改這個object就很麻煩(需要一個一個進到class裡面做修改)。如果使用IoC,Spring會直接在容器中建立object,各個class如果需要用到的時候再去容器內取用即可。 優點: 1. Loose coupling between classes (類間的鬆耦合,降低類之間的關聯性) 2. Lifecycle Management 3. More testable 實作: 需要用到"**@Component**"這個註解 * 用法:只能用在class上 * 用途:將該class變成由Spring容器所管理的object i.e. 只要有加上@Component,每當Spring被啟動時,系統就會自動在容器內建立該class的object,**這些object又被稱為bean** Sample Interface ```java public interface Printer { void print(String message); } ``` Interface implementation ```java @Component public class HpPrinter implements Printer { @Override public void print(String message) { System.out.println("HP Printer: " + message); } } ``` A class uses this object ```java @Component public class Teacher { // 不用再new object @Autowired private Printer printer; public void teach() { printer.print("I am a teacher"); } } ``` Container內就會自動生成一個bean hpPrinter (很像是自動執行Printer printer = new HpPrinter(); 這行程式) 重點: 1. 要使用該物件的class,也要加上@Component的註解 2. 物件內的變數需要加上@Autowired的註解,稱為"Dependency Injection(DI 依賴注入)" ### 2. @Autowired & @Qualifier @Autowired * 用法:通常加在class變數上方 * 用途:根據**變數的類型**,去Spring容器中尋找有沒有符合類型的bean * **變數類型盡量使用Interface(因為多型的關係,比較有彈性)** 這時候就會產生一個問題,如果在Spring容器中有兩個class實作了同一個Interface,那在注入的時候會注入哪一個bean呢? ![](https://hackmd.io/_uploads/rJuknGcAh.png) 要解決這個問題就需要使用"@Qualifier" @Qualifier * 用法:通常加在class變數上方,通常會跟@Autowired一起使用 * 用途:**指定要載入的bean的名字** * Syntax: **@Qualifier("name of bean")** * 需要注意的是這邊的bean的名字會是**class名稱,但第一個英文字母會是小寫** * @Autowired跟@Qualifier沒有使用順序上的差異 Sample Code ```Java @RestController public class MyController { @Autowired // @Qualifier("canonPrinter") @Qualifier("hpPrinter") private Printer printer; @RequestMapping("/test") public String test() { // System.out.println("Hi"); printer.Print("Hello, world!"); return "Hello, world!"; } } ``` ### 3. 創建bean的方法 1. 在class上面加上@Component(較常見) 2. 使用@Configuration + @Bean註解 @Configuration * 用法:只能加在class上 * 用途:Spring中的**設定用的註解**,表示這個class是用來設定Spring用的 * class名稱不重要,Spring在意的是設定的內容(也就是這個class裡面的程式碼) @Bean * 用法:只能加在**帶有@Configuration**的class的**方法**上面(注意是加在"method"上) * 用途:在Spring容器中創建一個bean ```java @Configuration public class MyConfiguration { @Bean public Printer myPrinter() { return new HpPrinter(); } } ``` ### 4. Bean的初始化:@PostConstruct & InitializingBean 1. 使用@PostConstruct(較常見) 2. 實現InitializingBean interface的afterPropertiesSet()方法 @PostConstruct * 在要初始化的那個bean的class當中建立一個初始化的方法,並加上@PostConstruct * **必須為public** * **返回值必須為void** * 方法名稱可以隨便取 * **必須不帶參數** Sample: ```java @Component public class HpPrinter implements Printer { private int count; @PostConstruct public void initialize() { count = 5; } @Override public void Print(String message) { count--; System.out.println("HP Printer prints: " + message); System.out.println("Times left: " + count); } } ``` @InitializingBean interface * 該class要實現InitializingBean interface * 改寫afterPropertiesSet() * 因為要再implements一個interface,比較麻煩,所以不常用 **Notes: 一次使用一種方式初始化即可** ### 5. Life Cycle of Bean 基本的Bean生命週期 1. 啟動Spring Boot 2. 創建Bean (@Component | @Configuration + @Bean) 3. 初始化Bean (@PostConstruct | implements initializingBean interface) 4. 可以開始使用Bean 5. 運行成功 Notes: 1. 若在容器內有多個Bean,Spring Boot會等所有Bean都可以被使用之後才會開始運行,也就是說只要**有一個Bean創建失敗,Spring Boot就不會被啟動** 2. Spring會去處理Bean之間的依賴關係。流程:Controller -> 遇到需要用到新的Bean的情況 -> 創建另一個Bean -> 初始化 -> 注入 -> 初始化Controller。**因此要特別注意不要循環注入Bean(不要循環依賴)** ### 6. 讀取Spring Boot設定檔 - application.properties + @Value 存放位置:src/main/resources 最重要的設定檔:application.properties * 用法:使用properties語法 **(key=value)** * 用途:存放Spring Boot的設定值 * =兩邊不用space * key的名字中可以帶有.,相當於中文的「的」 * comment: # 讀取方式:使用@Value * 用法:加在Bean或是設定Spring用的class裡面的**變數**上 * 用途:讀取Spring Boot設定檔(application.properties)中key的值 * 語法: ``` 無預設值的語法: @Value("${key}") 有預設值的語法: @Value("${key:value}") -> 只有在找不到設定檔的key的情況下才會使用預設值 ``` * 設定檔變數類型與class變數類型要一致 Sample: application.properties ```properties my.name=John ``` java class ```java @Component public class myBean { @Value("${my.name}") private String name; } ``` --- ## AOP - Aspect-Oriented Programming AOP(Aspect-Oriented Programming) 切面導向程式設計 目的:**將各class裡面需要用到的共同邏輯獨立出來成一個「切面」,讓所有類別方法都可以使用到這一個共同邏輯**。例如:測量方法執行時間,產生log紀錄等等 ### 1. @Aspect * 用法:加在class上面 **(需要和@Component一起使用)** (這個class一定要先成為一個bean才能變成切面) * 用途:宣告這個class是一個切面 * @Aspect與@Component沒有加註順序問題 ### 2. @Before * 用法:加在切面class的方法上 * 用途:在切入點的方法 **執行前**執行 寫法: ```java @Aspect @Component public class MyAspect { @Before("execution(* com.example.demo.HpPrinter.*(..))") public void before() { System.out.println("I am before!!!"); } } ``` 說明: 1. @Before裡面的參數"execution...""是指我們指定的方法(**切入點**) 2. @Before註解的意思是在執行()裡面的方法之前先執行下面的方法 3. 下面的方法就是告訴方法的內容 換句話說,Spring在執行我們指定的方法之前,會先去執行System.out.println("I am before!!!");的這行程式 ### 2. @After * 用法:加在切面class的方法上 * 用途:在切入點的方法 **執行後**執行 其餘都跟@Before一樣 ```java @After("execution(* com.example.demo.HpPrinter.*(..))") public void after() { System.out.println("I am after!!!"); } ``` ### 3. @Around * 用法:加在切面class的方法上 * 用途:在切入點的方法 **執行前與後**執行(i.e 包住) * 寫法比較複雜,不用記,用到再查即可 * Object obj = pjp.proceed();的意思就是指"執行插入方法" ```java @Around("execution(* com.example.demo.HpPrinter.*(..))") public Object around(ProceedingJoinPoint pjp) throws Throwable { System.out.println("I am around before..."); Object obj = pjp.proceed(); System.out.println("I am around after..."); return obj; } ``` 利用@Around方法實現測量方法執行時間: ```java @Around("execution(* com.example.demo.HpPrinter.*(..))") public Object around(ProceedingJoinPoint pjp) throws Throwable { Date start = new Date(); Object obj = pjp.proceed(); Date end = new Date(); long time = end.getTime() - start.getTime(); System.out.println("Execution Time: " + time + "ms"); return obj; } ``` 切入點表達式與其意義: execution(* com.example.demo.HpPrinter.*(..))代表 切入點為com.example.demo package裡面的HpPrinter Class內所有的方法 有用到再去查即可 使用AOP的步驟: 步驟: 1. 先在pom.xml的檔案中新增AOP dependency ``` <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-aop</artifactId> </dependency> ``` 2. 新增class ```java @Aspect @Component public class MyAspect { @Before("execution(* com.example.demo.HpPrinter.*(..))") public void before() { System.out.println("I am before!!!"); } // ... } ``` 使用AOP的情境: * 權限驗證 (實務用法:Spring Security) * 統一的Exception處理 (實務用法:@ControllerAdvice) * Log紀錄 --- ## Spring MVC ### 1. HTTP協議 * 規定前後端之間資料的傳輸格式 * 可以分成request(請求)和response(回應)兩部分 #### Http Request * http method(GET, POST...) * url * request header: 通用資訊 * request body: 請求參數 #### Http Response * http status code (Spring Boot預設返回200 500,要自定義的話需要使用ResponseEntity,詳細後述) * 1xx:資訊 (少用) * **2xx:成功** * 200 OK:請求成功 * 201 Created:請求成功並創建資源,通常用在POST的response * 202 Accepted:請求已經接受,但尚未處理完成 * **3xx:重新導向** * 301 Moved Permanently:永久重新導向,新的url通常放在response header的"Location"中回傳 * 302 Found:臨時重新導向。url也是放在response header的"Location" * **4xx:前端請求錯誤** * 400 Bad Request:前端請求的參數有誤(請求格式有問題等等) * 401 Unauthorized:沒有通過身分驗證 * 403 Forbidden:請求被後端拒絕,通常是權限不足導致的 * 404 Not Found:網頁不存在 * **5xx:後端處理有問題** * 500 Internal Server Error:後端處理錯誤(可能是有bug導致) * 503 Service Unavailable:後端暫時無法處理請求 * 504 Gateway Timeout:請求超時 * response header: 通用資訊 * response body:**後端要回傳的數據** ### 2. URL Route Spring Boot URL: ``` http://localhost:8080/test ``` * http: protocol * localhost: domain name(域名) * 8080: port * /test: route #### @RequestMapping("route") * 用法:加在class或方法上,小括號裡填寫url路徑 * 用途:將url路徑對應到方法上 on method: ```java @RequestMapping("/test") public String test() { return "Hello, world!"; } ``` ``` the url is http://localhost:8080/test ``` on class: ```java @RequestMapping("/detail") @RestController public class MyController { @RequestMapping("/product") public String product() { return "The first one is apple, the second one is orange"; } @RequestMapping("/user") public String user() { return "{user: John}"; } } ``` ``` > the urls are http://localhost:8080/detail/product http://localhost:8080/detail/user ``` Notes: * **class上一定要加上@Controller或是@RestController才會生效。**(@Controller/@RestController的用途就是將class變成bean,並且讓這個class可以使用@RequestMapping) * Do not include domain name in the url route ### 3. JSON Format: Key-Value pair Key must usd double quotation mark Value: - null - int - float - String - boolean - Object ```jsonld { "id": 123, "name": "John Wick", "score": 95.5, "graduated": true, "age": null, "courseList": ["Math", "English"], "pet": { "name": "blacky", "age": 5 }, "petList": [ { "name": "white", "age": 3 }, { "name": "brown", "age": 4 } ] } ``` #### Return JSON in Spring Boot There are two ways: - @RestController(較常用) - @Controller(加在class) + @ResponseBody(加在方法) @RestController ```java @RestController public class MyController { @RequestMapping("/product") public String product() { return XXX; } } ``` @Controller + @ResponseBody ```java @Controller public class MyController { @ResponseBody @RequestMapping("/product") public String product() { return XXX; } } ``` Create JSON 1. Create a class with getter and setter ```java public class Student { String name; public String getName() { return name; } public void setName(String name) { this.name = name; } } ``` 2. Instantiate the class 3. Return the object ```java @RestController public class MyController { @RequestMapping("/user") public Student user() { Student student = new Student(); student.setName("John"); return student; } } ``` 當RequestMapping所註解的方法是自定義類別時,Spring Boot會自動將class轉換成JSON(底層使用Jackson library) ![](https://hackmd.io/_uploads/Hytqn-cy6.png) ### 4. 取得參數 - @RequestParam - @RequestBody - @RequestHeader - @PathVariable #### 4-1. @RequestParam * 用法:只能加在方法的參數上 * 用途:取得url裡面的參數(query parameter,即?後面的部分) * **多傳參數會被Spring Boot忽略,少傳參數會Bad Request** * 一些設定: * 自定義變數名稱: @RequestParam(name = "testId") Integer id。 不常用,看看即可。 * **是否為必須(預設是true):** @RequestParam(required = false) * **提供預設值:(最常用)** @RequestParam(defaultValue = "10") 使用預設值可以避免NullPointerException的問題 * 以上三者可同時使用,用逗號間隔 Example: Single Parameter url: http://localhost:8080/test?id=123 ```java @RestController public class MyController { @RequestMapping("/test") public void test(@RequestParam Integer id) { System.out.println("id: " + id); } } ``` If the query parameter is not Integer, then will result in 400 Bad Request. Multiple Parameter ```java @RestController public class MyController { @RequestMapping("/test") public void test(@RequestParam Integer id, @RequestParam String name) { System.out.println("id: " + id); System.out.println("name: " + name); } } ``` #### 4-2. @RequestBody * 用法:只能加在方法的參數上 * 用途:取得request body裡面的參數(JSON to Java Object) * Request Method記得用POST 步驟: 1. 建立一個class,其參數一一對應JSON Key 2. 將class產生的物件當參數傳入方法 Example: request body(JSON) ```jsonld { "id": 123, "name": "John" } ``` corresponded Java class ```java public class Student { Integer id; String name; // Getter and Setter } ``` restcontroller ```java @RequestMapping("/test4") public String test4(@RequestBody Student student) { System.out.println("student id: " + student.getId()); System.out.println("student name: " + student.getName()); return "test4 Success"; ``` * request body可以多傳參數,但接收的參數數量只看class裡面定義的變數數量(多的會被忽略) * request body也可以少傳參數,沒有值得部分會變成null #### 4-3. @RequestHeader * 用法:只能加在方法的參數上 * 用途:取得request header裡面的參數 * 格式:key:value (冒號區隔) * 設定:與@RequestParam用法相同 **Notes: RequestHeader通常會使用name="Content-Type"這種寫法** ```java @RequestMapping("/test5") public String test5(@RequestHeader(name = "Content-Type") String contentType) { // code return "xxx"; } ``` 常見Header | Request header | 意義 | 常見的值 | | -------- | -------- | -------- | | Content-Type | 用於表示Request Body的格式 |application/json (JSON), application/octet-stream (文件上傳), multipart/form-data (圖片上傳)| | Authorization | 用於身分驗證 | | #### 4-4. @PathVariable * 用法:只能加在方法的參數上 * 用途:取得**url路徑的值** Example: ``` http://localhost:8080/test6/123 ``` 這邊的```test6/123```就是url路徑,```123```就是路徑上面的值 寫法 ```java @RequestMapping("/test6/{id}") public String test6(@PathVariable Integer id) { // code return "xxx"; } ``` **Notes: {}內的參數名稱一定要跟方法內的參數一致** 多參數寫法 ``` http://localhost:8080/test6/123/john ``` ```java @RequestMapping("/test6/{id}/{name}") public String test6(@PathVariable Integer id, @PathVariable String name) { // code return "xxx"; } ``` --- ### 5. RESTful API Implementation #### API - 用工程師的方法去說明某個功能該如何使用 - 目的:寫清楚這個功能要怎麼使用 Example API Documentation: HTTP Request GET /getProducts | Name | Type | Description | Required?| | -------- | -------- | -------- |--------| | size | Integer | xxx |yes| | search | String | xxx |no| Corresponding Code: ```java @RequestMapping("/getProducts") public Product getProduct(@RequestParams Integer size, @RequestParams(required = false) String search) { //... } ``` #### RESTful - 一種設計風格,目的在簡化溝通成本 - REST風格: 1. 使用http method表示動作 | http method | DB Manipulation | | -------- | -------- | | POST | Create | | GET | Read | | PUT | Update | | DELETE | Delet | 2. 使用URL Route來描述資源之間的階層關係 **Keyword: @PathVariable** | http method + url | Purpose | | -------- | -------- | | GET /users | Get all users info | | GET /users/123 | Get user info where id=123 | | GET /users/123/article | Get articles from user id=123 | | GET /users/123/article/456 | Get no.456 article from user id=123 | 3. response body返回JSON or XML **Keyword: @RestController** #### 實作 New Class ```java pblic class Student { Integer id; String name; // Create Getter and Setter } ``` API Controller 1. Create controller class ```java @RestController public class StudentController { } ``` 2. Limit http request method These two codes are the same ```java @RequestMapping(value = "/students", method = RequestMethod.POST) ``` ```java // This is a more common way @PostMapping("/students") ``` Other http methods' syntax are identical 3. Implement Notes: * When designing POST/PUT method, values in url and in request body are both used, so we have to use @PathVariable & @RequestBody to get those values ```java @RestController public class StudentController { @PostMapping("/students") public String create(@RequestBody Student student) { return "Execute Create in DB"; } @GetMapping("/students/{studentId}") public String read(@PathVariable Integer studentId) { return "Execute Read in DB"; } @PutMapping("/students/{studentId}") public String update(@PathVariable Integer studentId, @RequestBody Student student) { return "Execute Update in DB"; } @DeleteMapping("/students/{studentId}") public String delete(@PathVariable Integer studentId) { return "Execute Delete in DB"; } } ``` ### 6. Value Validation - if-else statement (not efficient) - validation annotation 1. Add dependency **(for spring boot version >2.3)** ```xml <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-validation</artifactId> </dependency> ``` 2. Add @Valid on parameter ```java @PostMapping("/students") public String create(@RequestBody @Valid Student student) { return "Execute Create in DB"; } ``` 3. Add @NotNUll on class attribute ```java public class Student { @NotNull Integer id; //... } ``` - Some Useful Annotation * @NotNull * @NotBlank: used on Strings * @NotEmpty: used on collections(lists, sets, maps), size should > 0 * @Min(value) * @Max(value) **Notes:** - 使用@RequestBody時,要在該參數加上@Valid註解,才能讓class裡面的驗證請求參數生效 - 使用@RequestParam, @RequestHeader, @PathVariable的時候,**則需要在Controller上面加上@Validated**才能讓驗證請求生效 ```java @Validated @RestController public class StudentController { @PostMapping("/students") public String create(@RequestBody @Valid Student student) { return "Execute Create in DB"; } @GetMapping("/students/{studentId}") public String read(@PathVariable Integer studentId) { return "Execute Read in DB"; } @PutMapping("/students/{studentId}") public String update(@PathVariable @Min(100) Integer studentId, @RequestBody Student student) { return "Execute Update in DB"; } @DeleteMapping("/students/{studentId}") public String delete(@PathVariable Integer studentId) { return "Execute Delete in DB"; } } ``` ### 7. ResponseEntity<?> * 用法:作為方法的返回類型 * 用途:自定義回傳的http response細節 * <?> 為泛型 ```java @RestController public class MyController { @RequestMapping("/test1") public ResponseEntity<String> test1() { return ResponseEntity.status(HttpStatus.ACCEPTED).body("HELLO WORLD!"); } } ``` .body()傳入的參數type要跟泛型定義的type一致 常用寫法:把對應table的java class以物件的方式傳入body .build(): 可以創建ResponseEntity物件 ```java @GetMapping("/products/{productId}") public ResponseEntity<Product> getProduct(@PathVariable Integer productId) { // Get product from service Product product = productService.getProductById(productId); // Check null if (product != null) { return ResponseEntity.status(HttpStatus.OK).body(product); } else { return ResponseEntity.status(HttpStatus.NOT_FOUND).build(); } } ``` ### 8. Controller層統一的Exception處理 利用 **@ControllerAdvice + @ExceptionHandler** 來自定義每個Exception所返回的http response #### @ControllerAdvice - 用法:只能加在class上 - 用途:將這個class變成一個bean,並且可以在內部使用@ExceptionHandler #### @ExceptionHandler - 用法:只能加在方法上 - 用途:去cath所有Exception 1. Create a new class 2. Add @ControllerAdvice on the class 3. Write a handle function with parameter of Exception class 4. Add @ExceptionHandler 5. Return customized HTTP response Controller class ```java @RestController public class MyController { @RequestMapping("/test1") public String test1() { throw new RuntimeException("test1 error"); } } ``` Exception Handling class ```java @ControllerAdvice public class MyExceptionHandler { @ExceptionHandler(RuntimeException.class) public ResponseEntity<String> handle(RuntimeException exception) { return ResponseEntity.status(HttpStatus.SERVICE_UNAVAILABLE) .body("RuntimeException: " + exception.getMessage()); } } ``` **For a more generalized handling for multiple exceptions, we just need to define the "super class" of those exceptions** ```java @ControllerAdvice public class MyExceptionHandler { @ExceptionHandler(Exception.class) public ResponseEntity<String> handle(Exception exception) { return ResponseEntity.status(HttpStatus.BAD_REQUEST) .body("Exception: " + exception.getMessage()); } } ``` ### 9. Interceptor 攔截器 攔截器的作用: 1. 擋下http request並檢查 2. 檢查無誤後,允許該request進入Controller,並執行方法 3. 若檢查發現有問題,就會直接返回錯誤給前端。此時Spring boot就不會執行方法 Steps: Create an Interceptor class ```java @Component public class MyInterceptor implements HandlerInterceptor { } ``` Override prehandler method ```java @Component public class MyInterceptor implements HandlerInterceptor { @Override public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception { System.out.println("Execute prehandle method in MyInterceptor"); return true; } } ``` Create a Config, and inject interceptor object ```java @Configuration public class MyConfig implements WebMvcConfigurer { @Autowired private MyInterceptor myInterceptor; @Override public void addInterceptors(InterceptorRegistry registry) { registry.addInterceptor(myInterceptor).addPathPatterns("/**"); } } ``` --- ## Spring JDBC ### 1. Connect database in Spring boot **Add 2 dependencies in pom.xml** This will allow Spring boot to use Spring JDBC ```xml <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-jdbc</artifactId> </dependency> ``` This will allow Spring to connect to MySQL https://mvnrepository.com/artifact/mysql/mysql-connector-java ```xml <!-- https://mvnrepository.com/artifact/mysql/mysql-connector-java --> <dependency> <groupId>mysql</groupId> <artifactId>mysql-connector-java</artifactId> <version>8.0.30</version> </dependency> ``` Add connection information into application.properties file (Configuring db settings) ``` spring.datasource.driver-class-name=com.mysql.cj.jdbc.Driver spring.datasource.url=jdbc:mysql://localhost:3306/myjdbc?serverTimezone=Asia/Taipei&characterEncoding=utf-8 spring.datasource.username=.... spring.datasource.password=.... ``` Notes: * myjdbc -> database to be used * serverTimezone -> Timezone you're in ### 2. Update - Java Bean "NamedParameterJdbcTemplate" (instantiation is done automatically by Spring) - need @Autowired Syntax ```java update(String sql, Map<String, Object> map); ``` sql: SQL Syntax map: Set value to the variable in SQL syntax update() includes INSERT, DELETE, UPDATE **Insert Example** ```java @RestController public class StudentController { @Autowired private NamedParameterJdbcTemplate namedParameterJdbcTemplate; @PostMapping("/students") public String insert(@RequestBody Student student){ String sql = "INSERT INTO student (id, name) VALUE (:studentId, :studentName)"; Map<String, Object> map = new HashMap<>(); map.put("studentId", student.getId()); map.put("studnetName", student.getName()); namedParameterJdbcTemplate.update(sql, map); return "INSERT successfully"; } } ``` Notes: - When we post the ```/students``` API, data will be inserted into database danamically - Be sure to create a ```Student``` class so that variables inside @RequestBody could be received **Delete Example** ```java @DeleteMapping("/students/{studentId}") public String delete(@PathVariable Integer studentId){ String sql = "DELETE FROM student WHERE id = :studentId"; Map<String, Object> map = new HashMap<>(); map.put("studentId", studentId); namedParameterJdbcTemplate.update(sql, map); return "DELETE successfully"; } ``` ### 3. Advanced Update #### 3.1. 如何知道**Auto_increment**的值為何 使用```KeyHolder keyHolder = new GeneratedKeyHolder();``` ```java @PostMapping("/students") public String insert(@RequestBody Student student) { String sql = "INSERT INTO student (name) VALUE (:studentName)"; Map<String, Object> map = new HashMap<>(); map.put("studentName", student.getName()); KeyHolder keyHolder = new GeneratedKeyHolder(); namedParameterJdbcTemplate.update(sql, new MapSqlParameterSource(map), keyHolder); int id = keyHolder.getKey().intValue(); return "INSERT successfully" + "mysql generated id:" + id; } ``` **Notes: 在```int id = keyHolder.getKey().intValue();```的地方,要根據table的類型來決定用哪個方法,如果是int就是.intValue()** #### 3.2. 大量插入數據的問題 使用```batchUpdate()``` ```java @PostMapping("/students/batch") public String insertList(@RequestBody List<Student> studentList) { String sql = "INSERT INTO student (name) VALUE (:studentName);"; MapSqlParameterSource[] parameterSources = new MapSqlParameterSource[studentList.size()]; for (int i = 0; i < studentList.size(); i++) { Student student = studentList.get(i); parameterSources[i] = new MapSqlParameterSource(); parameterSources[i].addValue("studentName", student.getName()); } namedParameterJdbcTemplate.batchUpdate(sql, parameterSources); return "Batch insert successfully"; } ``` Notes: ```batchUpdate()```的執行效率會比for loop+update()的效率高 #### 4. Query Syntax ```java query(String sql, Map<String, Object> map, RowMapper<T> rowMapper); ``` sql: SQL Syntax map: Set value to the variable in SQL syntax rowMapper: Convert query result into Java Object **Return: List** **RowMapper實作** - Create ```RowMapper class``` - Override ```mapRow``` method ```java public class StudentRowMapper implements RowMapper<Student> { @Override public Student mapRow(ResultSet resultSet, int i) throws SQLException { // Get data from database Integer id = resultSet.getInt("id"); String name = resultSet.getString("name"); // Convert into Java Object Student student = new Student(); student.setId(id); student.setName(name); // Return Java Object return student; } } ``` A more common way to write this method ```java public class StudentRowMapper implements RowMapper<Student> { @Override public Student mapRow(ResultSet resultSet, int i) throws SQLException { Student student = new Student(); student.setId(resultSet.getInt("id");); student.setName(resultSet.getString("name")); return student; } } ``` - Instantiate ```RowMapper Class``` ```java @GetMapping("/students") public List<Student> select() { String sql = "SELECT id, name FROM student;"; Map<String, Object> map = new HashMap<>(); List<Student> list = namedParameterJdbcTemplate.query(sql, map, new StudentRowMapper()); return list; } ``` Data Type Mapping Reference: ![](https://hackmd.io/_uploads/SJPyOPPb6.png) Query for only 1 item ```java @GetMapping("/students/{studentId}") public Student select_one(@PathVariable int studentId) { String sql = "SELECT id, name FROM student WHERE id = :studentId"; Map<String, Object> map = new HashMap<>(); map.put("studentId", studentId); List<Student> list = namedParameterJdbcTemplate.query(sql, map, new StudentRowMapper()); // To avoid IndexOutOfBoundException if (list.size() > 0) { return list.get(0); } else { return null; } } ``` #### 5. QueryForObject * **Return: Java Object** * **僅適合用在```SELECT COUNT(*)```的情境** Syntax ```java @GetMapping("/students/{studentId}") public void count(@PathVariable int studentId) { String countSql = "SELECT COUNT(*) FROM student"; Map<String, Object> map = new HashMap<>(); Integer count = namedParameterJdbcTemplate.queryForObject(countSql, map, Integer.class); System.out.println("Total count in student table: " + count); } ``` --- ## MVC架構模式 - Controller-Service-Dao ### What is MVC: MVC架構模式的用途就是將系統拆分**為Model, View, Controller**三個部分。 - **Controller(控制器)**:負責轉發Http request - **Model(模型)**:負責業務邏輯、數據處理(與database互動) - **View(視圖)**:用html模板呈現數據 因為目前主流工程都是前後端分離,並使用JSON進行數據傳遞,因此View的重要性就愈來愈低。 MVC架構的優點: - 職責分離、易於維護 - 利於團隊分工 - 可重複使用寫好的程式 ### MVC in Spring Boot: Will be presented in **Controller-Service-Dao** structure - **Controller**:負責接收Http request、驗證請求參數。 - **Service**:負責業務邏輯 - **Dao(Data Access Object)**:數據處理(與database互動) ![](https://hackmd.io/_uploads/S1ERPhO-6.png) Notes: 1. Class命名需要以Controller, Service, Dao做結尾 2. 需要將Controller, Service, Dao都變成Bean,並使用@Autowired注入 (只需Controller需要用```@RestController```,其他兩層用```@Component```) 3. Controller should not directly connected to Dao. 4. **Dao only allows SQL queries, no service logic allowed.** ### Implementation: #### Step 1. 在專案package下新增各層的package(controller, service, dao) ![](https://hackmd.io/_uploads/Sk98zTuba.png) #### Step 2. 從Dao開始,先建立一個interface來決定要實作哪些方法 StudentDao Interface: ```java public interface StudentDao { Student getById(Integer studentId); } ``` Note: 方法名稱getById的意思就是「用Id去取得資料」,也有findById或queryById的寫法 #### Step 3. 實作Dao Interface的class StudentDaoImpl class: ```java @Component public class StudentDaoImpl implements StudentDao { @Autowired private NamedParameterJdbcTemplate namedParameterJdbcTemplate; @Override public Student getById(Integer studentId) { String sql = "SELECT id, name FROM student WHERE id = :studentId"; Map<String, Object> map = new HashMap<>(); map.put("studentId", studentId); List<Student> list = namedParameterJdbcTemplate.query(sql, map, new StudentRowMapper()); if (list.size() > 0) { return list.get(0); } else { return null; } } } ``` Notes: 1. 記得在class加上```@Component```,讓這個class變成bean 2. 記得使用```@Autowired```注入Spring JDBC #### Step 4. 建立Service Interface StudentService Interface: ```java public interface StudentService { Student getById(Integer studentId); } ``` #### Step 5. 實作Service class StudentServiceImpl class: ```java @Component public class StudentServiceImpl implements StudentService { @Autowired private StudentDao studentDao; @Override public Student getById(Integer studentId) { return studentDao.getById(studentId); } } ``` Notes: 1. 記得在class加上```@Component```,讓這個class變成bean 2. 記得使用```@Autowired``` **注入Dao的Interface** (注入Interface的原因前面有提過,原因是可以減少類之間的耦合,方便程式修改,以後修改的class可以透過多型來向上轉換) #### Step 6. 完成Controller ```java @RestController public class StudentController { @Autowired private StudentService studentService; @GetMapping("/students/{studentId}") public Student select(@PathVariable Integer studentId) { return studentService.getById(studentId); } } ``` Notes: 1. 記得在加上```@RestController``` 2. 記得使用```@Autowired``` **注入Service的Interface** 三層結構圖: ![](https://hackmd.io/_uploads/rktOUpO-6.png) #### Appendix. 對應table的class 1. 通常會把對應table查詢欄位的該class物件,統一整理到一個package當中(一般來說是model package) 2. 一個對應model會有一個專屬對應的Dao #### 另一種寫法 - Controller: ```@RestController``` - Service: ```@Service``` - Component: ```@Repository``` --- ## Transaction 交易 - 「交易」是一種資料庫的用法 - 用途:可以在交易中包含「多個資料庫操作」,這些資料操作會一起成功or一起失敗 - 若交易中發生失敗時,就會撤回原本的資料庫操作,稱為Rollback ### @Transactional - 用法:加在class或是方法上 **(通常是加在Service的方法上)** - 用途:使用「交易」來管理這個方法中的資料庫操作 ```java @Transactional @Override public void transfer(Integer fromAccountId, Integer toAccountId, Integer money) { // User A 扣除轉帳金額 accountDao.decreaseMoney(fromAccountId, money); // simulate exception Integer a = 1 / 0; // User B 收到轉入金額 accountDao.addMoney(toAccountId, money); } ``` **Note: 只有支援ACID的資料庫才能夠支援transaction交易** --- ## Mutiple Database Connection - 需要在datasource後面指定要連線的database - url寫法由```.url=jdbc:```改為```jdbc-url=jdbc:``` 實際設定步驟如下: 1. 設定application.properties 2. 設定configuration class 3. 在Dao層注入bean **(記得用```@Qualifier```區分prefix)** application.properties設定範例如下: ``` # Multiple Database spring.datasource.test1.driver-class-name=com.mysql.cj.jdbc.Driver spring.datasource.test1.jdbc-url=jdbc:mysql://localhost:3306/test1?serverTimezone=Asia/Taipei&characterEncoding=utf-8 spring.datasource.test1.username=xxx spring.datasource.test1.password=xxx spring.datasource.test2.driver-class-name=com.mysql.cj.jdbc.Driver spring.datasource.test2.jdbc-url=jdbc:mysql://localhost:3306/test2?serverTimezone=Asia/Taipei&characterEncoding=utf-8 spring.datasource.test2.username=xxx spring.datasource.test2.password=xxx ``` Configuration class創建bean ```java @Configuration public class DataSourceConfiguration { // 連線到 test1 資料庫的 DataSource 和 NamedParameterJdbcTemplate @Bean @ConfigurationProperties(prefix = "spring.datasource.test1") public DataSource test1DataSource() { return DataSourceBuilder.create().build(); } @Bean public NamedParameterJdbcTemplate test1JdbcTemplate( @Qualifier("test1DataSource") DataSource dataSource) { return new NamedParameterJdbcTemplate(dataSource); } // 連線到 test2 資料庫的 DataSource 和 NamedParameterJdbcTemplate @Bean @ConfigurationProperties(prefix = "spring.datasource.test2") public DataSource test2DataSource() { return DataSourceBuilder.create().build(); } @Bean public NamedParameterJdbcTemplate test2JdbcTemplate( @Qualifier("test2DataSource") DataSource dataSource) { return new NamedParameterJdbcTemplate(dataSource); } } ``` 注入bean ```java @Autowired @Qualifier("test1JdbcTemplate") private NamedParameterJdbcTemplate test1JdbcTemplate; @Autowired @Qualifier("test2JdbcTemplate") private NamedParameterJdbcTemplate test2JdbcTemplate; ``` --- ## Spring Data JPA - JPA:全稱為Java Persistence API - 用來「定義」要如何操作DB - 提供許多註解使用(@Entity, @Table...etc) - Hibernate - 一種ORM框架,去實現JPA - 根據JPA註解,負責自動生成SQL語法去操作DB - 優點:開發效率高 - 缺點:效能差,較難寫出複雜的SQL語法 ### 1. Setting Add dependency ```xml <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-data-jpa</artifactId> </dependency> ``` Add driver(same as JDBC) Configurate DB setting at application.properties(same as JDBC) ``` spring.datasource.driver-class-name=com.mysql.cj.jdbc.Driver spring.datasource.url=jdbc:mysql://localhost:3306/myjpa?serverTimezone=Asia/Tokyo&characterEncoding=utf-8 spring.datasource.username=xxx spring.datasource.password=xxx ``` ### 2. Map the class with table Step 1. Create a class that matches db table ```java @Entity @Table(name="student") // match table name in db public class Student { // @Column -> match variables with columns // @Id -> for primary key // @GenerateValue -> for auto_increment @Id @GeneratedValue(strategy = GenerationType.IDENTITY) @Column(name="id") Integer id; @Column(name="name") String name; // getter and setter } ``` Step 2. Create an interface that extends CrudRepository ```java public interface StudentRepository extends CrudRepository<Student, Integer> { } ``` **Note: ```CrudRepository<a, b>``` a: the class of the corresponding table b: data type of primary key** Step 3. Create a Controller ```java @RestController public class StudentController { @Autowired private StudentRepository studentRepository; @PostMapping("/students") public String insert(@RequestBody Student student) { studentRepository.save(student); return "INSERT db Executed."; } } ``` Note: 可以在propertires檔案中增加```spring.jpa.show-sql=true```來查看自動生成的SQL ### 3. Details in CrudRespository Interface - save(): 可以「新增」或「修改」DB中的資料 **Note: 如果要使用save方法來Update data,一定要先查詢data是否存在** - findById(): 根據ID去查詢資料,返回值是Optional\<T> - deletById(): 根據ID去刪除資料 CRUD Operation Sample: ```java @RestController public class StudentController { @Autowired private StudentRepository studentRepository; // Create @PostMapping("/students") public String insert(@RequestBody Student student) { studentRepository.save(student); return "INSERT data Executed."; } // Read @GetMapping("/students/{studentId}") public Student read(@PathVariable Integer studentId) { Student student = studentRepository.findById(studentId).orElse(null); return student; } // Update @PutMapping("/students/{studentId}") public String update(@PathVariable Integer studentId, @RequestBody Student student) { Student s = studentRepository.findById(studentId).orElse(null); if (s != null) { s.setName(student.getName()); studentRepository.save(s); return "UPDATE data Executed."; } else { return "UPDATE Failed, data does not exist."; } } // Delete @DeleteMapping("/students/{studentId}") public String delete(@PathVariable Integer studentId) { studentRepository.deleteById(studentId); return "DELETE data Executed."; } } ``` Note: 1. ```Student student = studentRepository.findById(studentId).orElse(null);```這一段的意思是如果在db找不到資料,就會回傳null(找到的話就回傳object) 2. **Update的寫法要注意,因為有多一道判斷** ### 4. Customized Queries 在Interface中加入findByXXX()的方法 ```java public interface StudentRepository extends CrudRepository<Student, Integer> { List<Student> findByName(String name); Student findByIdAndName(Integer id, String name); } ``` **Note: 參數名稱不重要,順序很重要,命名規則要依照邏輯去寫** ### 5. 原生SQL查詢:@Query 目的:用來解決findByXxx無法寫出複雜查詢邏輯的問題 用途:在Spring Data JPA中執行SQL語法 位置:加註在方法上,讓該方法去執行SQL Notes: 1. 用問號代表參數,用數字代表第幾個參數 2. **```nativeQuery = true```才是代表一般的SQL語法**(false會是JPQL的語法) 3. 優先使用findByXxx(),複雜邏輯才會用@Query Sample: ```java public interface StudentRepository extends CrudRepository<Student, Integer> { @Query(value = "SELECT id, name FROM student WHERE id = ?1 AND name = ?2", nativeQuery = true) Student test1(Integer id, String name); } ``` --- ## Spring Boot Unit Test Version Check: * Spring Boot <= 2.1 -> 只能用JUnit4 * Spring Boot >= 2.4 -> 只能用JUnit5 Add dependency (通常在使用Spring boot時就會自帶,可以檢查有沒有) ```xml <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-test</artifactId> <scope>test</scope> <exclusions> <exclusion> <groupId>org.junit.vintage</groupId> <artifactId>junit-vintage-engine</artifactId> </exclusion> </exclusions> </dependency> ``` 其中exclusions的那段程式目的是要禁用JUnit4的功能,避免不必要的困擾 Add test class(Could be Generated by IntelliJ) Notes: - need to add ```@Test``` on the unit test method. - **class should be pulic** Sample: ```java import org.junit.jupiter.api.Test; import static org.junit.jupiter.api.Assertions.*; public class CalculatorTest { @Test public void add() { Calculator calculator = new Calculator(); int result = calculator.add(1, 2); assertEquals(3, result); } } ``` Unit Testing的特性: - 可被自動化 - 各個單元測試互相獨立,彼此不依賴 - 結果要穩定,不被外部服務所影響 Notes: - **測試程式要放在test資料夾裡面** - 測試class要用「原class的名字加上Test做結尾」來命名 - 測試class的package要跟原class的package保持一致 ### 1. JUnit5: #### @Test 用途:加在方法上,讓該方法成為一個單元測試code(Test Case) 注意:只能在test資料夾底下使用 **格式:固定為public void,且不帶參數** 命名:方法命名上建議讓人可以清楚了解這個測試是在測甚麼 #### assert用法 常用的assert見下表: ![](https://hackmd.io/_uploads/rJ_OCqjZT.png) Sample: ```java @Test public void add() { Calculator calculator = new Calculator(); int result = calculator.add(1, 2); assertNotNull(result); assertEquals(3, result); assertTrue(result > 1); } @Test public void divide() { Calculator calculator = new Calculator(); assertThrows(ArithmeticException.class, () -> { calculator.divide(1, 0); }); } ``` **Note: assertThrows通常會用lambda表達式的寫法來寫** ### 2. JUnit5其他常用註解: #### @BeforeEach, @AfterEach - @BeforeEach: 每次@Test開始前,都會執行一次 - @AfterEach: 每次@Test結束後,都會執行一次 - 比較常用 ```java public class MyTest { @BeforeEach public void beforeEach() { System.out.println("Execute beforeEach"); } @AfterEach public void afterEach() { System.out.println("Execute afterEach"); } @Test public void test1() { System.out.println("Execute test1"); } @Test public void test2() { System.out.println("Execute test2"); } } ``` > Execute beforeEach Execute test1 Execute afterEach Execute beforeEach Execute test2 Execute afterEach #### @BeforeAll, @AfterAll - @BeforeAll: 在所有@Test開始前執行一次 - @AfterAll: 在所有@Test結束後執行一次 - **兩者必須為static方法** - 不常用,因為是static,沒辦法與容器中的bean有互動 ```java public class MyTest { @BeforeAll public static void beforeAll() { System.out.println("Execute beforeAll"); } @AfterAll public static void afterAll() { System.out.println("Execute afterAll"); } @Test public void test1() { System.out.println("Execute test1"); } @Test public void test2() { System.out.println("Execute test2"); } } ``` > Execute beforeAll Execute test1 Execute test2 Execute afterAll #### @Disabled, @DisplayName - @Disabled: 忽略該@Test不執行(加在Test Case上) - @DisplayName: 自定義顯示名稱(後面參數傳入字串) ### 3. Controller-Server-Dao Testing #### 3.1. Service / Dao Layer - 只要**在測試的class上加上```@SpringBootTest```**,則在運行單元測試的時候,Spring Boot就會去啟動Spring容器,創建所有的bean出來 - 同時@Configuration的設定也會被執行,效果等同於直接運行Spring Boot Application **重點:** 若執行測試刪除的test case時,資料庫裡面的測試資料就會被刪除,此時測試取得資料的test case就會失敗。 解法有二: 1. 將getById()與deleteById()的測試對象分開(不太好的解法,無法確認數據是否乾淨) 2. **在deleteById()上面加上@Transactional註解,讓spring boot進行rollback** Notes: 兩個不同的@Transactional ![](https://hackmd.io/_uploads/BJEZtnsbp.png) Dao Layer Unit Testing Sample Code ```java @SpringBootTest public class StudentDaoImplTest { @Autowired private StudentDao studentDao; @Test public void getById() { Student student = studentDao.getById(3); assertNotNull(student); assertEquals("Judy", student.getName()); assertEquals(100, student.getScore()); assertTrue(student.isGraduate()); assertNotNull(student.getCreateDate()); } @Test @Transactional public void deleteById() { studentDao.deleteById(3); Student student = studentDao.getById(3); assertNull(student); } @Test @Transactional public void insert() { Student student = new Student(); student.setName("John"); student.setScore(66.6); student.setGraduate(true); Integer studentId = studentDao.insert(student); Student result = studentDao.getById(studentId); assertNotNull(result); assertEquals("John", result.getName()); assertEquals(66.6, result.getScore()); assertTrue(result.isGraduate()); assertNotNull(result.getCreateDate()); } @Test @Transactional public void update() { Student student = studentDao.getById(4); student.setName("Kevin"); studentDao.update(student); Student result = studentDao.getById(4); assertNotNull(result); assertEquals("Kevin", result.getName()); } } ``` #### 3.2. Controller Layer - MockMvc 目的: - MockMvc即是以「模擬前端的行為」來測試API是否運行正確 - 不能用直接注入bean的方式來測試 方法: - 在測試用class加上```@SpringBootTest```與```@AutoConfigureMockMvc``` - 用```@Autowired```注入MockMvc 架構: ![](https://hackmd.io/_uploads/Hk_x6RjZp.png) JSONPath Reference: https://jsonpath.com/ Controller Layer Unit Testing Sample Code ```java @SpringBootTest @AutoConfigureMockMvc public class StudentControllerTest { @Autowired private MockMvc mockMvc; // test GET method @Test public void getById() throws Exception { RequestBuilder requestBuilder = MockMvcRequestBuilders // create a mock request object that use GET method .get("/students/{studentId}", 3) // URL path .header("headerName", "headerValue") // add customized header .queryParam("graduate", "true"); MvcResult mvcResult = mockMvc.perform(requestBuilder) // execute request .andDo(print()) // allow JSON to be printed out (for better understanding of JSON structure) .andExpect(status().is(200)) .andExpect(jsonPath("$.id", equalTo(3))) .andExpect(jsonPath("$.name", notNullValue())) .andReturn(); // andReturn: can obtain the complete return result from API (can be only put at the end) String body = mvcResult.getResponse().getContentAsString(); // get response body System.out.println("Returned Response body is " + body); } // test POST method @Test public void create() throws Exception { ObjectMapper = objectMapper = new ObjectMapper(); Student student = new Student(); student.setName("Hank"); student.setScore(14.6); student.setGraduate(false); // java object -> json string String jsonString = objectMapper.writeValueAsString(student) RequestBuilder requestBuilder = MockMvcRequestBuilders .post("/students") .contentType(MediaType.APPLICATION_JSON) // this line is very important!!! .content(jsonString); mockMvc.perform(requestBuilder) .andExpect(status().is(201)); } } ``` **Note:** - **在測試POST的時候一定要記得加contentType(MediaType.APPLICATION_JSON)** ### 4. 隔絕外部依賴(提升測試穩定性): Mockito #### Mock測試 目的:避免為了測試某個單元測試,而去構建整個bean的dependency 做法:創造一個假的bean,去替換掉原本Spring容器中的bean 講白話點:StudentService跟StudentDao有關聯,如果要測試StudentService的時候StudentDao有問題或是資料庫有問題,這時候做StudentService的測試就會失敗。 所以我們就需要模擬一個bean,去吐出我們想要的結果 #### Syntax: - 在注入的bean上加上```@MockBean```註解 - 創造一個模擬物件並指定返回值 - ```Mockito.when(測試方法).thenReturn(模擬物件);``` Mockito Sample Code ```java @SpringBootTest public class StudentServiceImplMockTest { @Autowired private StudentService studentService; @MockBean private StudentDao studentDao; @BeforeEach public void BeforeEach() { // Create a mock object simulating return value from studentDao Student mockStudent = new Student(); mockStudent.setId(100); mockStudent.setName("I am Mock"); // Return mock object when method studentDao.getById() is invoked Mockito.when(studentService.getById(Mockito.any())).thenReturn(mockStudent); } @Test public void getById() { Student student = studentService.getById(3); assertNotNull(student); assertEquals(3, student.getId()); assertEquals("Judy", student.getName()); } } ``` > Test Failed > Expected :3 > Actual :100 Notes: * ```studentDao.getById(Mockito.any())```中的```Mockito.any()```的意思為接受任意參數,可以避免掉空指針問題 * 使用```@BeforeEach```註解,就可以在進行所有test的時候先跑一次產生Mock object的程式,讓測試程式更簡潔 #### 其他用法 模擬噴出Exception: ```Mockito.when(測試方法).thenReturn(new Runtime Exception());``` 紀錄方法的使用次數、順序: ```Mockito.verify(studentDao, Mockito.times(2)).getById(Mockito.any());``` #### 限制: - 不能mock static方法 - 不能mock private方法 - 不能mock final class #### @MockBean vs. @SpyBean - ```@MockBean```:產生假的bean替換原本的bean。沒定義的方法預設返回null - ```@SpyBean```: 原本的bean仍正常,只是替換掉幾個方法。沒定義的方法就預設使用原本真實的方法 ### 5. H2資料庫 * H2是一種嵌入式資料庫 * 可在Spring Boot啟動時生成,運行結束後銷毀 * H2資料庫可以降低單元測試對實體資料庫的依賴,讓單元測試在任何一台電腦都可以跑測試,也不用安裝資料庫軟體 Step 1. Add dependency URL: https://mvnrepository.com/artifact/com.h2database/h2 ```xml <!-- https://mvnrepository.com/artifact/com.h2database/h2 --> <dependency> <groupId>com.h2database</groupId> <artifactId>h2</artifactId> <version>2.2.224</version> <scope>test</scope> </dependency> ``` Step 2. 在test資料夾增加一個單元測試用的設定檔,來設定H2資料庫連線資訊 ```test->resources->application.properties``` **Note: 主程式中的設定檔跟測試用的設定檔裡面的設定要保持一致** properties setting by convention: ``` spring.datasource.driver-class-name=org.h2.Driver spring.datasource.url=jdbc:h2:mem:testdb spring.datasource.username=sa spring.datasource.password=sa #spring.jpa.hibernate.ddl-auto=none # This line is for Spring Data JPA ``` **解決中文亂碼問題** 在properties檔案下入以下兩行 ``` server.servlet.encoding.charset=UTF-8 server.servlet.encoding.force=true ``` Step 3. Create a file named **```schema.sql```** under resources, and add **CREATE TABLE IF NOT EXISTS** SQL syntax Step 4. Create a file named **```data.sql```** under resources, and add **INSERT INTO** SQL syntax ### 6. Convention over Configuration 意思為「約定大於配置」或「慣例優於設定」,是Spring Boot的特性之一。意思是我們不需要添加任何設定,只要知道運作規則就可以直接使用。 e.g. 添加的```data.sql```與```schema.sql```這兩個檔案,就是這種特性的展現。 --- ## Maven ### Intro * Maven是在Spring Boot開發中,負責「Library管理」與「Project構建」的工具 ### Library管理 * 用途:管理這個Spring Boot project可以使用哪些功能 用法:透過```pom.xml```檔案進行管理 * Library都統一放在\<dependencies>標籤下,每一個\<dependency>代表一個Library。基本上每個載入的寫法結構上都差不多一樣,**但需要注意的是,spring-boot-starter開頭的library是不可以指定version的值的**。 (因為這些library都是由spring boot開發的,為避免版本混用產生問題,故都是在一開始設定spring boot版本的時候統一設定) * \<scope>標籤:指定使用場景。例如H2資料庫的scope就是test * \<exclusions>標籤:指定不加載某個library。(參見前文exclude JUnit4的地方) ### Maven Repository(Maven倉庫) * 用途:儲存Spring Boot project所使用到的Library(以jar檔形式儲存) * Maven倉庫可分為local(本地)與remote(遠端),查找jar檔順序是先local再remote ### Project構建 * 用途:打包Spring Boot project中的程式 用法:透過```pom.xml```檔案和```Maven指令```進行操作 * Maven指令:(分3個生命週期) 同一條生命週期裡,後面的指令會去執行前面的指令 * clean生命週期: * clean:刪除target資料夾中的檔案(Compiled過後的程式) * default生命週期: * compile * test * package: 打包成.jar檔,並存放在target資料夾 * install * deploy **Note: 要打包之前記得先clean一次,這樣才能確保打包的程式是最乾淨的** --- ## 不同環境下的application.properties設定 開發環境:```application-dev.properties``` 測試環境:```application-test.properties``` 兩個設定檔是可以同時存在的 切換設定檔的方式:intellij右上角>Edit Configurations...>Spring Boot>application>Active Profile的欄位,填上dev就是使用dev的設定,test就是test的設定 ![](https://hackmd.io/_uploads/Hk2FCl6bT.png) --- ## Log的級別與規範 ### 添加Log 在Controller class加上 ```java private final static Logger log = LoggerFactory.getLogger(StudentController.class); ``` (from org.slf4j) ### 使用log ```java log.info("some string {}", variable) ``` {} represent the variable ### 常用等級 info():資訊 warn():警告 error():錯誤,需要協助查看 --- ## ObjectMapper - JSON String <-> Java Object * 用途:將JSON字串和Java Object互相轉換 * 使用條件:只要確保pom.xml中有加上spring-boot-starter-web即可 * 用法: * ```writeValueAsString()```: Java Object to JSON String * ```readValue()```: JSON String to Java Object * preparation: 在主package下新增一個```objectmapper```的package,然後把所有轉換class都放進去 ### 範例: Example: 先建立一個對應的class ```java @JsonInclude(JsonInclude.Include.NON_NULL) // 如果要過濾null值可以加上這一行 @JsonIgnoreProperties(ignoreUnknown = true) // 可以解決key對不上的問題 public class User { private Integer id; private String name; // omit getter and setter } ``` 主程式: ```java @RestController public class ObjectMapperController { @GetMapping("/convert") public String convert() throws JsonProcessingException { User user = new User(); user.setId(1); user.setName("John"); ObjectMapper objectMapper = new ObjectMapper(); // User -> json string String jsonResult = objectMapper.writeValueAsString(user); System.out.println("json value is: " + jsonResult); // json string -> User String jsonString = "{\"id\":3,\"name\":\"Amy\"}"; User userResult = objectMapper.readValue(jsonString, User.class); System.out.println("id: " + userResult.getId()); System.out.println("name: " + userResult.getName()); return "convert success"; } } ``` Notes: * readValue()的第二個參數,就是對應的class的名稱 ### 解決沒設定到的值會顯示null的問題 若有些值在setter設定的時候沒有設定到,就會用null來呈現 可以透過```@JsonInclude(JsonInclude.Include.NON_NULL)```的註解過濾掉null **(加在class上)** ### 解決JSON String突然多出key-value pair的問題 如果JSON String有多出欄位,除了在class新增對應欄位之外,也可以使用```@JsonIgnoreProperties(ignoreUnknown = true)```直接無視,這樣就不用新增欄位,降低以後維護的成本 ### 解決變數命名風格不同的問題 若變數不是使用java傳統的駝峰式命名,可以在變數上加```@JsonProperty("對應的key變數")```註解,這樣就不會在轉換的時候出問題 ### 複雜轉換(List, Map等等) 參考:https://kucw.github.io/blog/2020/6/java-jackson/ --- ## 使用RestTemplate發起Http Request ### RestTemplate 用途:在Spring Boot中發起一個RESTful Http Request - 即,可以發起GET、POST、PUT、DELETE的請求 - 可以將收到的response body中的JSON字串,轉換成Java Object - 條件:確保pom.xml中有spring-boot-starter-web即可 Quick API Setup: https://mocki.io/ ```jsonld { "id": 123, "name": "Allen" } ``` ### 實作 創建對應的class ```java public class Student { Integer id; String name; // omit getter and setter } ``` 這裡一樣也可以使用```@JsonProperty()```的註解 Controller ```java @RestController public class RestTemplateController { @GetMapping("/getForObject") public String getForObject() { RestTemplate restTemplate = new RestTemplate(); Student student = restTemplate.getForObject( "https://mocki.io/v1/b2d46933-83e8-4fc6-b2c1-f04c93cc3f65", Student.class); System.out.println("id: " + student.getId()); System.out.println("name: " + student.getName()); return "getForObject success"; } } ``` ### 其他用法 * Query String ```java @GetMapping("/getForObjectWithParam") public String getForObjectWithParam() { RestTemplate restTemplate = new RestTemplate(); Map<String, Object> queryParamMap = new HashMap<>(); queryParamMap.put("graduate", true); Student student = restTemplate.getForObject( "url", Student.class, queryParamMap); return "success"; } ``` 用Map增加參數後call API,其效果相當於在url後面加上```?graduate=true```的query string * ResponseEntity: 可以取status code ```java @GetMapping("/getForEntity") public String getForEntity() { RestTemplate restTemplate = new RestTemplate(); ResponseEntity<Student> studentEntity = restTemplate.getForEntity( "url", Student.class); // Get http request status code System.out.println(studentEntity.getStatusCode()); return "success"; } ``` * Post For Object 注意Request Body的寫法 會先new一個物件,設定參數後當成第二的參數傳入post方法 ```java @GetMapping("/postForObject") public String postForObject() { RestTemplate restTemplate = new RestTemplate(); Student studentRequestBody = new Student(); studentRequestBody.setName("John"); Student result = restTemplate.postForObject( "url", studentRequestBody, Student.class ); return "success"; } ``` 上面的寫法就等同於post url, request body參數是{"id": null, "name": "John", "contact_phone": null} * Post For Entity ```java @GetMapping("/postForEntity") public String postForEntity() { RestTemplate restTemplate = new RestTemplate(); Student studentRequestBody = new Student(); studentRequestBody.setName("John"); ResponseEntity<Student> responseEntity = restTemplate.postForEntity( "url", studentRequestBody, Student.class ); return "success"; } ``` * Exchange Exchange可以發出GET request也可以發出POST request,不過寫法比較複雜 Sample Code: ```java @GetMapping("/exchange") public String exchange() { RestTemplate restTemplate = new RestTemplate(); // use exchange to execute GET request HttpHeaders requestHeaders = new HttpHeaders(); requestHeaders.set("header1", "123"); HttpEntity requestEntity = new HttpEntity(requestHeaders); Map<String, Object> queryParamMap = new HashMap<>(); queryParamMap.put("graduate", true); ResponseEntity<Student> getStudentEntity = restTemplate.exchange( "url", HttpMethod.GET, requestEntity, Student.class, queryParamMap ); // use exchange to execute POST request HttpHeaders requestHeaders2 = new HttpHeaders(); requestHeaders2.set("header2", "456"); requestHeaders2.setContentType(MediaType.APPLICATION_JSON); // This line of code is important!! Student studentRequestBody = new Student(); studentRequestBody.setName("John"); HttpEntity requestEntity2 = new HttpEntity(studentRequestBody, requestHeaders2); ResponseEntity<Student> postStudentEntity = restTemplate.exchange( "url", HttpMethod.POST, requestEntity2, Student.class ); return "success"; } ``` ## Thymeleaf - 前端模板引擎 用途:支援html開發與渲染 條件:需要在pom.xml加入```spring-boot-starter-thymeleaf``` ```xml <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-thymeleaf</artifactId> </dependency> ``` **注意:添加的註解是```@Contoller```註解** ### 實作 ```java @Controller public class ThymeleafController { @GetMapping("/home") public String home() { return "index"; //字串要跟html檔名相同 } } ``` **特別注意一下返回值與返回的字串** 其對應的index.html通常是放在resources/templates裡面 (Thymeleaf會幫忙做轉換) ### 其他用法: - 動態傳遞數據: 後端java ```java @Controller public class ThymeleafController { @GetMapping("/home") public String home(Model model) { Student student = new Student(); student.setId(1); student.setName("John"); model.addAttribute("myStudent", student); return "index"; } } ``` 前端html變數應對寫法 ```htmlembedded <p th:text="${myStudent.id}"></p> <p th:text="${myStudent.name}"></p> ``` - 頁面跳轉 新增對應的html連結跟Controller方法即可 前 ```html <a href="/hello">Go To Hello Page</a> ``` 後 ```java @GetMapping("/hello") public String hello() { return "hello"; } ``` - 顯示圖片 放置路徑: static/images 新增對應的html標籤即可 ```html <img th:src="@{/images/dog.png}"/> ``` - 提交Form表單 前 ```html <form method="post" action="login"> Username: <input type="text" name="userName"></br> Password: <input type="text" name="userPassword"> <input type="submit" value="Login"> </form> ``` 後 ```java @PostMapping("/login") public String login(String userName, String userPassword) { // service logic return "login"; } ``` 再返回一個login.html頁面給user --- ## Bean的三種注入方式 ### 變數注入 (Field Injection) 優點:簡單 缺點:容易注入過多的bean進來 Spring官方不太推薦使用field injection(但目前看起來沒什麼差別) ```java @Component public class Teacher { @Autowired private Printer printer; @Autowired private Book book; } ``` ### 建構子注入 (Constructor Injection) Spring官方推薦寫法 優點:容易釐清依賴關係、測試,變數可為final 缺點:寫起來冗長(可以用Lombok簡化) ```java @Component public class Teacher { private final Printer printer; private final Book book; @Autowired public Teacher(Printer printer, Book book) { this.printer = printer; this.book = book } } ``` ### Setter注入 (Setter Injection) 參考即可,不推薦 ```java @Component public class Teacher { private Printer printer; private Book book; @Autowired public void setPrinter(Printer printer) { this.printer = printer; } @Autowired public void setBook(Book book) { this.book = book; } } ``` --- ## Spring Boot 3 更新內容 - 僅支援Java 17之後的版本 - 從Java EE 遷移到 Jakarta EE (import package會不同) - javax.* 轉移到 jakarta.* - 持續優化GraalVM技術 - 編譯出啟動速度更快、jar檔更小的Spring Boot程式 Reference: https://kucw.github.io/blog/2019/10/java-graalvm/ --- ## 實戰 - 打造電商網站 ### 實作商品查詢功能 1. 創建Git repository、Spring Boot Project 2. 在MySQL建立product table 3. 在application.properties檔案中建立database連線資訊 4. 建立Controller-Service-Dao layer package 5. 定義product對應的java class,加上getter & setter 7. 建立Dao Interface + implement class 8. 撰寫SQL, Map, ProductRowMapper\<Product>,並實作裡面的方法 9. 建立Service Interface + implement class,注入Dao Interface 10. 建立Controller class,注入Service Interface 11. 返回ResponseEntity\<Product> #### 定義商品種類 - 利用Enum Notes: 1. 一定要大寫 2. 兩單字以上用underscore連接 ```java public enum ProductCategory { FOOD, CAR, BOOK, E_BOOK } ``` 使用: 利用name()或是valueOf()的方法在Enum跟String之間做轉換 ```java public class MyTest { public static void main(String[] args) { ProductCategory category = ProductCategory.FOOD; String s = category.name(); System.out.println(s); // FOOD String s1 = "CAR"; ProductCategory category1 = ProductCategory.valueOf(s1); System.out.println(category1); // CAR } } ``` 實作: 可以將Product.class裡面的category變數由String轉成Enum。 優點: - 可以直接從程式中直接知道類別資訊 - 透過預先定義data來檢查資料庫是否有髒資料出現 ```java public class Product { private ProductCategory category; } ``` **改寫之後要檢查RowMapper,基本上都要改寫,因為資料型別不同** ```java // Convert data type from string to Enum String categoryStr = resultSet.getString("category"); ProductCategory category = ProductCategory.valueOf(categoryStr); product.setCategory(category); ``` #### 格式化返回的時間 class中Date所返回的時間,其實時區是(GMT+0),會跟資料庫上面記錄的時間不同,所以需要調整 調整方式:在properties檔案加入以下設定 ``` spring.jackson.time-zone=GMT+8 ``` 設定時間格式 ``` spring.jackson.date-format=yyyy-MM-dd HH:mm:ss ``` #### Timestamp - 是一個整數 - 計算從UTC 1970/1/1 00\:00\:00 到現在的總秒數 #### Java 8 新增的時間系列用法 https://kucw.github.io/blog/2020/6/java-date/ ### 實作CRUD **這邊會建議在多建立一個新的class來專門接收前端的post request,讓validation的code比較乾淨,不會影響原本的table對應class** 通常會新增一個dto package(data transfer object),然後把class放進去 Notes: - Dto的class: 專門跟前端互動 - Dao的class: 專門跟db互動 **Notes: 在class的變數上加上```@NotNull```註解後,記得在Controller的方法參數再加上```@Valid```** #### Read的細節 - 記得要先check回傳的值是否為null #### Update的細節 - **在實作Update功能時,通常會建議先檢查商品存不存在,不存在回傳not found,存在才去update後返回更新的物件** - **last modified date也記得要更新** example ```java // Check if product exists Product product = productService.getProductById(productId); // if not, return NOT_FOUND if (product == null) { return ResponseEntity.status(HttpStatus.NOT_FOUND).build(); } ``` #### Delete的細節 - **delete反而不需要去檢查商品存不存在**,只需要回傳結果(NOT FOUND)即可 - 多一個判斷對delete API來說是多餘且不正確的設計 ### 查詢商品列表 概念:前端都會call同一個API,但差別就是帶入的參數不同(i.e. ?後面的條件),後端就要根據不同條件給出不同查詢結果 通常會有以下三種功能 - Filtering 查詢條件 - Keyword 關鍵字查詢 - Sorting 排序 - Pagination 分頁 #### 基本功能 - 回傳全部結果 - 依照RESTful的設計理念,因為每個url都是一個資源,因此如果要實作回傳list類的API,**不管如何都要回傳200給前端,代表有查到東西(只是可能list裡面沒有內容)。因此,在實作的時候就不用判斷要查詢的資料是否存在** #### 進階功能 - Filtering Controller Example **重點:```@RequestParam(required = false)```** 因為同一支查詢的API,有可能查全部或是只查部分,此時條件不一定是required ```java @GetMapping("/products") public ResponseEntity<List<Product>> getProducts( @RequestParam(required = false) ProductCategory category ) { List<Product> productList = productService.getProducts(category); return ResponseEntity.status(HttpStatus.OK).body(productList); } ``` Dao Example **重點:SQL拼接** * **用```WHERE 1=1```的這個條件當拼接的地方,可以大幅減少多個查詢時sql撰寫的問題** * AND前面記得加space ```java @Override public List<Product> getProducts(ProductCategory category) { // SQL query String sql = "SELECT product_id, product_name, category, " + "image_url, price, stock, description, created_date, last_modified_date " + "FROM product WHERE 1=1"; Map<String, Object> map = new HashMap<>(); if (category != null) { sql = sql + " AND category = :category"; map.put("category", category.name()); } List<Product> productList = namedParameterJdbcTemplate.query(sql,map, new ProductRowMapper()); return productList; } ``` #### 進階功能 - Keyword sql拼接後寫法(前後%為模糊查詢,寫法要注意一下) ```java if (search != null) { sql = sql + " AND product_name LIKE :search"; map.put("search", "%" + search + "%"); } ``` #### 改善參數傳遞的方式 如果Request Parameter有很個,用@RequestParam的方式傳參數進去方法會很容易出錯,也不好維護 **解決辦法:在dto新增一個class去存那些傳進來的參數** ```java public class ProductQueryParams { private ProductCategory category; private String search; // getter and setter } ``` Controller 修改 ```java @GetMapping("/products") public ResponseEntity<List<Product>> getProducts( @RequestParam(required = false) ProductCategory category, @RequestParam(required = false) String search ) { ProductQueryParams productQueryParams = new ProductQueryParams(); productQueryParams.setCategory(category); productQueryParams.setSearch(search); List<Product> productList = productService.getProducts(productQueryParams); return ResponseEntity.status(HttpStatus.OK).body(productList); } ``` 其他Service跟Dao層也按照這個方式修改 #### 進階功能 - Sorting 這邊採取的設計是```@RequestParam(defaultValue=xxx)``` 即便前端沒傳東西,還是會有預設的排序方式(所以後面也不需要null判斷) Controller: ```java @GetMapping("/products") public ResponseEntity<List<Product>> getProducts( @RequestParam(defaultValue = "created_date") String orderBy, @RequestParam(defaultValue = "desc") String sort ) { List<Product> productList = productService.getProducts(category); return ResponseEntity.status(HttpStatus.OK).body(productList); } ``` SQL Syntax concat ```sql sql = sql + " ORDER BY " + productQueryParams.getOrderBy() + " " + productQueryParams.getSort(); ``` #### 進階功能 - Pagination Controller: ```java @Validated @GetMapping("/products") public ResponseEntity<List<Product>> getProducts( @RequestParam(defaultValue = "5") @Max(1000) @Min(0) Integer limit, @RequestParam(defaultValue = "0") @Min(0) Integer offset ) { List<Product> productList = productService.getProducts(category); return ResponseEntity.status(HttpStatus.OK).body(productList); } ``` **Note:** - 這邊選擇用defaultValue而不是用required=false的原因是為了**降低資料庫的負擔,如果每次都去存取全部的資料,會大幅影響資料庫效能** - **這邊也建議設計前端傳來的數字的上限(同樣for效能考量)** - 新增下限以防錯誤數值傳入(譬如負數) - **記得加上```@Validated```** #### 進階功能 - Pagination加強版(分頁+總筆數) 分頁: - 根據返回的response body格式,可以區分成兩類 1. 僅回傳商品數據 2. 回傳商品數據+總筆數(回傳一個java object) 回傳架構: ```jsonld { "limit": 3, "offset": 0, "total": 7, "result" : [商品] } ``` 回傳總筆數的用意是可以給前端拿去計算分頁頁數 Create a Page class using Generics ```java public class Page <T> { private Integer limit; private Integer offset; private Integer total; private List<T> results; // getter and setter } ``` 調整controller ```java // 前略 // 計算總筆數 Integer total = productService.countProduct(productQueryParams); // 新的回傳物件 Page<Product> page = new Page<>(); page.setLimit(limit); page.setOffset(offset); page.setTotal(total); page.setResults(productList); return ResponseEntity.status(HttpStatus.OK).body(page); ``` Dao ```java public Integer countProduct(ProductQueryParams productQueryParams) { // SQL query String sql = "SELECT COUNT(*) FROM product WHERE 1=1"; Map<String, Object> map = new HashMap<>(); // Filtering Search if (productQueryParams.getCategory() != null) { sql = sql + " AND category = :category"; map.put("category", productQueryParams.getCategory().name()); } // Keyword Search if (productQueryParams.getSearch() != null) { sql = sql + " AND product_name LIKE :search"; map.put("search", "%" + productQueryParams.getSearch() + "%"); } Integer totalCount = namedParameterJdbcTemplate.queryForObject(sql, map, Integer.class); return totalCount; } ``` --- ### 實作註冊帳號功能 #### 檢查註冊的email - 在Service / Dao Layer中增加邏輯 - 用前端傳過來的email值,由Service去呼叫Dao查詢db ```java // UserServiceImpl @Override public Integer register(UserRegisterRequest userRegisterRequest) { User user = userDao.getUserByEmail(userRegisterRequest.getEmail()); // if email is registered if (user != null) { log.warn("The email: {} has been registered.", userRegisterRequest.getEmail()); // Return 400 to frontend throw new ResponseStatusException(HttpStatus.BAD_REQUEST); } return userDao.createUser(userRegisterRequest); } // UserDaoImpl @Override public User getUserByEmail(String email) { String sql = "SELECT user_id, email, password, created_date, last_modified_date " + "FROM user WHERE email = :email"; Map<String, Object> map = new HashMap<>(); map.put("email", email); List<User> userList = namedParameterJdbcTemplate.query(sql, map, new UserRowMapper()); if (userList.size() > 0) { return userList.get(0); } else { return null; } } ``` #### 添加Log final變數 ```java private final static Logger log = LoggerFactory.getLogger(UserServiceImpl.class); ``` #### 常用的email檢查註解 - ```@Email``` ```java public class UserRegisterRequest { @NotBlank @Email private String email; } ``` #### 隱藏返回的password - ```@JsonIgnore``` ```java public class User { @JsonIgnore private String password; } ``` #### 登入功能 **Note: String的比較一定要用```.equals()```** Service Layer實作 ```java @Override public User login(UserLoginRequest userLoginRequest) { User user = userDao.getUserByEmail(userLoginRequest.getEmail()); // check user's existence if (user == null) { log.warn("This email: {} is not registered", userLoginRequest.getEmail()); throw new ResponseStatusException(HttpStatus.BAD_REQUEST); } // check password if (user.getPassword().equals(userLoginRequest.getPassword())) { return user; } else { log.warn("Password incorrect for email: {}", userLoginRequest.getEmail()); throw new ResponseStatusException(HttpStatus.BAD_REQUEST); } } ``` #### 如何安全地儲存密碼 - 禁止使用明碼的方式儲存密碼 - 資料庫加密 #### Hash - 經過一個函數將字串轉換成亂碼雜湊值(hash value) - 同一個字串,轉換出來的hash value一定是一樣的 - 可能會有collision(碰撞)的狀況,也就是多個字串轉換成同一個hash value - 嚴格定義上Hash不能算加密,因為hash value沒辦法被解密 #### 對稱加密 - 只有一把key,可以使用該key進行加密跟解密 #### 非對稱加密 - 有兩把key: - 私鑰:自己保管,不可洩漏 - 公鑰:公開,可廣發所有人 - 私鑰加密,公鑰解密 - 公鑰加密,私鑰解密 Hash實作(比較簡單) - MD5 實作邏輯: 1. 使用者註冊新帳號 2. 在service layer進行MD5 hash 3. User table儲存的就是hash value 4. 使用者登入時也是在service layer加密後,跟table比較,再決定是否可以讓user登入 語法: ```java String hashedPassword = DigestUtils.md5DigestAsHex(userRegisterRequest.getPassword().getBytes()); ``` 實作: 註冊 ```java @Override public Integer register(UserRegisterRequest userRegisterRequest) { User user = userDao.getUserByEmail(userRegisterRequest.getEmail()); // if email is registered if (user != null) { log.warn("The email: {} has been registered.", userRegisterRequest.getEmail()); throw new ResponseStatusException(HttpStatus.BAD_REQUEST); } // Use MD5 to generate hash value for password String hashedPassword = DigestUtils.md5DigestAsHex(userRegisterRequest.getPassword().getBytes()); userRegisterRequest.setPassword(hashedPassword); return userDao.createUser(userRegisterRequest); } ``` 登入 ```java @Override public User login(UserLoginRequest userLoginRequest) { User user = userDao.getUserByEmail(userLoginRequest.getEmail()); // check user's existence if (user == null) { log.warn("This email: {} is not registered", userLoginRequest.getEmail()); throw new ResponseStatusException(HttpStatus.BAD_REQUEST); } // Use MD5 to generate hashed password String hashedPassword = DigestUtils.md5DigestAsHex(userLoginRequest.getPassword().getBytes()); // check password if (user.getPassword().equals(hashedPassword)) { return user; } else { log.warn("Password incorrect for email: {}", userLoginRequest.getEmail()); throw new ResponseStatusException(HttpStatus.BAD_REQUEST); } } ``` --- ### 實作訂單功能 #### 創建訂單 - url路徑: /users/{userId}/orders - 用JSON object 模擬訂單內容 ```json { "buyItemList": [ { "productId": 9, "quantity": 1 }, { "productId": 10, "quantity": 2 } ] } ``` Note: 這邊建議直接設計成JSON Objec,如果只設計成JSON List的話擴充性不高 - 針對該JSON設計class ```java public class CreateOrderRequest { @NotEmpty private List<BuyItem> buyItemList; } ``` ```java public class BuyItem { @NotNull private Integer productId; @NotNull private Integer quantity; } ``` Controller ```java @PostMapping("/users/{userId}/orders") public ResponseEntity<?> createOrder(@PathVariable Integer userId, @RequestBody @Valid CreateOrderRequest createOrderRequest) { } ``` Service層的createOrder(訂單)細節 - 因為訂單table有兩個表(order, orderItem),所以會call兩次Dao層 - **如果需要更新兩張以上的表,切記要加上```@Transactional```的註解** createOrderItems(訂單明細)細節 - 用batchUpdate效率會比for loop還高 #### 查詢訂單 - 取得OrderItem的時候,記得LEFT JOIN product table - 如果有join其他table,RowMapper也要去接住那些欄位,連帶影響class需要去擴充欄位 example ```java public class OrderItemRowMapper implements RowMapper<OrderItem> { @Override public OrderItem mapRow(ResultSet resultSet, int i) throws SQLException { OrderItem orderItem = new OrderItem(); orderItem.setOrderId(resultSet.getInt("order_id")); orderItem.setProductId(resultSet.getInt("product_id")); orderItem.setQuantity(resultSet.getInt("quantity")); orderItem.setAmount(resultSet.getInt("amount")); // 下面是原本不該table class的變數,需要另外新增 // 因為join了商品的table orderItem.setPoductName(resultSet.getString("product_name")); orderItem.setImageUrl(resultSet.getString("image_url")); return orderItem; } } ``` - 另外因為在service層要一起將Order跟OrderItemList一起回傳給前端,所以Order class也需要擴充 ```java private List<OrderItem> orderItemList; // Getter and Setter ``` ```java @Override public Order getOrderById(Integer orderId) { // 取得訂單 Order order = orderDao.getOrderById(orderId); // 取得訂單明細 List<OrderItem> orderItemList = orderDao.getOrderItemsByOrderId(orderId); order.setOrderItemList(orderItemList); return order; } ``` #### 檢查user是否存在、扣除庫存、更新庫存功能 - Check User ```java // Add log private final static Logger log = LoggerFactory.getLogger(OrderServiceImpl.class); @Autowired private UserDao userDao; ``` ```java // Check user's existence User user = userDao.getUserById(userId); if (user == null) { log.warn("User: {} doest not exist.", userId); throw new ResponseStatusException(HttpStatus.BAD_REQUEST); } ``` - Check Stock ```java @Transactional @Override public Integer createOrder(Integer userId, CreateOrderRequest createOrderRequest) { // Check user's existence User user = userDao.getUserById(userId); if (user == null) { log.warn("User: {} doest not exist.", userId); throw new ResponseStatusException(HttpStatus.BAD_REQUEST); } int totalAmount = 0; List<OrderItem> orderItemList = new ArrayList<>(); for (BuyItem buyItem : createOrderRequest.getBuyItemList()) { Product product = productDao.getProductById(buyItem.getProductId()); // check stock of product // no stock if (product == null) { log.warn("Product {} does not exist", buyItem.getProductId()); throw new ResponseStatusException(HttpStatus.BAD_REQUEST); // not enough } else if (product.getStock() < buyItem.getQuantity()) { log.warn("Product {} is not enough. You ordered {}, and the remaining stock is {}.", product.getProductId(), buyItem.getQuantity(), product.getStock()); throw new ResponseStatusException(HttpStatus.BAD_REQUEST); } // Minus product stock productDao.updateStock(product.getProductId(), product.getStock() - buyItem.getQuantity()); // Count total amount int itemAmount = buyItem.getQuantity() * product.getPrice(); totalAmount += itemAmount; // Convert BuyItem to OrderItem object OrderItem orderItem = new OrderItem(); orderItem.setProductId(buyItem.getProductId()); orderItem.setQuantity(buyItem.getQuantity()); orderItem.setAmount(totalAmount); orderItemList.add(orderItem); } // Create order Integer orderId = orderDao.createOrder(userId, totalAmount); orderDao.createOrderItems(orderId, orderItemList); return orderId; } ``` Update Stock ```java // Minus product stock productDao.updateStock(product.getProductId(), product.getStock() - buyItem.getQuantity()); ``` ```java @Override public void updateStock(Integer productId, Integer stock) { String sql = "UPDATE product SET stock = :stock, last_modified_date = :lastModifiedDate" + " WHERE product_id = :productId"; Map<String, Object> map = new HashMap<>(); map.put("stock", stock); map.put("lastModifiedDate", new Date()); map.put("productId", productId); namedParameterJdbcTemplate.update(sql, map); } ``` #### 查詢訂單明細 Controller Layer ```java @GetMapping("/users/{userId}/orders") public ResponseEntity<Page<Order>> getOrders( @PathVariable Integer userId, @RequestParam(defaultValue = "10") @Max(1000) @Min(0) Integer limit, @RequestParam(defaultValue = "0") @Min(0) Integer offset ) { OrderQueryParams orderQueryParams = new OrderQueryParams(); orderQueryParams.setUserId(userId); orderQueryParams.setLimit(limit); orderQueryParams.setOffset(offset); // Get order list List<Order> orderList = orderService.getOrders(orderQueryParams); // Get total number of orders Integer count = orderService.countOrder(orderQueryParams); // Pagination Page<Order> page = new Page<>(); page.setLimit(limit); page.setOffset(offset); page.setTotal(count); page.setResults(orderList); return ResponseEntity.status(HttpStatus.OK).body(page); } ``` Service Layer ```java @Override public Integer countOrder(OrderQueryParams orderQueryParams) { return orderDao.countOrder(orderQueryParams); } @Override public List<Order> getOrders(OrderQueryParams orderQueryParams) { List<Order> orderList = orderDao.getOrders(orderQueryParams); for (Order order: orderList) { List<OrderItem> orderItemList = orderDao.getOrderItemsByOrderId(order.getOrderId()); order.setOrderItemList(orderItemList); } return orderList; } ``` Dao Layer ```java @Override public Integer countOrder(OrderQueryParams orderQueryParams) { String sql = "SELECT COUNT(*) FROM `order` WHERE 1=1"; Map<String, Object> map = new HashMap<>(); sql = addFilteringSql(sql, map, orderQueryParams); Integer total = namedParameterJdbcTemplate.queryForObject(sql, map, Integer.class); return total; } @Override public List<Order> getOrders(OrderQueryParams orderQueryParams) { String sql = "SELECT order_id, user_id, total_amount, created_date, last_modified_date FROM `order`" + " WHERE 1+1"; Map<String, Object> map = new HashMap<>(); // filter sql = addFilteringSql(sql, map, orderQueryParams); // sorting sql = sql + " ORDER BY created_date DESC"; // pagination sql = sql + " LIMIT :limit OFFSET :offset"; map.put("limit", orderQueryParams.getLimit()); map.put("offset", orderQueryParams.getOffset()); List<Order> orderList = namedParameterJdbcTemplate.query(sql, map, new OrderRowMapper()); return orderList; } private String addFilteringSql(String sql, Map<String, Object> map, OrderQueryParams orderQueryParams) { if (orderQueryParams.getUserId() != null) { sql = sql + " AND user_id = :userId"; map.put("userId", orderQueryParams.getUserId()); } return sql; } ``` ### 完整Source Code https://github.com/css186/springboot-mall ## Deployment 問題 - java 版本 https://stackoverflow.com/questions/53604111/heroku-cannot-deploy-java-11-spring-boot-app 解決方式:在專案資料夾最外面(跟src同level)新增```system.properties```檔案,裡面加上```java.runtime.version=11``` 其他版本亦同 - 隱藏Database connection credentials問題 適用spring boot 2.4版本以上 1. 在resource底下新增secret.properties(檔名隨意),然後把db帳號密碼放進去 2. 在application.properties import secret.properties ```spring.config.import=optional:./src/main/resources/secret.properties```optional的用意在於即便沒有找到檔案也可以執行 3. 上傳github時將secret.properties放入.gitignore裡面 4. 如果要部屬到線上平台,記得在平台提供的cofig設定中加入帳號密碼設定(格式同application.properties),避免因為連線資訊沒有一起上傳github而無法執行