Command Palette

Search for a command to run...

[Spring Boot Basics] Công cụ tăng năng suất trong Spring Boot: DevTools, Lombok và Actuator cơ bản

Chương 7 nói về những công cụ nằm quanh code của ứng dụng chứ không thuộc một tầng nào bên trong. Spring Initializr liệt kê ba công cụ như vậy cạnh các starter quen thuộc: DevTools, tự restart ứng dụng khi một class thay đổi; Lombok, sinh getter, constructor và logger lúc compile; và Actuator, thêm các endpoint vận hành như /actuator/health/actuator/info. Cả ba đều dễ thêm vào, và mỗi công cụ có một cách hỏng mà tutorial hiếm khi cho thấy: một lần restart xảy ra khi bạn không ngờ tới, một entity có toString làm sập cả request, một health endpoint cho bất kỳ ai xem database của bạn.

Các ví dụ dùng Spring Boot 4.1.1 và Java 21, ứng dụng chạy ở port 8139 thay vì 8080 mặc định. Các số đo thời gian chỉ mang tính tham khảo, và mỗi số đều ghi kèm load average một phút lúc đo. Đường dẫn dài trong output được rút gọn thành /…/.

Ba thẻ công cụ DevTools, Lombok và Actuator: một mũi tên restart, một ký hiệu annotation và một nhịp health

Bài viết đi qua các công cụ theo thứ tự bạn gặp chúng trong một ngày làm việc: DevTools khi sửa code, Lombok khi viết class, Actuator khi ứng dụng đã chạy ở đâu đó.

Project: devtools, lombok và actuator từ Spring Initializr

Project được tạo với các dependency quen thuộc của catalogue cộng thêm ba công cụ:

Bash
curl -s "https://start.spring.io/starter.zip?type=gradle-project&language=java&bootVersion=4.1.1&javaVersion=21&groupId=com.example&artifactId=demo&name=demo&packageName=com.example.demo&dependencies=web,validation,data-jpa,h2,devtools,lombok,actuator" -o demo.zip

Mỗi công cụ rơi vào một dependency configuration khác nhau, và lựa chọn đó là điều đầu tiên cần hiểu về nó:

build.gradle
dependencies {
	implementation 'org.springframework.boot:spring-boot-h2console'
	implementation 'org.springframework.boot:spring-boot-starter-actuator'
	implementation 'org.springframework.boot:spring-boot-starter-data-jpa'
	implementation 'org.springframework.boot:spring-boot-starter-validation'
	implementation 'org.springframework.boot:spring-boot-starter-webmvc'
	compileOnly 'org.projectlombok:lombok'
	developmentOnly 'org.springframework.boot:spring-boot-devtools'
	runtimeOnly 'com.h2database:h2'
	annotationProcessor 'org.projectlombok:lombok'
	testImplementation 'org.springframework.boot:spring-boot-starter-actuator-test'
	testImplementation 'org.springframework.boot:spring-boot-starter-data-jpa-test'
	testImplementation 'org.springframework.boot:spring-boot-starter-validation-test'
	testImplementation 'org.springframework.boot:spring-boot-starter-webmvc-test'
	testCompileOnly 'org.projectlombok:lombok'
	testRuntimeOnly 'org.junit.platform:junit-platform-launcher'
	testAnnotationProcessor 'org.projectlombok:lombok'
}
Công cụGradle configurationMavenCó trên compile classpathCó trong jar đóng gói
DevToolsdevelopmentOnlyruntime + optionalkhôngkhông
LombokcompileOnly + annotationProcessoroptional + annotationProcessorPathskhông
Actuatorimplementationcompile scope

./gradlew dependencies resolve Lombok thành 1.18.46, phiên bản Spring Boot 4.1.1 quản lý, và cho thấy Actuator starter kéo theo bốn module của Boot: spring-boot-actuator, spring-boot-actuator-autoconfigure, spring-boot-healthspring-boot-micrometer-metrics cùng Micrometer 1.17.1. Project Maven tạo từ cùng request Initializr được build bằng ./mvnw package: jar của nó không chứa jar Lombok hay DevTools nào, dù pom không có <excludes> cho chúng.

Code của ứng dụng là catalogue sản phẩm của các chương trước ở dạng thu nhỏ: entity Product với khóa IDENTITY, sku unique và giá numeric(10,2), một ProductRepository, một seeder thêm hai sản phẩm, và một controller:

src/main/java/com/example/demo/product/ProductController.java
@RestController
@RequestMapping("/api/products")
public class ProductController {
 
    private final ProductRepository repository;
 
    public ProductController(ProductRepository repository) {
        this.repository = repository;
    }
 
    @GetMapping
    public List<ProductResponse> findAll() {
        return repository.findAll(Sort.by("id")).stream().map(ProductResponse::from).toList();
    }
 
    @GetMapping("/version")
    public String version() {
        return "v1";
    }
}
src/main/resources/application.properties
spring.application.name=demo
server.port=8139
spring.datasource.url=jdbc:h2:mem:catalog
spring.jpa.open-in-view=false

GET /api/products/version tồn tại chỉ để được sửa trong lúc ứng dụng đang chạy.

Spring Boot DevTools

DevTools thay đổi những gì và vì sao nó là developmentOnly

Chạy bằng ./gradlew bootRun, log của ứng dụng đã cho thấy DevTools đang làm việc:

Text
[  restartedMain] com.example.demo.DemoApplication         : Starting DemoApplication using Java 21.0.6 with PID 46199 (/…/demo/build/classes/java/main started by you in /…/demo)
[  restartedMain] .e.DevToolsPropertyDefaultsPostProcessor : Devtools property defaults active! Set 'spring.devtools.add-properties' to 'false' to disable
[  restartedMain] .e.DevToolsPropertyDefaultsPostProcessor : For additional web related logging consider setting the 'logging.level.web' property to 'DEBUG'
[  restartedMain] o.s.b.h.a.H2ConsoleAutoConfiguration     : H2 console available at '/h2-console'. Database available at 'jdbc:h2:mem:catalog'
[  restartedMain] com.example.demo.DemoApplication         : Started DemoApplication in 1.494 seconds (process running for 1.641)
  • restartedMain là tên thread. DevTools không chạy ứng dụng trên main: nó khởi động ứng dụng lại trên một thread mới, bên trong một classloader do nó kiểm soát, và đó là điều khiến restart khả thi.
  • Devtools property defaults active! nghĩa là DevTools đã thêm các giá trị property tiện cho lúc phát triển, liệt kê ở một mục bên dưới. Dòng H2 console là một trong số đó: spring.h2.console.enabled mặc định là false và DevTools bật nó lên.

DevTools restart ứng dụng mỗi khi classpath thay đổi, đúng điều production không bao giờ được làm, và nó mở H2 console cùng stack trace đầy đủ cho bất kỳ ai. developmentOnly đặt nó lên classpath của bootRun và không ở đâu khác. Jar đóng gói chứng minh điều đó:

Bash
./gradlew bootJar
jar tf build/libs/demo-0.0.1-SNAPSHOT.jar | grep -E 'devtools|lombok'

grep không in gì và thoát với status 1: trong 80 jar dưới BOOT-INF/lib/, không có jar nào là DevTools hay Lombok. Chạy bằng java -jar build/libs/demo-0.0.1-SNAPSHOT.jar, cùng ứng dụng đó log trên main, không in dòng DevTools nào, không có dòng H2 console, và khởi động trong 1.957, 1.638 và 1.787 giây qua ba lần chạy.

DevTools cũng từ chối kích hoạt ngay cả khi nó trong jar. Thêm bootJar { classpath configurations.developmentOnly } cho một lần build, BOOT-INF/lib/spring-boot-devtools-4.1.1.jar nằm trong jar, nhưng java -jar vẫn chạy trên main, không log Devtools property defaults active! và không có H2 console. DevTools 4.1.1 chỉ bật restart khi classloader của main thread là application classloader của JDK, còn dưới java -jar đó là LaunchedClassLoader của Spring Boot. Tài liệu Spring Boot gọi ứng dụng như vậy là "production application"; -Dspring.devtools.restart.enabled=true bỏ qua phép kiểm tra này, và chỉ dành cho trường hợp đặc biệt.

Restart tự động hoạt động thế nào: hai classloader

Một CommandLineRunner nhỏ cho thấy các class được nạp từ đâu:

src/main/java/com/example/demo/ClassLoaderReport.java
@Component
class ClassLoaderReport implements CommandLineRunner {
 
    @Override
    public void run(String... args) {
        System.out.println("your class:    " + getClass().getClassLoader());
        System.out.println("Spring Boot:   " + SpringApplication.class.getClassLoader());
        System.out.println("parent of yours: " + getClass().getClassLoader().getParent());
    }
}

Dưới ./gradlew bootRun:

Text
your class:    org.springframework.boot.devtools.restart.classloader.RestartClassLoader@657e2a5a
Spring Boot:   jdk.internal.loader.ClassLoaders$AppClassLoader@2c854dc5
parent of yours: jdk.internal.loader.ClassLoaders$AppClassLoader@2c854dc5

Dưới java -jar, hai dòng đầu đều in org.springframework.boot.loader.launch.LaunchedClassLoader@378bf509.

DevTools chia classpath làm hai. Mọi thứ nằm trong jar, Spring, Hibernate, Tomcat, H2 và khoảng 80 thư viện khác, ở lại trong base classloader, tức AppClassLoader của JDK. Các thư mục output của chính project, build/classes/java/mainbuild/resources/main, được nạp bởi một restart classloader có parent là base classloader. Một thread File Watcher poll các thư mục đó. Khi có thay đổi, DevTools đóng application context, bỏ restart classloader đi, tạo một cái mới và chạy lại main trên một thread restartedMain mới, trong cùng JVM.

Đổi "v1" thành "v2" trong controller rồi chạy ./gradlew classes ở một terminal khác cho ra:

Text
[   File Watcher] rtingClassPathChangeChangedEventListener : Restarting due to 1 class path change (0 additions, 0 deletions, 1 modification)
[       Thread-1] o.s.boot.tomcat.GracefulShutdown         : Commencing graceful shutdown. Waiting for active requests to complete
[tomcat-shutdown] o.s.boot.tomcat.GracefulShutdown         : Graceful shutdown complete
[       Thread-1] j.LocalContainerEntityManagerFactoryBean : Closing JPA EntityManagerFactory for persistence unit 'default'
[       Thread-1] o.s.b.f.support.DisposableBeanAdapter    : Invocation of destroy method failed on bean with name 'inMemoryDatabaseShutdownExecutor': org.h2.jdbc.JdbcSQLNonTransientConnectionException: Database is already closed (to disable automatic closing at VM shutdown, add ";DB_CLOSE_ON_EXIT=FALSE" to the db URL) [90121-240]
[       Thread-1] com.zaxxer.hikari.HikariDataSource       : HikariPool-1 - Shutdown completed.
[  restartedMain] com.example.demo.DemoApplication         : Started DemoApplication in 0.223 seconds (process running for 14.396)
[  restartedMain] .ConditionEvaluationDeltaLoggingListener : Condition evaluation unchanged

curl http://localhost:8139/api/products/version trả về v2. PID không đổi và process running for 14.396 tính từ lần khởi động đầu tiên: JVM vẫn giữ nguyên, chỉ context được dựng lại. Dòng WARN đến từ inMemoryDatabaseShutdownExecutor, một bean DevTools thêm vào để tắt database in-memory khi restart; trên phiên bản H2 này nó thấy database đã đóng từ trước và log điều đó ở mỗi lần restart, không ảnh hưởng gì tới ứng dụng sau restart. Condition evaluation unchanged cho biết lần restart không làm thay đổi auto-configuration nào được áp dụng.

Restart nhanh hơn khởi động lạnh vì các class của 80 jar kia đã được nạp sẵn trong base classloader, và phần code chạy nhiều của chúng đã được JIT compile, khi context mới bắt đầu. Đo trên cùng project:

Cách khởi độngSố lần chạyStarted … in, tốt nhấtLoad average
./gradlew bootRun lạnh (có DevTools)61.488 s3.02
java -jar (không DevTools)31.638 s2.77
DevTools restart sau khi sửa controller100.185 s2.63
Từ lúc lưu file đến Started, với ./gradlew -t classes32.02 s2.63

Lần restart dựng lại context trong khoảng một phần tám thời gian khởi động lạnh. Dòng cuối mới là thời gian bạn thực sự chờ: Gradle phát hiện thay đổi và compile, rồi watcher của DevTools, vốn poll mỗi giây (spring.devtools.restart.poll-interval, mặc định 1s) và chờ 400 ms không có thay đổi nào nữa (spring.devtools.restart.quiet-period, mặc định 400ms) để một lần compile ghi nhiều file class chỉ gây một lần restart chứ không phải nhiều lần. Bản thân lần restart là phần nhỏ nhất trong hai giây đó.

DevTools chia classpath: các jar ở lại trong base AppClassLoader, còn build/classes và build/resources được nạp bởi một RestartClassLoader mà File Watcher bỏ đi và tạo lại khi có thay đổi, kèm số đo 1.488 s khởi động lạnh so với 0.185 s restart

Kích hoạt restart từ Gradle và từ IDE

DevTools theo dõi build/classesbuild/resources, không phải src, nên restart cần có thứ gì đó compile. Từ command line, một terminal thứ hai chạy Gradle ở chế độ continuous:

Bash
./gradlew -t classes
Text
Waiting for changes to input files...
new file: /…/demo/src/main/java/com/example/demo/product/ProductController.java
Change detected, executing build...
 
> Task :compileJava
> Task :processResources UP-TO-DATE
> Task :classes
 
BUILD SUCCESSFUL in 468ms

Lần build mất 468 ms, và terminal bootRun log Restarting due to 1 class path change ngay sau đó. Chín trong mười lần restart ở bảng trên được kích hoạt theo cách này. Một lần ./gradlew classes riêng lẻ làm điều tương tự mà không theo dõi; tài liệu Spring Boot 4.1.1 nhắc tới gradle buildmvn compile.

Trong IDE, compiler của chính IDE ghi output. Tài liệu Spring Boot viết rằng trong IntelliJ IDEA, build project (Build -> Build Project) kích hoạt restart, và trong Eclipse, lưu một file đã sửa cũng vậy. JetBrains mô tả action Update Running Application cho run configuration Spring Boot, với các lựa chọn "On 'Update' action" và "On frame deactivation" có thể build project hoặc cập nhật một trigger file. Các cách dùng trong IDE này không được chạy thử cho bài viết.

Những gì không kích hoạt restart

Không phải file nào trong các thư mục được theo dõi cũng làm ứng dụng restart. Giá trị mặc định của spring.devtools.restart.exclude, đọc từ configuration metadata của 4.1.1:

Text
META-INF/maven/**,META-INF/resources/**,resources/**,static/**,public/**,templates/**,**/*Test.class,**/*Tests.class,git.properties,META-INF/build-info.properties

Static resource và template được phục vụ trực tiếp từ thư mục classpath, nên thay đổi ở đó không cần restart. Ghi thẳng <p>hello v4</p> vào build/resources/main/static/hello.html không tạo ra dòng log nào, và GET /hello.html tiếp theo trả về nội dung mới kèm Cache-Control: no-store. Thêm một dòng vào build/resources/main/application.properties làm ứng dụng restart trong vòng năm giây: Restarting due to 1 class path change (0 additions, 0 deletions, 1 modification).

Với Gradle có một điểm cần để ý. Sửa src/main/resources/static/hello.html rồi chạy ./gradlew processResources làm ứng dụng restart, với Restarting due to 2 class path changes (0 additions, 0 deletions, 2 modifications). processResources copy lại mọi resource khi bất kỳ resource nào thay đổi: thời điểm sửa đổi của build/resources/main/application.properties chuyển từ 15:04:23 sang 15:04:45 dù file nguồn không bị động tới, và application.properties không nằm trong danh sách loại trừ. -t classes cũng vậy, vì nó chạy processResources. Để sửa static file và template mà không restart khi dùng Gradle, cho bootRun đọc resource từ thư mục nguồn:

build.gradle
tasks.named('bootRun') {
	sourceResources sourceSets.main
}

Khi đó, sửa src/main/resources/static/hello.html được phục vụ ngay ở request tiếp theo, không restart và không build, trong khi sửa src/main/resources/application.properties vẫn làm ứng dụng restart. Để loại trừ thêm đường dẫn mà không mất các giá trị mặc định, dùng spring.devtools.restart.additional-exclude.

Các property mặc định DevTools áp dụng

Devtools property defaults active! nói tới các giá trị mà từng module Spring Boot khai báo trong file META-INF/spring-devtools.properties của riêng nó, với tiền tố defaults., và DevTools thêm chúng thành một property source độ ưu tiên thấp tên là devtools. Liệt kê các file đó trên runtime classpath của project và trong jar spring-boot-thymeleaf 4.1.1:

PropertyKhi có DevToolsMặc định thông thườngKhai báo trong
spring.h2.console.enabledtruefalsespring-boot-h2console
spring.web.error.include-stacktracealwaysneverspring-boot-autoconfigure
spring.web.error.include-messagealwaysneverspring-boot-autoconfigure
spring.web.error.include-binding-errorsalwaysneverspring-boot-autoconfigure
spring.web.resources.cache.period0không đặtspring-boot-autoconfigure
spring.web.resources.chain.cachefalsetruespring-boot-autoconfigure
spring.template.provider.cachefalsekhông có trong metadataspring-boot-autoconfigure
spring.mvc.log-resolved-exceptiontruefalsespring-boot-webmvc
server.servlet.session.persistenttruefalsespring-boot-web-server
server.servlet.jsp.init-parameters.developmenttruekhông đặtspring-boot-web-server
spring.thymeleaf.cachefalsetruespring-boot-thymeleaf, khi có Thymeleaf trên classpath

Nhóm property về lỗi là thứ bạn nhận ra đầu tiên. Dưới bootRun, mọi body lỗi của Spring Boot trong bài này đều có field "trace" chứa toàn bộ stack trace và field "message", hai field mà ứng dụng đóng gói, với mặc định never, không đưa vào. Tiện khi làm ở máy mình, và là thêm một lý do DevTools phải nằm ngoài production. spring.devtools.add-properties=false tắt các giá trị mặc định này.

LiveReload, remote DevTools và thiết lập toàn cục

DevTools từng khởi động một LiveReload server để báo cho extension của trình duyệt tải lại trang. Tính năng này bị deprecate từ Spring Boot 4.1.0 và không có gì thay thế, và spring.devtools.livereload.enabled giờ mặc định là false, nên không lần chạy nào ở trên log ra LiveReload server.

  • Remote DevTools restart một ứng dụng chạy ở nơi khác từ thay đổi ở máy bạn qua RemoteSpringApplicationspring.devtools.remote.secret; nó cần DevTools được đóng gói vào jar, và tài liệu nói không bao giờ bật nó trên môi trường production.
  • spring.devtools.restart.enabled=false trong application.properties dừng việc theo dõi nhưng vẫn khởi tạo restart classloader; để bỏ hẳn, đặt system property trước SpringApplication.run.
  • Thiết lập toàn cục cho mọi project trên máy đặt trong ~/.config/spring-boot/spring-boot-devtools.properties (hoặc .yaml, .yml), những tên file DevTools 4.1.1 tìm tới.

Lombok

Cài đặt Lombok và annotation processor là gì

Thiết lập của Initializr đã có ở trên: compileOnly để các annotation compile được, annotationProcessor để javac chạy Lombok, và cặp test… cho source test. Lombok không cần lúc runtime, và danh sách file trong jar cho thấy nó không được đóng gói.

Một annotation processor là plugin mà javac chạy trong lúc compile. Nó nhận các phần tử có annotation trong source và, qua API chuẩn, có thể sinh ra source file mới; MapStruct và configuration processor của Spring Boot làm việc theo cách đó. Lombok đi xa hơn những gì API chuẩn cho phép. Nó dùng các class nội bộ của javac để thêm method vào syntax tree của chính class đang được compile, nên getter được sinh ra tồn tại trong file .class mà không có ở đâu trong source. Thiết kế đó là nguồn gốc của cả sự tiện lợi lẫn cái giá của nó.

Một class có @Data đi qua javac với Lombok annotation processor, nơi getter, setter, equals, hashCode và toString được thêm vào bytecode; bên cạnh là bốn bẫy trên entity đã tái hiện cùng exception thật của chúng

Một class hai field với @Data:

src/main/java/com/example/demo/lab/CustomerForm.java
package com.example.demo.lab;
 
import lombok.Data;
 
@Data
public class CustomerForm {
 
    private String email;
    private String fullName;
}
Bash
javap -p -cp build/classes/java/main com.example.demo.lab.CustomerForm
Text
Compiled from "CustomerForm.java"
public class com.example.demo.lab.CustomerForm {
  private java.lang.String email;
  private java.lang.String fullName;
  public com.example.demo.lab.CustomerForm();
  public java.lang.String getEmail();
  public java.lang.String getFullName();
  public void setEmail(java.lang.String);
  public void setFullName(java.lang.String);
  public boolean equals(java.lang.Object);
  protected boolean canEqual(java.lang.Object);
  public int hashCode();
  public java.lang.String toString();
}

@Data@Getter, @Setter, @RequiredArgsConstructor, @ToString@EqualsAndHashCode gộp lại. equalshashCode được sinh ra đọc mọi field, và toString in mọi field; hãy nhớ điều này khi tới phần entity.

@Getter, @Setter, @RequiredArgsConstructor và @Slf4j trong một service

Cách dùng phổ biến nhất trong code Spring là một service:

src/main/java/com/example/demo/lab/PriceService.java
@Slf4j
@Service
@RequiredArgsConstructor
public class PriceService {
 
    private final ProductRepository repository;
 
    public BigDecimal total() {
        BigDecimal total = repository.findAll().stream()
                .map(p -> p.getPrice())
                .reduce(BigDecimal.ZERO, BigDecimal::add);
        log.info("Catalogue total is {}", total);
        return total;
    }
}
Text
public class com.example.demo.lab.PriceService {
  private static final org.slf4j.Logger log;
  private final com.example.demo.product.ProductRepository repository;
  public java.math.BigDecimal total();
  public com.example.demo.lab.PriceService(com.example.demo.product.ProductRepository);
  private static java.math.BigDecimal lambda$total$0(com.example.demo.product.Product);
  static {};
}
  • @RequiredArgsConstructor sinh một constructor public nhận mọi field final. Với một constructor duy nhất, Spring inject qua nó, nên đây chính là constructor injection bình thường.
  • @Slf4j sinh private static final org.slf4j.Logger log, field mà bài 14 viết tay.
  • @Getter@Setter trên class hoặc field sinh đúng những gì @Data đã sinh ở trên, không có equals, hashCodetoString.

@Value và @Builder so với Java record

Cho DTO chỉ đọc, Lombok có @Value, và @Builder cho builder. Series viết DTO bằng record. Cùng ba field theo ba cách:

src/main/java/com/example/demo/lab/ProductValue.java
@Value
public class ProductValue {
 
    String sku;
    String name;
    BigDecimal price;
}
src/main/java/com/example/demo/lab/ProductView.java
@Value
@Builder
public class ProductView {
 
    String sku;
    String name;
    BigDecimal price;
}
src/main/java/com/example/demo/lab/ProductRecord.java
public record ProductRecord(String sku, String name, BigDecimal price) {
}

javap -p trên từng class:

Text
public final class com.example.demo.lab.ProductValue {
  private final java.lang.String sku;
  private final java.lang.String name;
  private final java.math.BigDecimal price;
  public com.example.demo.lab.ProductValue(java.lang.String, java.lang.String, java.math.BigDecimal);
  public java.lang.String getSku();
  public java.lang.String getName();
  public java.math.BigDecimal getPrice();
  public boolean equals(java.lang.Object);
  public int hashCode();
  public java.lang.String toString();
}
public final class com.example.demo.lab.ProductView {
  private final java.lang.String sku;
  private final java.lang.String name;
  private final java.math.BigDecimal price;
  com.example.demo.lab.ProductView(java.lang.String, java.lang.String, java.math.BigDecimal);
  public static com.example.demo.lab.ProductView$ProductViewBuilder builder();
  public java.lang.String getSku();
  public java.lang.String getName();
  public java.math.BigDecimal getPrice();
  public boolean equals(java.lang.Object);
  public int hashCode();
  public java.lang.String toString();
}
public final class com.example.demo.lab.ProductRecord extends java.lang.Record {
  private final java.lang.String sku;
  private final java.lang.String name;
  private final java.math.BigDecimal price;
  public com.example.demo.lab.ProductRecord(java.lang.String, java.lang.String, java.math.BigDecimal);
  public final java.lang.String toString();
  public final int hashCode();
  public final boolean equals(java.lang.Object);
  public java.lang.String sku();
  public java.lang.String name();
  public java.math.BigDecimal price();
}

@Value và record sinh ra class gần như giống nhau: final, field private final, một constructor, equals, hashCodetoString theo giá trị. Record không cần thư viện nào và accessor là sku() thay vì getSku(). Thêm @Builder vào @Value làm constructor nhận mọi field trở thành package-private, điều này quan trọng ngay khi Jackson đọc class. Cùng một body JSON được POST tới một endpoint thử nghiệm nhận từng type làm @RequestBody:

Type của @RequestBodyResponse
ProductRecord (record)200, ProductRecord[sku=KB-001, name=Mechanical keyboard, price=89.90]
ProductValue (@Value)200, ProductValue(sku=KB-001, name=Mechanical keyboard, price=89.90)
CustomerForm (@Data)200, CustomerForm(email=a@b.c, fullName=Alice)
ProductView (@Value + @Builder)500

Lỗi 500 log ra:

Text
tools.jackson.databind.exc.InvalidDefinitionException: Cannot construct instance of `com.example.demo.lab.ProductView` (no Creators, like default constructor, exist): cannot deserialize from Object value (no delegate- or property-based Creator)

Câu trả lời của Lombok là @Jacksonized, khiến Jackson dùng builder. Trên Spring Boot 4 nó không compile được nguyên như vậy:

Text
ProductView.java:11: warning: Ambiguous: Jackson2 and Jackson3 exist; define which variant(s) you want in 'lombok.config'. See https://projectlombok.org/features/experimental/Jacksonized
@Jacksonized
^
ProductView.java:11: error: package com.fasterxml.jackson.databind.annotation does not exist
@Jacksonized
^

Lombok mặc định sinh annotation của Jackson 2, và @JsonDeserialize của Jackson 2 nằm trong com.fasterxml.jackson.databind.annotation, package không có trên classpath: Spring Boot 4 dùng Jackson 3, với package databind là tools.jackson.databind. Một dòng trong lombok.config ở thư mục gốc project đã sửa được, và cùng request POST đó trả 200:

lombok.config
config.stopBubbling = true
lombok.jacksonized.jacksonVersion += 3
Java recordLombok @ValueLombok @Data
Cần thư viện và annotation processingkhông
Accessorsku()getSku()getSku(), setSku(…)
Immutable, class finalkhông
equals, hashCode, toString
Làm request body với Jackson 3đượcđược, nhờ constructor publicđược, qua setter
Builderkhông, trừ khi thêm @Builder của Lombok, vốn dùng được trên record@Builder; khi đó Jackson cần @Jacksonized cấu hình cho Jackson 3@Builder
Record pattern trong switchinstanceofkhôngkhông

Với DTO, record đã cho mọi thứ @Value cho, mà không cần dependency lúc build.

Bẫy 1: @Data trên quan hệ hai chiều

Entity là nơi Lombok gây lỗi thật. Order của bài 28 với @Data ở cả hai phía của quan hệ @OneToMany/@ManyToOne:

src/main/java/com/example/demo/order/Order.java
@Entity
@Table(name = "orders")
@Data
public class Order {
 
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;
 
    @Column(nullable = false)
    private String customerEmail;
 
    @OneToMany(mappedBy = "order", cascade = CascadeType.ALL)
    private List<OrderLine> lines = new ArrayList<>();
 
    public void addLine(OrderLine line) {
        lines.add(line);
        line.setOrder(this);
    }
}
src/main/java/com/example/demo/order/OrderLine.java
@Entity
@Table(name = "order_lines")
@Data
public class OrderLine {
 
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;
 
    @ManyToOne(fetch = FetchType.LAZY, optional = false)
    @JoinColumn(name = "order_id", nullable = false)
    private Order order;
 
    @ManyToOne(fetch = FetchType.LAZY, optional = false)
    @JoinColumn(name = "product_id", nullable = false)
    private Product product;
 
    @Column(nullable = false)
    private int quantity;
 
    @Column(nullable = false, precision = 10, scale = 2)
    private BigDecimal unitPrice;
}

Một service tạo order có một line và log nó trước khi lưu:

src/main/java/com/example/demo/order/OrderService.java
    @Transactional
    public Order create(CreateOrderRequest request) {
        Product product = products.getReferenceById(request.productId());
        Order order = new Order();
        order.setCustomerEmail(request.customerEmail());
        OrderLine line = new OrderLine();
        line.setProduct(product);
        line.setQuantity(request.quantity());
        line.setUnitPrice(product.getPrice());
        order.addLine(line);
        log.info("Creating {}", order);
        return orders.save(order);
    }

Order.toString() in lines, mỗi OrderLine.toString() in order của nó, và order lại in lines. POST /api/orders trả 200 và order đã được lưu, nhưng log cho thấy:

Text
SLF4J(E): Failed toString() invocation on an object of type [com.example.demo.order.Order]
SLF4J(E): Reported exception:
java.lang.StackOverflowError
	at java.base/java.util.AbstractCollection.toString(AbstractCollection.java:451)
	at java.base/java.lang.String.valueOf(String.java:4465)
	at com.example.demo.order.Order.toString(Order.java:18)
	at java.base/java.lang.String.valueOf(String.java:4465)
	at com.example.demo.order.OrderLine.toString(OrderLine.java:19)
	at java.base/java.lang.String.valueOf(String.java:4465)
	at java.base/java.lang.StringBuilder.append(StringBuilder.java:173)
	at java.base/java.util.AbstractCollection.toString(AbstractCollection.java:459)
[nio-8139-exec-1] com.example.demo.order.OrderService      : Creating [FAILED toString()]

SLF4J 2 định dạng các argument {} một cách phòng thủ: nó bắt StackOverflowError, in ra standard error và log [FAILED toString()] thay cho order. Bug vẫn còn đó, chỉ bị giấu đi. Viết thành log.info("Creating " + order), cách gọi toString() trước khi SLF4J nhận được gì, cùng request đó trả 500:

Text
[nio-8139-exec-1] o.a.c.c.C.[.[.[/].[dispatcherServlet]    : Servlet.service() for servlet [dispatcherServlet] in context with path [] threw exception [Handler dispatch failed: java.lang.StackOverflowError] with root cause
 
java.lang.StackOverflowError
	at com.example.demo.order.Order.toString(Order.java:18) ~[main/:na]
	at com.example.demo.order.OrderLine.toString(OrderLine.java:19) ~[main/:na]
	at com.example.demo.order.Order.toString(Order.java:18) ~[main/:na]
	at com.example.demo.order.OrderLine.toString(OrderLine.java:19) ~[main/:na]

hashCode được sinh ra có cùng vòng lặp. Một test thêm một order có một line vào HashSet:

src/test/java/com/example/demo/tag/TagEqualityTest.java
    @Test
    void orderInHashSet() {
        Order order = new Order();
        order.setCustomerEmail("alice@example.com");
        order.addLine(new OrderLine());
        Set<Order> orders = new HashSet<>();
        orders.add(order);
    }
Text
TagEqualityTest > orderInHashSet() FAILED
    java.lang.StackOverflowError
        at com.example.demo.order.Order.getId(Order.java:23)
        at com.example.demo.order.Order.hashCode(Order.java:18)
        at com.example.demo.order.OrderLine.hashCode(OrderLine.java:19)
        at com.example.demo.order.Order.hashCode(Order.java:18)
        at com.example.demo.order.OrderLine.hashCode(OrderLine.java:19)

Bẫy 2: @EqualsAndHashCode trên mọi field trong HashSet

Không có quan hệ thì không có đệ quy, nhưng hashCode trên mọi field bao gồm cả id, và JPA gán id khi entity được persist. Một entity Tag với @Data, id IDENTITYname unique, test bằng @DataJpaTest:

src/test/java/com/example/demo/tag/TagEqualityTest.java
    @Test
    void tagIsLostInHashSetAfterSave() {
        Tag tag = new Tag();
        tag.setName("sale");
        Set<Tag> tags = new HashSet<>();
        tags.add(tag);
        System.out.println(">>> before save: id=" + tag.getId() + ", hashCode=" + tag.hashCode());
 
        repository.save(tag);
 
        System.out.println(">>> after save:  id=" + tag.getId() + ", hashCode=" + tag.hashCode());
        System.out.println(">>> tags.contains(tag) = " + tags.contains(tag));
        System.out.println(">>> tags.size() = " + tags.size() + ", tags.remove(tag) = " + tags.remove(tag));
        assertThat(tags.contains(tag)).isTrue();
    }
Text
    >>> before save: id=null, hashCode=3528649
    >>> after save:  id=1, hashCode=3526171
    >>> tags.contains(tag) = false
    >>> tags.size() = 1, tags.remove(tag) = false
TagEqualityTest > tagIsLostInHashSetAfterSave() FAILED
    org.opentest4j.AssertionFailedError: 
    Expecting value to be true but was false

Set vẫn giữ tag, lưu trong bucket của hash code 3528649; khi tìm bằng 3526171, nó không được tìm thấy mà cũng không xóa được. Giới hạn các method được sinh vào id cũng không giúp gì: một entity IdTag với @EqualsAndHashCode(onlyExplicitlyIncluded = true)@EqualsAndHashCode.Include trên id in ra >>> id-only: hashCode 102 -> 60, contains=false trong cùng dạng test. Bài 26 đã giải thích cách sửa, so sánh bằng id với hashCode là hằng số, và Lombok không sinh được điều đó.

Bẫy 3: toString chạm vào quan hệ lazy sau khi transaction kết thúc

Với spring.jpa.open-in-view=false, như trong cả series, persistence context đóng lại cùng transaction của service. Một controller log entity nó nhận được:

src/main/java/com/example/demo/order/OrderController.java
    @GetMapping("/{id}")
    public String findById(@PathVariable long id) {
        Order order = service.findById(id);
        log.info("Loaded " + order);
        return "order " + order.getId();
    }

GET /api/orders/1 trả 500, và body (đã cắt stack trace do DevTools thêm vào) có:

Text
"message":"Cannot lazily initialize collection of role 'com.example.demo.order.Order.lines' with key '1' (no session)"
Text
org.hibernate.LazyInitializationException: Cannot lazily initialize collection of role 'com.example.demo.order.Order.lines' with key '1' (no session)
	at org.hibernate.collection.spi.AbstractPersistentCollection.throwLazyInitializationException(AbstractPersistentCollection.java:664)
	at org.hibernate.collection.spi.AbstractPersistentCollection.withTemporarySessionIfNeeded(AbstractPersistentCollection.java:239)
	at org.hibernate.collection.spi.AbstractPersistentCollection.initialize(AbstractPersistentCollection.java:624)
	at org.hibernate.collection.spi.AbstractPersistentCollection.read(AbstractPersistentCollection.java:149)
	at org.hibernate.collection.spi.PersistentBag.toString(PersistentBag.java:637)
	at java.base/java.lang.String.valueOf(String.java:4465)
	at com.example.demo.order.Order.toString(Order.java:18)
	at java.base/java.lang.String.valueOf(String.java:4465)
	at com.example.demo.order.OrderController.findById(OrderController.java:28)

Controller không hề chạm vào lines; toString được sinh ra thì có. Với log.info("Loaded {}", order) request trả 200 và log ghi Loaded [FAILED toString()], SLF4J lại nuốt exception.

Bẫy 4: @Builder trên JPA entity

@Builder trên order có @Data là cám dỗ tiếp theo. Build hỏng tại chỗ service vẫn viết new Order():

Text
OrderService.java:22: error: constructor Order in class Order cannot be applied to given types;
        Order order = new Order();
                      ^
  required: Long,String,List<OrderLine>
  found:    no arguments
  reason: actual and formal argument lists differ in length
Order.java:31: warning: @Builder will ignore the initializing expression entirely. If you want the initializing expression to serve as default, add @Builder.Default. If it is not supposed to be settable during building, make the field final.
    private List<OrderLine> lines = new ArrayList<>();
                            ^

@Builder thêm một constructor package-private nhận mọi field, và khi đó @Data không sinh constructor riêng nữa, nên no-args constructor mà JPA yêu cầu biến mất. Khi service chuyển sang Order.builder(), một @DataJpaTest cho thấy cả hai hệ quả:

src/test/java/com/example/demo/order/OrderBuilderTest.java
    @Test
    void builderLeavesLinesNull() {
        Order order = Order.builder().customerEmail("alice@example.com").build();
        System.out.println(">>> lines = " + order.getLines());
        order.addLine(new OrderLine());
    }
 
    @Test
    void readingAnOrderBackNeedsANoArgsConstructor() {
        Order order = Order.builder().customerEmail("alice@example.com").lines(new ArrayList<>()).build();
        Long id = entityManager.persistAndFlush(order).getId();
        entityManager.clear();
        System.out.println(">>> saved order " + id + ", reading it back");
        repository.findById(id);
    }
Text
[    Test worker] org.hibernate.orm.core                   : HHH000182: No default (no-argument) constructor for class [com.example.demo.order.Order] (class must be instantiated by Interceptor)
    >>> saved order 1, reading it back
OrderBuilderTest > readingAnOrderBackNeedsANoArgsConstructor() FAILED
    org.springframework.orm.jpa.JpaSystemException: No default constructor for entity 'com.example.demo.order.Order'
        Caused by:
        org.hibernate.InstantiationException: No default constructor for entity 'com.example.demo.order.Order'
    >>> lines = null
OrderBuilderTest > builderLeavesLinesNull() FAILED
    java.lang.NullPointerException: Cannot invoke "java.util.List.add(Object)" because "this.lines" is null
        at com.example.demo.order.Order.addLine(Order.java:34)

Lưu thì được, đọc lại dòng đó thì không, và cảnh báo ở trên đã thành sự thật: builder bỏ qua = new ArrayList<>(), nên addLine ném exception. Thêm một @NoArgsConstructor thường cạnh @Builder thì build hỏng, vì builder không còn constructor nhận mọi field để gọi:

Text
Order.java:21: error: constructor Order in class Order cannot be applied to given types;
@Builder
^
  required: no arguments
  found:    Long,String,List<OrderLine>

Tổ hợp compile không có cảnh báo và qua được cả ba phép kiểm tra, builder có lines rỗng, no-args constructor có lines rỗng, và đọc lại được dòng đã lưu, là:

src/main/java/com/example/demo/order/Order.java
@Entity
@Table(name = "orders")
@Data
@Builder
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@AllArgsConstructor(access = AccessLevel.PRIVATE)
public class Order {
 
    // id and customerEmail as before
 
    @Builder.Default
    @OneToMany(mappedBy = "order", cascade = CascadeType.ALL)
    private List<OrderLine> lines = new ArrayList<>();

Bốn annotation để lấy lại những gì một constructor cho sẵn, và @Data cùng các bẫy 1 đến 3 vẫn còn trên class.

Tập Lombok an toàn cho entity

Phần còn an toàn trên entity là phần không phải đoán: accessor, và một toString chỉ gồm các cột đơn giản. Thay @Data trên cả hai class:

src/main/java/com/example/demo/order/Order.java
@Entity
@Table(name = "orders")
@Data
@Getter
@ToString(onlyExplicitlyIncluded = true) 
@NoArgsConstructor(access = AccessLevel.PROTECTED) 
public class Order {
 
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    @ToString.Include
    private Long id;
 
    @Column(nullable = false)
    @ToString.Include
    private String customerEmail;
 
    @OneToMany(mappedBy = "order", cascade = CascadeType.ALL)
    private List<OrderLine> lines = new ArrayList<>();
 
    public Order(String customerEmail) { 
        this.customerEmail = customerEmail; 
    } 
 
    public void addLine(OrderLine line) {
        lines.add(line);
        line.setOrder(this);
    }
 
    @Override
    public boolean equals(Object o) { 
        if (this == o) return true; 
        if (!(o instanceof Order other)) return false; 
        return id != null && id.equals(other.getId()); 
    } 
 
    @Override
    public int hashCode() { 
        return Order.class.hashCode(); 
    } 
}
src/main/java/com/example/demo/order/OrderLine.java
@Entity
@Table(name = "order_lines")
@Data
@Getter
@ToString
@NoArgsConstructor(access = AccessLevel.PROTECTED) 
public class OrderLine {
 
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;
 
    @ManyToOne(fetch = FetchType.LAZY, optional = false)
    @JoinColumn(name = "order_id", nullable = false)
    @Setter(AccessLevel.PACKAGE) 
    @ToString.Exclude
    private Order order;
 
    @ManyToOne(fetch = FetchType.LAZY, optional = false)
    @JoinColumn(name = "product_id", nullable = false)
    @ToString.Exclude
    private Product product;
 
    // quantity, unitPrice, a constructor taking product and quantity,
    // and equals and hashCode on the id, written as in Order
}

Khi service dùng new Order(email)new OrderLine(product, quantity), và cả hai câu log vẫn để nối chuỗi, POST /api/ordersGET /api/orders/1 đều trả 200 và log:

Text
[nio-8139-exec-1] com.example.demo.order.OrderService      : Creating Order(id=null, customerEmail=alice@example.com)
[nio-8139-exec-2] com.example.demo.order.OrderController   : Loaded Order(id=1, customerEmail=alice@example.com)

Một @DataJpaTest đặt order mới vào HashSet trước khi persist:

Text
    >>> before persist: Order(id=null, customerEmail=alice@example.com) [OrderLine(id=null, quantity=2, unitPrice=89.90)]
    >>> after persist:  Order(id=1, customerEmail=alice@example.com) [OrderLine(id=1, quantity=2, unitPrice=89.90)] contains=true
Trên JPA entityKết luậnLý do
@Getteran toànaccessor đơn giản
@Setter trên classtránhmở id và collection cho mọi nơi gọi; đặt @Setter trên những field thực sự thay đổi
@ToString(onlyExplicitlyIncluded = true) với @ToString.Include trên các cộtan toànkhông quan hệ, không đệ quy, không lazy loading
@ToString.Exclude trên mọi quan hệan toàn, nhưng mỗi quan hệ mới đều phải nhớ thêmbẫy 3 là hậu quả khi thiếu exclude
@NoArgsConstructor(access = PROTECTED)an toànconstructor mà JPA cần
@Datakhông dùngbẫy 1, 2 và 3
@EqualsAndHashCode, kể cả onlyExplicitlyIncluded trên idkhông dùngbẫy 2: hash đổi khi id được gán; viết equals theo id với hashCode hằng số
@Buildertránhlàm mất no-args constructor, bỏ qua giá trị khởi tạo của field nếu thiếu @Builder.Default
@Valuekhông dùngfield final và không có no-args constructor, hai điều khiến entity dạng record hỏng ở bài 26

Cái giá của Lombok bên ngoài code

  • Công cụ. Mọi công cụ đọc source của bạn đều phải hiểu Lombok: IDE, phân tích tĩnh, tìm kiếm code. IntelliJ IDEA đã tích hợp sẵn plugin Lombok từ bản 2020.3, theo trang hướng dẫn IntelliJ của Lombok; một editor không chạy Lombok thì không thấy được các method chỉ tồn tại trong bytecode.
  • JDK. Vì Lombok can thiệp vào các class nội bộ của javac, mỗi JDK mới cần một bản Lombok mới. Changelog của nó ghi "Initial JDK21 support" ở 1.18.30, JDK 25 ở 1.18.40, JDK 26 ở 1.18.46 và JDK 27 ở 1.18.48 ngày 1 tháng 9 năm 2026, trong khi Spring Boot 4.1.1 quản lý 1.18.46. Ghim một bản Lombok cũ hơn trên JDK 21 cho thấy điều đó nghĩa là gì:
build.gradle
ext['lombok.version'] = '1.18.28'
Text
> Task :compileJava FAILED
 
* What went wrong:
Execution failed for task ':compileJava' (registered by plugin class 'org.gradle.api.plugins.JavaBasePlugin').
> java.lang.NoSuchFieldError: Class com.sun.tools.javac.tree.JCTree$JCImport does not have member field 'com.sun.tools.javac.tree.JCTree qualid'

Cả module ngừng compile, không chỉ các class dùng Lombok. Vì vậy việc nâng cấp JDK phải chờ một bản Lombok hỗ trợ nó.

  • lombok.config. Thiết lập cho cả project nằm trong các file lombok.config; Lombok đọc file cạnh source và mọi file ở các thư mục cha, cho tới file có config.stopBubbling = true. Lời khuyên cũ bảo thêm lombok.addLombokGeneratedAnnotation = true để công cụ đo coverage bỏ qua các method được sinh. Từ Lombok 1.18.34 đó đã là mặc định: javap -v trên CustomerForm tìm thấy lombok.Generated trên cả chín thành phần được sinh mà không cần cấu hình gì, và java -jar lombok.jar config -g --verbose ghi key này là (default: true). Gradle không coi lombok.config là input của bước compile: sau khi thêm lombok.addLombokGeneratedAnnotation = false, ./gradlew compileJava báo UP-TO-DATE và class vẫn mang chín annotation đó, cho tới khi ./gradlew compileJava --rerun xóa chúng.
  • Bỏ Lombok. delombok ghi code được sinh ra trở lại thành source, đó là cách một project gỡ Lombok:
Bash
java -jar lombok.jar delombok src/main/java -d build/delombok

Trên CustomerForm nó sinh ra constructor, bốn accessor, equals, canEqual, hashCodetoString, mỗi cái có @java.lang.SuppressWarnings("all")@lombok.Generated: 79 dòng cho một class hai field.

Vì sao series này không dùng Lombok

Code của series, kể cả project tổng kết ở bài 42, không dùng Lombok, và các lần chạy trên là lý do. DTO là record, thứ từ Java 16 đã cho những gì @Value cho mà không cần processor hay cấu hình, và Jackson 3 đọc được mà không cần @Jacksonized. Entity giữ constructor, getter và equals theo id viết tường minh, vì mọi lối tắt của Lombok tiết kiệm được nhiều hơn một getter, @Data, @EqualsAndHashCode, @Builder, đều hỏng trên entity ở trên, và các lỗi đó xuất hiện lúc runtime hoặc bị logger nuốt mất. Và code của một tutorial nên compile được trên JDK tiếp theo mà không phải chờ một thư viện. Với service, @RequiredArgsConstructor@Slf4j vô hại, và một team đã dùng Lombok không mất gì nhiều khi giữ chúng; một constructor viết tường minh cũng chỉ vài dòng.

Spring Boot Actuator cơ bản

Actuator starter thêm những gì

spring-boot-starter-actuator kéo theo các module liệt kê ở đầu bài và đăng ký các endpoint: thao tác trên ứng dụng đang chạy như health, info, metrics, loggers và environment, mỗi endpoint có thể được expose qua HTTP hoặc JMX. Log lúc khởi động cho biết cái gì truy cập được qua HTTP:

Text
[  restartedMain] o.s.b.a.e.web.EndpointLinksResolver      : Exposing 1 endpoint beneath base path '/actuator'

/actuator và /actuator/health mặc định

Bash
curl -i http://localhost:8139/actuator
Text
HTTP/1.1 200 
Content-Type: application/vnd.spring-boot.actuator.v3+json
Content-Length: 243
JSON
{"_links":{"self":{"href":"http://localhost:8139/actuator","templated":false},"health-path":{"href":"http://localhost:8139/actuator/health/{*path}","templated":true},"health":{"href":"http://localhost:8139/actuator/health","templated":false}}}

/actuator là trang discovery liệt kê link tới mọi endpoint đã expose. Mặc định chỉ có health: management.endpoints.web.exposure.include mặc định là health theo metadata của 4.1.1.

Bash
curl -i http://localhost:8139/actuator/health
Text
HTTP/1.1 200 
Content-Type: application/vnd.spring-boot.actuator.v3+json
JSON
{"groups":["liveness","readiness"],"status":"UP"}

status là kết quả tổng hợp của mọi health indicator. groups liệt kê các nhóm probe liveness và readiness, được Spring Boot 4.1.1 tạo mặc định (management.endpoint.health.probes.enabled mặc định là true); probe cho Kubernetes thuộc khóa Advanced. GET /actuator/health/db trả 404 với body rỗng, và GET /actuator/info trả 404, JSON lỗi thông thường của Spring Boot với "message":"No static resource actuator/info.": một endpoint chưa expose hoàn toàn không có mapping HTTP.

Chi tiết health: show-details và show-components

management.endpoint.health.show-details mặc định là never. Đặt thành always:

src/main/resources/application.properties
management.endpoint.health.show-details=always
JSON
{
  "components": {
    "db": {
      "details": { "database": "H2", "validationQuery": "isValid()" },
      "status": "UP"
    },
    "diskSpace": {
      "details": { "total": 245107195904, "free": 11658166272, "threshold": 10485760, "path": "/…/demo/.", "exists": true },
      "status": "UP"
    },
    "livenessState": { "status": "UP" },
    "ping": { "status": "UP" },
    "readinessState": { "status": "UP" },
    "ssl": {
      "details": { "expiringChains": [], "invalidChains": [], "validChains": [] },
      "status": "UP"
    }
  },
  "groups": ["liveness", "readiness"],
  "status": "UP"
}

Mỗi component là một health indicator mà Spring Boot tự cấu hình vì công nghệ tương ứng có trên classpath:

  • db: mượn một connection từ DataSource và chạy phép kiểm tra isValid() của JDBC.
  • diskSpace: dung lượng trống nơi ứng dụng chạy, DOWN khi thấp hơn management.health.diskspace.threshold, mặc định 10MB.
  • ping: luôn UP; nó chứng minh ứng dụng còn trả lời.
  • livenessStatereadinessState: trạng thái sẵn sàng của ứng dụng, được các nhóm probe dùng.
  • ssl: các certificate chain của SSL bundle đã cấu hình; ứng dụng này không có cái nào.

GET /actuator/health/db giờ trả 200 với {"details":{"database":"H2","validationQuery":"isValid()"},"status":"UP"}. Các giá trị còn lại của thiết lập:

Thiết lậpBody của GET /actuator/health
show-details=never (mặc định){"groups":["liveness","readiness"],"status":"UP"}
show-components=always, details never{"components":{"db":{"status":"UP"},"diskSpace":{"status":"UP"},"livenessState":{"status":"UP"},"ping":{"status":"UP"},"readinessState":{"status":"UP"},"ssl":{"status":"UP"}},"groups":["liveness","readiness"],"status":"UP"}
show-details=when-authorized, không có security{"groups":["liveness","readiness"],"status":"UP"}
show-details=alwaysbody ở trên

when-authorized khi không có Spring Security hoạt động như never: không request nào được xác thực cả. Mục security sẽ gán cho nó một role.

Khi health chuyển sang DOWN

Để thấy một indicator hỏng, database phải hỏng trong lúc ứng dụng đang chạy. H2 được chạy riêng như một TCP server, java -cp h2-2.4.240.jar org.h2.tools.Server -tcp -tcpPort 9139 -ifNotExists, và ứng dụng trỏ tới nó bằng spring.datasource.url=jdbc:h2:tcp://localhost:9139/mem:catalog cùng spring.jpa.hibernate.ddl-auto=create-drop, vì với URL dạng server, Spring Boot không tạo schema và seeder hỏng với Table "PRODUCTS" not found. Sau đó process H2 bị kill:

Bash
curl -i http://localhost:8139/actuator/health
Text
HTTP/1.1 503 
Content-Type: application/vnd.spring-boot.actuator.v3+json
Connection: close
JSON
{"components":{"db":{"details":{"error":"org.springframework.jdbc.CannotGetJdbcConnectionException: Failed to obtain JDBC Connection"},"status":"DOWN"},"diskSpace":{"details":{"total":245107195904,"free":10325200896,"threshold":10485760,"path":"/…/demo/.","exists":true},"status":"UP"},"livenessState":{"status":"UP"},"ping":{"status":"UP"},"readinessState":{"status":"UP"},"ssl":{"details":{"expiringChains":[],"invalidChains":[],"validChains":[]},"status":"UP"}},"groups":["liveness","readiness"],"status":"DOWN"}

Một component DOWN làm kết quả tổng hợp thành DOWN, và status DOWN được trả bằng 503, đúng thứ mà load balancer kiểm tra URL này dựa vào. Câu trả lời mất 30.1 giây, và log giải thích vì sao:

Text
Caused by: java.sql.SQLTransientConnectionException: HikariPool-5 - Connection is not available, request timed out after 30007ms (total=0, active=0, idle=0, waiting=0)
Caused by: org.h2.jdbc.JdbcSQLNonTransientConnectionException: Connection is broken: "java.net.ConnectException: Connection refused: localhost:9139" [90067-240]
[nio-8139-exec-3] o.s.b.j.h.DataSourceHealthIndicator      : DataSource health check failed
[nio-8139-exec-3] o.s.b.h.a.e.HealthEndpointSupport        : Health contributor org.springframework.boot.jdbc.health.DataSourceHealthIndicator (db) took 30073ms to respond

Phép kiểm tra db mượn connection như mọi request, nên nó chờ hết connectionTimeout của HikariPool, mặc định 30 giây, rồi mới báo lỗi. Thứ gì poll health đều cần timeout dài hơn thế, hoặc một timeout ngắn hơn cho pool. /actuator/health/liveness/actuator/health/readiness vẫn trả 200 cùng lúc đó: mặc định db không thuộc các nhóm probe.

Expose /actuator/info, và vì sao include=* nguy hiểm

info truy cập được khi có trong danh sách exposure:

src/main/resources/application.properties
management.endpoint.health.show-details=always
management.endpoints.web.exposure.include=health,info 

Log ghi Exposing 2 endpoints beneath base path '/actuator', /actuator có thêm link info, và GET /actuator/info trả 200 với {}: endpoint đã tồn tại, nhưng chưa contributor nào có gì để nói.

Lối tắt thường thấy trong nhiều câu trả lời là include=*. Trên ứng dụng này nó log Exposing 12 endpoints beneath base path '/actuator', và trang discovery liệt kê beans, conditions, configprops, env, health, info, loggers, mappings, metrics, sbom, scheduledtasksthreaddump. Bản thân không endpoint nào trong số đó yêu cầu xác thực.

env liệt kê mọi property source và property, kèm nơi mỗi giá trị đến từ đâu. Giá trị bị che mặc định, vì management.endpoint.env.show-values mặc định là never. GET /actuator/env/spring.datasource.url trả về:

JSON
{"activeProfiles":[],"defaultProfiles":["default"],"property":{"source":"Config resource 'class path resource [application.properties]' via location 'optional:classpath:/'","value":"******"},"propertySources":[{"name":"server.ports"},{"name":"servletConfigInitParams"},{"name":"servletContextInitParams"},{"name":"systemProperties"},{"name":"systemEnvironment"},{"name":"random"},{"name":"Config resource 'class path resource [application.properties]' via location 'optional:classpath:/'","property":{"origin":"class path resource [application.properties] - 3:23","value":"******"}},{"name":"devtools"},{"name":"applicationInfo"},{"name":"Management Server"}]}

Với show-values=always, một thay đổi hay gặp khi debug, một API key thử nghiệm đặt trong application.properties hiện ra dưới dạng chữ thường. Những endpoint nguy hiểm khác:

  • configprops hiển thị mọi bean @ConfigurationProperties cùng giá trị đã bind, trong đó có spring.datasource với url, bị che theo cùng cách (management.endpoint.configprops.show-values mặc định là never).
  • heapdump tải về bản dump heap của JVM. Spring Boot 4.1.1 không phục vụ nó kể cả với *, vì management.endpoint.heapdump.access mặc định là none: nó trả 404. Với access=unrestricted nó trả 200 cùng 78 MB heap, và strings tìm thấy API key thử nghiệm trong đó bốn lần, bất kể show-values đặt thế nào.
  • loggers ghi được. Một POST /actuator/loggers/org.hibernate.SQL không xác thực với {"configuredLevel":"DEBUG"} trả 204, và level vẫn ở DEBUG sau đó.

Hãy liệt kê đích danh các endpoint bạn cần, và bắt xác thực cho mọi thứ trừ health, như mục cuối trình bày.

/actuator/info có thể hiển thị những gì

Endpoint info gom dữ liệu từ các info contributor. Trong 4.1.1 các contributor env, java, osprocess tắt theo mặc định (management.info.env.enabled và các property còn lại mặc định là false, ssl cũng vậy); contributor buildgit tự bật khi file của chúng tồn tại.

src/main/resources/application.properties
management.endpoints.web.exposure.include=health,info
management.info.env.enabled=true 
management.info.java.enabled=true 
management.info.os.enabled=true 
info.app.name=Catalogue API 
info.app.description=Products and orders 
info.app.owner=platform-team 

Phần build đến từ META-INF/build-info.properties, do build tool ghi ra:

build.gradle
springBoot {
	buildInfo()
}

Gradle chạy task bootBuildInfo trước processResources; ./mvnw package ghi ra cùng file đó trong project Maven. Phần git đến từ file git.properties. Trong Gradle, plugin com.gorylenko.gradle-git-properties ghi file này; 4.0.1 là phiên bản hiện tại trên Gradle Plugin Portal:

build.gradle
plugins {
	id 'java'
	id 'org.springframework.boot' version '4.1.1'
	id 'io.spring.dependency-management' version '1.1.7'
	id 'com.gorylenko.gradle-git-properties' version '4.0.1'
}

Trong một repository git init có một commit, ./gradlew classes chạy :generateGitProperties:bootBuildInfo, và sau một lần bootRun mới, endpoint trả về nội dung sau (lần chạy đó cũng bật process; phần này được bỏ ra ở đây và mô tả bên dưới):

JSON
{
  "app": { "name": "Catalogue API", "description": "Products and orders", "owner": "platform-team" },
  "git": { "branch": "main", "commit": { "id": "5a9191e", "time": "2026-09-16T08:19:18Z" } },
  "build": { "artifact": "demo", "name": "demo", "time": "2026-09-16T08:19:39.782Z", "version": "0.0.1-SNAPSHOT", "group": "com.example" },
  "java": {
    "jvm": { "name": "OpenJDK 64-Bit Server VM", "vendor": "Homebrew", "version": "21.0.6" },
    "runtime": { "name": "OpenJDK Runtime Environment", "version": "21.0.6" },
    "vendor": { "name": "Homebrew", "version": "Homebrew" },
    "version": "21.0.6"
  },
  "os": { "arch": "aarch64", "name": "Mac OS X", "version": "26.6.2" }
}
  • app là mọi property info.*, chép nguyên.
  • git ở mode mặc định simple (management.info.git.mode) chỉ hiển thị branch, commit id rút gọn và thời điểm commit. File git.properties được sinh ra chứa nhiều hơn thế, 19 key, trong đó có địa chỉ email của git user trên máy, và mode simple giữ chúng ngoài response.
  • buildtime thay đổi sau mỗi lần build: file ghi 08:19:31 sau ./gradlew classes còn response ghi 08:19:39 sau khi bootRun build lại. Một file đổi sau mỗi lần build là thứ DevTools không nên phản ứng theo, vì vậy META-INF/build-info.propertiesgit.properties nằm trong danh sách loại trừ restart của nó.

management.info.process.enabled=true thêm phần process với PID, user hệ điều hành sở hữu process, thư mục làm việc, heap và số lần GC. Những thông tin đó, cùng owner và các phiên bản ở trên, là chi tiết nên giữ khỏi người gọi ẩn danh.

/actuator, /actuator/health và /actuator/info trả về gì khi mặc định và sau cấu hình của bài này: trang discovery, health với sáu component và 503 DOWN, và info với app, git, build, java và os

Bảo vệ Actuator bên cạnh security chain của API

Sau đó security được thêm vào cùng project: spring-boot-starter-security, spring-boot-starter-security-oauth2-resource-server, và một bản rút gọn của API chain ở bài 36 với cùng securityMatcher("/api/**"), JWT resource server và các role ADMIN/USER. Token cho adminalice được ký bằng JwtEncoder của ứng dụng.

Khi chỉ có chain đó, và show-details=always vẫn còn đặt:

Request, không tokenStatus
GET /actuator/health200, có mọi component và chi tiết
GET /actuator/info200, có app, git, build, java và os
GET /actuator200
GET /api/orders/1401

Không có gì bảo vệ Actuator. FilterChainProxy ở mức TRACE cho thấy lý do:

Text
[nio-8139-exec-1] o.s.security.web.FilterChainProxy        : Trying to match request against DefaultSecurityFilterChain defined as 'apiSecurityFilterChain' in [class path resource [com/example/demo/common/SecurityConfig.class]] matching [Or [PathPattern [/api/**]]] and having filters [DisableEncodeUrl, WebAsyncManagerIntegration, SecurityContextHolder, HeaderWriter, Logout, OAuth2ProtectedResourceMetadata, BearerTokenAuthentication, RequestCac…
[nio-8139-exec-1] o.s.security.web.FilterChainProxy        : No security for GET /actuator/info

Một request không khớp chain nào thì không đi qua security filter nào cả, và chain mặc định của Spring Boot không được tạo khi ứng dụng đã định nghĩa chain của riêng mình. Mọi thứ được expose dưới /actuator công khai như một static file. Cách sửa là một chain riêng cho các endpoint của Actuator:

src/main/java/com/example/demo/common/SecurityConfig.java
import org.springframework.boot.health.actuate.endpoint.HealthEndpoint; 
import org.springframework.boot.security.autoconfigure.actuate.web.servlet.EndpointRequest; 
 
    @Bean
    @Order(0) 
    SecurityFilterChain actuatorSecurityFilterChain(HttpSecurity http) { 
        http 
                .securityMatcher(EndpointRequest.toAnyEndpoint()) 
                .authorizeHttpRequests(auth -> auth 
                        .requestMatchers(EndpointRequest.to(HealthEndpoint.class)).permitAll() 
                        .anyRequest().hasRole("ADMIN")) 
                .oauth2ResourceServer(oauth2 -> oauth2.jwt(Customizer.withDefaults())) 
                .csrf(csrf -> csrf.disable()) 
                .sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS)); 
        return http.build(); 
    } 
  • EndpointRequest.toAnyEndpoint() khớp mọi endpoint đã expose và trang discovery, dựng từ chính cấu hình của Actuator chứ không phải một /actuator/** viết cứng. Trong 4.1.1 class này nằm trong spring-boot-security, package org.springframework.boot.security.autoconfigure.actuate.web.servlet.
  • EndpointRequest.to(HealthEndpoint.class)/actuator/health và các đường dẫn con, công khai cho load balancer.
  • Mọi thứ còn lại cần ROLE_ADMIN. Nếu muốn body của 401 và 403 giống API, trỏ entry point và access denied handler của chain này tới ProblemDetail handler của ứng dụng như API chain đang làm.

Chi tiết health chỉ dành cho admin:

src/main/resources/application.properties
management.endpoint.health.show-details=always 
management.endpoint.health.show-details=when-authorized 
management.endpoint.health.roles=ADMIN 
management.endpoints.web.exposure.include=health,info
RequestKhông tokenalice (USER)admin (ADMIN)
GET /actuator/health200, {"groups":["liveness","readiness"],"status":"UP"}200, như bên trái200, có mọi component và chi tiết
GET /actuator/health/db404404200
GET /actuator/info401, WWW-Authenticate: Bearer resource_metadata="http://localhost:8139/.well-known/oauth-protected-resource"403200
GET /actuator401403200
GET /api/products200200200

Cách thứ hai để giữ Actuator ngoài mạng công khai là management.server.port: với management.server.port=9139, log có thêm một dòng Tomcat started on port 9139, /actuator/health trả 404 trên 8139 và 200 trên 9139, còn /api/products trả 404 trên 9139. Actuator chain ở trên vẫn áp dụng trên management port, nơi /actuator/info không token trả 401.

Custom endpoint, custom health indicator, metrics với Micrometer và Prometheus, tracing, liveness và readiness probe cho Kubernetes, và bảo mật Actuator vượt ra ngoài bộ rule này thuộc về khóa Advanced.

FAQ

Vì sao ứng dụng Spring Boot restart khi tôi chỉ sửa một static file?

Vì với Gradle, file đó không đi vào classpath một mình. processResources copy lại mọi resource khi bất kỳ resource nào thay đổi, nên application.properties trong build/resources/main có thời điểm sửa đổi mới và DevTools log Restarting due to 2 class path changes. File dưới static/templates/ mặc định được loại khỏi restart, nên thay đổi chỉ chạm vào chúng thì không restart. Thêm sourceResources sourceSets.main vào task bootRun để phục vụ resource từ src/main/resources mà không cần build.

Spring Boot DevTools có nằm trong jar production không?

Không, với thiết lập của Initializr. developmentOnly của Gradle và dependency optional của Maven giữ nó ở ngoài: jar tf trên bootJar của Gradle và trên jar của Maven không tìm thấy spring-boot-devtools. Ngay cả khi cố tình đóng gói vào, java -jar cũng không kích hoạt nó, vì DevTools 4.1.1 chỉ bật restart khi classloader của main thread là application classloader của JDK.

LiveReload còn dùng được trong Spring Boot 4.1 không?

Nó bị deprecate từ Spring Boot 4.1.0 và không có gì thay thế, và spring.devtools.livereload.enabled mặc định là false, nên server không khởi động trừ khi bạn bật lên. Bản thân restart không bị deprecate.

Có nên dùng @Data trên JPA entity không?

Không. Trên order và các line của nó, @Data làm toStringhashCode đệ quy tới StackOverflowError, làm một tag đã lưu biến mất khỏi HashSet vì hash của nó đổi theo id, và làm toString ném LazyInitializationException bên ngoài transaction. Hãy dùng @Getter, một @ToString giới hạn trong các cột, một no-args constructor protected, và tự viết equalshashCode theo id.

Nên dùng Lombok @Value hay Java record cho DTO?

Record. Bytecode gần như giống hệt: class final, field final, một constructor, equals, hashCodetoString. Record không cần annotation processor, hỗ trợ record pattern, và Jackson 3 trong Spring Boot 4 đọc được nó làm request body, trong khi @Value kèm @Builder hỏng với InvalidDefinitionException cho tới khi @Jacksonized được cấu hình cho Jackson 3 trong lombok.config.

Vì sao /actuator/info trả 404 trong Spring Boot?

Vì mặc định chỉ health được expose qua HTTP, và endpoint chưa expose thì không có mapping. Thêm management.endpoints.web.exposure.include=health,info. Sau đó endpoint trả {} cho tới khi có contributor mang dữ liệu: property info.* cần management.info.env.enabled=true, còn phần buildgit cần build-info.propertiesgit.properties trên classpath.

/actuator/health có được bảo vệ khi dùng Spring Security không?

Chỉ khi có một security filter chain khớp với nó. Với một chain duy nhất có securityMatcher/api/**, /actuator/health/actuator/info đã expose trả 200 cho request ẩn danh, kể cả toàn bộ chi tiết, và trace log ghi No security for GET /actuator/info. Thêm một chain dùng EndpointRequest.toAnyEndpoint() cho phép health và yêu cầu role cho phần còn lại.

Kết luận

DevTools, Lombok và Actuator mỗi công cụ tiết kiệm công sức ở một chỗ khác nhau. DevTools giữ các jar trong base classloader và chỉ dựng lại class của bạn, nên một lần restart mất 0.185 s trong khi khởi động lạnh mất 1.488 s, miễn là có thứ compile thay đổi của bạn và bạn biết rằng build resource bằng Gradle cũng gây restart. Lombok bỏ bớt boilerplate lúc compile, vô hại với constructor của service và logger nhưng nguy hiểm trên entity, nơi @Data, @EqualsAndHashCode@Builder đều hỏng; record đã lo phần DTO, và đó là lý do series này không dùng Lombok. Actuator cho ứng dụng một health check với component thật và 503 khi database mất, một trang info với dữ liệu build và git, và nó công khai bên cạnh một security chain /api/** cho tới khi bạn cho nó một chain riêng.

Bài 40 tiếp tục Chương 7 với các tác vụ phổ biến trong ứng dụng Spring Boot: upload và download file, gửi email, lên lịch công việc với @Scheduled, và @Async cơ bản.

Bài viết liên quan

[Spring Boot Basics] Đóng gói và chạy ứng dụng Spring Boot: JAR thực thi, profile và Dockerfile đơn giản

Đóng gói và chạy ứng dụng Spring Boot 4.1.1: ./gradlew build so với bootJar và file -plain.jar, bên trong JAR thực thi có gì (MANIFEST.MF, JarLauncher, BOOT-INF/classes, BOOT-INF/lib, classpath.idx), đặt tên artifact với Gradle và Maven, java -jar với profile postgres trên PostgreSQL, environment variable và file config bên ngoài cho password, graceful shutdown và exit code, Dockerfile trên eclipse-temurin:21-jre, ENTRYPOINT dạng exec và dạng shell đo bằng docker stop, user không phải root, multi-stage build và thứ mà layer dependency của Gradle thực sự cache, heap mặc định khi chạy với --memory=512m, và ứng dụng cùng PostgreSQL trên Docker network và trong Docker Compose.

[Spring Boot Basics] Phân trang và sắp xếp trong Spring Boot: Pageable, Sort và trả kết quả phân trang qua API

Phân trang và sắp xếp trong Spring Boot 4.1.1 với Spring Data JPA, trên H2 và PostgreSQL: Sort với ignoreCase, nullsFirst, nullsLast và SQL mỗi cách sinh ra trên hai database, TypedSort đã deprecated, PageRequest đếm từ 0, Page, Slice và List cùng count query và row thừa phía sau từng loại, khi nào Spring Data bỏ count, @Query và native query với Pageable, totalElements bị thổi phồng khi phân trang JOIN FETCH, controller nhận Pageable với @PageableDefault, max-page-size và one-indexed parameter, warning khi serialize PageImpl, PagedModel so với record PageResponse, ProblemDetail 400 cho sort property không tồn tại, và OFFSET so với keyset scrolling bằng Window.

[Spring Boot Basics] Server-side rendering với Thymeleaf trong Spring Boot: template, form và validation

Server-side rendering với Thymeleaf trong Spring Boot 4.1.1: khi nào SSR hợp hơn JSON API, view name trở thành classpath:/templates/products/list.html ra sao, năm loại standard expression, th:text và th:utext với XSS, th:each cùng #numbers và #temporals, form tạo mới có validation với th:field và th:errors, vị trí bắt buộc của BindingResult, Post/Redirect/Get với flash attribute, fragment, CSS tĩnh, spring.thymeleaf.cache thực sự thay đổi gì, và trang lỗi HTML.

[Spring Boot Basics] Profile và thứ tự ưu tiên cấu hình trong Spring Boot: biến môi trường và tham số dòng lệnh

Profile và thứ tự ưu tiên cấu hình của Spring Boot 4.1.1: application-dev.yml được merge lên application.yml theo từng key, mọi cách set spring.profiles.active và profile nào thắng khi hai profile cùng active, spring.profiles.default, file nhiều document, profile group và @Profile expression, thứ tự property source từ command-line argument xuống tới @PropertySource, nơi Spring Boot tìm file config, environment variable, spring.config.import và lỗi khởi động mà từng sai lầm gây ra.