2025/08/04

Spring Boot 3 打包

可用 jar, war, docker, GraalVM image 方式打包

jar

用 jar application 方式打包

<packaging>jar</packaging>

同時要加上

    <build>
        <plugins>
            <plugin>
                <groupId>org.springframework.boot</groupId>
                <artifactId>spring-boot-maven-plugin</artifactId>
            </plugin>
        </plugins>
    </build>

使用兩種指令打包,二選一

  • mvn package

    打包到 target 目錄

  • mvn install

    打包後,安裝到本地的 MAVEN repository


打包後,會產生兩個

  • test-0.0.1-SNAPSHOT.jar

    這個可透過 java -jar test-0.0.1-SNAPSHOT.jar 啟動

  • test-0.0.1-SNAPSHOT.jar.original


META-INF/MANIFEST.MF 內容為

Manifest-Version: 1.0
Created-By: Maven JAR Plugin 3.4.2
Build-Jdk-Spec: 17
Implementation-Title: test
Implementation-Version: 0.0.1-SNAPSHOT
Main-Class: org.springframework.boot.loader.launch.JarLauncher
Start-Class: com.test.test.TestApplication
Spring-Boot-Version: 3.4.0
Spring-Boot-Classes: BOOT-INF/classes/
Spring-Boot-Lib: BOOT-INF/lib/
Spring-Boot-Classpath-Index: BOOT-INF/classpath.idx
Spring-Boot-Layers-Index: BOOT-INF/layers.idx

另一個 test-0.0.1-SNAPSHOT.jar.original,無法直接啟動,只是一個 jar


war

要先產生一個繼承 SpringBootServletInitializer 抽象類別

package com.test.test;

import org.springframework.boot.builder.SpringApplicationBuilder;
import org.springframework.boot.web.servlet.support.SpringBootServletInitializer;

public class ServletInitializer extends SpringBootServletInitializer {

    @Override
    protected SpringApplicationBuilder configure(SpringApplicationBuilder application) {
        return application.sources(TestApplication.class);
    }

}

修改打包方式

<packaging>war</packaging>

排除 embedded tomcat

        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-tomcat</artifactId>
            <scope>provided</scope>
        </dependency>

可同時支援 jar 跟 war

package com.test.test;

import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.boot.builder.SpringApplicationBuilder;
import org.springframework.boot.web.servlet.support.SpringBootServletInitializer;

@SpringBootApplication
public class TestApplication extends SpringBootServletInitializer {

    @Override
    protected SpringApplicationBuilder configure(SpringApplicationBuilder builder) {
        return startBuilder(builder);
    }

    public static void main(String[] args) {
//        SpringApplication.run(TestApplication.class, args);
        startBuilder(new SpringApplicationBuilder()).run(args);
    }

    private static SpringApplicationBuilder startBuilder(SpringApplicationBuilder builder) {
        return builder.sources(TestApplication.class);
    }

}

build 會產生 test-0.0.1-SNAPSHOT.war,可以放在 tomcat 或是用 java -jar 啟動


啟動 application

使用 java 指令

java -jar test-0.0.1-SNAPSHOT-exec.war

直接啟動

在 pom.xml 要修改設定

    <build>
        <plugins>
            <plugin>
                <groupId>org.springframework.boot</groupId>
                <artifactId>spring-boot-maven-plugin</artifactId>
                <configuration>
                    <executable>true</executable>
                </configuration>
            </plugin>
        </plugins>
    </build>

然侯就能直接運作

./test-0.0.1-SNAPSHOT-exec.war

note: 在不支援的 OS 要修改 pom.xml

    <build>
        <plugins>
            <plugin>
                <groupId>org.springframework.boot</groupId>
                <artifactId>spring-boot-maven-plugin</artifactId>
                <configuration>
                    <classifier>exec</classifier>
                    <executable>true</executable>
                    <embeddedLaunchScript>...</embeddedLaunchScript>
                </configuration>
            </plugin>
        </plugins>
    </build>

系統 service

可用 init.d 或 systemd service 啟動

init.d

在 /etc/init.d 建立一個 link

sudo ln -s test-0.0.1-SNAPSHOT.war /etc/init.d/test

然後就能用 service 啟動

sudo service test start

systemd

在 /etc/systemd/system 目錄中,建立檔案 test.service

[Unit]
Description-test
After=syslog.target

[Service]
User=test
ExecStart=/home/test/test-0.0.1-SNAPSHOT.war
# process receive SIGTERM signal will return 128+15=143 exit status code
SuccessExitStatus=143

[Install]
WantedBy=multi-user.target

然後

sudo systemctl start test
sudo systemctl enable test

Docker

docker -v
docker version

以 Dockerfile 產生 image

FROM eclipse-temurin:17-jre
ARG JAR_FILE=target/*.jar
COPY ${JAR_FILE} application.jar
ENTRYPOINT ["java", "-Djava.security.egd=file:/dev/./urandom","-jar","/application.jar"]
EXPOSE 8080

eclipse-temurin 是 Eclipse 提供的 open JDK image

ARG 定義參數

用指令產生 image

docker build -t test/docker-all:1.0 .

-t tags,image 的 name:tags

啟動

docker run -dp 8080:8080 test/docker-all:1.0 

分層 image

定義分層的是 laryers.idx 檔案,內容為

- "dependencies":
  - "BOOT-INF/lib/"
- "spring-boot-loader":
  - "org/"
- "snapshot-dependencies":
- "application":
  - "BOOT-INF/classes/"
  - "BOOT-INF/classpath.idx"
  - "BOOT-INF/layers.idx"
  - "META-INF/"

一般會異動的只有 classes

建立新的 Dockerfile

FROM eclipse-temurin:17-jre as builder
WORKDIR application
ARG JAR_FILE=target/*-exec.jar
COPY ${JAR_FILE} application.jar
RUN java -Djarmode=layertools -jar application.jar extract

FROM eclipse-temurin:17-jre as builder
WORKDIR application
COPY --from-builder application/dependencies/ ./
COPY --from-builder application/spring-boot-loader/ ./
COPY --from-builder application/snapshot-dependencies/ ./
COPY --from-builder application/application/ ./
ENTRYPOINT ["java", "org.springframework.boot.loader.JarLauncher"]
EXPOSE 8081

注意 -Djarmode=layertools

layertools list

> java -Djarmode=layertools -jar test-0.0.1-SNAPSHOT.war list
dependencies
spring-boot-loader
snapshot-dependencies
application

產生 image

docker build -t test/docker-layer:1.0 .

分層建構不支援 executable jar,要修改 executable 設定

    <build>
        <plugins>
            <plugin>
                <groupId>org.springframework.boot</groupId>
                <artifactId>spring-boot-maven-plugin</artifactId>
                <configuration>
                    <executable>false</executable>
                </configuration>
            </plugin>
        </plugins>
    </build>

Cloud Native Buildpacks

可透過 Maven/Gradle 產生 Cloud Native Buildpacks image

Cloud Native Buildpacks 是 2018 由 Pivotal, Heroku 發起,於10月加入Cloud Native沙盒,目標是要統一Buildpacks生態系。

修改 pom.xml

    <build>
        <plugins>
            <plugin>
                <groupId>org.springframework.boot</groupId>
                <artifactId>spring-boot-maven-plugin</artifactId>
                <executions>
                    <execution>
                        <goals>
                            <goal>build-image</goal>
                        </goals>
                    </execution>
                </executions>
                <configuration>
                    <executable>false</executable>
                    <image>
                        <name>test/docker-cnb:${project.version}</name>
                    </image>
                </configuration>
            </plugin>
        </plugins>
    </build>

以指令產生

mvn clean package

也可以用 build-image target

mvn spring-boot:build-image

        <configuration>
          <executable>false</executable>
          <image>
            <name>test/docker-cnb:${project.version}</name>
            <pullPolicy>IF_NOT_PRESENT</pullPolicy>
          </image>
        </configuration>

可加上 pullPolicy

  • ALWAYS

  • IF_NOT_PRESENT: 不存在基礎 image 時才拉取

  • NEVER


GraalVM image

Spring Boot 3.0 支援 GraalVM 22.3+, Native Build Tools Plugin 0.9.17+

GraalVM 是 2019 年 Oracle 發表的高性能,跨語言通用 VM,可提升 Java, Scala, Grooby, Kotlin 等基於 JVM 的 application。

GraalVM 有一個 Java 撰寫的高級 JIT compiler,借助 Truffle,可執行 Javascript, Ruby, Python

# Getting Started with Oracle GraalVM

產生 native-image

javac HelloWorld.java
native-image HelloWorld
./helloworld
Hello, World!

GraalVM applicaiton 跟傳統 application 的差異

對於 Java applicaiton,GraalVM 提供兩種運作方法

  • 在 HotSpot VM 上使用 Graal 的 JIT compiler,一邊編譯,一邊運作

  • 以 AOT 編譯的 GraalVM 原生 image 可執行檔案運作,是靜態預先編譯

GraalVM applcation 的差別

  • GraalVM image 會進行靜態分析

  • 產生 image 時,無法執行到的 code 會被刪除,不會成為可執行檔案的一部分

  • GraalVM 不能直接偵測 code 的動態元素,ex: reflection, resource, 序列化, 動態代理,必須提前宣告設定

  • 應用的 classpath 路徑是固定的,不能修改

  • 不支援 bean 的延遲載入

  • 某些地方不完全相容傳統的 java app


spring boot 產生 image 時

  • classpath 固定,無法變更

  • bean 不能被修改,不支援 @Profile@ConditionalOnProperty

Spring AOT 在產生 image 時,同時會產生

  • Java source code

  • byte code

  • GraalVM 相關 JSON 描述檔案,包含

    • resource-config.json Resource hints

    • reflection.config.json Reflection hints

    • serialization-config.json Serialization hints

    • proxy-config.json Java Proxy hints

    • jni-config.json JNI hints


https://start.spring.io/ 產生的 project,增加 Graal VM Nartive Support,maven pom.xml 裡面就包含了 native, native test 兩個 profiles

        <plugins>
            <plugin>
                <groupId>org.graalvm.buildtools</groupId>
                <artifactId>native-maven-plugin</artifactId>
            </plugin>
        </plugins>

使用 maven 產生 image

mvn -Pnative spring-boot:build-image

可用 docker 執行

docker run --rm -p 8080:8080 docker.io/library/spring-boot-graalvm:1.0

也可以安裝 GraalVM Native Build Tools

sdk install java 23.0.1-graal
sdk use java 23.0.1-graal

用 maven

mvn -Pnative native:compile

會產生

  • spring-boot-graalvm: app 可執行檔案

  • spring-boot-graalvm.build_artifacts.txt: 說明 txt

2025/07/28

Spring Boot 3 unit test

libary 有兩個

  • spring-boot-test: 測試核心功能

  • spring-boot-test-autoconfigure: 測試的自動設定

spring-boot-starter-test 是集合

        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-test</artifactId>
            <scope>test</scope>
        </dependency>

測試還包含其他 library

  • JUnit 5

  • AssertJ

  • Hamcrest

  • Mockito

  • JSONassert

  • JsonPath


spring 提供一個 @SpringBootTest annotation,裡面有一個 webEnvironment 環境變數,支援以下設定

  • MOCK (default)

    載入一個 WebApplicationContext 並提供 Mock Web Environment。不會啟動內嵌的 web server,並可以結合 @AutoConfigureMockMvcor @AutoConfigureWebTestClient 進行 MOCK 測試

  • RANDOM_PORT

    載入一個 WebApplicationContext,提供一個真實的 web environment,隨機 port

  • DEFINED_PORT

    載入一個 WebApplicationContext,提供一個真實的 web environment,指定 port,預設 8080

  • NONE

    載入一個 WebApplicationContext,不提供 web environment


Spring MVC 測試

MvcTests.java

package com.test.test;

import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.boot.test.web.client.TestRestTemplate;

import java.util.HashMap;
import java.util.Map;

import static org.assertj.core.api.Assertions.assertThat;

@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
public class MvcTests {

    @Test
    public void getUserTest(@Autowired TestRestTemplate testRestTemplate) {
        Map<String, String> multiValueMap = new HashMap<>();
        multiValueMap.put("username", "test1");
        Result result = testRestTemplate.getForObject("/user/get?username={username}",
                Result.class, multiValueMap);
        assertThat(result.getCode()).isEqualTo(0);
        assertThat(result.getMsg()).isEqualTo("ok");
    }

}

呼叫 /user/test,檢查回傳的資料是否正確

從 log 可發現 tomcat 運作在另一個 port

Tomcat started on port 53594 (http) with context path '/'

MOCK 測試

package com.test.test;

import org.junit.jupiter.api.Test;
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.test.web.servlet.MockMvc;
import org.springframework.test.web.servlet.request.MockMvcRequestBuilders;

import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.content;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;

@SpringBootTest
@AutoConfigureMockMvc
class MockMvcTests {

    @Test
    public void getUserTest(@Autowired MockMvc mvc) throws Exception {
        mvc.perform(MockMvcRequestBuilders.get("/user/get?username={username}", "test"))
                .andExpect(status().isOk())
                .andExpect(content().string("{\"code\":0,\"msg\":\"ok\",\"data\":\"test\"}"));
    }

}

在class 上面增加 @AutoConfigureMockMvc ,然後在 method 上加上 @Autowired MockMvc mvc 就可進行 MOCK 測試。

從 log 發現並沒有真的啟動一個 web server


MOCK 元件測試

package com.test.test;

import com.test.test.service.UserService;
import org.junit.jupiter.api.Test;
import org.mockito.BDDMockito;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.test.context.bean.override.mockito.MockitoBean;

import static org.assertj.core.api.Assertions.assertThat;

@SpringBootTest
class MockBeanTests {

//    @Autowired
//    private UserService userService;

    @MockitoBean
    private UserService userService;

    @Test
    public void countAllUsers() {
        BDDMockito.given(this.userService.countAllUsers()).willReturn(88);
        assertThat(this.userService.countAllUsers()).isEqualTo(88);
    }

}

@MockitoBean 代表該變數是被 Mock 覆蓋

這邊透過 BDDMockito 模擬 countAllUsers 回傳結果


JSON 測試

spring 提供各種技術元件的單元測試

想要測試 JSON,可使用 JsonTestersAutoConfiguration,只需要在測試類別加上 @JsonTest

先新增一個檔案 test/resources/jack.json

{"id":10001, "name":"Jack", "birthday": "2000-10-08 21:00:00"}

User POJO

package com.test.test;

import com.fasterxml.jackson.annotation.JsonFormat;
import lombok.AllArgsConstructor;
import lombok.Data;

import java.time.LocalDateTime;

@Data
@AllArgsConstructor
public class User {
    private long id;
    private String name;
    @JsonFormat(timezone = "GMT+8", pattern = "yyyy-MM-dd HH:mm:ss")
    private LocalDateTime birthday;

}

JsonTests.java

package com.test.test;

import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.json.JsonTest;
import org.springframework.boot.test.json.JacksonTester;

import java.time.LocalDateTime;

import static org.assertj.core.api.Assertions.assertThat;

@JsonTest
class JsonTests {

    @Autowired
    private JacksonTester<User> json;

    @Test
    void serialize() throws Exception {
        User user = new User(10001L, "Jack",
                LocalDateTime.of(2000, 10, 8, 21, 0, 0));
        assertThat(this.json.write(user)).isEqualToJson("/jack.json");
        assertThat(this.json.write(user)).hasJsonPathStringValue("@.name");
        assertThat(this.json.write(user)).
                extractingJsonPathStringValue("@.name").isEqualTo("Jack");
    }

    @Test
    void deserialize() throws Exception {
        String content = "{\"id\":10002, \"name\":\"Petty\", \"birthday\": \"2025-01-01 01:01:00\"}";
        assertThat(this.json.parse(content))
                .isEqualTo(new User(10002L, "Petty",
                        LocalDateTime.of(2025, 1, 1, 1, 1, 0)));
        assertThat(this.json.parseObject(content).getName()).isEqualTo("Petty");
    }

}

2025/07/21

Spring Boot 3 test

斷點測試

用 main 直接啟動 Debug mode

在啟動 application 時,用 Debug mode 方式啟動。當程式到達斷點時,會自動暫停

用 maven plugin

使用 main 啟動 debug mode 的缺點是,無法使用 maven plugins。

使用 spring boot maven plugin 啟動 application 會 fork process 執行,故無法像 main 的方式一樣,直接 debug。需要用遠端測試的方法。

首先是需要在 spring boot maven plugin 中,設定 jvmArguments 測試參數,並啟動 application,然後新增一個遠端測試

啟動測試模式

有兩種方法

  • 在 plugin 設定中指定測試模式的 jvmArguments

        <build>
            <plugins>
                <plugin>
                    <groupId>org.springframework.boot</groupId>
                    <artifactId>spring-boot-maven-plugin</artifactId>
                    <configuration>
                        <jvmArguments>
                            -Xdebug -Xrunjdwp:transport=dt_socket,server=y,suspend=y,address=5005
                        </jvmArguments>
                    </configuration>
                </plugin>
            </plugins>
        </build>
  • 使用 mvn 啟動時,指定測試參數

    mvn spring-boot:run -Dspring-boot.run.jvmArguments="-Xdebug -Xrunjdwp:transport=dt_socket,server=y,suspend=y,address=5005"

啟動後,會在 console 最後面看到

> mvn spring-boot:run
[INFO] --- spring-boot-maven-plugin:3.4.0:run (default-cli) @ test ---
[INFO] Attaching agents: []
Listening for transport dt_socket at address: 5005

遠端測試

透過 IDE 建立 Remote test application

連接後,剛剛的 mvn 才會完成啟動,出現 application 的 log

Connected to the target VM, address: 'localhost:5005', transport: 'socket'

JVM remote debug 要處理斷點會比較麻煩


開發者工具

spring boot 提供 devtools,功能可自動禁用 cache,支援 application 自動重啟,即時 reload

只要引用 libary 即可

        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-devtools</artifactId>
            <optional>true</optional>
        </dependency>

optional 代表只會在本專案/子專案中使用,不會傳遞到引用該專案的project

devtools 是在開發時使用,打包時 spring boot 預設不會把 devtools 包裝進去。可修改此設定

        <plugins>
            <plugin>
                <groupId>org.springframework.boot</groupId>
                <artifactId>spring-boot-maven-plugin</artifactId>
                <configuration>
                    <excludeDevtools>false</excludeDevtools>
                </configuration>
            </plugin>
        </plugins>

並在啟動時,加上 -Dspring.devtools.restart.enabled=true 啟用

但不建議將 devtools 打包進去


預設值

Name Default Value
server.error.include-binding-errors always
server.error.include-message always
server.error.include-stacktrace always
server.servlet.jsp.init-parameters.development true
server.servlet.session.persistent true
spring.docker.compose.readiness.wait only-if-started
spring.freemarker.cache false
spring.graphql.graphiql.enabled true
spring.groovy.template.cache false
spring.h2.console.enabled true
spring.mustache.servlet.cache false
spring.mvc.log-resolved-exception true
spring.reactor.netty.shutdown-quiet-period 0s
spring.template.provider.cache false
spring.thymeleaf.cache false
spring.web.resources.cache.period 0
spring.web.resources.chain.cache false

devtools 預設禁用 cache,因為開發階段禁用 cache,較容易排除問題

可透過設定修改

spring:
  devtools:
    add-properties: false

自動 restart

devtools 會監控檔案,發生變更就會快速自動 restart。這邊使用兩個 class loader

  • base classloader

    基本不會變更的檔案,ex: 3rd party jar

  • restart classloader

    application 相關檔案

restart 時,base classloader 不需要異動,只需要重建一個 restart classloader

但要注意:AspectJ AOP programming 不支援 auto restart


觸發機制

  • Eclipse: 修改 save 後就會觸發

  • IntelliJ IDEA: 要重新 build 才會觸發

  • build tool: 在 IDE 使用 mvn compile/grdle build 會觸發


修改時頻繁 restart,會消耗很多資源

可將 IDE 改為手動編譯,或是指定一個觸發檔案

spring:
  devtools:
    restart:
      trigger-file: .reloadtrigger

只有在 .reloadtrigger 檔案被修改時,才會自動 restart


IDE 有支援自動產生 trigger file

然後在 application 有個 update 按鈕

此方法只適用於用 main 啟動的 spring application,不適用 mvn


排除資源

預設狀況下以下資源目錄不會觸發 restart,但會觸發自動 reload

  • /META-INF/maven

  • /META-INF/resources

  • /resources

  • /static

  • /public

  • /templates

可修改此設定

spring:
  devtools:
    restart:
      exclude: static/**,public/**
      # 保留預設,增加其他目錄
      # additional-exclude:

禁用 log

application 自動 restart 都會列印 log,可用以下方式關閉

spring:
  devtools:
    restart:
      log-confition-evaluation-delta: false

禁用 auto restart

spring:
  devtools:
    restart:
      enabled: false

auto reload

browser 要安裝 LiveReload plugin,並打開 devtool 的 auto reload 功能

devtools 內建一個 LiveReload server 在 port 35729

預設狀況下以下資源目錄不會觸發 restart,但會觸發自動 reload

  • /META-INF/maven

  • /META-INF/resources

  • /resources

  • /static

  • /public

  • /templates

可關閉 auto reload

spring:
  devtools:
    livereload:
      enabled: false

global 設定

$HOME/.config/spring-boot 目錄中,設定檔名為 spring-boot-devtools。支援 properties 或 yaml

spring-boot-devtools.properties

spring-boot-devtools.yaml

spring-boot-devtools.yml

$HOME 預設是 user 主目錄,可用以下方式修改

  • 設定 SPRING_DEVTOOLS_HOME 環境變數

  • 設定 spring-devtools.home 系統參數

example: spring-boot-devtools.yml

spring:
  devtools:
    restart:
      log-condition-evaluation-delta: false
      trigger-file: ".reloadtrigger"

2025/06/30

Spring Boot 3 Artemis

Spring Boot 支援

  • JMS

    Java message Service,支援 JmsTemplate

  • AMQP

    Advanced Message Queue Protocol

    為 RabbitMQ 提供 RabbitTemplate

  • Apache Kafka

    KafkaTemplate

  • STOMP

    Simple Text-Oriented Message Protocol

org.springframework.boot.autoconfigure.AutoConfiguration.imports

自動設定類別

  • JmsAutoConfiguration

  • ArtemisAutoConfiguration

  • RabbitAutoConfiguration

  • KafkaAutoConfiguration


ActiveMQ

JMS 主流 ActiveMQ, RocketMQ

JMS 有兩個版本

  • JMS 1.1

  • JMS 2.0

ActiveMQ 有兩個主要版本

  • ActiveMQ Classic

    ActiveMQ 5.x 以前

    JMS 1.1

  • ActiveMQ Artemis

    ActiveMQ 6.x+

    JMS 2.0


下載 apache-artemis-2.38.0-bin.tar.gz

tar zxvf apache-artemis-2.38.0-bin.tar.gz
mkdir -p /root/download/artemis/data/
apache-artemis-2.38.0/bin/artemis create mybroker

建立 broker

# ../apache-artemis-2.38.0/bin/artemis create mybroker
Creating ActiveMQ Artemis instance at: /root/download/artemis/data/mybroker

--user:
What is the default username?
admin

--password: is mandatory with this configuration:
What is the default password?


--allow-anonymous | --require-login:
Allow anonymous access?, valid values are Y, N, True, False
n

Auto tuning journal ...
done! Your system can make 7.58 writes per millisecond, your journal-buffer-timeout will be 132000

You can now start the broker by executing:

   "/root/download/artemis/data/mybroker/bin/artemis" run

Or you can run the broker in the background using:

   "/root/download/artemis/data/mybroker/bin/artemis-service" start

修改 /root/download/artemis/data/mybroker/etc/bootstrap.xml 的 artemis uri,由 localhost 改為 0.0.0.0

   <web path="web" rootRedirectLocation="console">
       <binding name="artemis" uri="http://0.0.0.0:8161">
           <app name="branding" url="activemq-branding" war="activemq-branding.war"/>
           <app name="plugin" url="artemis-plugin" war="artemis-plugin.war"/>
           <app name="console" url="console" war="console.war"/>
       </binding>
   </web>

啟動後,可瀏覽 web console http://192.168.1.89:8161/


pom.xml 增加 spring-boot-starter-artemis

        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-artemis</artifactId>
        </dependency>

org.springframework.boot.autoconfigure.AutoConfiguration.imports

自動設定類別

  • JmsAutoConfiguration

  • JndiConnectionFactoryAutoConfiguration

  • ArtemisAutoConfiguration

只要設定 spring.jms.*spring.artemis.*

spring:
  jms:
    cache:
      enabled: true
      session-cache-size: 5 # default: 1
  artemis:
    mode: native
    broker-url: tcp://localhost:61616
    user: admin
    password: password

也可以加上 pool

spring:
  jms:
    cache:
      enabled: true
      session-cache-size: 5 # default: 1
  artemis:
    mode: native
    broker-url: tcp://localhost:61616
    user: admin
    password: password
    pool:
      enabled: true # default: false
      max-connections: 50 # default: 1
      idle-timeout: 5s # default: 30s
      max-sessions-per-connection: 100 # default: 500

還須要加上 library

        <dependency>
            <groupId>org.messaginghub</groupId>
            <artifactId>pooled-jms</artifactId>
        </dependency>

補上 web library

        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-web</artifactId>
        </dependency>

        <dependency>
            <groupId>org.projectlombok</groupId>
            <artifactId>lombok</artifactId>
            <scope>provided</scope>
        </dependency>

MsgController.java

package com.test.artemis;

import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.jms.annotation.JmsListener;
import org.springframework.jms.core.JmsTemplate;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;

@RequiredArgsConstructor
@RestController
@Slf4j
public class MsgController {

    private final JmsTemplate jmsTemplate;

    /**
     * send message
     * @param msg
     * @return
     */
    @RequestMapping("/send")
    public String sendMsg(@RequestParam("msg") String msg) {
        jmsTemplate.convertAndSend("test-queue", msg);
        return "message is sent";
    }

    /**
     * receive message
     * @param msg
     */
    @JmsListener(destination = "test-queue")
    public void receiveMsg(String msg) {
        log.info("receive ActiveMQ message:{}", msg);
    }

}

測試網址

http://localhost:8080/send?msg=test1234

console 可看到

receive ActiveMQ message:test1234

2025/06/23

Spring Boot 3 amqp

RabbitMQ 重要元件有

  • ConnectionFactory

  • Channel: 發送訊息

  • Exchange: 交換器,分發訊息

  • Queue: 儲存 producer 發送的訊息

  • RoutingKey: exchange 把 producer 的訊息寫入不同的 queue

  • BindingKey: exchange 綁定到不同的 queue

exchange 支援的訊息模式:

  • direct

    預設 路由模式。發送訊息到指定的 Routing Key,寫入不同的 queue

  • fanout

    廣播模式。將訊息送到綁定的所有 queues 裡面。性能最好,最常用

  • headers

    匹配模式。根據訊息的 header 跟 queue 的參數匹配。性能差,少用

  • topic

    匹配模式。可使用 * 匹配


rabbitmq cli 設定新增 user

rabbitmqctl add_user admin password
rabbitmqctl set_permissions -p "/" admin ".*" ".*" ".*"
rabbitmqctl set_user_tags admin administrator
rabbitmqctl delete_user guest
rabbitmqctl list_users

pom.xml

        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-amqp</artifactId>
        </dependency>

        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-web</artifactId>
        </dependency>

        <dependency>
            <groupId>org.projectlombok</groupId>
            <artifactId>lombok</artifactId>
            <scope>provided</scope>
        </dependency>

org.springframework.boot.autoconfigure.AutoConfiguration.imports 註冊

RabbitAutoConfiguration。參數綁定類別為 RabbitProperties。參數為 spring.rabbitmq.*

application.yml

spring:
  rabbitmq:
    host: localhost
    port: 5672
    username: admin
    password: password

也可以設定為 uri 形式

spring:
  rabbitmq:
    addresses: "amqp://admin:password@localhost"

MsgController.java

package com.test.amqp;

import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.amqp.rabbit.annotation.RabbitListener;
import org.springframework.amqp.rabbit.core.RabbitTemplate;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;

@RequiredArgsConstructor
@RestController
@Slf4j
public class MsgController {

    private final RabbitTemplate rabbitTemplate;

    /**
     * send message: Direct mode
     * @param msg
     * @return
     */
    @RequestMapping("/send")
    public String sendMsg(@RequestParam("msg") String msg) {
        rabbitTemplate.convertAndSend("test-direct-exchange",
                "test-direct-routing-key", msg);
        return "message sent";
    }

    /**
     * receive message: Direct Mode
     * @param msg
     */
    @RabbitListener(queues = "test-direct-queue")
    public void receiveMsg(String msg) {
        log.info("message received:{}", msg);
    }

}

RabbitMQ 無法自動產生 queue, exchange。除了能在 web console 設定,也可以透過程式設定

package com.test.amqp;

import org.springframework.amqp.core.Binding;
import org.springframework.amqp.core.BindingBuilder;
import org.springframework.amqp.core.DirectExchange;
import org.springframework.amqp.core.Queue;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;

@Configuration
public class RabbitMQConfig {

    /**
     * create queue: Direct Mode
     * @return
     */
    @Bean
    public Queue testDirectQueue() {
        return new Queue("test-direct-queue");
    }

    /**
     * create exchange: Direct Mode
     * @return
     */
    @Bean
    public DirectExchange TestDirectExchange() {
        return new DirectExchange("test-direct-exchange");
    }

    /**
     * create routing key: Direct Mode
     * @param testDirectQueue
     * @return
     */
    @Bean
    public Binding testDirectBinding(Queue testDirectQueue) {
        return BindingBuilder.bind(testDirectQueue)
                .to(TestDirectExchange()).with("test-direct-routing-key");
    }

}

測試

http://localhost:8080/send?msg=test1234

2025/06/09

Spring Boot 3 09 Cache

spring 提供對 cache 的 API 介面

  • org.springframework.cache.Cache

  • org.springframework.cache.CacheManager

如果 application 沒有註冊 CacheManager 或 CacheResolver,spring 會依照以下順序檢測 cache component

Generic -> JCache (JSR-107) (EhCache3, Hazelcast, Infinispan..) -> Hazelcast -> Infinispan -> Couchbase -> Redis -> Caffeine -> Cache2k -> Simple

org.springframework.boot.autoconfigure.AutoConfiguration.imports 有註冊自動設定類別

自動設定類別為 CacheAutoConfiguration,參數綁定類別為 CacheProperties

設定參數 spring.cache.*

pom.xml

        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-cache</artifactId>
        </dependency>

                <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-web</artifactId>
        </dependency>

application.yml

spring:
  cache:
    type: none
    # none 代表禁用 cache
    # type: redis

type 可設定為 COUCHBASE/GENERIC/REDIS/HAZELCAST/CACHE2K/CAFFEINE/JCACHE/INFINISPAN/NONE/SIMPLE


預設簡單 cache

如果沒有設定任何 cache 元件,就是使用 Simple,也就是 thread-safe 的 ConcurrentHashMap

pom.xml 加上 web

        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-web</artifactId>
        </dependency>

application 要加上 @EnableCaching

@EnableCaching
@SpringBootApplication
public class CacheApplication {

    public static void main(String[] args) {
        SpringApplication.run(CacheApplication.class, args);
    }

}

先建立一個兩數相乘的 cache service

CacheService.java

package com.test.cache;

import lombok.extern.slf4j.Slf4j;
import org.springframework.cache.annotation.Cacheable;
import org.springframework.stereotype.Service;

@Slf4j
@Service
public class CacheService {

    @Cacheable("calc")
    public int multiply(int a, int b) {
        int c = a * b;
        log.info("{} * {} = {}", a, b, c);
        return c;
    }

}

@Cacheable 代表該 method 使用 cache,這是用 AOP 的方法做的

一個測試用的 web service

package com.test.cache;

import lombok.RequiredArgsConstructor;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;

@RequiredArgsConstructor
@RestController
public class CacheController {

    private final CacheService cacheService;

    @RequestMapping("/multiply")
    public int multiply(@RequestParam("a") int a,
                        @RequestParam("b") int b) {
        return cacheService.multiply(a, b);
    }

}

測試網址 http://localhost:8080/multiply?a=2&b=3

重複多測試幾次,console log 上面都還是只有一行 log。代表重複的參數,會直接從 cache 取得結果,不會進入 service

com.test.cache.CacheService              : 2 * 3 = 6

Redis Cache

pom.xml 加上 redis

        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-data-redis</artifactId>
        </dependency>

application.yml

spring:
  data:
    redis:
      host: localhost
      port: 6379
      database: 0
      password: password

redis-cli

# redis-cli -a password
Warning: Using a password with '-a' or '-u' option on the command line interface may not be safe.
127.0.0.1:6379> keys *
1) "calc::SimpleKey [2, 3]"
127.0.0.1:6379> get "calc::SimpleKey [2, 3]"
"\xac\xed\x00\x05sr\x00\x11java.lang.Integer\x12\xe2\xa0\xa4\xf7\x81\x878\x02\x00\x01I\x00\x05valuexr\x00\x10java.lang.Number\x86\xac\x95\x1d\x0b\x94\xe0\x8b\x02\x00\x00xp\x00\x00\x00\x06"

可在建立 cache 名稱,time-to-live 代表只存放 10s

spring:
  data:
    redis:
      host: localhost
      port: 6379
      database: 0
      password: password
  cache:
    type: redis
    cache-names: "calc,test"
    redis:
      time-to-live: "10s"

針對不同 cache 設定不同規則,可透過 RedisCacheManagerBuilderCustomizer

package com.test.cache;

import org.springframework.boot.autoconfigure.cache.RedisCacheManagerBuilderCustomizer;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.data.redis.cache.RedisCacheConfiguration;

import java.time.Duration;

@Configuration
public class CacheConfiguration {

    /**
     * 比 application.yml 的設定內容優先權高
     * @return
     */
    @Bean
    public RedisCacheManagerBuilderCustomizer myRedisCacheManagerBuilderCustomizer() {
        return (builder) -> builder
                .withCacheConfiguration("calc", RedisCacheConfiguration
                        .defaultCacheConfig().entryTtl(Duration.ofSeconds(5)))
                .withCacheConfiguration("test", RedisCacheConfiguration
                        .defaultCacheConfig().entryTtl(Duration.ofMinutes(10)));

    }

}

網址 http://localhost:8080/multiply?a=2&b=3

calc, test 兩個 redis cache 都有資料

# redis-cli -a password -n test
Warning: Using a password with '-a' or '-u' option on the command line interface may not be safe.
127.0.0.1:6379> keys *
1) "calc::SimpleKey [2, 3]"

# redis-cli -a password -n calc
Warning: Using a password with '-a' or '-u' option on the command line interface may not be safe.
127.0.0.1:6379> keys *
1) "calc::SimpleKey [2, 3]"

2025/06/02

Spring Boot 3 quartz

簡單的任務可用 spring task scheduler,複雜的要改用 quartz

pom.xml 要加上 spring-boot-starter-quartz

        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-quartz</artifactId>
        </dependency>

自動設定類別是 QuartzAutoConfiguration,透過註冊的 SchedulerFactoryBean 就能產生 Scheduler。參數綁定類別為 QuartzProperties,參數為 spring.quartz.*

產生 task

只要繼承 QuartzJobBean,實作 executeInternal,就可以產生 quartz 的 Job

SimpleTask.java

package com.test.quartz;

import lombok.extern.slf4j.Slf4j;
import org.quartz.JobExecutionContext;
import org.springframework.scheduling.quartz.QuartzJobBean;

@Slf4j
public class SimpleTask extends QuartzJobBean {

    @Override
    protected void executeInternal(JobExecutionContext context) {
        log.info("simple task");
    }

}

設定

  • JobDetail

  • Calendar: 指定/排除特定時間

  • Trigger

TaskConfig.java

package com.test.quartz;

import jakarta.annotation.PostConstruct;
import lombok.RequiredArgsConstructor;
import org.quartz.*;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.scheduling.quartz.SchedulerFactoryBean;

@RequiredArgsConstructor
@Configuration
public class TaskConfig {

    public static final String SIMPLE_TASK = "simple-task";
//    private final SchedulerFactoryBean schedulerFactoryBean;
//
//    @PostConstruct
//    public void init() throws SchedulerException {
//        Scheduler scheduler = schedulerFactoryBean.getScheduler();
//        boolean exists = scheduler.checkExists(JobKey.jobKey(SIMPLE_TASK));
//        if (!exists) {
//            scheduler.scheduleJob(simpleTask(), simpleTaskTrigger());
//        }
//    }

    @Bean
    public JobDetail simpleTask() {
        return JobBuilder.newJob(SimpleTask.class)
                .withIdentity(SIMPLE_TASK)
                .withDescription("simple-task")
                .storeDurably()
                .build();
    }

    @Bean
    public Trigger simpleTaskTrigger() {
        CronScheduleBuilder cronScheduleBuilder = CronScheduleBuilder.cronSchedule("0/3 * * * * ? *");
        return TriggerBuilder.newTrigger()
                .withIdentity("simple-task-trigger")
                .forJob(simpleTask())
                .withSchedule(cronScheduleBuilder)
                .build();
    }

}

cron expression 多了最後一個 [<year>] 可以不填寫

<second> <minute> <hour> <day of month> <month> <day of week> [<year>]

ex:

"0/3 * * * * ? 2025-2030" 代表 2025-2030 每 3s 執行一次

啟動後,會看到

org.quartz.core.QuartzScheduler          : Scheduler meta-data: Quartz Scheduler (v2.3.2) 'quartzScheduler' with instanceId 'NON_CLUSTERED'
  Scheduler class: 'org.quartz.core.QuartzScheduler' - running locally.
  NOT STARTED.
  Currently in standby mode.
  Number of jobs executed: 0
  Using thread pool 'org.quartz.simpl.SimpleThreadPool' - with 10 threads.
  Using job-store 'org.quartz.simpl.RAMJobStore' - which does not support persistence. and is not clustered.

設定參數

以下代表將 thread 設定為 5 個

spring:
  quartz:
    properties:
      org:
        quartz:
          threadPool:
            threadCount: 5

persistence

quartz 支援兩種 persistence 方式

  • memory

    預設。每次停止 application 就會把資料丟掉

  • JDBC

pom.xml 加上 JDBC

        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-data-jdbc</artifactId>
        </dependency>
        <dependency>
            <groupId>com.mysql</groupId>
            <artifactId>mysql-connector-j</artifactId>
        </dependency>

application.yml

spring:
  datasource:
    driver-class-name: com.mysql.cj.jdbc.Driver
    url: jdbc:mysql://localhost:3306/testweb
    username: root
    password: password
  quartz:
    job-store-type: jdbc
    jdbc:
      initialize-schema: always # always
    overwrite-existing-jobs: true
    properties:
      org:
        quartz:
          threadPool:
            threadCount: 5

initialize-schema 有三種

  • ALWAYS: 每一次都會重建

  • EMBEDDED: 只初始化嵌入式 DB

  • NEVER


動態維護 task

如果 task 太多,會有大量設定的 code,可改用 SchedulerFactoryBean 動態 add/remove task

package com.test.quartz;

import jakarta.annotation.PostConstruct;
import lombok.RequiredArgsConstructor;
import org.quartz.*;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.scheduling.quartz.SchedulerFactoryBean;

@RequiredArgsConstructor
@Configuration
public class TaskConfig {

    public static final String SIMPLE_TASK = "simple-task";
    private final SchedulerFactoryBean schedulerFactoryBean;

    @PostConstruct
    public void init() throws SchedulerException {
        Scheduler scheduler = schedulerFactoryBean.getScheduler();
        boolean exists = scheduler.checkExists(JobKey.jobKey(SIMPLE_TASK));
        if (!exists) {
            scheduler.scheduleJob(simpleTask(), simpleTaskTrigger());
        }
    }

//    @Bean
    public JobDetail simpleTask() {
        return JobBuilder.newJob(SimpleTask.class)
                .withIdentity(SIMPLE_TASK)
                .withDescription("simple-task")
                .storeDurably()
                .build();
    }

//    @Bean
    public Trigger simpleTaskTrigger() {
        CronScheduleBuilder cronScheduleBuilder = CronScheduleBuilder.cronSchedule("0/3 * * * * ? *");
        return TriggerBuilder.newTrigger()
                .withIdentity("simple-task-trigger")
                .forJob(simpleTask())
                .withSchedule(cronScheduleBuilder)
                .build();
    }

}

2025/05/26

Spring Boot 3 Scheduler

spring 的 scheduler task 主要需要 spring-context library,因為任何一個 spring boot start 都有包含 spring-context,所以都有支援 scheduler。

spring 提供自動設定類別

  • TaskExecutionAutoConfiguration

  • TaskSchedulingAutoConfiguration

註冊在 org.springframework.boot.autoconfigure.AutoConfiguration.imports 檔案中

如果 context 裡面沒有任何 Executor instance,就會自動註冊一個預設的 ThreadPoolTaskExecutor,該類別是使用 java.util.concurrent.Executor interface 實作自己的 task executor。同時註冊了一個 TaskExecutorBuilder instance,用來實作自訂的 task executor thread pool。

參數綁定類別是 TaskExecutionProperties

參數是 spring.task.execution.*


如果 context 裡面沒有任何一個 instance

  • SchedulerConfigurer

    org.springframework.scheduling.annotation

  • TaskScheduler

    org.springframework.scheduling

  • SchedulerdExecutorService

    java.util.concurrent

就會自動註冊一個 ThreadPoolTaskScheduler instance。

可以註冊一個 TaskSchedulerBuilder instance,參數對應綁定類別是 TaskSchedulingProperties。參數是 spring.task.scheduling.*


  • thread size < core thread size(coreSize)

    • 產生新的 thread 執行 task
  • thread size = core thread size(coreSize) 且 queueCapacity is not full

    • 將 task 放入 queue
  • thread size = core thread size(coreSize) 且 queueCapacity is full 且 thread size < max thread size (maxSize)

    • 產生新的 thread 執行 task
  • coreSize, queueCapacity, maxSize 都滿了

    • 拒絕執行 task

    • 預設為 AbortPolicy (ref: ExecutorConfigurationSupport)

  • thread 空閒時間到達 keepAlive 時

    • 刪除 thread

    • 預設只會刪除 core thread 以外的 threads

    • allowCoreThreadTimeout 可控制是否要刪除 core thread


spring task 使用起來比 quartz 簡單

先在 application 加上 @EnableScheduling

@EnableScheduling
@SpringBootApplication
public class TaskApplication {

    public static void main(String[] args) {
        SpringApplication.run(TaskApplication.class, args);
    }

}
  • @EnableScheduling

    表示啟用 scheduling,會引用 SchedulingConfiguraiton 自動設定,這是透過註冊 ScheduledAnnotationBeanPostProcessor instance 實現

  • @EnableAsync

    啟用 task 非同步運作,這是引用 AsyncConfigurationSelector,根據不同的 AOP 選擇不同的設定類別

這兩個都屬於 spring-context 的 annotation,不需要其他 library


SimpleTask

SmpleTask.java

package com.test.task;

import lombok.extern.slf4j.Slf4j;
import org.springframework.scheduling.annotation.Async;
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Component;

@Slf4j
@Component
public class SimpleTask {

    @Async
    @Scheduled(cron = "*/3 * * * * *")
    public void printTask() {
        log.info("SimpleTask is working ...");
    }

}

執行時,固定會以 "scheduling-1" 這個 thread 執行

[   scheduling-1] com.test.task.SimpleTask                 : SimpleTask is working ...

沒有用到 ThreadPoolTaskExecutor


修改 application,加上 @EnableAsync,才會使用到 ThreadPoolTaskExecutor

@EnableScheduling
@EnableAsync
@SpringBootApplication
public class TaskApplication {

    public static void main(String[] args) {
        SpringApplication.run(TaskApplication.class, args);
    }

}

執行結果,會是 task-1 ~ task-8,然後一直重複。 ThreadPoolTaskExecutor 預設有 8 個 coreThread

[         task-1] com.test.task.SimpleTask                 : SimpleTask is working ...
[         task-2] com.test.task.SimpleTask                 : SimpleTask is working ...

cron

<second> <minute> <hour> <day of month> <month> <day of week>
  • second: 0~59

  • minute: 0~59

  • hour: 0~23

  • day of month: 1~31

  • month: 1~12 或 JAN ~ DEC

  • day of week: 0~7,0 代表週日,或是 MON ~ SUN

example:

cron expression desc
*/3 * * * * * 每 3s 執行一次
0 0 * * * * 每小時 0分 0 秒執行一次
0 0 9~16 * * * 每天 9 ~ 16 點整點執行一次
0 0 9,16 * * * 每天 9,16 點執行一次
0 0/30 9-16 * * * 每天 9 ~ 16 點,每 30 分執行一次

支援用以下的寫法替代

  • @yearly 等同 0 0 0 1 1 *

  • @monthly

  • @weekly

  • @daily

  • @hourly

cron expression 不支援年


自訂 thread pool

修改系統設定參數

application.yml

spring:
  task:
    execution:
      pool:
        # core thread size
        coreSize: 5
        # max thread size
        maxSize: 10
        # max queue size
        queueCapacity: 50
        # thread keep alive for 10s
        keepAlive: 10s
        #
        allowCoreThreadTimeout: false
    scheduling:
      pool:
        size: 3

拒絕的策略無法透過參數設定修改,只能使用預設的 AbortPolicy


自訂 thread pool bean

TaskConfig.java 產生兩個 thread pool: taskExecutor1, taskExecutor2

package com.test.task;

import org.springframework.boot.autoconfigure.task.TaskExecutionProperties;
import org.springframework.boot.context.properties.PropertyMapper;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.context.annotation.Lazy;
import org.springframework.context.annotation.Primary;
import org.springframework.scheduling.concurrent.ThreadPoolTaskExecutor;

import java.time.Duration;
import java.util.concurrent.ThreadPoolExecutor;

@Configuration
public class TaskConfig {

    @Lazy
    @Bean
    @Primary
    public ThreadPoolTaskExecutor taskExecutor1(TaskExecutionProperties taskExecutionProperties) {
        ThreadPoolTaskExecutor taskExecutor = new ThreadPoolTaskExecutor();

        PropertyMapper map = PropertyMapper.get().alwaysApplyingWhenNonNull();
        TaskExecutionProperties.Pool pool = taskExecutionProperties.getPool();
        map.from(pool::getQueueCapacity).to(taskExecutor::setQueueCapacity);
        map.from(pool::getCoreSize).to(taskExecutor::setCorePoolSize);
        map.from(pool::getMaxSize).to(taskExecutor::setMaxPoolSize);
        map.from(pool::getKeepAlive).asInt(Duration::getSeconds).to(taskExecutor::setKeepAliveSeconds);
        map.from(pool::isAllowCoreThreadTimeout).to(taskExecutor::setAllowCoreThreadTimeOut);
        map.from("my-task1-").whenHasText().to(taskExecutor::setThreadNamePrefix);

        // default: AbortPolicy
        taskExecutor.setRejectedExecutionHandler(new ThreadPoolExecutor.AbortPolicy());

        return taskExecutor;
    }

    @Lazy
    @Bean
    public ThreadPoolTaskExecutor taskExecutor2(TaskExecutionProperties taskExecutionProperties) {
        ThreadPoolTaskExecutor taskExecutor = new ThreadPoolTaskExecutor();

        PropertyMapper map = PropertyMapper.get().alwaysApplyingWhenNonNull();
        TaskExecutionProperties.Pool pool = taskExecutionProperties.getPool();
        map.from(10).to(taskExecutor::setQueueCapacity);
        map.from(3).to(taskExecutor::setCorePoolSize);
        map.from(5).to(taskExecutor::setMaxPoolSize);
        map.from(20).to(taskExecutor::setKeepAliveSeconds);
        map.from(true).to(taskExecutor::setAllowCoreThreadTimeOut);
        map.from("my-task2-").whenHasText().to(taskExecutor::setThreadNamePrefix);

        return taskExecutor;
    }
}

修改剛剛的 SimpleTask

@Async("taskExecutor2") 加上 taskExecutor2,指定 thread pool

@Slf4j
@Component
public class SimpleTask {

    @Async("taskExecutor2")
    @Scheduled(cron = "*/3 * * * * *")
    public void printTask() {
        log.info("SimpleTask is working ...");
    }

}

執行結果

[     my-task2-1] com.test.task.SimpleTask                 : SimpleTask is working ...
[     my-task2-2] com.test.task.SimpleTask                 : SimpleTask is working ...
[     my-task2-3] com.test.task.SimpleTask                 : SimpleTask is working ...
[     my-task2-1] com.test.task.SimpleTask                 : SimpleTask is working ...

2025/05/12

Spring Boot 3 Data 2

Spring Data JPA

JPA: Java Persistence API,提供 POJO 物件持久化的標準規格,可把 Java 物件直接映射到資料庫中

JPA 是規格,Hibermate, TopLink, OpenJPA 都是 JPA 的實作。目前以 Hibernate 為 Java 最流行的 JPA framework。

Spring Data JPA 底層也是使用 Hibernate,最後透過 JDBC 連接資料庫。

spring-boot-starter-data-jpa 自動設定了 JPA,自動設定類別是

  • JpaRepositoriesAutoConfiguration

  • HibernateJpaAutoConfiguration

pom.xml

        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-data-jpa</artifactId>
        </dependency>

        <dependency>
            <groupId>org.projectlombok</groupId>
            <artifactId>lombok</artifactId>
            <scope>provided</scope>
        </dependency>

        <dependency>
            <groupId>com.mysql</groupId>
            <artifactId>mysql-connector-j</artifactId>
        </dependency>

        <dependency>
            <groupId>org.hibernate.orm</groupId>
            <artifactId>hibernate-community-dialects</artifactId>
        </dependency>

        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-web</artifactId>
        </dependency>

        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-test</artifactId>
            <scope>test</scope>
        </dependency>

application.yml

spring:
  application:
    name: data2

  datasource:
    driver-class-name: com.mysql.cj.jdbc.Driver
    url: jdbc:mysql://localhost/testweb
    username: root
    password: password

  jpa:
    hibernate:
      ddl-auto: update
    show-sql: true
    properties:
      hibernate:
        # dialect: org.hibernate.dialect.MySQLDialect
        dialect: org.hibernate.community.dialect.MySQLLegacyDialect

傳統的 JPA 必需要設定 persistence.xml,但在 spring boot 不需要。

Entity class 只需要有 @Entity@SpringBootApplication 或是 @EnableAutoConfiguration 就會自動掃描所有 @Entity

Data2Application 直接啟動即可

@SpringBootApplication
public class Data2Application {

    public static void main(String[] args) {
        SpringApplication.run(Data2Application.class, args);
    }

}

UserController.java

package com.test.data2.controller;

import com.test.data2.entity.UserDO;
import com.test.data2.repo.UserRepository;
import lombok.RequiredArgsConstructor;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.RestController;

@RequiredArgsConstructor
@RestController
public class UserController {

    private final UserRepository userRepository;

    @GetMapping("/user/info/{id}")
    public UserDO getUserInfo(@PathVariable("id") long id){
        UserDO userDO = userRepository.findById(id).orElseGet(null);
        return userDO;
    }

}

UserRepository.java

package com.test.data2.repo;

import com.test.data2.entity.UserDO;
import org.springframework.data.repository.CrudRepository;

public interface UserRepository extends CrudRepository<UserDO, Long> {

}

UserDO.java

package com.test.data2.entity;

import jakarta.persistence.*;
import lombok.Data;

import java.io.Serializable;
import java.util.Date;

@Data
@Entity(name = "user")
public class UserDO implements Serializable {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    @Column(nullable = false, unique = true)
    private String username;

    @Column(nullable = false)
    private String phone;

    @Column(name = "create_time", nullable = false)
    private Date createTime;

    @Column(nullable = false)
    private Integer status;

}

啟動後,可從網址 http://localhost:8080/user/info/2 取得 userid = 2 的資料

console 會出現 sql

Hibernate: select ud1_0.id,ud1_0.create_time,ud1_0.phone,ud1_0.status,ud1_0.username from user ud1_0 where ud1_0.id=?

note:

如果掃描不到,可加上 @EntityScan 指定 package/class

Spring data JPA 的 db operation 稱為 repository

  • org.springframework.data.repository.CrudRepository

  • org.springframework.data.repository.ListCrudRepository

  • org.springframework.data.repository.ListPagingRepository

  • org.springframework.data.repository.PagingAndSortingRepository

  • org.springframework.data.repository.kotlin.*

  • org.springframework.data.repository.reactive.*

UserRepository interface 會被自動掃描到,在 controller 能自動注入使用


MyBatis

MyBatis是一個Java持久化框架,它通過 XML 或 annotation 把物件與儲存程序或SQL語句關聯起來,對映成資料庫內對應的 record。

MyBatis-Plus 是 MyBatis 的封裝。


Redis

REmote DIctionary Server: Redis,是一種 key-value 的 NoSQL memory database。通常用在 cache 資料。

spring-boot-starter-data-redis

org.springframework.boot.autoconfigure.AutoConfiguration.imports 有註冊幾個自動設定類別

  • RedisAutoConfiguration

  • RedisReactiveAutoConfiguration

  • RedisRepositoriesAutoConfiguration

RedisAutoConfiguration 對應的參數綁定類別是 RedisProperties

在 application.yml 設定即可使用

spring:
  application:
    name: data3
  data:
    redis:
      host: localhost
      port: 6379
      database: 0
      password: password
#      client-type: jedis

RedisTemplate

有兩個預設的模板可直接使用

  • RedisTemplate

    可處理 <Object, Object>

  • StringRedisTemplate

    只能處理 <String, String> 的資料,預設使用 JDK 的 serializer

因為 StringRedisTemplate 繼承自 RedisTemplate,必須設定兩個 RedisTemplate,所以要搭配 @ConditionalOnSingleCandidate 注入

RedisTemplate 提供的 interface:

interface desc
GeoOperations 地理空間
HashOperations Hash data
HyperLogLogOperations HyperLogLog
ListOperations List
SetOperations Set
ValueOperations String
ZSetOperations Ordered Set

設定 pom.xml,要加上 spring-boot-starter-integration, spring-integration-redis

    <dependencies>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-web</artifactId>
        </dependency>

        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-data-redis</artifactId>
        </dependency>

        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-integration</artifactId>
        </dependency>
        <dependency>
            <groupId>org.springframework.integration</groupId>
            <artifactId>spring-integration-redis</artifactId>
        </dependency>

        <dependency>
            <groupId>org.projectlombok</groupId>
            <artifactId>lombok</artifactId>
            <scope>provided</scope>
        </dependency>

        <dependency>
            <groupId>org.apache.commons</groupId>
            <artifactId>commons-lang3</artifactId>
        </dependency>

        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-test</artifactId>
            <scope>test</scope>
        </dependency>
    </dependencies>

要新增一個連線,注入 StringRedisTemplate 使用

package com.test.data3;

import lombok.RequiredArgsConstructor;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.data.redis.core.ValueOperations;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;

@RequiredArgsConstructor
@RestController
public class RedisController2 {

    private final StringRedisTemplate stringRedisTemplate;

    @RequestMapping("/redis2/set")
    public String set(@RequestParam("name") String name, @RequestParam("value") String value) {
        ValueOperations<String, String> valueOperations = stringRedisTemplate.opsForValue();
        valueOperations.set(name, value);
        return valueOperations.get(name);
    }

}

呼叫網址 http://localhost:8080/redis2/set?name=test&value=12345 ,就會設定一個 redis key,可使用 redis-cli 檢查

> redis-cli -a password
127.0.0.1:6379> keys *
1) "test"
127.0.0.1:6379> get test
"12345"

也可以直接注入對應的 RedisTemplate 使用 valueOperations

@RequiredArgsConstructor
@RestController
public class RedisController3 {

//    private final StringRedisTemplate stringRedisTemplate;

    @Resource(name = "stringRedisTemplate")
    private ValueOperations<String, String> valueOperations;

    @RequestMapping("/redis3/set")
    public String set(@RequestParam("name") String name, @RequestParam("value") String value) {
        valueOperations.set(name, value);
        return valueOperations.get(name);
    }

}

自訂 RedisTemplate

@Configuration
public class RedisConfig {

    @Bean
    public RedisTemplate<String, Object> redisTemplate(RedisConnectionFactory factory) {
        RedisTemplate<String, Object> template = new RedisTemplate<>();
        template.setConnectionFactory(factory);

        StringRedisSerializer stringSerializer = new StringRedisSerializer();
        RedisSerializer jacksonSerializer = getJacksonSerializer();

        template.setKeySerializer(stringSerializer);
        template.setValueSerializer(jacksonSerializer);
        template.setHashKeySerializer(stringSerializer);
        template.setHashValueSerializer(jacksonSerializer);
        template.setEnableTransactionSupport(true);
        template.afterPropertiesSet();

        return template;
    }

    private RedisSerializer getJacksonSerializer() {
        ObjectMapper om = new ObjectMapper();
        om.setVisibility(PropertyAccessor.ALL, JsonAutoDetect.Visibility.ANY);
        om.activateDefaultTyping(LaissezFaireSubTypeValidator.instance,
                ObjectMapper.DefaultTyping.NON_FINAL);
        return new GenericJackson2JsonRedisSerializer(om);
    }
}

這裡 key 使用了 StringRedisSerializer, value 使用了 GenericJackson2JsonRedisSerializer

以下是幾個常用的 serializer

serializer desc
StringRedisSerializer String/byte[] 轉換器
JdkSerializationRedisSerializer JDK serializer
OxmSerializer XML serializer
Jackson2JsonRedisSerializer JSON serializer, 需要定義 JavaType
GenericJackson2JsonRedisSerializer JSON serializer, 不需要定義 JavaType

MongoDB

springboot 提供兩種 starter

  • spring-boot-starter-data-mongodb

  • spring-boot-starter-data-mongodb-reactive

首先修改 pom.xml

        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-web</artifactId>
        </dependency>

        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-data-mongodb</artifactId>
        </dependency>

        <dependency>
            <groupId>org.projectlombok</groupId>
            <artifactId>lombok</artifactId>
            <scope>provided</scope>
        </dependency>

        <dependency>
            <groupId>org.apache.commons</groupId>
            <artifactId>commons-lang3</artifactId>
        </dependency>

在 org.springframework.boot.autoconfigure.AutoConfiguration.imports 註冊了幾個自動設定類別

  • MongoDataAutoConfiguration

  • MongoReactiveDataAutoConfiguration

  • MongoRepositoriesAutoConfiguration

  • MongoAutoConfiguration

  • MongoReactiveAutoConfiguration

設定 application.yml

spring:
  data:
    mongodb:
      uri: mongodb://localhost:27017/test

使用 mongoTemplate

@RequiredArgsConstructor
@RestController
public class MongoController {

    public static final String COLLECTION_NAME = "test";
    private final MongoTemplate mongoTemplate;

    @RequestMapping("/mongo/insert")
    public User insert(@RequestParam("name") String name, @RequestParam("sex") int sex) {
        // add
        User user = new User(RandomUtils.nextInt(), name, sex);
        mongoTemplate.insert(user, COLLECTION_NAME);

        // query
        Query query = new Query(Criteria.where("name").is(name));
        return mongoTemplate.findOne(query, User.class, COLLECTION_NAME);
    }
}

新增 User.java

package com.test.data4;

import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;
import org.springframework.data.mongodb.core.mapping.Document;

@NoArgsConstructor
@AllArgsConstructor
@Data
@Document(collection = "test")
public class User {
    private long id;
    private String name;
    private int sex;

}

發送網址 http://localhost:8080/mongo/insert?name=test&sex=1

就會 insert 一筆資料到 mongodb collection: test

/* 1 */
{
    "_id" : NumberLong(708252679),
    "name" : "test",
    "sex" : 1,
    "_class" : "com.test.data4.User"
}

MongoRepository

新增 UserRepository

package com.test.data4;

import org.springframework.data.mongodb.repository.MongoRepository;

import java.util.List;

public interface UserRepository extends MongoRepository<User, Long> {

    List<User> findByName(String name);

}

修改 MongoController.java

    private final UserRepository userRepository;

    @RequestMapping("/mongo/insert2")
    public User repoInsert(@RequestParam("name") String name, @RequestParam("sex") int sex) {
        // add
        User user = new User(RandomUtils.nextInt(), name, sex);
        userRepository.save(user);

        // query
        return userRepository.findByName(name).get(0);
    }

發送網址 http://localhost:8080/mongo/insert2?name=test2&sex=1

就會新增 test2


Elasticsearch

springboot 支援3種客戶端

  • Elasticsearch 官方 REST 客戶端

  • Elasticsearch 官方 Java API Client

  • spring data elasticsearch 提供的 ReactiveElasticsearchClient

pom.xml

        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-data-elasticsearch</artifactId>
        </dependency>

在 org.spingframework.boot.autoconfigure.AutoConfiguration.imports 註冊了

  • ElasticsearchDataAutoConfiguration

  • ElasticsearchRepositoriesAutoConfiguration

  • ReactiveElasticsearchRepositoriesAutoConfiguration

  • ElasticsearchClientAutoConfiguration

  • ElasticsearchRestClientAutoConfiguration

  • ReactiveElasticsearchClientAutoConfiguration

參數綁定 ElasticsearchProperties

參數 spring.elasticsearch.*

application.yml

spring:
  elasticsearch:
    uris: http://localhost:9200
    connection-timeout: 5s
    socket-timeout: 10s
    username: elastic
    password: password

這邊是用關閉 xpack,使用 http 的方式連線

改用 https 可參考

ref: spring-boot-starter-data-elasticsearch es带x-pack后台配置 - 微信公众号-醉鱼Java - 博客园


使用 ElasticsearchTemplate

EsController.java

import lombok.RequiredArgsConstructor;
import org.apache.commons.lang3.RandomUtils;
import org.springframework.data.elasticsearch.client.elc.ElasticsearchTemplate;
import org.springframework.data.elasticsearch.core.mapping.IndexCoordinates;
import org.springframework.data.elasticsearch.core.query.Criteria;
import org.springframework.data.elasticsearch.core.query.CriteriaQuery;
import org.springframework.data.elasticsearch.core.query.Query;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;

@RequiredArgsConstructor
@RestController
public class EsController {

    public static final String INDEX_JAVA = "java";
    private final ElasticsearchTemplate elasticsearchTemplate;

    @RequestMapping("/es/insert")
    public User insert(@RequestParam("name") String name, @RequestParam("sex") int sex) throws InterruptedException {
        // add
        User user = new User(RandomUtils.nextInt(), name, sex);
        IndexCoordinates indexCoordinates =  IndexCoordinates.of(INDEX_JAVA);
        User save = elasticsearchTemplate.save(user, indexCoordinates);

        // delay and query
        Thread.sleep(1000l);
        Query query = new CriteriaQuery(Criteria.where("name").is(name));
        return elasticsearchTemplate.searchOne(query, User.class, indexCoordinates).getContent();
    }
}

User.java

@NoArgsConstructor
@AllArgsConstructor
@Data
@Document(indexName = "java")
public class User {
    private long id;
    private String name;
    private int sex;

}

使用 UserRepository

修改 EsController.java

    private final UserRepository userRepository;
    @RequestMapping("/es/repo/insert")
    public User repoInsert(@RequestParam("name") String name, @RequestParam("sex") int sex) {
        // add
        User user = new User(RandomUtils.nextInt(), name, sex);
        userRepository.save(user);

        // query
        return userRepository.findByName(name).get(0);
    }

UserRepository.java

import org.springframework.data.elasticsearch.repository.ElasticsearchRepository;

import java.util.List;

public interface UserRepository extends ElasticsearchRepository<User, Long> {

    List<User> findByName(String name);

}

程式跟 MongoDB 類似

2025/05/05

Spring Boot 3 Data 1

spring boot 支援資料庫、connection pool、transaction 自動設定。資料庫還支援 H2, HSQL, Derby 嵌入式資料庫。NoSQL 支援 Mongodb, Neo4j, Elasticsearch, Redis, GemFire or Geode, Cassandra, Couchbase, LDAP, InfluxDB。比較常用的是 Redis, MongoDB, Elasticsearch。


嵌入式資料庫

存放在記憶體的資料庫

不需要設定 URL,只要引用 library 即可

        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-data-jpa</artifactId>
        </dependency>

        <dependency>
            <groupId>org.hsqldb</groupId>
            <artifactId>hsqldb</artifactId>
            <scope>runtime</scope>
        </dependency>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-test</artifactId>
            <scope>test</scope>
        </dependency>

資料來源 DataSource

java 是透過 javax.sql.DataSource 連接關聯式資料庫。spring boot 只需要引用 spring-boot-starter-data-jdbc 即可,另外還需要該關聯式資料庫的 JDBC driver,例如 MySQL 要用 mysql-connector

        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-data-jdbc</artifactId>
        </dependency>

        <dependency>
            <groupId>com.mysql</groupId>
            <artifactId>mysql-connector-j</artifactId>
        </dependency>

spring-boot-starter-data-jdbc 包含預設的 HikariCP connection pool

DataSource 自動設定的類別是 DataSourceAutoConfiguration

自動配置檔案 org.springframework.boot.autoconfigure.AutoConfiguration.imports

因為資料庫自動設定類別使用 @ConditionalOnClass,如果 classpath 裡面有以下兩個類別,就會自動完成設定

  • DataSource.class

  • EmbeddedDatabaseType.class

會先設定外部關聯式資料庫 DataSource,再設定嵌入式資料庫。如果沒有自動設定的類別,就會使用 connection pool 的預設設定值

application.yml 的設定參數為 spring.datasource.*

spring:
  application:
    name: data1

  datasource:
    driver-class-name: com.mysql.cj.jdbc.Driver
    #driver-class-name: org.mariadb.jdbc.Driver
    url: jdbc:mariadb://localhost/testweb
    username: root
    password: password

也可以使用 MariaDB 的 driver

        <dependency>
            <groupId>org.mariadb.jdbc</groupId>
            <artifactId>mariadb-java-client</artifactId>
        </dependency>

driver-class 也要同時修改

    driver-class-name: org.mariadb.jdbc.Driver

因 Dialect 在 MySQL 版本有差異,啟動 application 會出現這樣的錯誤資訊

org.hibernate.dialect.Dialect            : HHH000511: The 5.5.68 version for [org.hibernate.dialect.MySQLDialect] is no longer supported, hence certain features may not work properly. The minimum supported version is 8.0.0. Check the community dialects project for available legacy versions.

這時候要調整使用的 Dialect

        <dependency>
            <groupId>org.hibernate.orm</groupId>
            <artifactId>hibernate-community-dialects</artifactId>
        </dependency>

application.yml

spring:
  jpa:
    properties:
      hibernate:
        # dialect: org.hibernate.dialect.MySQLDialect
        dialect: org.hibernate.community.dialect.MySQLLegacyDialect

啟動時,同時會看到 Hikari connection pool 的 log

HikariPool-1 - Starting...
HikariPool-1 - Added connection org.mariadb.jdbc.Connection@3a175162
HikariPool-1 - Start completed.

自訂 DataSource

直接註冊一個實作 DataSource 的 bean 即可

package com.test.data1;

import org.mariadb.jdbc.MariaDbDataSource;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;

import javax.sql.DataSource;

@Configuration(proxyBeanMethods = false)
public class DbConfig {
    @Bean
    @ConfigurationProperties(prefix="spring.datasource")
    public DataSource dataSource() {
        return new MariaDbDataSource();
    }

}

也可定義兩個 DataSource,但第一個要設定為 @Primary

package com.test.data1;

import org.mariadb.jdbc.MariaDbDataSource;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.context.annotation.Primary;

import javax.sql.DataSource;

@Configuration(proxyBeanMethods = false)
public class DbConfig2 {

    @Primary
    @Bean
    @ConfigurationProperties(prefix="spring.datasource.xx.one")
    public DataSource dataSource1() {
        return new MariaDbDataSource();
    }

    @Bean
    @ConfigurationProperties(prefix="spring.datasource.xx.two")
    public DataSource dataSource2() {
        return new MariaDbDataSource();
    }
}

透過 AbstractRoutingDataSource#determineCurrentLookupKey 決定哪一個 data source。


Connection Pool

spring boot 預設使用 HikariCP

自動設定類別是 PooledDataSourceConfiguration

spring 提供以下幾種 connection pool,依照 libary dependency 判斷順序選擇

  • HikariCP

  • Tomcat

  • DBCP2

  • Oracle UCP

如果都沒有,就直接使用JDBC connection

可設定 spring.datasource.type ,這樣就不會自動選擇 connection pool

spring:
  datasource:
    type: org.apache.commons.dbcp2.BasicDataSource

資料庫初始化

spring boot 可在啟動時處理 DDL, DML

目前是用 SqlInitializationAutoConfiguration 自動設定

對應參數綁定是 SqlInitializationProperties,可透過 spring.sql.init.* 參數設定

spring:
  application:
    name: data1

  datasource:
    #driver-class-name: com.mysql.cj.jdbc.Driver
    driver-class-name: org.mariadb.jdbc.Driver
    url: jdbc:mariadb://localhost/testweb
    username: root
    password: password
  sql:
    init:
      mode: ALWAYS
      continue-on-error: true
      schema-locations: sql/create_user.sql
      data-locations:
        - classpath:sql/insert_user.sql

mode: 資料庫初始化模式

  • ALWAYS: 會對外部關聯式資料庫初始化,application 每一次啟動,該 sql 都會被執行一次

  • EMBEDDED: 只會對 embedded database 自動初始化

  • NEVER

continue-on-error: true,初始化錯誤,要不要繼續執行。如果 mode 為 ALWAYS,就會忽略重複 insert 有 primary key 的資料的錯誤

schema-locations: DDL script

data-locations: DML script


JdbcTemplate

JdbcTemplate 是 spring 用來簡化 JDBC operation 的 wrapper,只需要引用 spring-boot-starter-data-jdbc 就可以使用

自動設定類別是 JdbcTemplateAutoConfiguration,裡面使用了 JdbcTempateConfiguration,有預設的 JdbcTemplate instance,在 application 中注入即可使用

@Slf4j
@RequiredArgsConstructor
@SpringBootApplication
public class Data1Application {
    public final JdbcTemplate jdbcTemplate;

    public static void main(String[] args) {
        SpringApplication.run(Data1Application.class, args);
    }

    @Bean
    @Transactional
    public CommandLineRunner commandLineRunner() {
        return testJdbcTemplate();
    }

    private CommandLineRunner testJdbcTemplate() {
        return (args) -> {
            log.info("testing JdbcTemplate...");
            String username = jdbcTemplate.queryForObject("select username from user where id = 1", String.class);
            log.info("id = 1, username = {}", username);
        };
    }

}

自訂 JdbcTemplate

可以在 spring.jdbc.template.* 修改 jdbcTemplate 的設定

spring:
  jdbc:
    template:
      max-rows: 3
    private CommandLineRunner testJdbcTemplate2() {
        return (args) -> {
            log.info("testing JdbcTemplate2...");
            List<Map<String, Object>> result= jdbcTemplate.queryForList("select username from user");
            log.info("result list size={}", result.size());
        };
    }

因為設定的限制,回傳的 list size 最多只會是 3


transaction

spring-boot-starter-data-jdbc 會引用 transaction 相關 library,自動設定類別有

  • TransactionAutoConfiguration

  • DataSourceTransactionManagerAutoConfiguration

參數綁定類別是 TransactionProperties

spring.transaction.* 參數設定

新增一個 user DTO 物件

package com.test.data1.entity;

import lombok.Data;
import org.springframework.data.relational.core.mapping.Column;

import java.time.LocalDateTime;

@Data
public class UserDO {

    private long id;

    private String username;

    private String phone;

    @Column(value = "create_time")
    private LocalDateTime createTime;

    private int status;

}

製作 DAO interface UserDao.java

package com.test.data1.dao;

public interface UserDao {
    void update();
}

UserDaoImpl.java

package com.test.data1.dao.impl;

import com.test.data1.dao.UserDao;
import lombok.RequiredArgsConstructor;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

@RequiredArgsConstructor
@Service
public class UserDaoImpl implements UserDao {

    public final JdbcTemplate jdbcTemplate;

    @Transactional
    @Override
    public void update() {
        jdbcTemplate.execute("update user set username = 'Petty' where id = 1");
        jdbcTemplate.execute("update user set username = 'Yoga' where id = 2");
        throw new RuntimeException("test exception");
    }

}

Data1Application.java

@Slf4j
@RequiredArgsConstructor
@SpringBootApplication
public class Data1Application {
    public final JdbcTemplate jdbcTemplate;

    public final UserDao userDao;

    public static void main(String[] args) {
        SpringApplication.run(Data1Application.class, args);
    }

    @Bean
    @Transactional
    public CommandLineRunner commandLineRunner() {
        return useJdbcTemplate();
    }

    private CommandLineRunner useJdbcTemplate() {
        return (args) -> {
            log.info("using JdbcTemplate...");
            BeanPropertyRowMapper<UserDO> rowMapper = new BeanPropertyRowMapper<>(UserDO.class);
            UserDO userDO = jdbcTemplate.queryForObject("select * from user where id = 2", rowMapper);
            log.info("user info : {}", userDO);

            List<UserDO> userDOList = jdbcTemplate.query("select * from user", rowMapper);
            log.info("user list: {}", userDOList);

            log.info("userDao.update()...");
            userDao.update();

            List<UserDO> userDOList2 = jdbcTemplate.query("select * from user", rowMapper);
            log.info("user list2: {}", userDOList2);
        };
    }
}

雖然在 update() 裡面修改了 username,但因爲 @Transactional 的關係,資料 rollback 回原本的狀態,修改的資料會被 rollback。


transaction 失敗的原因

  1. Database 不支援 transaction。MySQL 的 MyISAM 不支援 transaction,在 5.5 以後預設是使用 InnoDB,這個才有支援 transaction

  2. 沒有被 spring 管理

    UserDaoImpl 裡面的 @Service 如果刪除,就不會受到 spring 管理

    //@Service
    public class UserDaoImpl implements UserDao {
    }
  3. @Transactional 的 method 必須要是 public method

    如果要用在非 public method,必須開啟 AspectJ framework 的靜態代理模式

  4. 呼叫內部 method

    business method 一定要經過 spring 才能處理 transaction

    以下這兩種狀況,transaction 都不會生效

    @Service
    public class UserDaoImpl implements UserDao {
       public void update1(User user) {
           //call update2
           update2(user);
       }
    
       @Transactional
       public void update2(User user) {
           //update user
       }
    }
    @Service
    public class UserDaoImpl implements UserDao {
       @Transactional
       public void update1(User user) {
           //call update2
           update2(user);
       }
    
       @Transactional(propagation = Propagation.REQUIRES_NEW)
       public void update2(User user) {
           //update user
       }
    }

    解決方法是在 UserDaoImpl 裡面注入自己,用該物件,呼叫另一個 method

    @EnableAspectJAutoProxy(exposeProxy = true)

    取得當前的 proxy,並呼叫 method

    ((UserDaoImpl)AopContext.currentProxy()).update2();
  5. 沒有配置 transaction manager

    必須要有 PlatformTransactionManager 才能處理 transaction

    @Bean
    public PlatformTransactionManager transactionManager(DataSource dataSource) {
       return new DataSourceTransactionalManager(dataSource);
    }

    spring-boot-starter-data-jdbc 裡面自動設定了一個 DataSourceTransactionalManager,所以可直接使用

  6. 設定不支援 transaction

    @Service
    public class UserDaoImpl implements UserDao {
       @Transactional
       public void update1(User user) {
           //call update2
           update2(user);
       }
    
       @Transactional(propagation = Propagation.NOT_SUPPORTED)
       public void update2(User user) {
           //update user
       }
    }
  7. 沒有 throw exception,就不會 rollback

    @Service
    public class UserDaoImpl implements UserDao {
       @Transactional
       public void update1(User user) {
           try{
           } catch(Exception e) {
           }
       }
    }
  8. exception 類別不同

    因為 spring 預設針對 RuntimeException 進行 rollback,如果該 method throw 的 exception 類別錯誤,也不會觸發 rollback,除非在 @Transactional 設定 rollbackFor Exception 類別

    @Service
    public class UserDaoImpl implements UserDao {
       @Transactional(rollbackFor = Exception.class)
       public void update1(User user) {
           try{
           } catch(Exception e) {
              throw new Exception(e.getMessage());
           }
       }
    }

2025/04/21

SpringBoot3 Web 3

CORS 跨來源資源共享

SAMEORIGIN 同源政策,是 Netscape 提出,所有支援 Javascript 的瀏覽器都使用這個策略,同源是 domain name, port, protocol 相同的意思,網頁執行一個 script 時,會檢查是否同源,非同源就會拒絕。

只要 domain name, port, protocol 某一個不同,就代表 cross origin。

CORS 允許伺服器指示瀏覽器允許從除其自身以外的任何來源載入資源。

spring MVC 本身透過在 method 上設定 @CrossOrigin

@Slf4j
@RestController
@Validated
public class ResponseBodyController {

    @CrossOrigin
    @GetMapping(value = "/user/json/{userId}", produces = MediaType.APPLICATION_JSON_VALUE)
    public User getJsonUserInfo(@PathVariable("userId") @Size(min = 5, max = 8) String userId) {
        User user = new User("test1", 18);
        user.setId(Long.valueOf(userId));
        log.info("user info: {}", user);
        return user;
    }
}

@CrossOrigin 預設支援所有 request 來源、所有 http header, methods

@CrossOrigin 也可以用在 class

@Slf4j
@CrossOrigin(origins = "https://www.test.com", maxAge=3600)
@RestController
public class ResponseBodyController {
}

也可以放在 WebMvcConfigurer

@Configuration
@RequiredArgsConstructor
public class WebConfig implements WebMvcConfigurer {

    @Override
    public void addCorsMappings(CorsRegistry registry) {
        registry.addMapping("/user/**")
                .allowedMethods("GET", "POST")
                .allowedOrigins("https://www.test.com")
                .allowCredentials(true)
                .maxAge(3600);
    }
}

安全性

加上 security library

        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-security</artifactId>
        </dependency>

啟動時會出現以下的 log

2024-11-20T16:36:50.740+08:00  WARN 5307 --- [           main] .s.s.UserDetailsServiceAutoConfiguration : 

Using generated security password: 06ee988c-9983-4187-b040-5bfadde83893

This generated password is for development use only. Your security configuration must be updated before running your application in production.

2024-11-20T16:36:50.784+08:00  INFO 5307 --- [           main] r$InitializeUserDetailsManagerConfigurer : Global AuthenticationManager configured with UserDetailsService bean with name inMemoryUserDetailsManager

瀏覽 http://localhost:8080/demo1/test 時,會出現 ## Please sign in 的登入畫面

這時候填寫

username: user
password: 06ee988c-9983-4187-b040-5bfadde83893

就可以打開 index 頁面

可在 application.yml 修改預設的帳號密碼

spring:
  security:
    user:
      name: root
      password: password

自訂安全機制

可註冊 SecurityFilterChain 與 UserDetailsService 類別,實作自訂安全機制

SecurityConfig.java

package tw.com.test.demo1.config;

import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.core.userdetails.User;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.security.provisioning.InMemoryUserDetailsManager;
import org.springframework.security.web.SecurityFilterChain;

import static org.springframework.security.config.Customizer.withDefaults;

@Configuration
public class SecurityConfig {

    @Bean
    public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
        return http.authorizeHttpRequests((authorize) -> {
            authorize.requestMatchers("/test/**").hasRole("TEST").requestMatchers("/**").permitAll();
        }).logout((logout) -> logout.logoutUrl("/")).formLogin(withDefaults()).build();
    }

    @Bean
    public UserDetailsService userDetailsService() {
        InMemoryUserDetailsManager manager = new InMemoryUserDetailsManager();
        manager.createUser(User.withUsername("test").password("{noop}test").roles("ADMIN", "TEST").build());
        manager.createUser(User.withUsername("root").password("{noop}root").roles("ADMIN").build());
        return manager;
    }

}

REST service

實作 REST service 有兩種方式

  • RestTemplate (servlet)

  • WebClient (reactive)

RestTemplate (servlet)

pom.xml

        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-web</artifactId>
        </dependency>

自動設定類別 RestTemplateAutoConfiguration

註冊在 org.springframework.boot.autoconfigure.AutoConfiguration.imports

ResTemplate 並沒有任何預設的註冊

必須要先註冊一個 RestTemplate instance,可透過 restTemplateBuilder 產生

@Configuration
@RequiredArgsConstructor
public class WebConfig implements WebMvcConfigurer {

    @Bean
    public RestTemplate defaultRestTemplate(RestTemplateBuilder restTemplateBuilder) {
        return restTemplateBuilder
                .build();
    }
}

新增一個 controller 測試

package tw.com.test.demo1.controller;

import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.client.RestClient;
import org.springframework.web.client.RestTemplate;
import tw.com.test.demo1.bean.User;

@Slf4j
@RestController
@RequiredArgsConstructor
public class CallRestController {

    public static final String GET_USERINFO_URL = "http://localhost:8080/demo1/user/json/{uid}";
    private final RestTemplate restTemplate;

    @GetMapping("/restTemplate/{uid}")
    public User restTemplate(@PathVariable("uid") String uid) {
        return this.restTemplate.getForObject(GET_USERINFO_URL, User.class, uid);
    }

}

用 postman 測試 GET http://localhost:8080/demo1/restTemplate/10000

回傳結果

{
    "id": 10000,
    "age": 18,
    "username": "test1"
}

自訂 RestTemplate,加上 timeout, basic authentication

    @Bean
    public RestTemplate defaultRestTemplate(RestTemplateBuilder restTemplateBuilder) {
        return restTemplateBuilder
                .setConnectTimeout(Duration.ofSeconds(5))
                .setReadTimeout(Duration.ofSeconds(5))
                .basicAuthentication("test", "test")
                .build();
    }

也可以直接實作 RestTemplateCustomizer

package tw.com.test.demo1.handler;

import lombok.extern.slf4j.Slf4j;
import org.apache.hc.client5.http.classic.HttpClient;
import org.apache.hc.client5.http.config.RequestConfig;
import org.apache.hc.client5.http.impl.classic.HttpClientBuilder;
import org.apache.hc.client5.http.impl.routing.DefaultProxyRoutePlanner;
import org.apache.hc.client5.http.routing.HttpRoutePlanner;
import org.apache.hc.core5.http.HttpException;
import org.apache.hc.core5.http.HttpHost;
import org.apache.hc.core5.http.protocol.HttpContext;
import org.springframework.boot.web.client.RestTemplateCustomizer;
import org.springframework.http.client.HttpComponentsClientHttpRequestFactory;
import org.springframework.stereotype.Component;
import org.springframework.web.client.RestTemplate;


@Slf4j
@Component
public class CustomRestTemplateCustomizer implements RestTemplateCustomizer {

    @Override
    public void customize(RestTemplate restTemplate) {
        HttpRoutePlanner routePlanner = new CustomRoutePlanner(new HttpHost("proxy.test.com"));
        RequestConfig requestConfig = RequestConfig.custom().build();
        HttpClient httpClient = HttpClientBuilder.create().setDefaultRequestConfig(requestConfig).setRoutePlanner(routePlanner).build();
        restTemplate.setRequestFactory(new HttpComponentsClientHttpRequestFactory(httpClient));
    }

    static class CustomRoutePlanner extends DefaultProxyRoutePlanner {

        CustomRoutePlanner(HttpHost proxy) {
            super(proxy);
        }

        @Override
        protected HttpHost determineProxy(HttpHost target, HttpContext context) throws HttpException {
            log.info("hostName is {}", target.getHostName());
            if ("localhost".equals(target.getHostName())) {
                return null;
            }
            return super.determineProxy(target, context);
        }

    }

}

有用到 http client 5

        <dependency>
            <groupId>org.apache.httpcomponents.client5</groupId>
            <artifactId>httpclient5</artifactId>
        </dependency>

        <dependency>
            <groupId>org.apache.httpcomponents</groupId>
            <artifactId>httpcore</artifactId>
        </dependency>

也可以實作最底層的 HttpAccessor


WebClient (reactive)

WebClient 是使用 speign WebFlux 的 http client

pom.xml

    <dependencies>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-webflux</artifactId>
        </dependency>

        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-test</artifactId>
            <scope>test</scope>
        </dependency>
        <dependency>
            <groupId>io.projectreactor</groupId>
            <artifactId>reactor-test</artifactId>
            <scope>test</scope>
        </dependency>

        <dependency>
            <groupId>org.projectlombok</groupId>
            <artifactId>lombok</artifactId>
            <scope>provided</scope>
        </dependency>
    </dependencies>

User.java

package tw.com.test.demo3.bean;

import lombok.AllArgsConstructor;
import lombok.Data;

@Data
@AllArgsConstructor
public class User {
    private Long id;
    private String username;
    private Integer age;
}

WebConfig.java

package tw.com.test.demo3.config;

import io.netty.channel.ChannelOption;
import io.netty.handler.timeout.ReadTimeoutHandler;
import io.netty.handler.timeout.WriteTimeoutHandler;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.http.client.reactive.ReactorClientHttpConnector;
import org.springframework.web.reactive.function.client.WebClient;
import reactor.netty.http.client.HttpClient;

@Configuration
public class WebConfig {

    @Bean
    public WebClient webClient(WebClient.Builder webClientBuilder) {
        return webClientBuilder.build();
    }

}

CallRestController.java

package tw.com.test.demo3.controller;

import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.reactive.function.client.WebClient;
import reactor.core.publisher.Mono;
import tw.com.test.demo3.bean.User;

@Slf4j
@RestController
@RequiredArgsConstructor
public class CallRestController {

    public static final String GET_USERINFO_URL = "http://localhost:8080/user/json/{uid}";

    private final WebClient webClient;

    @GetMapping("/webClient/{uid}")
    public Mono<User> webClient(@PathVariable("uid") String uid) {
        return this.webClient.get().uri(GET_USERINFO_URL, uid).retrieve().bodyToMono(User.class);
    }

}

這是要呼叫的 rest url

package tw.com.test.demo3.controller;

import lombok.extern.slf4j.Slf4j;
import org.springframework.http.MediaType;
import org.springframework.validation.annotation.Validated;
import org.springframework.web.bind.annotation.*;
import tw.com.test.demo3.bean.User;

@Slf4j
@RestController
@Validated
public class ResponseBodyController {

    @CrossOrigin
    @GetMapping(value = "/user/json/{userId}", produces = MediaType.APPLICATION_JSON_VALUE)
    public User getJsonUserInfo(@PathVariable("userId") String userId) {
        User user = new User((long)1000, "test", 18);
        user.setId(Long.valueOf(userId));
        log.info("user info: {}", user);
        return user;
    }

}

以 GET method 呼叫 http://localhost:8080/webClient/10000

就能取得結果

{
    "id": 10000,
    "username": "test",
    "age": 18
}

自訂 WebClient

修改 WebConfig.java,改用 reactor netty 的 ReactorClientHttpConnector

package tw.com.test.demo3.config;

import io.netty.channel.ChannelOption;
import io.netty.handler.timeout.ReadTimeoutHandler;
import io.netty.handler.timeout.WriteTimeoutHandler;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.http.client.reactive.ReactorClientHttpConnector;
import org.springframework.web.reactive.function.client.WebClient;
import reactor.netty.http.client.HttpClient;

@Configuration
public class WebConfig {

//    @Bean
//    public WebClient webClient(WebClient.Builder webClientBuilder) {
//        return webClientBuilder.build();
//    }

    @Bean
    public WebClient webClient(WebClient.Builder webClientBuilder) {
        HttpClient httpClient = HttpClient.create().option(ChannelOption.CONNECT_TIMEOUT_MILLIS, 3).doOnConnected(conn -> {
            conn.addHandlerLast(new ReadTimeoutHandler(3000));
            conn.addHandlerLast(new WriteTimeoutHandler(3000));
        });
        ReactorClientHttpConnector connector = new ReactorClientHttpConnector(httpClient);
        return webClientBuilder.clientConnector(connector).build();
    }

}

可改用 ClientHttpConnector 的其他實作的客戶端,目前 spring boot 支援

  • Reactor netty (default)

  • Jetty RS Client

  • Apache HttpClient

  • JDK HttpClient