---
# System prepended metadata

title: SpringBoot 開發紀錄
tags: [SpringBoot]

---

---
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`
