Cấu hình là chỗ ít ai đọc kỹ cho tới hôm một giá trị không có tác dụng và không ai hiểu vì sao.
Chuỗi rỗng không phải "chưa cấu hình"
smtp:
host: ${SMTP_HOST:smtp.mac-dinh.com}
không đặt gì -> smtp.host = 'smtp.mac-dinh.com'
SMTP_HOST= -> smtp.host = '' <- KHÔNG dùng mặc định
SMTP_HOST=that.com -> smtp.host = 'that.com'
Dòng giữa là cái bẫy. Biến tồn tại với giá trị rỗng, nên cú pháp :mặc-định không kích hoạt — nó chỉ áp dụng khi biến không có mặt.
Đây là chuyện có thật trong Docker Compose:
environment:
SMTP_HOST: ${SMTP_HOST:-} # luôn đặt biến, có thể rỗng
CLAUDE.md của blog này ghi rõ: chỉ làm vậy khi rỗng thật sự có nghĩa — ví dụ SMTP_HOST= nghĩa là không gửi mail. Còn lại phải ghi mặc định thật vào compose.
Và có một hệ quả tinh vi hơn: Spring Boot tạo bean gửi mail chỉ cần thuộc tính spring.mail.host tồn tại. Nên không được kết luận "chưa cấu hình SMTP" chỉ vì có bean — phải kiểm chính chuỗi host.
Đừng đổi tên biến giữa các tầng
CLAUDE.md ghi lại một lỗi cũ: compose truyền SMTP_HOST thành SPRING_MAIL_HOST, và API_RATE_LIMIT thành BLOG_API_RATE_LIMIT. Đọc application.yml không thấy chúng đâu, mà đặt sai tên trong .env thì im lặng không có tác dụng.
Ba nơi phải khớp:
| Nơi | Vai trò |
|---|---|
application.yml |
khai ${TEN_BIEN:mặc định} — đọc file này là thấy hết nút chỉnh được |
docker-compose.yml |
truyền biến vào container, giữ nguyên tên |
.env.example |
hướng dẫn cho người vận hành |
Blog này có EnvironmentDocumentationTest canh chừng ba nơi đó khớp nhau. Một test rẻ cho một lớp lỗi rất khó tìm.
Thứ tự ưu tiên
Từ cao xuống thấp, phần hay dùng:
tham số dòng lệnh --ung-dung.ten=x
biến môi trường UNG_DUNG_TEN=x
application-{profile}.yml ngoài jar
application.yml ngoài jar
application-{profile}.yml trong jar
application.yml trong jar
@Value mặc định / @ConfigurationProperties
Điểm quan trọng cho vận hành: tệp ngoài jar thắng tệp trong jar, nên gắn một application.yml vào container là ghi đè được mà không dựng lại ảnh.
Và ánh xạ tên: ung-dung.so-luong đọc được từ biến môi trường UNG_DUNG_SO_LUONG. Spring gọi đó là "relaxed binding" — chấm thành gạch dưới, gạch nối biến mất, chữ hoa hết.
@ConfigurationProperties thay vì @Value
@ConfigurationProperties(prefix = "ung-dung")
@Validated
public record Cai(@NotBlank String ten, @Min(1) int soLuong,
boolean bat, List<String> danhSach) {}
java -Dung-dung.so-luong=0 -jar cfg.jar
Property: ung-dung.soLuong
Value: "0"
Reason: must be greater than or equal to 1
Ứng dụng không khởi động, và thông báo chỉ đúng thuộc tính, đúng giá trị, đúng lý do.
Đây là lợi thế lớn nhất so với @Value: cấu hình sai bị bắt lúc khởi động, không phải lúc 2 giờ sáng khi một nhánh mã hiếm gặp chạy tới.
Bốn ưu điểm khác:
Nhóm lại theo chủ đề thay vì rải @Value khắp mã.
Kiểu mạnh, gồm List, Map, Duration (30s, 5m), DataSize (10MB), enum.
Test dễ — chỉ là một record, dựng bằng new trong test đơn vị.
Tài liệu tự sinh. Thêm spring-boot-configuration-processor và IDE gợi ý tên thuộc tính kèm mô tả.
record là lựa chọn tự nhiên: bất biến, gọn, không cần getter/setter. Spring Boot 3 hỗ trợ trực tiếp.
Profile
không đặt gì -> ten = "mac dinh trong application.yml", profile = []
-Dspring.profiles.active=prod -> ten = "gia tri cua profile prod", profile = [prod]
application-prod.yml chồng lên application.yml, chỉ ghi đè những khoá nó khai.
Ba lời khuyên:
Giữ ít profile. dev, test, prod là đủ. Mỗi profile là một tổ hợp cấu hình chưa được kiểm — và bài 49 vừa đo: mỗi tổ hợp cũng là một ngữ cảnh test riêng.
Đừng dùng profile cho bí mật. Mật khẩu không nằm trong application-prod.yml được commit vào Git.
@Profile trên bean là công cụ sắc. Nó tiện, nhưng nó nghĩa là ứng dụng ở prod có tập bean khác ứng dụng bạn đang test. Dùng cho hạ tầng (cài đặt giả cho dev), đừng dùng cho logic nghiệp vụ.
Blog này dùng đúng vậy: bản Local của ImageStorage cho dev, Minio cho sản xuất.
Bí mật
Không commit vào Git. Kể cả repo riêng tư, kể cả "tạm thời" — lịch sử Git giữ mãi.
Ba mức, theo thứ tự tôi khuyên:
Biến môi trường — đủ cho phần lớn dự án. Cẩn thận: /actuator/env in ra tất cả (bài 46), và nhiều hệ thống ghi biến môi trường vào log lỗi.
Tệp gắn vào container, quyền 600. Spring đọc được qua cú pháp ${...} hoặc Docker secrets.
Kho bí mật — Vault, AWS Secrets Manager. Cho phép xoay vòng khoá và ghi vết ai đọc gì. Đáng khi có nhiều dịch vụ hoặc yêu cầu tuân thủ.
Và luôn: không ghi bí mật ra log (bài 46).
Ba cái bẫy đã trả giá
${BIEN:?} trong Docker Compose làm hỏng cả docker compose build, không chỉ up. Dùng :- rồi kiểm tra trong script.
Cờ nằm trong CSDL, không phải trong .env. Blog này có allow_registration là một cột trong bảng cấu hình. Deploy mới luôn bắt đầu ở trạng thái tắt, và tìm nó trong .env sẽ không bao giờ thấy. Ghi rõ vào tài liệu cấu hình nào ở đâu.
Đừng khai hibernate.dialect cho PostgreSQL. Hibernate 6 tự nhận ra từ kết nối; khai tay chỉ nhận cảnh báo HHH90000025. Và lưu ý khi đọc log: mất kết nối CSDL lúc khởi động báo "Unable to determine Dialect without JDBC metadata" — nghe như thiếu cấu hình dialect nhưng nguyên nhân thật nằm ở dòng Caused by phía trên.
Làm mới cấu hình lúc đang chạy
Spring Boot thuần không làm được — đổi thuộc tính phải khởi động lại.
Spring Cloud Config cộng @RefreshScope làm được, nhưng nó kéo theo một dịch vụ cấu hình nữa. Với ứng dụng chạy trong container, khởi động lại thường đơn giản và đáng tin hơn — nếu khởi động lại là việc đáng sợ, đó mới là vấn đề cần sửa.
Thử ba mươi giây
grep -rn '@Value' --include='*.java' src/main | wc -l
Con số lớn nghĩa là cấu hình của bạn đang rải khắp mã, không có kiểm tra, và không có chỗ nào liệt kê đầy đủ. Gom vào vài lớp @ConfigurationProperties là việc làm một lần, và nó bắt lỗi cấu hình lúc khởi động thay vì lúc chạy.
Ngày mai: Actuator — sức khoẻ, chỉ số, và những endpoint không được để lọt ra Internet.