@Value ổn khi bạn chỉ đọc một giá trị trong một class. Nó bắt đầu lộ giới hạn khi một nhóm key liên quan được đọc ở nhiều nơi: chuỗi key bị chép vào mọi class cần dùng, một key gõ sai vẫn compile và khởi động bình thường, và không có gì kiểm tra con số bạn cấu hình làm port có thật sự là một port hợp lệ hay không.
@ConfigurationProperties là cách Spring Boot giải quyết chuyện đó. Bạn mô tả một prefix bằng một Java type, thường là record, và Boot bind mọi key dưới prefix đó vào type ấy đúng một lần. Trong lúc bind, nó chuyển chuỗi thành int, Duration, DataSize, enum, list, map và object lồng nhau, chấp nhận nhiều cách viết cho cùng một key, và nếu bạn yêu cầu, validate kết quả trước khi application được phép khởi động. Bài này đi qua toàn bộ những điều đó, kể cả những chỗ nó lặng lẽ làm khác với ý bạn.
![]()
Mọi thứ bên dưới chạy trên OpenJDK 21.0.6 với Spring Boot 4.1.1 (Spring Framework 7.0.9, Hibernate Validator 9.1.3.Final, Jakarta Validation 3.1.1) và Gradle 9.7.1, trên project sinh bởi Spring Initializr với dependencies=web,validation,configuration-processor. Mọi output, thông báo lỗi và file được sinh ra đều copy từ các lần chạy đó; các dòng log đã được cắt bỏ phần timestamp ở đầu.
Vì sao @Value không còn đủ khi config lớn dần
Bài trước khép lại bằng danh sách giới hạn của @Value. Đây là chúng trong một application nhỏ. Cấu hình mail nằm trong application.properties, và một giá trị trong đó bị sai:
app.mail.host=smtp.example.com
app.mail.port=70000Ba class đọc các key này:
@Service
public class MailSender {
private final String host;
private final int port;
public MailSender(@Value("${app.mail.host}") String host, @Value("${app.mail.port}") int port) {
this.host = host;
this.port = port;
}
public String send(String to) {
InetSocketAddress address = new InetSocketAddress(host, port);
return "sending to " + to + " via " + address;
}
}@Component
public class MailHealthCheck {
@Value("${app.mail.host}")
private String host;
@Value("${app.mail.port}")
private int port;
}@Component
public class BounceProcessor {
@Value("${app.mail.hots:localhost}")
private String host;
@Value("${app.mail.port:25}")
private int port;
}In ra giá trị mà mỗi class nhận được, ngay sau khi khởi động:
Started DemoApplication in 0.508 seconds (process running for 0.624)
MailSender -> smtp.example.com:70000
MailHealthCheck -> smtp.example.com:70000
BounceProcessor -> localhost:70000Application khởi động trong nửa giây. Nó chỉ hỏng ở request đầu tiên thật sự gửi mail, và request đó trả về HTTP 500:
Servlet.service() for servlet [dispatcherServlet] in context with path [] threw exception [Request processing failed: java.lang.IllegalArgumentException: port out of range:70000] with root cause
java.lang.IllegalArgumentException: port out of range:70000Hai mươi dòng đó chứa ba vấn đề riêng biệt:
- Key là chuỗi lặp lại trong mọi class.
app.mail.hostxuất hiện ba lần; đổi tên nhóm key nghĩa là phải đi tìm từng bản sao. - Gõ sai mà không báo gì.
BounceProcessorhỏiapp.mail.hots, key này không tồn tại, nên giá trị mặc địnhlocalhostđược dùng và không có dòng log nào. - Không ai kiểm tra giá trị.
70000là mộtinthợp lệ nhưng là một TCP port không hợp lệ. Type system chấp nhận nó, và lỗi đến muộn hơn, ở lần gọi đầu tiên dùng tới giá trị đó.
@ConfigurationProperties xử lý cả ba bằng cách mô tả app.mail đúng một lần, dưới dạng một type.
Bind config vào record
Project sinh ra đã có sẵn mọi thứ bài này cần. Bản thân @ConfigurationProperties nằm trong jar lõi spring-boot; spring-boot-starter-validation thêm Hibernate Validator, còn dòng annotationProcessor thêm bộ sinh metadata dùng ở phần sau:
dependencies {
implementation 'org.springframework.boot:spring-boot-starter-validation'
implementation 'org.springframework.boot:spring-boot-starter-webmvc'
annotationProcessor 'org.springframework.boot:spring-boot-configuration-processor'
testImplementation 'org.springframework.boot:spring-boot-starter-validation-test'
testImplementation 'org.springframework.boot:spring-boot-starter-webmvc-test'
testRuntimeOnly 'org.junit.platform:junit-platform-launcher'
}Một properties class là một record có annotation mang prefix. Mỗi component là một key bên dưới prefix đó:
@ConfigurationProperties("app.mail")
public record MailProperties(String host, int port, String from, boolean tls, List<String> recipients) {
}app.mail.host=smtp.example.com
app.mail.port=587
app.mail.from=noreply@example.com
app.mail.tls=true
app.mail.recipients=ops@example.com,dev@example.comClass này vẫn phải được đăng ký. Thêm @ConfigurationPropertiesScan vào application class là một trong ba cách, được so sánh ở một phần riêng bên dưới:
@SpringBootApplication
@ConfigurationPropertiesScan
public class DemoApplication {
public static void main(String[] args) {
SpringApplication.run(DemoApplication.class, args);
}
}Sau đó MailProperties là một bean bình thường, inject qua constructor. In ra tên bean và chính bean đó:
[app.mail-com.example.demo.MailProperties]
MailProperties[host=smtp.example.com, port=587, from=noreply@example.com, tls=true, recipients=[ops@example.com, dev@example.com]]Tên bean gồm prefix, một dấu gạch ngang, rồi tên đầy đủ của class. Hiếm khi bạn cần tới nó, nhưng đó là chuỗi bạn sẽ gặp trong danh sách bean và trong thông báo lỗi.
Thiếu key không phải là lỗi. Record này, được bind dưới một prefix không có key nào:
@ConfigurationProperties("app.empty")
public record EmptyProps(String host, int port, boolean tls, Integer retries, List<String> recipients, Duration timeout) {
}cho ra:
EmptyProps[host=null, port=0, tls=false, retries=null, recipients=null, timeout=null]Reference nhận null còn primitive nhận giá trị 0 của nó — kể cả port=0, hiếm khi là thứ bạn muốn. Giá trị mặc định và validation, cả hai đều ở bên dưới, là hai cách ngăn chuyện đó lọt lên production mà không ai hay.
Khi nào cần @ConstructorBinding?
Không cần cho record ở trên, và cũng không cần cho một class thường chỉ có một constructor: một final class có một constructor hai argument, không có annotation nào, cũng bind y như vậy.
Class thường có một điều kiện đi kèm: tên parameter của constructor phải có trong class file. Plugin Gradle của Boot compile với -parameters, nên project sinh ra đã có sẵn những tên này. Khi bỏ flag đó khỏi compileJava, chính class này làm quá trình khởi động dừng lại với Unable to create instance for com.example.demo.SingleCtorProps kèm gợi ý Ensure that your compiler is configured to use the '-parameters' flag. Record thì vẫn bind được khi không có flag, vì tên các component của record luôn được ghi trong class file.
Quy tắc, đúng như DefaultBindConstructorProvider của Boot 4.1.1 cài đặt, khá ngắn:
- Constructor có
@ConstructorBindingluôn được dùng. - Nếu không, khi class có đúng một constructor và constructor đó nhận parameter, Boot bind qua nó. Record và class thường được đối xử như nhau.
- Nếu không, khi có đúng một constructor không private và nó nhận parameter, Boot bind qua constructor đó.
- Còn lại thì không có constructor nào để bind, và Boot quay về JavaBean binding qua setter. Một constructor có
@Autowiredcũng khiến class bị loại khỏi constructor binding.
Cái bẫy nằm ở quy tắc 4. Thêm một constructor tiện lợi vào record, và giờ nó có hai constructor public, nên Boot ngừng dùng constructor binding mà không nói một lời:
@ConfigurationProperties("app.mail")
public record MailProperties(String host, int port) {
public MailProperties(String host) {
this(host, 25);
}
}org.springframework.beans.factory.BeanCreationException: Error creating bean with name 'app.mail-com.example.demo.MailProperties': Failed to instantiate [com.example.demo.MailProperties]: No default constructor foundRecord không có constructor không argument nào để JavaBean binding gọi, nên context thất bại. Cách sửa là chỉ rõ constructor dùng để bind. Với record, nghĩa là khai báo compact canonical constructor để có chỗ đặt annotation:
@ConfigurationProperties("app.mail")
public record MailProperties(String host, int port) {
@ConstructorBinding
public MailProperties {
}
public MailProperties(String host) {
this(host, 25);
}
}MailProperties[host=smtp.example.com, port=587]Một class thường có cả constructor không argument lẫn constructor nhận đủ argument, nhưng không có setter, thì hỏng theo cách khác. Boot chọn JavaBean binding rồi không có chỗ nào để đặt giá trị:
Failed to bind properties under 'app.two' to com.example.demo.TwoCtorProps:
Property: app.two.host
Value: "two.example.com"
Origin: class path resource [application.properties] - 8:14
Reason: java.lang.IllegalStateException: No setter found for property: hostProperties class dùng record bất biến tới mức nào?
Record không có setter, nên không bean nào gán lại được host sau khi bind; compiler từ chối ngay. Collection thì khác. Binder điền component List bằng một ArrayList thông thường, và bất kỳ bean nào giữ properties cũng sửa được nó:
recipients class = java.util.ArrayList
after add: [ops@example.com, dev@example.com, intruder@example.com]Mọi bean khác đã inject MailProperties giờ đều thấy thêm người nhận đó. Một compact constructor bên trong record bịt lỗ hổng này bằng một bản sao phòng thủ:
public MailProperties {
recipients = (recipients == null) ? List.of() : List.copyOf(recipients);
}recipients().add -> java.lang.UnsupportedOperationExceptionCompact constructor chính là canonical constructor chứ không phải constructor thứ hai, nên constructor binding vẫn được suy ra và không cần @ConstructorBinding.
JavaBean binding với getter và setter
Trước khi có record, một properties class có constructor không argument và một setter cho mỗi property. Boot vẫn hỗ trợ dạng đó, và dùng nó bất cứ khi nào không quy tắc constructor nào ở trên áp dụng được.
@ConfigurationProperties("app.mail")
public class MailProperties {
private String host = "localhost";
private int port = 25;
private Duration timeout = Duration.ofSeconds(10);
private List<String> recipients = new ArrayList<>(List.of("ops@example.com"));
private final Smtp smtp = new Smtp();
public String getHost() { return host; }
public void setHost(String host) { this.host = host; }
public int getPort() { return port; }
public void setPort(int port) { this.port = port; }
public Duration getTimeout() { return timeout; }
public void setTimeout(Duration timeout) { this.timeout = timeout; }
public List<String> getRecipients() { return recipients; }
public void setRecipients(List<String> recipients) { this.recipients = recipients; }
public Smtp getSmtp() { return smtp; }
public static class Smtp {
private boolean auth;
private String username;
public boolean isAuth() { return auth; }
public void setAuth(boolean auth) { this.auth = auth; }
public String getUsername() { return username; }
public void setUsername(String username) { this.username = username; }
}
}Chỉ set hai key:
app.mail.host=smtp.example.com
app.mail.smtp.auth=truehost=smtp.example.com port=25 timeout=PT10S recipients=[ops@example.com] smtp.auth=true smtp.username=nullCó hai chi tiết đáng để ý. Mọi field không có key vẫn giữ giá trị khởi tạo của nó. Và smtp có getter nhưng không có setter: binder gọi getSmtp() rồi bind auth vào instance đã tồn tại, thay vì thay thế nó.
JavaBean binding vẫn là lựa chọn đúng trong vài trường hợp:
- Giá trị mặc định dễ viết bằng code hơn, ví dụ một list mutable có sẵn phần tử, hoặc một object lồng nhau không bao giờ được
null, khởi tạo ngay tại chỗ khai báo field. - Class không thuộc về bạn và chỉ có setter. Bind trên
@Beanmethod, trình bày bên dưới, dựa đúng vào điều này. - Class có nhiều hơn một constructor mà bạn không muốn đặt annotation.
Cái giá phải trả là tính mutable: bean nào giữ MailProperties cũng có thể gọi setPort(1) lúc runtime.
Giá trị mặc định: field initializer hay @DefaultValue
Constructor không có field initializer để dựa vào, nên constructor binding mang giá trị mặc định trong annotation. @DefaultValue nhận một chuỗi, được chuyển đổi y hệt một giá trị đọc từ file properties:
@ConfigurationProperties("app.mail")
public record MailProperties(
@DefaultValue("localhost") String host,
@DefaultValue("25") int port,
@DefaultValue("10s") Duration timeout,
@DefaultValue("ops@example.com") List<String> recipients,
@DefaultValue Smtp smtp,
Tls tls) {
public record Smtp(boolean auth, String username) {
}
public record Tls(boolean enabled, String protocol) {
}
}Chỉ set app.mail.host=smtp.example.com:
MailProperties[host=smtp.example.com, port=25, timeout=PT10S, recipients=[ops@example.com], smtp=Smtp[auth=false, username=null], tls=null]smtp và tls đều là record lồng nhau không có key nào. @DefaultValue rỗng trên smtp bảo Boot vẫn tạo nó; tls không có annotation nên giữ null.
| JavaBean binding | Constructor binding | |
|---|---|---|
| Giá trị mặc định nằm ở đâu | field initializer | @DefaultValue("…") trên parameter |
| Giá trị mặc định viết thế nào | bất kỳ expression Java nào | một chuỗi, chuyển đổi như property ("10s", "25") |
| Object lồng nhau không có key | giá trị mà field được khởi tạo | null, trừ khi parameter có @DefaultValue rỗng |
| Giá trị đơn không có key và không có mặc định | giữ giá trị ban đầu | null, 0 hoặc false |
Đăng ký một class @ConfigurationProperties
Annotation chỉ mô tả cách bind; nó không tạo ra bean. Có ba cách đăng ký class, và chúng không thay thế cho nhau được.
@EnableConfigurationProperties đặt trên bất kỳ @Configuration class nào, liệt kê type một cách tường minh. Đây là cách chính các auto-configuration của Boot sử dụng: HttpEncodingAutoConfiguration ở chương trước đăng ký ServletEncodingProperties theo đúng cách này.
@Configuration
@EnableConfigurationProperties(MailProperties.class)
public class MailConfig {
}@ConfigurationPropertiesScan đặt trên application class, tìm mọi type @ConfigurationProperties trong package đó và các package con, nên thêm một properties class mới không cần sửa thêm dòng nào ở đâu cả. Cả hai annotation cho ra cùng một bean:
[app.mail-com.example.demo.MailProperties] -> MailProperties[host=smtp.example.com, port=587]@Component trên properties class biến nó thành một component được scan bình thường. Cách này chạy được với JavaBean, và bean khi đó có tên đơn giản là mailProperties. Nó không chạy được với record, vì component scanning tự tạo bean và cố inject String host như một dependency:
***************************
APPLICATION FAILED TO START
***************************
Description:
MailProperties is annotated with @ConstructorBinding but it is defined as a regular bean which caused dependency injection to fail.
Action:
Update your configuration so that MailProperties is defined via @ConfigurationPropertiesScan or @EnableConfigurationProperties.Record này không hề có @ConstructorBinding; thông báo đang nói tới constructor binding mà Boot tự suy ra. Exception bên dưới là No qualifying bean of type 'java.lang.String' available.
| Cách đăng ký | Tên bean | Record và constructor binding | Dùng cho |
|---|---|---|---|
@EnableConfigurationProperties(MailProperties.class) | app.mail-com.example.demo.MailProperties | có | thư viện và auto-configuration, nơi danh sách cần tường minh |
@ConfigurationPropertiesScan | app.mail-com.example.demo.MailProperties | có | application có nhiều properties class |
@Component | mailProperties | không, khởi động thất bại | các properties class dạng JavaBean sẵn có |
Chuyện gì xảy ra nếu quên đăng ký?
Tùy vào việc có ai cần tới nó hay không. Nếu một bean inject class chưa đăng ký, quá trình khởi động thất bại với phần phân tích thiếu bean chung chung, không nhắc gì tới configuration properties:
***************************
APPLICATION FAILED TO START
***************************
Description:
Parameter 0 of constructor in com.example.demo.MailService required a bean of type 'com.example.demo.MailProperties' that could not be found.
Action:
Consider defining a bean of type 'com.example.demo.MailProperties' in your configuration.Nếu không ai inject nó thì chẳng có gì thất bại cả. Hỏi context các bean thuộc type đó sau khi khởi động thành công:
MailProperties beans: []@ConfigurationProperties trên một class chưa đăng ký đơn giản là không làm gì. Khi thông báo ở trên nêu tên một class mà bạn biết chắc có annotation này, cách sửa là đăng ký nó, chứ không phải viết thêm một @Bean method.
Bind object lồng nhau, list, map và enum
Binder duyệt cây key dưới prefix và dựng object graph đúng như type mô tả. Một record bao quát mọi dạng đáng biết:
@ConfigurationProperties("app.mail")
public record MailProperties(
String host,
Smtp smtp,
List<String> recipients,
List<Server> servers,
Map<String, String> headers,
Map<String, Object> extra,
Mode mode,
Duration timeout,
@DurationUnit(ChronoUnit.SECONDS) Duration retryDelay,
DataSize maxAttachmentSize,
@DataSizeUnit(DataUnit.MEGABYTES) DataSize mailboxQuota,
Period retention) {
public record Smtp(boolean auth, boolean starttls, String username) {
}
public record Server(String host, int port) {
}
public enum Mode {
SMTP, SMTPS, LOG_ONLY
}
}app.mail.host=smtp.example.com
app.mail.smtp.auth=true
app.mail.smtp.starttls=true
app.mail.smtp.username=mailer
app.mail.recipients=ops@example.com,dev@example.com
app.mail.servers[0].host=mx1.example.com
app.mail.servers[0].port=25
app.mail.servers[1].host=mx2.example.com
app.mail.servers[1].port=2525
app.mail.headers.reply-to=support@example.com
app.mail.headers.x-priority=1
app.mail.extra.dkim.selector=mail2026
app.mail.extra.dkim.enabled=true
app.mail.extra.tracking=off
app.mail.mode=log-only
app.mail.timeout=30s
app.mail.retry-delay=5
app.mail.max-attachment-size=10MB
app.mail.mailbox-quota=512
app.mail.retention=90dhost = smtp.example.com
smtp = Smtp[auth=true, starttls=true, username=mailer]
recipients = [ops@example.com, dev@example.com]
servers = [Server[host=mx1.example.com, port=25], Server[host=mx2.example.com, port=2525]]
headers = {reply-to=support@example.com, x-priority=1}
extra = {dkim={selector=mail2026, enabled=true}, tracking=off}
mode = LOG_ONLY
timeout = PT30S
retryDelay = PT5S
maxAttachmentSize = 10485760B
mailboxQuota = 536870912B
retention = P90D
Mỗi dạng hoạt động như sau:
- Object lồng nhau.
app.mail.smtp.*trở thành recordSmtp. Type lồng sâu tới đâu thì binding đi sâu tới đó. List<String>. Giá trị phân tách bằng dấu phẩy được tách thành các phần tử. Dạng có index,app.mail.recipients[0]=…vàapp.mail.recipients[1]=…, bind ra đúng list đó.List<Server>. Object trong list cần index:servers[0].host,servers[0].port.Map<String, String>. Mọi thứ sauheaders.là key.Map<String, Object>. Dấu chấm trong phần còn lại của key tạo ra map lồng nhau, nênextra.dkim.selectorthành{dkim={selector=…}}. Giá trị vẫn là chuỗi:extra.dkim.enabledlàjava.lang.Stringchứ không phảiBoolean, vì value type được khai báo làObjectkhông cho binder đích nào để chuyển đổi.
Enum được so khớp khá dễ dãi. Converter thử tên hằng chính xác trước; nếu không khớp, nó đưa cả hai phía về chữ thường và bỏ mọi ký tự không phải chữ cái hay chữ số rồi mới so sánh. log-only, log_only, logOnly, LogOnly, logonly và cả log only đều bind thành LOG_ONLY, còn giá trị rỗng bind thành null. Giá trị không khớp hằng nào sẽ dừng application, và phần phân tích liệt kê các hằng hợp lệ:
Description:
Failed to bind properties under 'app.mail.mode' to com.example.demo.MailProperties$Mode:
Property: app.mail.mode
Value: "nope"
Origin: class path resource [application.properties] - 15:15
Reason: failed to convert java.lang.String to com.example.demo.MailProperties$Mode (caused by java.lang.IllegalArgumentException: No enum constant com.example.demo.MailProperties.Mode.nope)
Action:
Update your application's configuration. The following values are valid:
LOG_ONLY
SMTP
SMTPSChuyển đổi Duration, DataSize và Period
java.time.Duration, DataSize của Spring và java.time.Period đều nhận dạng viết tắt có suffix, cộng với một đơn vị mặc định cho số trần. Đơn vị mặc định mỗi type một khác, và cùng một chữ cái lại mang nghĩa khác nhau ở các type khác nhau, nên bảng này đáng để giữ lại. Mọi dòng đều được bind qua converter của Boot 4.1.1:
| Type | Giá trị | Bind thành | Quy tắc |
|---|---|---|---|
Duration | 30s | PT30S | suffix ns, us, ms, s, m, h, d; không phân biệt hoa thường, nên 30S cũng được |
Duration | PT30S | PT30S | ISO-8601, viết hoa hay thường đều được (pt30s vẫn chạy) |
Duration | 30 | PT0.03S | số trần là mili giây |
Duration + @DurationUnit(SECONDS) | 30 | PT30S | đơn vị chỉ áp dụng cho số trần; 500ms vẫn là 500 ms |
Duration | 1.5s, 1h30m, 30 s, 1w | lỗi | không có số thập phân, không ghép đơn vị, không có khoảng trắng, không có tuần |
DataSize | 10MB | 10485760B | suffix B, KB, MB, GB, TB; hệ nhị phân, nên 1 KB = 1024 B |
DataSize | 10 | 10B | số trần là byte |
DataSize + @DataSizeUnit(MEGABYTES) | 512 | 536870912B | đơn vị chỉ áp dụng cho số trần |
DataSize | 10 MB | 10485760B | khoảng trắng bị bỏ qua |
DataSize | 10mb, 10M, 10MiB, 1.5MB | lỗi | suffix chỉ được viết hoa: Unknown data unit suffix 'mb' |
Period | 90d | P90D | suffix y, m, w, d, không phân biệt hoa thường, theo đúng thứ tự đó |
Period | 6m | P6M | ở đây m là tháng, còn trong Duration là phút |
Period | 1y2m3w4d | P1Y2M25D | tuần được gộp vào ngày |
Period | 90 | P90D | số trần là ngày; @PeriodUnit đổi được điều đó |
Period | P1Y2M3D | P1Y2M3D | ISO-8601 |
Period | 2d1y, 6mo | lỗi | sai thứ tự đơn vị, suffix không tồn tại |
Giá trị không parse được sẽ dừng application với cùng định dạng phân tích như enum ở trên:
Failed to bind properties under 'app.mail.timeout' to java.time.Duration:
Property: app.mail.timeout
Value: "1.5s"
Origin: class path resource [application.properties] - 16:18
Reason: failed to convert java.lang.String to java.time.Duration (caused by java.lang.IllegalArgumentException: '1.5s' is not a valid duration)Hãy ưu tiên Duration và DataSize thay vì long timeoutMillis hay int maxSizeMb. Đơn vị khi đó nằm ngay trong giá trị, 30s không thể bị nhầm thành ba mươi mili giây, và tên field không còn phải gánh đơn vị.
Relaxed binding
Key trong file không cần viết giống tên component Java. Bind một field, smtpHost, qua bốn lần chạy riêng biệt, mỗi lần một cách viết:
@ConfigurationProperties("app.mail")
public record MailProperties(String smtpHost) {
}| Lần chạy | Nguồn | Key như đã viết | smtpHost |
|---|---|---|---|
| 1 | application.properties | app.mail.smtp-host=kebab.example.com | kebab.example.com |
| 2 | application.properties | app.mail.smtpHost=camel.example.com | camel.example.com |
| 3 | application.properties | app.mail.smtp_host=underscore.example.com | underscore.example.com |
| 4 | environment variable | APP_MAIL_SMTPHOST=env.example.com | env.example.com |

Cả bốn đều chạy được vì Boot không so sánh tên theo đúng cách chúng được viết. ConfigurationPropertyName so sánh từng phần tử ở dạng uniform, tức chuyển về chữ thường và bỏ mọi ký tự không phải chữ cái hay chữ số, nên smtp-host, smtpHost, smtp_host và SMTPHOST đều rút gọn về smtphost.
Sự linh hoạt đó là để đọc được bất kỳ cách viết nào bạn nhận được. Với file của chính bạn, Boot có một dạng canonical duy nhất: kebab-case, chữ thường, - giữa các từ, như app.mail.smtp-host. Prefix trong annotation bắt buộc phải ở dạng canonical, và Boot kiểm tra điều này trước khi bind bất cứ thứ gì:
***************************
APPLICATION FAILED TO START
***************************
Description:
Configuration property name 'app.mailService' is not valid:
Invalid characters: 'S'
Bean: app.mailService-com.example.demo.MailProperties
Reason: Canonical names should be kebab-case ('-' separated), lowercase alpha-numeric characters and must start with a letter
Action:
Modify 'app.mailService' so that it conforms to the canonical names requirements.Với environment variable, quy tắc là: thay dấu chấm bằng dấu gạch dưới, bỏ dấu gạch ngang, và viết hoa phần còn lại. app.mail.smtp-host thành APP_MAIL_SMTPHOST. Boot 4.1.1 vẫn chấp nhận một dạng cũ, trong đó dấu gạch ngang cũng thành dấu gạch dưới — một lần chạy với APP_MAIL_SMTP_HOST=legacy.example.com bind ra smtpHost = legacy.example.com — nhưng vì _ còn dùng để phân tách các cấp, dạng được ghi trong tài liệu mới là dạng không mơ hồ. Environment variable được xếp thứ tự ưu tiên thế nào so với file của bạn là chủ đề của bài sau; ở đây chỉ quy tắc đặt tên của chúng là quan trọng.
Tên environment variable cho list và map
Index trong environment variable được viết giữa hai dấu gạch dưới. Set các environment variable sau, không có property nào khác:
APP_MAIL_RECIPIENTS_0_=a@example.com
APP_MAIL_RECIPIENTS_1_=b@example.com
APP_MAIL_SERVERS_0_HOST=mx1.example.com
APP_MAIL_SERVERS_0_PORT=25
APP_MAIL_SERVERS_1_HOST=mx2.example.com
APP_MAIL_SERVERS_1_PORT=2525
APP_MAIL_HEADERS_XPRIORITY=1
APP_MAIL_HEADERS_REPLY_TO=support@example.comsẽ bind ra kết quả dưới đây, với key của map được in trong ngoặc nhọn để thấy chính xác cách viết:
recipients = [a@example.com, b@example.com]
servers = [Server[host=mx1.example.com, port=25], Server[host=mx2.example.com, port=2525]]
headers = {<reply.to>=support@example.com, <xpriority>=1}List hoạt động đúng như mong đợi. Map thì không, và đó là phần cần nhớ:
| Đích | Environment variable | Kết quả |
|---|---|---|
recipients[0] | APP_MAIL_RECIPIENTS_0_ hoặc APP_MAIL_RECIPIENTS_0 | phần tử đầu tiên; dấu gạch dưới ở cuối là tùy chọn |
recipients | APP_MAIL_RECIPIENTS=a@example.com,b@example.com | hai phần tử |
servers[1].port | APP_MAIL_SERVERS_1_PORT | port của Server thứ hai |
một key của headers | APP_MAIL_HEADERS_XPRIORITY | key xpriority |
một key của headers | APP_MAIL_HEADERS_REPLY_TO | key reply.to, vì mọi _ đều thành . |
một key của extra trong Map<String, Object> | APP_MAIL_EXTRA_DKIM_SELECTOR | map lồng nhau {dkim={selector=mail2026}} |
Key của map lấy từ environment variable luôn bị đưa về chữ thường, và dấu gạch dưới không bao giờ trở thành dấu gạch ngang. Ngay cả một environment variable set bằng env với tên APP_MAIL_HEADERS_X-Priority=1 (shell không cho export tên chứa -) cũng đến nơi với key x-priority. Key của map cần chữ hoa hoặc dấu câu thì nên đặt trong file.
Key của map và cú pháp ngoặc vuông
Trong file, key của map giữ nguyên chữ hoa chữ thường, nhưng relaxed binding vẫn loại bỏ một số ký tự. Cú pháp ngoặc vuông [...] tắt việc đó cho từng key:
app.mail.headers.X-Mailer=demo
app.mail.headers.X_Campaign=spring
app.mail.headers.Reply@To=support@example.com
app.mail.headers.[Reply@To]=support@example.com
app.mail.headers./unsubscribe=unsub@example.com
app.mail.headers.[/bounce]=bounces@example.com
app.mail.headers.reply.to=help@example.com
app.mail.extra.dkim.domain=example.com
app.mail.extra.[dkim.selector]=mail2026headers = {<X-Mailer>=demo, <X_Campaign>=spring, <ReplyTo>=support@example.com, <Reply@To>=support@example.com, <unsubscribe>=unsub@example.com, </bounce>=bounces@example.com, <reply.to>=help@example.com}
extra = {dkim={domain=example.com}, dkim.selector=mail2026}| Key như đã viết | Key của map sau khi bind |
|---|---|
X-Mailer | X-Mailer: giữ chữ hoa và - |
X_Campaign | X_Campaign: giữ _ |
Reply@To | ReplyTo: bỏ @ |
[Reply@To] | Reply@To |
/unsubscribe | unsubscribe: bỏ / |
[/bounce] | /bounce |
reply.to trong Map<String, String> | reply.to |
dkim.domain trong Map<String, Object> | lồng nhau: dkim={domain=…} |
[dkim.selector] trong Map<String, Object> | một key phẳng, dkim.selector |
Không có ngoặc vuông, key của map giữ lại chữ cái với nguyên chữ hoa chữ thường, chữ số, - và _, còn mọi ký tự khác bị bỏ. Trong ngoặc vuông, key được dùng đúng như đã viết, và với Map<String, Object>, ngoặc vuông còn ngăn dấu chấm tạo ra map lồng nhau. Trong YAML, ngoặc vuông phải nằm trong dấu nháy: "[/bounce]": bounces@example.com bind ra key /bounce, còn [X-Priority]: 1 không có dấu nháy tạo ra key [X-Priority], giữ luôn cả ngoặc vuông.
Validate config ngay lúc khởi động với @Validated
Constraint đặt trên các component, và @Validated trên class bật chúng lên:
@Validated
@ConfigurationProperties("app.mail")
public record MailProperties(
@NotBlank String host,
@Min(1) @Max(65535) int port,
@NotBlank @Email String from,
@Pattern(regexp = "[A-Z]{2,10}") String subjectTag,
@Valid Smtp smtp) {
public record Smtp(@NotBlank String username, @Min(1) @Max(20) int maxConnections) {
}
}Các constraint đến từ jakarta.validation.constraints, @Valid từ jakarta.validation, còn @Validated từ org.springframework.validation.annotation. Validation chạy ngay sau khi bind, trong lúc bean đang được tạo, nên một vi phạm sẽ dừng context. Đây là đúng giá trị app.mail.port=70000 mà @Value đã chấp nhận, với mọi key khác đều hợp lệ:
app.mail.host=smtp.example.com
app.mail.port=70000
app.mail.from=noreply@example.com
app.mail.subject-tag=SHOP
app.mail.smtp.username=mailer
app.mail.smtp.max-connections=5***************************
APPLICATION FAILED TO START
***************************
Description:
Binding to target com.example.demo.MailProperties failed:
Property: app.mail.port
Value: "70000"
Origin: class path resource [application.properties] - 2:15
Reason: must be less than or equal to 65535
Action:
Update your application's configuration
Đây là fail-fast. Process thoát trước khi phục vụ bất kỳ request nào, và báo cáo nêu tên key, giá trị, file kèm dòng và cột, cùng quy tắc mà giá trị đã vi phạm. Một bản deploy mang config hỏng sẽ thất bại ngay khi được deploy, chứ không phải ở request thật đầu tiên tình cờ cần tới giá trị đó.
Boot báo mọi vi phạm cùng lúc, không chỉ vi phạm đầu tiên. Làm hỏng sáu giá trị:
app.mail.host=
app.mail.port=70000
app.mail.from=noreply-at-example.com
app.mail.subject-tag=shop
app.mail.smtp.username=
app.mail.smtp.max-connections=50Description:
Binding to target com.example.demo.MailProperties failed:
Property: app.mail.host
Value: ""
Origin: class path resource [application.properties] - 2:0
Reason: must not be blank
Property: app.mail.subjectTag
Value: "shop"
Origin: class path resource [application.properties] - 4:22
Reason: must match "[A-Z]{2,10}"
Property: app.mail.port
Value: "70000"
Origin: class path resource [application.properties] - 2:15
Reason: must be less than or equal to 65535
Property: app.mail.from
Value: "noreply-at-example.com"
Origin: class path resource [application.properties] - 3:15
Reason: must be a well-formed email address
Property: app.mail.smtp.maxConnections
Value: "50"
Reason: must be less than or equal to 20
Property: app.mail.smtp.username
Value: ""
Reason: must not be blankVài chi tiết trong báo cáo này nên biết trước khi gặp nó trong log thật. Các vi phạm không theo thứ tự trong file. Vi phạm trên property lồng nhau không có dòng Origin. Property được báo theo tên Java (subjectTag, maxConnections) chứ không phải key kebab-case bạn đã viết. Và host rỗng ở dòng 1 được định vị tại 2:0, tức đầu dòng kế tiếp.
⚠️ Constraint không có tác dụng gì nếu class thiếu
@Validated. Cùng file hỏng đó, bind vào cùng record chỉ bỏ đi đúng annotation này, vẫn khởi động bình thường và đưa cho mọi beanMailProperties[host=, port=70000, from=noreply-at-example.com, subjectTag=shop, smtp=Smtp[username=, maxConnections=50]].
Object lồng nhau chỉ được validate khi có @Valid
@Validated validate chính properties object. Nó không đi sâu vào object lồng nhau trừ khi component đó được đánh dấu @Valid. Lấy một file có các key cấp trên hợp lệ còn nhóm smtp thì không:
app.mail.host=smtp.example.com
app.mail.port=587
app.mail.from=noreply@example.com
app.mail.subject-tag=SHOP
app.mail.smtp.username=
app.mail.smtp.max-connections=50Bỏ @Valid khỏi component smtp và không đổi gì khác:
@Valid Smtp smtp) {
Smtp smtp) { Application khởi động:
MailProperties[host=smtp.example.com, port=587, from=noreply@example.com, subjectTag=SHOP, smtp=Smtp[username=, maxConnections=50]]@NotBlank và @Max(20) trên Smtp chưa bao giờ được đánh giá. Đặt @Valid trở lại, và cùng file đó dừng application:
Binding to target com.example.demo.MailProperties failed:
Property: app.mail.smtp.username
Value: ""
Reason: must not be blank
Property: app.mail.smtp.maxConnections
Value: "50"
Reason: must be less than or equal to 20@Valid còn một lỗ hổng nữa: nó chỉ validate object lồng nhau nếu object đó tồn tại. Bỏ hết key app.mail.smtp.* thì record bind ra smtp=null, @Valid bỏ qua, nên application vẫn khởi động. Mỗi khai báo trong hai khai báo sau đều bịt được lỗ hổng này:
| Khai báo component | Kết quả khi không có key app.mail.smtp.* nào |
|---|---|
@Valid Smtp smtp | khởi động với smtp=null |
@Valid @NotNull Smtp smtp | thất bại: app.mail.smtp, must not be null |
@Valid @DefaultValue Smtp smtp | thất bại: app.mail.smtp.username, must not be blank; app.mail.smtp.maxConnections, must be greater than or equal to 1 |
@NotNull nói rằng cả nhóm là bắt buộc. @DefaultValue rỗng dựng một Smtp rỗng và để constraint của chính nó quyết định, cách này hợp hơn khi một số field bên trong có giá trị mặc định hợp lý.
Metadata cho IDE với spring-boot-configuration-processor
Còn một vấn đề từ phần đầu chưa được giải quyết: key gõ sai. @ConfigurationProperties bỏ qua các key dưới prefix không khớp property nào, nên file này:
app.mail.hots=smtp.example.com
app.mail.port=587bind ra MailProperties[host=null, port=587], và application vẫn khởi động. Có hai cách phòng vệ, một lúc khởi động và một ngay trong editor.
Lúc khởi động, ignoreUnknownFields = false biến key lạ thành lỗi binding:
@ConfigurationProperties(prefix = "app.mail", ignoreUnknownFields = false)
public record MailProperties(String host, int port) {
} Property: app.mail.hots
Value: "smtp.example.com"
Origin: class path resource [application.properties] - 1:15
Reason: The elements [app.mail.hots] were left unbound.Tuy vậy, thiết lập này có hai mặt: một key còn sót lại từ phiên bản cũ của class cũng làm hỏng quá trình khởi động. Cách phòng vệ phổ biến hơn đến sớm hơn, ngay trong editor.
Build sinh sẵn đã có annotationProcessor 'org.springframework.boot:spring-boot-configuration-processor'. Lúc compile, processor đọc mọi type @ConfigurationProperties và ghi ra một bản mô tả JSON cho các key của nó. Đây là phiên bản có Javadoc của properties record:
/**
* Settings for the outgoing mail client.
*
* @param host host name of the SMTP server
* @param port port the SMTP server listens on
* @param from address placed in the From header of every message
* @param timeout how long to wait for the server before giving up
* @param maxAttachmentSize largest attachment accepted before a message is rejected
* @param recipients addresses that receive operational alerts
* @param smtp authentication settings for the SMTP connection
*/
@Validated
@ConfigurationProperties("app.mail")
public record MailProperties(
@NotBlank String host,
@DefaultValue("587") @Min(1) @Max(65535) int port,
@NotBlank @Email String from,
@DefaultValue("30s") Duration timeout,
@DefaultValue("10MB") DataSize maxAttachmentSize,
List<String> recipients,
@Valid @DefaultValue Smtp smtp) {
public MailProperties {
recipients = (recipients == null) ? List.of() : List.copyOf(recipients);
}
/**
* SMTP authentication settings.
*
* @param username account used to authenticate against the SMTP server
* @param maxConnections upper bound on concurrent SMTP connections
*/
public record Smtp(String username, @DefaultValue("5") @Min(1) @Max(20) int maxConnections) {
}
}./gradlew compileJava ghi ra build/classes/java/main/META-INF/spring-configuration-metadata.json, và từ đó file được đóng gói vào jar dưới đường dẫn META-INF/spring-configuration-metadata.json:
{
"groups": [
{
"name": "app.mail",
"type": "com.example.demo.MailProperties",
"sourceType": "com.example.demo.MailProperties"
},
{
"name": "app.mail.smtp",
"type": "com.example.demo.MailProperties$Smtp",
"sourceType": "com.example.demo.MailProperties",
"sourceMethod": "smtp()"
}
],
"properties": [
{
"name": "app.mail.from",
"type": "java.lang.String",
"description": "address placed in the From header of every message",
"sourceType": "com.example.demo.MailProperties"
},
{
"name": "app.mail.host",
"type": "java.lang.String",
"description": "host name of the SMTP server",
"sourceType": "com.example.demo.MailProperties"
},
{
"name": "app.mail.max-attachment-size",
"type": "org.springframework.util.unit.DataSize",
"description": "largest attachment accepted before a message is rejected",
"sourceType": "com.example.demo.MailProperties",
"defaultValue": "10MB"
},
{
"name": "app.mail.port",
"type": "java.lang.Integer",
"description": "port the SMTP server listens on",
"sourceType": "com.example.demo.MailProperties",
"defaultValue": 587
},
{
"name": "app.mail.recipients",
"type": "java.util.List<java.lang.String>",
"description": "addresses that receive operational alerts",
"sourceType": "com.example.demo.MailProperties"
},
{
"name": "app.mail.smtp.max-connections",
"type": "java.lang.Integer",
"description": "upper bound on concurrent SMTP connections",
"sourceType": "com.example.demo.MailProperties$Smtp",
"defaultValue": 5
},
{
"name": "app.mail.smtp.username",
"type": "java.lang.String",
"description": "account used to authenticate against the SMTP server",
"sourceType": "com.example.demo.MailProperties$Smtp"
},
{
"name": "app.mail.timeout",
"type": "java.time.Duration",
"description": "how long to wait for the server before giving up",
"sourceType": "com.example.demo.MailProperties",
"defaultValue": "30s"
}
],
"hints": [],
"ignored": {
"properties": []
}
}Đọc file này:
descriptionlấy từ Javadoc: các tag@paramcủa record, hoặc comment Javadoc trên từng field của JavaBean. Một JavaBean compile cùng project với/** Directory that receives archived messages. */trên một field nhận đúng câu đó.defaultValuelấy từ@DefaultValue, hoặc từ field initializer mà processor đọc được. Với field JavaBeanDuration retention = Duration.ofDays(30), nó ghi"30d".- Tên ở dạng canonical. Component
maxAttachmentSizeđược liệt kê làapp.mail.max-attachment-size. - Constraint không có trong đó. Metadata mô tả key, không mô tả quy tắc dành cho giá trị.
Các editor hỗ trợ Spring Boot, như IntelliJ IDEA và các extension Spring Boot cho VS Code và Eclipse, đọc file này từ classpath để gợi ý key trong application.properties và application.yml, hiển thị description khi hover, và đánh dấu những key không properties class nào khai báo — chính là cách app.mail.hots bị bắt trước khi bất cứ thứ gì chạy. Processor cũng xử lý @Bean method có @ConfigurationProperties (phần tiếp theo), nhưng không xử lý @Value: một class có @Value("${app.alerts.webhook-url}") không sinh ra entry nào cho key đó. Với những key như vậy, một file src/main/resources/META-INF/additional-spring-configuration-metadata.json viết tay sẽ được gộp vào file sinh ra lúc compile.
Bind class của thư viện bên thứ ba bằng @Bean
@ConfigurationProperties cũng dùng được trên @Bean method. Boot bind các key dưới prefix vào object mà method trả về, thông qua các setter của nó, và đó là cách cấu hình một class bạn không thể đặt annotation. ThreadPoolTaskExecutor của Spring là một JavaBean thuần:
@Configuration
public class MailExecutorConfig {
@Bean
@ConfigurationProperties("app.pool")
public ThreadPoolTaskExecutor mailExecutor() {
return new ThreadPoolTaskExecutor();
}
}app.pool.core-pool-size=4
app.pool.max-pool-size=16
app.pool.queue-capacity=500
app.pool.keep-alive-seconds=120
app.pool.thread-name-prefix=mail-corePoolSize = 4
maxPoolSize = 16
queueCapacity = 500
keepAliveSeconds = 120
threadNamePrefix = mail-
underlying core = 4
queue remaining = 500
queue class = java.util.concurrent.LinkedBlockingQueue
task ran on mail-1
Executor beans = [mailExecutor]queue remaining = 500 cho thấy thứ tự. Executor tạo LinkedBlockingQueue của nó đúng một lần, khi bean được khởi tạo, và queue có chỗ cho đúng 500 task, nên việc bind đã diễn ra sau khi @Bean method trả về và trước khi khởi tạo. Relaxed binding cũng áp dụng ở đây — core-pool-size tới được setCorePoolSize — và configuration processor sinh ra nhóm app.pool với chín key từ các setter, không có description vì không có source Javadoc để đọc.
Dòng cuối là back-off từ chương trước. applicationTaskExecutor của Boot chỉ được tạo khi chưa có bean Executor nào, mà mailExecutor lại là một bean như vậy, nên bean mặc định của Boot biến mất. Thêm spring.task.execution.mode=force sẽ đưa nó trở lại bên cạnh bean của bạn: Executor beans = [mailExecutor, applicationTaskExecutor].
Gõ sai ở đây cũng im lặng y như vậy: app.pool.core-pool-sise=4 để lại corePoolSize = 1, giá trị mặc định của chính class đó.
@Value hay @ConfigurationProperties
Mọi dòng dưới đây đều lấy từ các lần chạy trong bài:
@Value | @ConfigurationProperties | |
|---|---|---|
| Relaxed binding | chỉ khi placeholder viết ở dạng kebab-case: ${app.mail.smtp-host} khớp smtpHost, smtp_host và APP_MAIL_SMTPHOST, còn ${app.mail.smtpHost} bỏ lỡ smtp-host và smtp_host | mọi cách viết trong bảng relaxed binding |
| Metadata cho IDE | không có: processor bỏ qua @Value | sinh lúc compile, kèm description và giá trị mặc định |
| Validation | không có | constraint Jakarta với @Validated, kiểm tra lúc khởi động |
| SpEL | có: #{'${app.mail.host}'.toUpperCase()} cho ra SMTP.EXAMPLE.COM | không: #{'hello'.toUpperCase()} được bind nguyên văn thành chuỗi đó |
| Gom nhóm | mỗi injection point một key, lặp lại ở mọi class | mỗi prefix một type, inject ở bất cứ đâu cần |
| Giá trị có cấu trúc | mỗi giá trị lá một placeholder | object lồng nhau, list các object và map |
| Key gõ sai | placeholder có giá trị mặc định che mất lỗi gõ | mặc định bị bỏ qua; IDE đánh dấu, hoặc bị từ chối với ignoreUnknownFields = false |
| Tính bất biến | field final khi inject qua constructor | record, cộng List.copyOf cho collection |
| Khả năng test | field injection cần reflection hoặc Spring context | new MailProperties(...) trong một unit test thuần |
Dùng @ConfigurationProperties cho mọi nhóm key liên quan, mọi thứ được đọc từ nhiều hơn một class, hoặc cần validation, tức gần như toàn bộ config của application. Giữ @Value cho một giá trị đơn lẻ dùng ở một chỗ, hoặc khi bạn thật sự cần một SpEL expression. Khi đã dùng @Value, hãy viết key ở dạng kebab-case, vì đó là cách viết duy nhất được so khớp theo relaxed binding.
Test properties class không cần Spring
Record được test bằng cách gọi constructor. Sau khi đã bind, nó chẳng cần gì tới Spring nữa:
package com.example.demo;
import java.time.Duration;
import java.util.ArrayList;
import java.util.List;
import jakarta.validation.Validation;
import jakarta.validation.Validator;
import jakarta.validation.ValidatorFactory;
import org.junit.jupiter.api.Test;
import org.springframework.util.unit.DataSize;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
class MailPropertiesTests {
private static MailProperties mail(int port, List<String> recipients) {
return new MailProperties("smtp.example.com", port, "noreply@example.com", Duration.ofSeconds(30),
DataSize.ofMegabytes(10), recipients, new MailProperties.Smtp("mailer", 5));
}
@Test
void recipientsAreCopiedAndReadOnly() {
List<String> source = new ArrayList<>(List.of("ops@example.com"));
MailProperties properties = mail(587, source);
source.add("intruder@example.com");
assertThat(properties.recipients()).containsExactly("ops@example.com");
assertThatThrownBy(() -> properties.recipients().add("dev@example.com"))
.isInstanceOf(UnsupportedOperationException.class);
}
@Test
void portOutOfRangeIsReported() {
try (ValidatorFactory factory = Validation.buildDefaultValidatorFactory()) {
Validator validator = factory.getValidator();
assertThat(validator.validate(mail(70000, List.of())))
.extracting(violation -> violation.getPropertyPath() + " " + violation.getMessage())
.containsExactly("port must be less than or equal to 65535");
}
}
}Test này dùng record có Javadoc ở phần metadata. Chạy ./gradlew test, với testLogging { events 'passed', 'failed' } được thêm vào task test để in ra từng kết quả:
MailPropertiesTests > recipientsAreCopiedAndReadOnly() PASSED
MailPropertiesTests > portOutOfRangeIsReported() PASSEDValidation.buildDefaultValidatorFactory() tìm thấy Hibernate Validator trên classpath nhờ validation starter, nên constraint được kiểm tra trong một unit test thuần, không cần application context. Thứ mà cách này không test là chính quá trình bind: tên key, các cách viết relaxed, chuyển đổi và lỗi lúc khởi động. Cho việc đó, spring-boot-test cung cấp ApplicationContextRunner (trong org.springframework.boot.test.context.runner), khởi động một context tối giản với các property bạn truyền vào; chương về testing sẽ nói tới nó.
FAQ
Record trong Spring Boot 4 có cần @ConstructorBinding không?
Không. Record chỉ có canonical constructor được bind qua constructor đó một cách tự động, và bất kỳ class nào có một constructor duy nhất nhận parameter cũng vậy. Bạn chỉ cần @ConstructorBinding khi type khai báo nhiều hơn một constructor; với record, đặt nó lên một compact canonical constructor khai báo tường minh. Nếu thiếu, constructor thứ hai khiến Boot quay về JavaBean binding, và record sẽ thất bại với No default constructor found.
Vì sao class @ConfigurationProperties của tôi không inject được?
Vì nó chưa được đăng ký. Chỉ riêng annotation không tạo ra bean: thêm @ConfigurationPropertiesScan vào application class, hoặc @EnableConfigurationProperties(YourProperties.class) vào một configuration class. Triệu chứng là lỗi quen thuộc required a bean of type ... that could not be found, hoặc chẳng có gì cả nếu không bean nào inject class đó. Đừng đặt @Component lên record; cách đó thất bại với thông báo chỉ bạn tới đúng hai annotation kia.
Vì sao constraint validation của tôi không có tác dụng?
Có ba nguyên nhân, đều đã minh họa ở trên: class thiếu @Validated, object lồng nhau thiếu @Valid, hoặc object lồng nhau là null vì không có key nào của nó, khi đó hãy thêm @NotNull hoặc @DefaultValue rỗng. Thiếu implementation của validator thì không im lặng: có jakarta.validation-api trên classpath nhưng không có Hibernate Validator, quá trình khởi động dừng với The Bean Validation API is on the classpath but no implementation could be found. Validation starter mang tới cả hai.
@ConfigurationProperties có báo lỗi khi gặp key lạ không?
Mặc định là không. Key dưới prefix không khớp property nào sẽ bị bỏ qua, nên key gõ sai không bind gì và field giữ giá trị mặc định hoặc null. Set ignoreUnknownFields = false để biến key lạ thành lỗi khởi động, và dựa vào metadata của configuration processor để IDE đánh dấu chúng ngay khi bạn gõ.
Duration không có suffix thì dùng đơn vị gì?
Mili giây: timeout=30 bind ra PT0.03S. DataSize mặc định là byte, còn Period là ngày. Đặt @DurationUnit, @DataSizeUnit hoặc @PeriodUnit lên component để đổi đơn vị cho số trần, hoặc đơn giản là luôn viết suffix.
Nên dùng @Value hay @ConfigurationProperties?
@ConfigurationProperties cho mọi nhóm key liên quan, mọi thứ được đọc ở nhiều hơn một class, và mọi thứ cần validation. @Value cho một giá trị đơn lẻ hoặc một SpEL expression. Bảng so sánh ở trên có đầy đủ chi tiết.
Kết luận
@ConfigurationProperties biến một prefix thành một type. Record được constructor binding mà không cần thêm annotation nào, miễn là nó giữ một constructor duy nhất, và @ConfigurationPropertiesScan hoặc @EnableConfigurationProperties biến nó thành bean. Binder dựng object lồng nhau, list, map và enum, đồng thời chuyển đổi Duration, DataSize và Period từ dạng viết tắt có suffix mà giờ bạn đã biết đơn vị mặc định. Relaxed binding chấp nhận kebab-case, camelCase, dấu gạch dưới và environment variable viết hoa cho cùng một field, với kebab-case là cách viết canonical. @Validated cùng các constraint Jakarta khiến config hỏng dừng application ngay lúc khởi động với một báo cáo chính xác, thay vì hỏng ở lần dùng đầu tiên, miễn là bạn nhớ @Valid cho các nhóm lồng nhau. Và configuration processor biến chính class đó thành metadata cho IDE, thứ cuối cùng bắt được key gõ sai.
Mọi thứ ở đây được bind từ một file application.properties duy nhất và, thoáng qua, vài environment variable. Deploy thật có nhiều file và nhiều environment variable, cộng thêm command-line argument, và cần giá trị khác nhau cho development, test và production. Đó là nội dung bài tiếp theo, Profiles và thứ tự ưu tiên cấu hình — dev/test/prod, environment variable, command-line argument — và giá trị nào thắng khi cùng một key được set ở nhiều nơi.