--- title: 'SpringBoot 開發紀錄' disqus: hackmd --- # 1. 下載檔案 * 將String 轉成 `byte[]` 做傳輸 * Header 設定 * MediaType.**APPLICATION_OCTET_STREAM**:二進制(byte) * headers.**setContentDispositionFormData**("attachment", "metadata.csv"); * **setContentDispositionFormData**:告訴瀏覽器應該如何處理接收到的回應內容 * **attachment**:響應內容作為附件下載 * **"metadata.csv"**:附件的檔名 ```java= @PostMapping("/metadataToCSV") public ResponseEntity<byte[]> metadataToCSV(@RequestBody String importFile) throws IOException { String csv = dataSetService.metadataToCSV(importFile); byte[] csvBytes = csv.getBytes(StandardCharsets.UTF_8); HttpHeaders headers = new HttpHeaders(); headers.setContentType(MediaType.APPLICATION_OCTET_STREAM); //設定檔名 csv headers.setContentDispositionFormData("attachment", "metadata.csv"); // headers.setContentDispositionFormData("attachment", "download.zip"); return ResponseEntity.status(HttpStatus.OK).headers(headers).body(csvBytes); } ``` ## 1.1 壓縮多檔案下載 ```java= private byte[] downloadFile(List<String> filePaths) throws IOException { ByteArrayOutputStream byteArrayOutputStream = new ByteArrayOutputStream(); try (ZipOutputStream zipOut = new ZipOutputStream(byteArrayOutputStream)) { for (String filePath : filePaths) { if (filePath != null) { Path path = Path.of(filePath); if (Files.exists(path)) { try (FileInputStream fis = new FileInputStream(path.toFile())) { ZipEntry zipEntry = new ZipEntry(path.getFileName().toString()); zipOut.putNextEntry(zipEntry); byte[] buffer = new byte[2048]; int bytesRead; while ((bytesRead = fis.read(buffer)) != -1) { zipOut.write(buffer, 0, bytesRead); } } } } } } return byteArrayOutputStream.toByteArray(); } ``` ## 1.2 超大檔案下載 * 使用 **StreamingResponseBody** 代替 **byte[]** * `import org.springframework.web.servlet.mvc.method.annotation.StreamingResponseBody;` ```java= private void downloadFile(List<String> filePaths, OutputStream outputStream) throws IOException { try (ZipOutputStream zipOut = new ZipOutputStream(outputStream)) { for (String filePath : filePaths) { if (filePath != null) { Path path = Path.of(filePath); if (Files.exists(path)) { try (FileInputStream fis = new FileInputStream(path.toFile())) { ZipEntry zipEntry = new ZipEntry(path.getFileName().toString()); zipOut.putNextEntry(zipEntry); byte[] buffer = new byte[2048]; int bytesRead; while ((bytesRead = fis.read(buffer)) != -1) { zipOut.write(buffer, 0, bytesRead); } } } else { throw new RuntimeException("File Not Found"); } } } } } ``` * Request ```java= @GetMapping("download") public ResponseEntity<StreamingResponseBody> downloadDatasetToCSV() { HttpHeaders headers = new HttpHeaders(); headers.setContentDispositionFormData("attachment", "download.zip"); return ResponseEntity.status(HttpStatus.OK) .headers(headers) .contentType(MediaType.APPLICATION_OCTET_STREAM) .body(out -> { downloadDatasetToCSVUseCase.downloadFile(List.of("a.txt","b.txt"), out); out.close(); }); } ``` * 參考網址:https://blog.csdn.net/RenshenLi/article/details/120384473 # 2. Spring 初始化時執行邏輯 ## 2.1 CommandLineRunner 初始化時執行邏輯 * 接口類,重寫當中**run**方法 * 作用:Spring Boot 應用程式啟動後,初始化**CommandLineRunner實現類**時,發現是**CommandLineRunner類**就會調用**run方法** `立即執行特定的任務或操作` * 例: ```java= import org.springframework.boot.CommandLineRunner; import org.springframework.stereotype.Component; import org.springframework.core.annotation.Order; @Component @Order(1) public class MyCommandLineRunner implements CommandLineRunner { @Override public void run(String... args) throws Exception { // 在應用程式啟動後執行的初始化邏輯 System.out.println("MyCommandLineRunner executed."); } } ``` * **run**(String... args):**執行邏輯 * **args參數**:`接收外部參數`,與main()接收的參數相同 * 添加 **@Order** 在SpringBoot環境中,提高實例化的順序 * 數值越小 => 順序越高 # 3. SpringBoot 測試 * **Spring 測試**:**預設使用JUnit5** * **Mockit 單元測試**: * **JUnit5**: `org.junit.jupiter.api.Test;` * **@ExtendWith(MockitoExtension.class)** * **JUnit4**: `org.junit.Test;` * **@RunWith(MockitoJUnitRunner.class)** * **整合測試**: * **使用JUnit5**,使用 **@SpringBootTest** * 可以不添加 **@ExtendWith(SpringExtension.class)** * **JUnit4**: * **@SpringBootTest** * **@RunWith(SpringJUnit4ClassRunner.class)** * 目標:掃描對應的package即可 * 代碼: * 此容器做Spring上下文的設定 ```java= import org.springframework.boot.autoconfigure.SpringBootApplication; import org.springframework.boot.autoconfigure.domain.EntityScan; import org.springframework.context.annotation.ComponentScan; import org.springframework.context.annotation.FilterType; import org.springframework.data.jpa.repository.config.EnableJpaAuditing; @ComponentScan(basePackages = {"com.nicolas.configTemplate"}, excludeFilters = { @ComponentScan.Filter(type = FilterType.ASSIGNABLE_TYPE, value = xxxApplication.class)})//忽略 xxxApplication @EntityScan(basePackages = {"com.nicolas.configTemplate"}) @EnableJpaAuditing @SpringBootApplication public abstract class NicolasApplicationTest { } ``` * 使用: ```java= import org.springframework.boot.test.context.SpringBootTest; import org.junit.jupiter.api.Test; //JUnit5 // import org.junit.Test; //JUnit4 @SpringBootTest(classes = {NicolasApplicationTest.class}) //設定要載入上下文的容器 //@RunWith(SpringJUnit4ClassRunner.class) JUnit4的核心 public class TemplateTest1 { @Test public void Test1(){ //測試邏輯 } } ``` * 基本 java 的assert 使用 * assert + boolean * True:通過 * False:失敗 ## 3.1 AssertJ 常用斷言的方式 * 方法:org.assertj.core.api.Assertions.assertThat; * 陣列是否為空: assertThat(actualList).isNotEmpty(); * 執行 **【遞歸比較】** 對象的所有字段: assertThat(expect).**usingRecursiveComparison**().isEqualTo(actual); * **比較物件是否一樣**:assertThat(actually).isEqualTo(csvData); * 執行遞歸比較對象的所有字段+ **忽略某些字段**: assertThat(dataset).usingRecursiveComparison() .**ignoringFields**("datasetId") .isEqualTo(flowerTestDuplicate); ## 3.2 Gson的LocalDateTime配置 ```java= class Test{ protected static final Gson GSON = new GsonBuilder() .registerTypeAdapter(LocalDate.class, (JsonSerializer<LocalDate>) (value, type, context) -> new JsonPrimitive(value.format(DateTimeFormatter.ISO_DATE_TIME))) .registerTypeAdapter(LocalDate.class, (JsonDeserializer<LocalDate>) (jsonElement, type, context) -> LocalDate.parse(jsonElement.getAsJsonPrimitive().getAsString())) .registerTypeAdapter(LocalDateTime.class, (JsonSerializer<LocalDateTime>) (value, type, context) -> new JsonPrimitive(value.format(DateTimeFormatter.ISO_DATE_TIME))) .registerTypeAdapter(LocalDateTime.class, (JsonDeserializer<LocalDateTime>) (jsonElement, type, context) -> LocalDateTime.parse(jsonElement.getAsJsonPrimitive().getAsString(), DateTimeFormatter.ISO_DATE_TIME)) .create(); } ``` ## 3.3 修改properties中的參數 * @TempDir:產生臨時的暫存檔案\目錄的路徑 * java.nio.file.Path * java.io.File * 範例:修改 model.inventory 的值 ```java= public abstract class AbstractIntegrationTest { @TempDir protected static Path tempDir; @DynamicPropertySource static void configureProperties(DynamicPropertyRegistry registry) { registry.add("model.inventory", () -> tempDir + "/model-file/"); } } ``` ## 3.4 非spring-web下 * **測試包下建置啟動類** ```java= package com.eunodata.source; import org.springframework.boot.autoconfigure.SpringBootApplication; @SpringBootApplication public abstract class SourceApplicationTest { } ``` * **測試**(`指定啟動類`) ```java= import com.eunodata.source.entity.domain.Source; import com.eunodata.source.service.repository.SourceRepository; import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.beans.factory.annotation.Qualifier; import org.springframework.boot.test.context.SpringBootTest; import java.util.List; @SpringBootTest(classes = SourceApplicationTest.class) public class SourceTest { @Autowired @Qualifier("sourceRepositoryImpl") SourceRepository sourceRepository; @Test public void checkSource() { List<Source> allSource = sourceRepository.findAllSource(); allSource.forEach(System.out::println); } } ``` ## 3.6 springboot 整合測試 ### 3.6.1 併發UnitTest * 測試類添加`@Execution` => **@Execution(CONCURRENT)** ```java= import org.junit.jupiter.api.parallel.Execution; import static org.junit.jupiter.api.parallel.ExecutionMode.CONCURRENT; @Execution(CONCURRENT) class AccountServiceTest { @Test void findAll() { } } ``` * 測試包下的resource中添加 `junit-platform.properties` ```propweties= junit.jupiter.execution.parallel.enabled = true ``` ### 3.6.2 整合測試 * 參考:https://hackmd.io/p1jWnH-zRvyhwtsBFndrrQ ```java= import com.eunodata.eunoai.core.application.handler.ResponseBodyBean; import com.eunodata.source.adapter.context.SourceAdapter; import com.eunodata.utils.uid.UIDUtil; import com.google.gson.*; import com.google.gson.reflect.TypeToken; import org.junit.jupiter.api.extension.ExtendWith; import org.junit.jupiter.api.io.TempDir; import org.junit.jupiter.api.parallel.Execution; import org.junit.jupiter.api.parallel.ExecutionMode; import org.mockito.junit.jupiter.MockitoExtension; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; import org.springframework.boot.test.context.SpringBootTest; import org.springframework.boot.test.mock.mockito.SpyBean; import org.springframework.test.context.ActiveProfiles; import org.springframework.test.context.DynamicPropertyRegistry; import org.springframework.test.context.DynamicPropertySource; import org.springframework.test.web.servlet.MockMvc; import org.testcontainers.containers.PostgreSQLContainer; import org.testcontainers.containers.RabbitMQContainer; import java.lang.reflect.Type; import java.nio.file.Path; import java.time.LocalDate; import java.time.LocalDateTime; import java.time.format.DateTimeFormatter; @SpringBootTest // 自帶 @ExtendWith(SpringExtension.class) @ActiveProfiles({"test", "log", "source"}) @AutoConfigureMockMvc @Execution(ExecutionMode.SAME_THREAD)//單線程執行 public abstract class AbstractIntegrationTest { protected static final Gson GSON = new GsonBuilder() .registerTypeAdapter(LocalDate.class, (JsonSerializer<LocalDate>) (value, type, context) -> new JsonPrimitive(value.format(DateTimeFormatter.ISO_DATE_TIME))) .registerTypeAdapter(LocalDate.class, (JsonDeserializer<LocalDate>) (jsonElement, type, context) -> LocalDate.parse(jsonElement.getAsJsonPrimitive().getAsString())) .registerTypeAdapter(LocalDateTime.class, (JsonSerializer<LocalDateTime>) (value, type, context) -> new JsonPrimitive(value.format(DateTimeFormatter.ISO_DATE_TIME))) .registerTypeAdapter(LocalDateTime.class, (JsonDeserializer<LocalDateTime>) (jsonElement, type, context) -> LocalDateTime.parse(jsonElement.getAsJsonPrimitive().getAsString(), DateTimeFormatter.ISO_DATE_TIME)) .create(); protected static final Type ERROR_TYPE = new TypeToken<ResponseBodyBean>() { }.getType(); protected static PostgreSQLContainer<?> postgres = new PostgreSQLContainer<>( "postgres:14-alpine" ); protected static PostgreSQLContainer<?> postgresAggregator = new PostgreSQLContainer<>( "postgres:14-alpine" ); protected static RabbitMQContainer messageQueue = new RabbitMQContainer( "rabbitmq:3.7.25-management-alpine" ); @TempDir protected static Path tempDirDy; protected static Path tempDir = null; static { postgres.withDatabaseName("eunoai"); postgresAggregator.withDatabaseName("eunoai_aggregator"); postgres.withInitScript("data/schema.sql");//不需要添加 classpath:,底層使用 URL的getResource()方法 postgresAggregator.withInitScript("data/schema_aggregator.sql"); postgresAggregator.start(); postgres.start(); messageQueue.start(); } @Autowired protected MockMvc mockMvc; @SpyBean protected SourceAdapter sourceAdapter; @DynamicPropertySource static void configureProperties(DynamicPropertyRegistry registry) { registry.add("spring.eunodata.eunoai.datasource.aggregator.jdbc-url", postgresAggregator::getJdbcUrl); registry.add("spring.eunodata.eunoai.datasource.aggregator.username", postgresAggregator::getUsername); registry.add("spring.eunodata.eunoai.datasource.aggregator.password", postgresAggregator::getPassword); registry.add("spring.eunodata.eunoai.datasource.jdbc-url", postgres::getJdbcUrl); registry.add("spring.eunodata.eunoai.datasource.username", postgres::getUsername); registry.add("spring.eunodata.eunoai.datasource.password", postgres::getPassword); registry.add("spring.eunodata.eunoai.datasource.log.jdbc-url", postgres::getJdbcUrl); registry.add("spring.eunodata.eunoai.datasource.log.username", postgres::getUsername); registry.add("spring.eunodata.eunoai.datasource.log.password", postgres::getPassword); registry.add("spring.eunodata.datasource.source.jdbc-url", postgres::getJdbcUrl); registry.add("spring.eunodata.datasource.source.username", postgres::getUsername); registry.add("spring.eunodata.datasource.source.password", postgres::getPassword); registry.add("eunoai.report-message-queue.USERNAME", messageQueue::getAdminUsername); registry.add("eunoai.report-message-queue.PASSWORD", messageQueue::getAdminPassword); registry.add("eunoai.report-message-queue.PORT", messageQueue::getAmqpPort); registry.add("eunoai.report-message-queue.HOST", messageQueue::getHost); // 避免多次創建類導致異常 if (tempDir == null) { tempDir = tempDirDy; } registry.add("model.inventory", () -> tempDir + "/model-file/"); registry.add("task.inventory", () -> tempDir + "/task-log/"); registry.add("dataset-tmp.inventory", () -> tempDir + "/dataset-tmp/"); registry.add("archived.ods", () -> tempDir + "/archived-ods/"); registry.add("config-path", () -> tempDir + "config/config.yaml"); registry.add("exploration.inventory", () -> tempDir + "/exploration-tmp/"); } protected String buildRandomString() { return "random" + UIDUtil.randomUID(); } } ``` * 使用:繼承AbstractIntegrationTest ```java= import org.junit.jupiter.api.Test; import org.springframework.http.MediaType; import org.springframework.test.context.jdbc.Sql; import org.springframework.test.context.jdbc.SqlConfig; import static org.assertj.core.api.Assertions.assertThat; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; @Sql(scripts = "classpath:data/applicationidentity/init.sql", config = @SqlConfig(dataSource = "CoreDatasource", transactionManager = "CoreTransactionManager", transactionMode = SqlConfig.TransactionMode.ISOLATED), executionPhase = Sql.ExecutionPhase.BEFORE_TEST_METHOD) class ActivateControllerTest extends AbstractIntegrationTest { @Test void isServerActivate_inactive() throws Exception { //1. 前設定 //2. 資料準備 String returnString = mockMvc.perform(get("/eunoapis/activate/") .contentType(MediaType.APPLICATION_JSON_VALUE)) .andExpect(status().isLocked()) .andReturn().getResponse().getContentAsString(StandardCharsets.UTF_8); Map<String, Object> msg = GSON.fromJson(returnString, new TypeToken<Map<String, Object>>() { }.getType()); //4.驗證 assertThat(msg.get("message")).hasToString(MessageUtils.getMessage("ENTER_PRODUCT_CODE")); } } ``` # 4. properties的使用 * properties中的變數`可以組合使用` * 使用 **${變數名}** * 設定變數的默認值 **${變數名:默認值}** ```properties= setting_inventory_dir=${setting_inventory} DATABASE_URL=jdbc:h2:file:${setting_inventory_dir};DB_CLOSE_DELAY=-1;DB_CLOSE_ON_EXIT=FALSE spring.eunodata.dataloader.datasource.jdbc-url=${DATABASE_URL} 設定變數的默認值 swagger.enable=${ENABLE_SWAGGER:false} fatch.data.time=${FATCH_DATA_TIME:5} ``` ## 4.1 jar 啟動時 設定properties中的變數 * 目標:設定properties中 **setting_inventory** 這個變數(`環境變數`) * 指令:java `-Dsetting_inventory=/setting_inventory/h2` -jar app.jar ```properties= setting_inventory_dir=${setting_inventory} ``` # 5. @Transactional * **@Transactional**是`調用AOP的代理對象` * **事務的運行步驟**: 1. **調用代理對象**(AOP) 2. **開啟事務**切面 3. 執行**前置的AOP**增強操做 * @Before * @Around 5. 調用目標對象的目標方法 6. 目標方法調用返回 7. 執行**後置的AOP**增強操做 * @Around * @After * @AfterThrowing * @AfterReturning 8. **提交**或**回滾**事務 9. **返回調用處** ## 5.1 @Transactional try-catch * 目標:當TestA類的execute()方法出錯時,除了rollback需要做額外的SQL處理 ### 5.1.1 目前可用作法 * 說明: * 建立3個類 * **Test類**:使用try-catch的位置 * TestA所觸發的異常,`在Test類處理` * **TestA類**:編寫要執行的方法,`不做異常處理` * **TestAExceptionHandler類**:當TestA類的execute()方法出錯時,要做的`額外處理的方法` * TestA類 ```java= public class TestA { @Transactional public void execute(){ //商業邏輯 } } ``` * TestAExceptionHandler類 ```java= public class TestAExceptionHandler { @Transactional public void execute() { //TestA的錯誤後,要執行的邏輯 } } ``` * Test類 ```java= public class Test { private final TestA testA; private final TestAExceptionHandler testAExceptionHandler; public Test(TestA testA, TestAExceptionHandler testAExceptionHandler) { this.testA = testA; this.testAExceptionHandler = testAExceptionHandler; } public void execute() { try { testA.execute(); } catch (Exception e) { testAExceptionHandler.execute(); } } } ``` ### 5.1.2 嘗試-1 AOP * **作法**:當TestA類的execute()拋出例外時,執行對應的操作 * **TestA類**: ```java= public class TestA { @Transactional public void execute(){ //商業邏輯 } } ``` * **TestAAspect類**: ```java= import org.aspectj.lang.JoinPoint; import org.aspectj.lang.annotation.AfterThrowing; import org.aspectj.lang.annotation.Aspect; import org.springframework.stereotype.Component; @Aspect @Component public class TestAAspect { @AfterThrowing(value = "xxx.TestA.execute()", throwing = "exception") public void logAfterThrowing(JoinPoint joinPoint, Throwable exception) { } } ``` * **問題發生**:一個@Transactional的流程,是包括AOP的,所以 **TestA.execute()** 觸發**ERROR**,`執行AOP中的SQL`的時候,**也會一同被RollBack** ### 5.1.3 嘗試-2 TestA類中try-catch * **作法**:在TestA類中**將execute()中的操作用try-catch包裹**,在catch部分**開啟一個新的Transactional**(`Propagation.REQUIRES_NEW`)來執行SQL * **注意:避免TestA類內部調用,導致Propagation.REQUIRES_NEW失效** * 新建一個**TestAExceptionHandler類**來做`錯誤的處理` * **TestAExceptionHandler類** ```java= public class TestAExceptionHandler { @Transactional(propagation = Propagation.REQUIRES_NEW) public void execute() { //TestA的錯誤後,要執行的邏輯 } } ``` * **TestA類** ```java= public class TestA { private final TestAExceptionHandler testAExceptionHandler; public TestA(TestAExceptionHandler testAExceptionHandler) { this.testAExceptionHandler = testAExceptionHandler; } @Transactional public void execute() { try { do_(); } catch (Exception e) { testAExceptionHandler.execute(); throw e; } } private void do_() { } } ``` * **運作步驟**: 1. 外部調用 execute() 執行 **事務開啟** 2. do_() 報ERROR 3. testAExceptionHandler.execute() 執行,**開啟新的事物** 4. testAExceptionHandler.execute() **提交\RollBack** 5. testAExceptionHandler.execute() **關閉新事物** 6. execute() **提交\RollBack** 7. execute() **關閉事務** * **問題發生**: >當**testAExceptionHandler.execute()** 中的操做(`detele某一條數據`)有影響到 **do_()** 中`所操作的相關物件`時,由於 **【事務提交的時間點不一樣】**,導致 **execute()** 在 `提交`\\`RollBack` 時會**觸發SQL的異常** * **例**:**execute()** 需**update**一個**T物件**,當中有一個**M物件**,但在**testAExceptionHandler.execute()** 就被該 **M物件** 從DB **刪除** 了,所以當**execute()** 在提交,就會 **失敗**(`找不到M物件`) ## 5.2 Transaction silently rolled back because it has been marked as rollback-only * 範例: ```java= class TestA { @Transactional public voild doA(){ //do try{ new TestB().doB() }catch(Exception e){ } //do } } class TestB { @Transactional public voild doB(){ //do } } ``` * 問題:當TestB的doB方法,發生異常時,被標記rollback,但調用方法(`doA()`)使用try-catch包括,使doA()正常執行,導致Transaction silently rolled back because it has been marked as rollback-only * 原因:doB()已被標記為rollback,但doA()是正常運行,導致事務異常 * doB()認為**要被**rollback * doA()**沒有要被**rollback * **捕獲併吞掉異常**:**在交易內捕獲了異常,但沒有將其重新拋出,從而讓Spring無法感知異常並正確處理事務狀態** * **解決**: * 不應該在事務中try-catch事務 * 將Exception拋出 # 6. Lazy Get File ## 6.1 一般\壓縮 ```java= @RestController public class LogController { @Value("${logs.directory}") private String logPath; @GetMapping("/logs") public ResponseEntity<String> getLogs(@Valid @RequestBody LogRepDTO repDTO) { return ResponseEntity.ok(readFile(repDTO.getPage(), repDTO.getSize(), logPath, repDTO.getFileName())); } @GetMapping("/gz-logs") public ResponseEntity<String> getGzLogs(@Valid @RequestBody GzLogRepDTO repDTO) { return ResponseEntity.ok(readGzFile(repDTO.getPage(), repDTO.getSize(), logPath, repDTO.getDir(), repDTO.getFileName())); } public String readFile(int page, int size, String logPath, String fileName) { int skipLines = page * size; StringBuilder logContent = new StringBuilder(); try (BufferedReader bufferedReader = new BufferedReader(new FileReader(logPath + File.separator + fileName))) { skipLines(bufferedReader,skipLines); readLines(bufferedReader,size,logContent); } catch (IOException e) { throw new RuntimeException(e); } return logContent.toString(); } public String readGzFile(int page, int size, String logPath, String dir, String fileName) { int skipLines = page * size; StringBuilder logContent = new StringBuilder(); try (BufferedReader bufferedReader = new BufferedReader(new InputStreamReader(new GZIPInputStream(new FileInputStream(logPath + File.separator + dir + File.separator + fileName))))) { // 跳過指定行 skipLines(bufferedReader,skipLines); readLines(bufferedReader,size,logContent); } catch (IOException e) { throw new RuntimeException(e); } return logContent.toString(); } private void skipLines(BufferedReader reader, int skipLines) throws IOException { // 跳过指定行 for (int i = 0; i < skipLines; i++) { if (reader.readLine() == null) { return; // 到达文件末尾 } } } private void readLines(BufferedReader reader, int size, StringBuilder logContent) throws IOException { // 读取指定行数 String line; for (int i = 0; i < size; i++) { line = reader.readLine(); if (line == null) { break; // 到达文件末尾 } logContent.append(line).append("\n"); } } } ``` # 7. Thymeleaf * pom.xml ```xml <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-thymeleaf</artifactId> </dependency> ``` ## 7.1 相關配置 ```properties= spring.thymeleaf.cache=false # html檔案放置的目錄 spring.thymeleaf.prefix=file:./static/templates/ # 後綴 spring.thymeleaf.suffix=.html spring.thymeleaf.encoding=UTF-8 spring.thymeleaf.mode=HTML # 只有靜態資源的存取路徑為/static/**時,才會處理請求 spring.mvc.static-path-pattern=/static/** ``` ### 7.1.1 css/js配置 * addResourceLocations:配置實際存放的位置 ```java= import org.springframework.web.servlet.config.annotation.ResourceHandlerRegistry; import org.springframework.web.servlet.config.annotation.WebMvcConfigurationSupport; public class WebConfig extends WebMvcConfigurationSupport { @Override protected void addResourceHandlers(ResourceHandlerRegistry registry) { registry.addResourceHandler("/css/**") .addResourceLocations("./static/css/"); //static目錄在當前專案下的 registry.addResourceHandler("/js/**") .addResourceLocations("./static/js/"); super.addResourceHandlers(registry); } } ``` ## 7.2 回傳html * 改用 @Controller ```java import org.springframework.beans.factory.annotation.Value; import org.springframework.stereotype.Controller; import org.springframework.ui.Model; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.PathVariable; import org.springframework.web.bind.annotation.RequestMapping; import java.io.File; import java.nio.file.Path; import java.util.Arrays; import java.util.stream.Collectors; @Controller @RequestMapping("/") public class IndexController { @Value("${logs.directory}") private String logPath; @GetMapping public String getDirList(Model model) { model.addAttribute("files", Arrays.stream(Path.of(logPath).toFile().listFiles()) .map(File::getName) .collect(Collectors.toList())); return "index"; //index為該html的檔名,儲放在spring.thymeleaf.prefix 配置的目錄之下 } } ``` * **model**.**addAttribute**(String attributeName, Object attributeValue) 是 Spring MVC 中用來**傳遞資料到視圖的方法之一**。 它的作用是將一個屬性新增到 Model 中,以便在渲染視圖時可以**存取到這個屬性** ### 7.2.1 渲染視圖時,使用屬性 * 兩個屬性:使用 **${?}** 包裹屬性 * **head** * **files** ```html= <!DOCTYPE html> <html lang="en" xmlns:th="http://www.thymeleaf.org"> <head> <meta charset="UTF-8"> </head> <body> <div th:if="${head != null}"> <p class="fs-2"> <span id="head" style="cursor: pointer;">Logs</span> <span> >> </span> <span id="dir" th:text=${head}></span> </p> </div> <div th:if="${head == null}"> <p class="fs-2"> <span id="head" style="cursor: pointer;">Logs</span> </p> </div> <div class="row"> <div class="col-12 col-lg-6"> <div class="card overflow-auto shadow" style="max-height: 50rem;"> <div class="card-body" id="list"> <div th:each="file : ${files}"> <span style="cursor: pointer;"> <p class="fs-4" th:text="${file}"></p> </span> </div> </div> </div> </div> </div> </div> </body> </html> ``` ## 7.3 html 連接 css/js ```html= <head> <meta charset="UTF-8"> <title>Log</title> <link rel="stylesheet" type="text/css" th:href="@{static/css/bootstrap.min.css}"> <link rel="stylesheet" type="text/css" th:href="@{static/css/jquery-ui.css}"> <script th:src="@{static/js/bootstrap.bundle.min.js}"></script> <script th:src="@{static/js/jquery-3.6.0.min.js}"></script> <script th:src="@{static/js/jquery-ui.min.js}"></script> <script th:src="@{static/js/main.js}"></script> </head> ``` # 8. log ## 8.1 log4j2 ```xm= <?xml version="1.0" encoding="UTF-8"?> <Configuration> <!-- <Configuration status="WARN" monitorInterval="30"> --> <Properties> <property name="LOG_HOME">./Logs</property> </Properties> <CustomLevels> <!-- <CustomLevel name="EUNO" intLevel="350" />--> <CustomLevel name="TRANS" intLevel="360" /> </CustomLevels> <Appenders> <Console name="consoleAppender" target="SYSTEM_OUT"> <!--設定日誌格式及顏色--> <PatternLayout pattern="%style{%d{ISO8601}}{bright,green} %highlight{%-5level} [%style{%t}{bright,blue}] %style{%C{}}{bright,yellow}: %msg%n%style{%throwable}{red}" disableAnsi="false" noConsoleNoAnsi="false"/> </Console> <!--*********************檔案日誌***********************--> <RollingFile name="dataLoaderInfoFileAppender" fileName="${LOG_HOME}/dataLoader.log" filePattern="${LOG_HOME}/$${date:yyyy-MM}/info-%d{yyyy-MM-dd HH}-%i.log.gz"> <!--設定日誌格式--> <PatternLayout> <pattern>%d %p %C{} [%t] %m%n</pattern> </PatternLayout> <Policies> <!-- 設定日誌檔案切分引數 --> <!--<OnStartupTriggeringPolicy/>--> <!--設定日誌基礎檔案大小,超過該大小就觸發日誌檔案滾動更新--> <SizeBasedTriggeringPolicy size="10 MB"/> <!--設定日誌檔案滾動更新的時間,依賴於檔案命名filePattern的設定--> <TimeBasedTriggeringPolicy interval="1" modulate="true"/> </Policies> <!--設定日誌的檔案個數上限,不設定預設為7個,超過大小後會被覆蓋;依賴於filePattern中的%i--> <!--<DefaultRolloverStrategy max="100"/>--> </RollingFile> <JDBC name="dbLogAppender" tableName="app_log"> <ConnectionFactory class="com.eunodata.eunoai.core.application.config.jdbcappender.LogConnectionFactory" method="getConnection"/> <Column name="log_time" isEventTimestamp="true"/> <Column name="backend_name" literal="'API-SERVER'"/> <ColumnMapping name="class" pattern="%logger"/> <ColumnMapping name="level" pattern="%level"/> <ColumnMapping name="thread" pattern="%t"/> <ColumnMapping name="message" pattern="%message"/> <ColumnMapping name="exception" pattern="%ex{full}"/> </JDBC> </Appenders> <Loggers> <!-- 根日誌設定 --> <Root level="info"> <AppenderRef ref="consoleAppender"/> <AppenderRef ref="dataLoaderInfoFileAppender"/> <AppenderRef ref="dbLogAppender"/> </Root> <!--spring日誌--> <Logger name="org.springframework" level="info"/> <!--druid資料來源日誌--> <!-- <Logger name="druid.sql.Statement" level="warn"/> --> <!-- mybatis日誌 --> <!-- <Logger name="com.mybatis" level="warn"/> --> <!-- <Logger name="org.hibernate" level="warn"/> --> <!-- <Logger name="com.zaxxer.hikari" level="info"/> --> <!-- <Logger name="org.quartz" level="info"/> --> <!-- <Logger name="com.andya.demo" level="debug"/> --> </Loggers> </Configuration> ``` * 參考: * https://www.cnblogs.com/antLaddie/p/15904895.html * https://www.cnblogs.com/hafiz/p/6170702.html ### 8.1.1 Console log ```xml= <Console name="consoleAppender" target="SYSTEM_OUT"> <!--設定日誌格式及顏色--> <PatternLayout pattern="%style{%d{ISO8601}}{bright,green} %highlight{%-5level} [%style{%t}{bright,blue}] %style{%C{}}{bright,yellow}: %msg%n%style{%throwable}{red}" disableAnsi="false" noConsoleNoAnsi="false"/> </Console> ``` ### 8.1.2 Log To File ```xml= <RollingFile name="dataLoaderInfoFileAppender" fileName="${LOG_HOME}/dataLoader.log" filePattern="${LOG_HOME}/$${date:yyyy-MM}/info-%d{yyyy-MM-dd HH}-%i.log.gz"> <!--設定日誌格式--> <PatternLayout> <pattern>%d %p %C{} [%t] %m%n</pattern> </PatternLayout> <Policies> <!-- 設定日誌檔案切分引數 --> <!--<OnStartupTriggeringPolicy/>--> <!--設定日誌基礎檔案大小,超過該大小就觸發日誌檔案滾動更新--> <SizeBasedTriggeringPolicy size="10 MB"/> <!--設定日誌檔案滾動更新的時間,依賴於檔案命名filePattern的設定--> <TimeBasedTriggeringPolicy interval="1" modulate="true"/> </Policies> <!--設定日誌的檔案個數上限,不設定預設為7個,超過大小後會被覆蓋;依賴於filePattern中的%i--> <!--<DefaultRolloverStrategy max="100"/>--> </RollingFile> ``` ### 8.1.3 Log To DB * 設定 ```xml= <JDBC name="dbLogAppender" tableName="app_log"> <ConnectionFactory class="com.eunodata.eunoai.core.application.config.jdbcappender.LogConnectionFactory" method="getConnection"/> <Column name="log_time" isEventTimestamp="true"/> <Column name="backend_name" literal="'API-SERVER'"/> <ColumnMapping name="class" pattern="%logger"/> <ColumnMapping name="level" pattern="%level"/> <ColumnMapping name="thread" pattern="%t"/> <ColumnMapping name="message" pattern="%message"/> <ColumnMapping name="exception" pattern="%ex{full}"/> </JDBC> ``` * **LogConnectionFactory類** * method屬性:須為靜態方法名 ```java= import com.zaxxer.hikari.HikariDataSource; import javax.sql.DataSource; import java.io.FileInputStream; import java.io.IOException; import java.nio.file.Path; import java.sql.Connection; import java.sql.SQLException; import java.util.Properties; public class LogConnectionFactory { private static DataSource dataSource; private LogConnectionFactory() { } public static Connection getConnection() throws SQLException, IOException { if (dataSource == null) { Properties properties = new Properties(); properties.load(new FileInputStream(Path.of("src/main/resources/application.properties").toAbsolutePath().toFile())); String value = (String) properties.get("spring.profiles.active"); if (value.startsWith("dev")){ properties.load(new FileInputStream(Path.of("src/main/resources/application-dev.properties").toAbsolutePath().toFile())); }else { properties.load(new FileInputStream(Path.of("src/main/resources/application-prod.properties").toAbsolutePath().toFile())); } HikariDataSource hikariDataSource = new HikariDataSource(); hikariDataSource.setJdbcUrl(properties.getProperty("DATABASE_URL")); hikariDataSource.setUsername(properties.getProperty("DATABASE_USERNAME")); hikariDataSource.setPassword(properties.getProperty("DATABASE_PASSWORD")); hikariDataSource.setDriverClassName(properties.getProperty("DATABASE_DRIVER")); hikariDataSource.setMaximumPoolSize(2); hikariDataSource.setPoolName("LogPool"); dataSource = hikariDataSource; } return dataSource.getConnection(); } } ``` # 9. Springboot 參數驗證 * 類加 **@Validated**(`org.springframework.validation.annotation.Validated`) * @RequestParam * @PathVariable * @Valid @RequestBody List<@Vaild 類> * 參數加 **@Valid**(`javax.validation.Valid;`) * @RequestBody ```java= @RestController @RequestMapping("/xxx") @Validated public class TestController { @PostMapping(value = "/", produces = MediaType.APPLICATION_JSON_VALUE, consumes = MediaType.APPLICATION_JSON_VALUE) public ResponseEntity<String> createAccount(@Valid @RequestBody XxxDTO xxxDTO) { ... return ResponseEntity.ok("{\"message\": \"Success\", \"statusCode\": 200}"); } @GetMapping("/app") public ResponseEntity<String> findAll(@RequestParam(value = "page") Integer page, @RequestParam(value = "size") Integer size, @RequestParam(value = "backendName") String backendName, @RequestParam(value = "msg", required = false) String msg, @Pattern(regexp = "INFO|DEBUG|WARN|ERROR") @RequestParam(value = "level", required = false) String level, @DateTimeFormat(pattern = "yyyy-MM-dd HH:mm:ss.SSS") @RequestParam(value = "start") LocalDateTime start, @DateTimeFormat(pattern = "yyyy-MM-dd HH:mm:ss.SSS") @RequestParam(value = "end") LocalDateTime end) { ... } } ``` # 10. ObjectMapper序列化/反序列化-父子類 * 使用存在於父類中的屬性進行判斷 * 父類 ```java= @JsonTypeInfo(use = JsonTypeInfo.Id.NAME, include = JsonTypeInfo.As.EXISTING_PROPERTY, property = "paymentProvider", visible = true) @JsonSubTypes({@JsonSubTypes.Type(value = GmoMerchantPaymentProfile.class, name = "GMO")}) @Data @NoArgsConstructor public abstract class BaseMerchantPaymentProfile { private String id; private String name; private PaymentProvider paymentProvider; @JsonFormat(shape = JsonFormat.Shape.STRING, pattern = "yyyy-MM-dd HH:mm:ss") private LocalDateTime createTime; @JsonFormat(shape = JsonFormat.Shape.STRING, pattern = "yyyy-MM-dd HH:mm:ss") private LocalDateTime updateTime; } ``` * `@JsonTypeInfo`:**判斷如何轉換**的設定 * `use = JsonTypeInfo.Id.NAME` * 透過**屬性值** (property value) + `@JsonSubTypes` 決定子類型的 * 通常會搭配 `@JsonSubTypes`一起使用,定義 **"paymentProvider"** 的值對**應到哪個類別** * `include = JsonTypeInfo.As.EXISTING_PROPERTY` * 不會額外插入 @type 或 _class 欄位,而是**利用已經存在的屬性** * `property = "paymentProvider"` * 指定用**哪個欄位**來決定子類型 => 這邊使用`paymentProvider` 屬性 * `visible = true` * 讓**這個屬性**(`paymentProvider`)在**反序列化**之後仍然**保留在物件裡** * 選擇EXISTING_PROPERTY時,要配置,不然該**屬性**(`paymentProvider`)為**Null** * `@JsonSubTypes`:設定子類(`多個`) * `@JsonSubTypes.Type`:設定一個子類 * `value`:子類 * `name`:當`paymentProvider`是?轉換成這個子類 * 當`paymentProvider`是 **"GMO"** 轉換成**GmoMerchantPaymentProfile** * **子類** ```java= @Data @EqualsAndHashCode(callSuper = true) @ToString(callSuper = true) @NoArgsConstructor public class GmoMerchantPaymentProfile extends BaseMerchantPaymentProfile { private String kanaName; private String englishName; private String shortName; private String contactName; private String contactPhone; private String contactOpeningHours; } ``` ###### tags: `Spring` `SpringBoot`