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 và /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 /…/.
![]()
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ụ:
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.zipMỗ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ó:
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'
}<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-actuator</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-devtools</artifactId>
<scope>runtime</scope>
<optional>true</optional>
</dependency>
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
<optional>true</optional>
</dependency>
<!-- and in maven-compiler-plugin, for default-compile and default-testCompile: -->
<annotationProcessorPaths>
<path>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
</path>
</annotationProcessorPaths>| Công cụ | Gradle configuration | Maven | Có trên compile classpath | Có trong jar đóng gói |
|---|---|---|---|---|
| DevTools | developmentOnly | runtime + optional | không | không |
| Lombok | compileOnly + annotationProcessor | optional + annotationProcessorPaths | có | không |
| Actuator | implementation | compile scope | có | có |
./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-health và spring-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:
@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";
}
}spring.application.name=demo
server.port=8139
spring.datasource.url=jdbc:h2:mem:catalog
spring.jpa.open-in-view=falseGET /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:
[ 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)restartedMainlà tên thread. DevTools không chạy ứng dụng trênmain: 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.enabledmặc định làfalsevà 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 đó:
./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ó có 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:
@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:
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@2c854dc5Dướ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/main và build/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:
[ 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 unchangedcurl 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 động | Số lần chạy | Started … in, tốt nhất | Load average |
|---|---|---|---|
./gradlew bootRun lạnh (có DevTools) | 6 | 1.488 s | 3.02 |
java -jar (không DevTools) | 3 | 1.638 s | 2.77 |
| DevTools restart sau khi sửa controller | 10 | 0.185 s | 2.63 |
Từ lúc lưu file đến Started, với ./gradlew -t classes | 3 | 2.02 s | 2.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 đó.

Kích hoạt restart từ Gradle và từ IDE
DevTools theo dõi build/classes và build/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:
./gradlew -t classesWaiting 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 468msLầ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 build và mvn 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:
META-INF/maven/**,META-INF/resources/**,resources/**,static/**,public/**,templates/**,**/*Test.class,**/*Tests.class,git.properties,META-INF/build-info.propertiesStatic 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 có 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:
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:
| Property | Khi có DevTools | Mặc định thông thường | Khai báo trong |
|---|---|---|---|
spring.h2.console.enabled | true | false | spring-boot-h2console |
spring.web.error.include-stacktrace | always | never | spring-boot-autoconfigure |
spring.web.error.include-message | always | never | spring-boot-autoconfigure |
spring.web.error.include-binding-errors | always | never | spring-boot-autoconfigure |
spring.web.resources.cache.period | 0 | không đặt | spring-boot-autoconfigure |
spring.web.resources.chain.cache | false | true | spring-boot-autoconfigure |
spring.template.provider.cache | false | không có trong metadata | spring-boot-autoconfigure |
spring.mvc.log-resolved-exception | true | false | spring-boot-webmvc |
server.servlet.session.persistent | true | false | spring-boot-web-server |
server.servlet.jsp.init-parameters.development | true | không đặt | spring-boot-web-server |
spring.thymeleaf.cache | false | true | spring-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
RemoteSpringApplicationvàspring.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=falsetrongapplication.propertiesdừng việc theo dõi nhưng vẫn khởi tạo restart classloader; để bỏ hẳn, đặt system property trướcSpringApplication.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 hai field với @Data:
package com.example.demo.lab;
import lombok.Data;
@Data
public class CustomerForm {
private String email;
private String fullName;
}javap -p -cp build/classes/java/main com.example.demo.lab.CustomerFormCompiled 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 là @Getter, @Setter, @RequiredArgsConstructor, @ToString và @EqualsAndHashCode gộp lại. equals và hashCode đượ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:
@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;
}
}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 {};
}@RequiredArgsConstructorsinh một constructor public nhận mọi fieldfinal. Với một constructor duy nhất, Spring inject qua nó, nên đây chính là constructor injection bình thường.@Slf4jsinhprivate static final org.slf4j.Logger log, field mà bài 14 viết tay.@Gettervà@Settertrên class hoặc field sinh đúng những gì@Datađã sinh ở trên, không cóequals,hashCodevàtoString.
@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:
@Value
public class ProductValue {
String sku;
String name;
BigDecimal price;
}@Value
@Builder
public class ProductView {
String sku;
String name;
BigDecimal price;
}public record ProductRecord(String sku, String name, BigDecimal price) {
}javap -p trên từng class:
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, hashCode và toString 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 @RequestBody | Response |
|---|---|
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:
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:
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:
config.stopBubbling = true
lombok.jacksonized.jacksonVersion += 3| Java record | Lombok @Value | Lombok @Data | |
|---|---|---|---|
| Cần thư viện và annotation processing | không | có | có |
| Accessor | sku() | getSku() | getSku(), setSku(…) |
| Immutable, class final | có | có | không |
equals, hashCode, toString | có | có | có |
| Làm request body với Jackson 3 | được | được, nhờ constructor public | được, qua setter |
| Builder | khô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 switch và instanceof | có | không | khô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:
@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);
}
}@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:
@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:
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:
[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:
@Test
void orderInHashSet() {
Order order = new Order();
order.setCustomerEmail("alice@example.com");
order.addLine(new OrderLine());
Set<Order> orders = new HashSet<>();
orders.add(order);
}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 IDENTITY và name unique, test bằng @DataJpaTest:
@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();
} >>> 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 falseSet 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) và @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:
@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ó:
"message":"Cannot lazily initialize collection of role 'com.example.demo.order.Order.lines' with key '1' (no session)"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():
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ả:
@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);
}[ 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:
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à:
@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:
@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();
}
}@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) và new OrderLine(product, quantity), và cả hai câu log vẫn để nối chuỗi, POST /api/orders và GET /api/orders/1 đều trả 200 và log:
[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:
>>> 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 entity | Kết luận | Lý do |
|---|---|---|
@Getter | an toàn | accessor đơn giản |
@Setter trên class | tránh | mở 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ột | an toàn | khô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êm | bẫy 3 là hậu quả khi thiếu exclude |
@NoArgsConstructor(access = PROTECTED) | an toàn | constructor mà JPA cần |
@Data | không dùng | bẫy 1, 2 và 3 |
@EqualsAndHashCode, kể cả onlyExplicitlyIncluded trên id | không dùng | bẫy 2: hash đổi khi id được gán; viết equals theo id với hashCode hằng số |
@Builder | tránh | làm mất no-args constructor, bỏ qua giá trị khởi tạo của field nếu thiếu @Builder.Default |
@Value | không dùng | field 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ì:
ext['lombok.version'] = '1.18.28'> 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 filelombok.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êmlombok.addLombokGeneratedAnnotation = trueđể công cụ đo coverage bỏ qua các method được sinh. Từ Lombok 1.18.34 đó đã là mặc định:javap -vtrênCustomerFormtìm thấylombok.Generatedtrê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 --verboseghi key này là(default: true). Gradle không coilombok.configlà input của bước compile: sau khi thêmlombok.addLombokGeneratedAnnotation = false,./gradlew compileJavabáoUP-TO-DATEvà class vẫn mang chín annotation đó, cho tới khi./gradlew compileJava --rerunxóa chúng.- Bỏ Lombok.
delombokghi code được sinh ra trở lại thành source, đó là cách một project gỡ Lombok:
java -jar lombok.jar delombok src/main/java -d build/delombokTrên CustomerForm nó sinh ra constructor, bốn accessor, equals, canEqual, hashCode và toString, mỗi cái có @java.lang.SuppressWarnings("all") và @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 và @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:
[ restartedMain] o.s.b.a.e.web.EndpointLinksResolver : Exposing 1 endpoint beneath base path '/actuator'/actuator và /actuator/health mặc định
curl -i http://localhost:8139/actuatorHTTP/1.1 200
Content-Type: application/vnd.spring-boot.actuator.v3+json
Content-Length: 243{"_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.
curl -i http://localhost:8139/actuator/healthHTTP/1.1 200
Content-Type: application/vnd.spring-boot.actuator.v3+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:
management.endpoint.health.show-details=alwaysmanagement:
endpoint:
health:
show-details: always{
"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ừDataSourcevà chạy phép kiểm traisValid()của JDBC.diskSpace: dung lượng trống nơi ứng dụng chạy,DOWNkhi thấp hơnmanagement.health.diskspace.threshold, mặc định10MB.ping: luônUP; nó chứng minh ứng dụng còn trả lời.livenessStatevàreadinessState: 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ập | Body 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=always | body ở 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:
curl -i http://localhost:8139/actuator/healthHTTP/1.1 503
Content-Type: application/vnd.spring-boot.actuator.v3+json
Connection: close{"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:
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 respondPhé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 và /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:
management.endpoint.health.show-details=always
management.endpoints.web.exposure.include=health,info management:
endpoint:
health:
show-details: always
endpoints:
web:
exposure:
include: health,infoLog 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, scheduledtasks và threaddump. 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ề:
{"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:
configpropshiển thị mọi bean@ConfigurationPropertiescùng giá trị đã bind, trong đó cóspring.datasourcevớiurl, bị che theo cùng cách (management.endpoint.configprops.show-valuesmặc định lànever).heapdumptả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.accessmặc định lànone: nó trả 404. Vớiaccess=unrestrictednó trả 200 cùng 78 MB heap, vàstringstìm thấy API key thử nghiệm trong đó bốn lần, bất kểshow-valuesđặt thế nào.loggersghi được. MộtPOST /actuator/loggers/org.hibernate.SQLkhông xác thực với{"configuredLevel":"DEBUG"}trả 204, và level vẫn ởDEBUGsau đó.
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, os và process 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 build và git tự bật khi file của chúng tồn tại.
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 management:
endpoints:
web:
exposure:
include: health,info
info:
env:
enabled: true
java:
enabled: true
os:
enabled: true
info:
app:
name: Catalogue API
description: Products and orders
owner: platform-teamPhần build đến từ META-INF/build-info.properties, do build tool ghi ra:
springBoot {
buildInfo()
}<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
<executions>
<execution>
<goals>
<goal>build-info</goal>
</goals>
</execution>
</executions>
</plugin>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:
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 và :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):
{
"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" }
}applà mọi propertyinfo.*, chép nguyên.gitở mode mặc địnhsimple(management.info.git.mode) chỉ hiển thị branch, commit id rút gọn và thời điểm commit. Filegit.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à modesimplegiữ chúng ngoài response.buildcótimethay đổi sau mỗi lần build: file ghi08:19:31sau./gradlew classescòn response ghi08:19:39sau khibootRunbuild 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ậyMETA-INF/build-info.propertiesvàgit.propertiesnằ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.

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 admin và alice đượ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 token | Status |
|---|---|
GET /actuator/health | 200, có mọi component và chi tiết |
GET /actuator/info | 200, có app, git, build, java và os |
GET /actuator | 200 |
GET /api/orders/1 | 401 |
Không có gì bảo vệ Actuator. FilterChainProxy ở mức TRACE cho thấy lý do:
[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/infoMộ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:
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 trongspring-boot-security, packageorg.springframework.boot.security.autoconfigure.actuate.web.servlet.EndpointRequest.to(HealthEndpoint.class)là/actuator/healthvà 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ớiProblemDetailhandler của ứng dụng như API chain đang làm.
Chi tiết health chỉ dành cho admin:
management.endpoint.health.show-details=always
management.endpoint.health.show-details=when-authorized
management.endpoint.health.roles=ADMIN
management.endpoints.web.exposure.include=health,infomanagement:
endpoint:
health:
show-details: always
show-details: when-authorized
roles: ADMIN
endpoints:
web:
exposure:
include: health,info| Request | Không token | alice (USER) | admin (ADMIN) |
|---|---|---|---|
GET /actuator/health | 200, {"groups":["liveness","readiness"],"status":"UP"} | 200, như bên trái | 200, có mọi component và chi tiết |
GET /actuator/health/db | 404 | 404 | 200 |
GET /actuator/info | 401, WWW-Authenticate: Bearer resource_metadata="http://localhost:8139/.well-known/oauth-protected-resource" | 403 | 200 |
GET /actuator | 401 | 403 | 200 |
GET /api/products | 200 | 200 | 200 |
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/ và 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 toString và hashCode đệ 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 equals và hashCode 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, hashCode và toString. 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 build và git cần build-info.properties và git.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 là /api/**, /actuator/health và /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 và @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.