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