Bài trước dựng một ứng dụng nhỏ với controller, service và repository, không dùng framework nào, rồi nối ba phần đó lại bằng tay trong main. Bài đó kết lại rằng một framework sẽ tự động hoá đúng phần việc ấy. Framework đó chính là Spring.
Spring là một container tạo ra các object của bạn và trao chúng cho nhau. Spring Boot là bản đóng gói của container đó kèm những mặc định hợp lý, nhờ vậy một project bắt đầu bằng một class với một annotation thay vì một thư mục XML. Toàn bộ nội dung dưới đây đến từ một project được build và chạy thật, nên mọi dòng log, mọi exception và mọi lỗi compile đều là thứ toolchain in ra.
![]()
Project được tạo từ Spring Initializr chỉ với dependency web, build bằng Maven wrapper đi kèm, chạy trên OpenJDK 21.0.6 (arm64) với Spring Boot 4.1.1, kéo theo Spring Framework 7.0.9, embedded Tomcat 11.0.24 và JUnit Jupiter 6.0.3. Có đúng một thứ bị cố tình bỏ khỏi mọi transcript bên dưới: Spring Boot in ra dòng Started DemoApplication in ... seconds ở cuối quá trình khởi động, và mọi con số kiểu đó đã được thay bằng [redacted], vì các bài này được viết trên máy dùng chung đang tải nặng, một con số startup đo ở đó chỉ là số ảo.
Spring khác Spring Boot ở chỗ nào
Spring Framework là container: nó đọc các class của bạn, quyết định class nào là bean, tạo chúng theo đúng thứ tự dependency và trao cho mỗi cái những collaborator mà nó khai báo. Phần đó đã cũ, ổn định và không áp đặt gì cả — bạn cấu hình sao thì nó làm vậy, và trước đây bạn phải tự cấu hình toàn bộ.
Spring Boot là một lớp quan điểm đặt lên trên. Nó đóng góp ba thứ và không thêm khái niệm nào mới: starter, tức các gói dependency để bạn thêm một artifact thay vì mười lăm; auto-configuration, tức tạo sẵn những bean mà một ứng dụng thông thường vẫn phải tự tạo, nhưng chỉ khi điều kiện cho chúng thoả mãn; và embedded server kèm launcher, nên thành phẩm là một jar chạy được có main chứ không phải một file WAR đem deploy vào thứ khác. Bỏ Boot đi thì mọi khái niệm Spring bên dưới vẫn còn nguyên — bạn chỉ phải viết nhiều config hơn rất nhiều để tới cùng một chỗ.
| Spring Framework | Spring Boot | |
|---|---|---|
| Cho bạn | IoC container, DI, AOP, web MVC stack | starter, auto-configuration, embedded server |
| Bạn viết | các bean definition | gần như không viết gì, rồi override chỗ nào không đồng ý |
| Thành phẩm | một library cho app dùng | một jar chạy được có main |
| Trong project này | spring-context 7.0.9, spring-webmvc 7.0.9 | spring-boot 4.1.1, spring-boot-autoconfigure 4.1.1 |
Tạo một project build được ngay từ đầu
Tạo project từ Spring Initializr. File nén có sẵn Maven wrapper (mvnw), nên máy không cần cài Maven.
curl -sS -o demo.zip "https://start.spring.io/starter.zip?type=maven-project&language=java&bootVersion=4.1.1.RELEASE&javaVersion=21&groupId=com.example&artifactId=demo&dependencies=web"
unzip -q demo.zip -d demo
cd demoCó một cái bẫy ở đây, biết trước thì đỡ mất cả tiếng. Initializr đặt id cho boot version kèm hậu tố .RELEASE, nhưng artifact đẩy lên Maven Central lại không có hậu tố đó. Project tạo với bootVersion=4.1.1.RELEASE sẽ có parent là spring-boot-starter-parent:4.1.1.RELEASE và lần build đầu tiên chết với Could not find artifact org.springframework.boot:spring-boot-starter-parent:pom:4.1.1.RELEASE in central. Sửa version của parent thành 4.1.1 là resolve được:
<parent>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>4.1.1</version>
<relativePath/>
</parent>Bất ngờ thứ hai nằm trong khối dependency. Chọn "Spring Web" trên Initializr với Boot 4 không còn sinh ra spring-boot-starter-web nữa:
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-webmvc</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-webmvc-test</artifactId>
<scope>test</scope>
</dependency>Tên cũ vẫn resolve được — chính POM của nó trên Maven Central tự mô tả là "Starter for building web, including RESTful, applications using Spring MVC. Uses Tomcat as the default embedded container (deprecated in favor of spring-boot-starter-webmvc)". Các bài viết cho Boot 3 sẽ nói spring-boot-starter-web; trên Boot 4 đó là cách viết đã deprecated.
@SpringBootApplication là toàn bộ phần bootstrap. Nó là một annotation ghép: @Configuration (class này có thể khai báo bean), @EnableAutoConfiguration (xét tới các auto-configuration class của Boot) và @ComponentScan (quét package này và mọi package con).
package com.example.demo;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
@SpringBootApplication
public class DemoApplication {
public static void main(String[] args) {
SpringApplication.run(DemoApplication.class, args);
}
}Một endpoint đơn giản là đủ để chứng minh cả bộ máy đang sống. Phần HTTP mapping, status code và validation là chủ đề riêng, không nằm trong bài này:
@RestController
public class HelloController {
private final GreetingService service;
public HelloController(GreetingService service) {
this.service = service;
}
@GetMapping("/hello")
public String hello(@RequestParam(defaultValue = "world") String name) {
return service.greet("en", name);
}
}./mvnw -B -DskipTests package
java -jar target/demo-0.0.1-SNAPSHOT.jar --server.port=18437 --spring.output.ansi.enabled=NEVER
curl "http://localhost:18437/hello?name=Spring"Hello, SpringNên tập thói quen luôn thêm --spring.output.ansi.enabled=NEVER: không có nó thì banner và các log level bị bọc trong ANSI escape sequence, nhìn trên terminal thì không thấy gì nhưng dán vào file hay ticket thì thành rác.
IoC container: đúng phần nối dây bạn từng làm bằng tay
Inversion of control nói cụ thể là một chuyện: code của bạn không còn gọi new cho các collaborator, và cũng không còn quyết định lúc nào chúng được tạo. Bạn khai báo một class cần gì, container lo thứ tự và đưa tận nơi.

Các stereotype annotation đánh dấu một class là ứng viên. @Component là cái chung nhất; @Service và @Repository chính là @Component với một cái tên nói rõ class thuộc tầng nào. Khi scan, Spring đối xử với cả ba như nhau — riêng @Repository còn dịch các exception của tầng persistence, chuyện này chỉ có ý nghĩa khi có công nghệ truy cập dữ liệu tham gia.
@Repository
public class GreetingRepository {
private final Map<String, String> byLocale = Map.of("en", "Hello", "vi", "Xin chao");
public Optional<String> find(String locale) {
return Optional.ofNullable(byLocale.get(locale));
}
}
@Service
public class GreetingService {
private final GreetingRepository repository;
private final Formatter formatter;
public GreetingService(GreetingRepository repository, Formatter formatter) {
this.repository = repository;
this.formatter = formatter;
}
public String greet(String locale, String name) {
String greeting = repository.find(locale).orElse("Hello");
return formatter.format(greeting, name);
}
}Trong GreetingService không có chỗ nào nói dependency của nó đến từ đâu. Đó chính là điểm mấu chốt: class chỉ mô tả nhu cầu, còn container là thứ duy nhất biết nhu cầu đó được đáp ứng ra sao.
Constructor injection, và vì sao nó hơn field injection
Từ Spring 4.3, một class có đúng một constructor thì không cần @Autowired trên constructor đó — container tự dùng nó. Constructor injection không phải chuyện gu thẩm mỹ; nó mang lại ba thứ rất cụ thể.
Field có thể để final. Field injection thì không, và compiler nói thẳng điều đó trước khi Spring kịp tham gia:
@Service
public class FinalField {
@Autowired
private final GreetingRepository repository;
public String greet(String locale) {
return repository.find(locale).orElse("Hello");
}
}FinalField.java:10: error: variable repository not initialized in the default constructor
private final GreetingRepository repository;
^
1 errorClass test được mà không cần container. Một bean dùng constructor injection chỉ là object bình thường, nên một test JUnit thuần tạo nó trực tiếp — không Spring, không reflection, không context:
@Test
void constructorInjectionNeedsNoSpring() {
GreetingService service =
new GreetingService(new GreetingRepository(), new PlainFormatter());
assertThat(service.greet("vi", "world")).isEqualTo("Xin chao, world");
}Thiếu dependency thì hỏng ngay và hỏng rõ. Với field injection, caller không có cách nào truyền dependency vào cả, nên một instance tạo ngoài container sẽ ở trạng thái dựng dở và chỉ lên tiếng ở lần gọi đầu tiên:
static class FieldInjectedGreetingService {
@Autowired
private GreetingRepository repository;
String greet(String locale) {
return repository.find(locale).orElse("Hello");
}
}NPE MESSAGE: Cannot invoke "com.example.demo.GreetingRepository.find(String)" because "this.repository" is nullBên trong container thì khác biệt vẫn là khác biệt đó, chỉ dịch sớm hơn về mặt thời gian: một constructor dependency không resolve được sẽ chặn ứng dụng ngay lúc khởi động, còn một field đánh dấu @Autowired(required = false) để lại một giá trị null nằm im trong object.
Bean bạn không sở hữu: @Configuration và @Bean
Bạn không thể gắn annotation lên một class của JDK hay của thư viện bên thứ ba. Với chúng, hãy viết một class @Configuration có các method @Bean — tên method thành tên bean, kiểu trả về thành type của bean:
@Configuration
@EnableConfigurationProperties(GreetingProperties.class)
public class AppConfig {
@Bean
public Clock clock() {
return Clock.system(ZoneId.of("UTC"));
}
@Bean
public CharacterEncodingFilter myEncodingFilter() {
CharacterEncodingFilter filter = new CharacterEncodingFilter();
filter.setEncoding(StandardCharsets.UTF_8.name());
filter.setForceResponseEncoding(true);
return filter;
}
}Một method @Bean có thể nhận parameter, và container resolve chúng đúng như cách resolve constructor parameter. Đây cũng là lối thoát cho những trường hợp cần logic thật — đọc environment variable, chọn implementation, dựng object bằng builder.
Hai candidate và không cái nào @Primary
Injection resolve theo type trước. Cho container hai bean cùng thoả một parameter type thì nó từ chối đoán. Đây là một trong những lỗi khởi động Spring phổ biến nhất, nên đáng nhìn tận mắt hơn là nghe kể lại. Hai implementation, không cái nào được đánh dấu:
public interface Formatter {
String format(String greeting, String name);
}
@Component
public class PlainFormatter implements Formatter {
@Override
public String format(String greeting, String name) {
return greeting + ", " + name;
}
}
@Component
public class ShoutFormatter implements Formatter {
@Override
public String format(String greeting, String name) {
return (greeting + ", " + name).toUpperCase();
}
}Ứng dụng không khởi động. Đây là report nguyên văn, chỉ rút gọn đường dẫn tuyệt đối thành /path/to/demo:
***************************
APPLICATION FAILED TO START
***************************
Description:
Parameter 1 of constructor in com.example.demo.GreetingService required a single bean, but 2 were found:
- plainFormatter: defined in file [/path/to/demo/target/classes/com/example/demo/PlainFormatter.class]
- shoutFormatter: defined in file [/path/to/demo/target/classes/com/example/demo/ShoutFormatter.class]
This may be due to missing parameter name information
Action:
Consider marking one of the beans as @Primary, updating the consumer to accept multiple beans, or using @Qualifier to identify the bean that should be consumedException gốc, được log vài dòng phía trên report, gọi tên cả type lẫn hai candidate:
org.springframework.beans.factory.UnsatisfiedDependencyException: Error creating bean with name 'greetingService': Unsatisfied dependency expressed through constructor parameter 1: No qualifying bean of type 'com.example.demo.Formatter' available: expected single matching bean but found 2: plainFormatter,shoutFormatterHai annotation giải quyết chuyện này, và chúng trả lời hai câu hỏi khác nhau. @Primary nằm trên bean, nghĩa là "khi không ai chỉ định gì khác thì dùng tôi". @Qualifier nằm ngay tại injection point, nghĩa là "riêng chỗ này tôi muốn cái kia":
@Component
@Primary
public class PlainFormatter implements Formatter { /* ... */ }public Diagnostics(GreetingService greetingService,
@Qualifier("shoutFormatter") Formatter shout) {
this.greetingService = greetingService;
this.shout = shout;
}QUALIFIER primary -> Hello, world
QUALIFIER shoutFormatter -> HELLO, WORLDNếu không có annotation nào trong hai cái đó, Spring còn một rule cuối trước khi bỏ cuộc: bean nào có tên trùng tên parameter thì thắng. Chính rule dự phòng ấy là lý do report kết thúc bằng lời khuyên về flag -parameters của compiler — không giữ tên parameter trong bytecode thì container mất đi một cách phân định. Parent POM của Spring Boot đã bật sẵn flag đó cho bạn.
Auto-configuration, thứ thực sự làm nên Boot
Starter chỉ là một gói dependency; nó không tạo ra bean nào. Thứ tạo bean là một tập các auto-configuration class, mỗi class được canh bởi những condition xét trên classpath, trên các bean đang có và trên các property, tất cả trước khi có một object nào được dựng.

Mỗi jar khai báo ứng viên của nó trong META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports. Boot đọc hết, rồi hỏi condition của từng cái xem nó có áp dụng không. Mấy condition thường gặp đáng thuộc nằm lòng:
| Condition | Khớp khi |
|---|---|
@ConditionalOnClass | một class có trên classpath |
@ConditionalOnMissingClass | một class không có trên classpath |
@ConditionalOnBean | đã tồn tại bean theo type hoặc theo tên |
@ConditionalOnMissingBean | chưa có bean nào như vậy — đây là rule back-off |
@ConditionalOnProperty | một property mang giá trị cho trước |
@ConditionalOnWebApplication | context là một web context |
Đọc CONDITIONS EVALUATION REPORT
Không cần tin suông chỗ nào cả. Khởi động ứng dụng với --debug và Boot in ra chính xác ứng viên nào khớp và vì sao:
java -jar target/demo-0.0.1-SNAPSHOT.jar --server.port=18437 --spring.output.ansi.enabled=NEVER --debug============================
CONDITIONS EVALUATION REPORT
============================
Positive matches:
-----------------
JacksonAutoConfiguration matched:
- @ConditionalOnClass found required class 'tools.jackson.databind.json.JsonMapper' (OnClassCondition)
HttpEncodingAutoConfiguration#characterEncodingFilter matched:
- @ConditionalOnMissingBean (types: org.springframework.web.filter.CharacterEncodingFilter; SearchStrategy: all) did not find any beans (OnBeanCondition)
Negative matches:
-----------------
GsonHttpMessageConvertersConfiguration:
Did not match:
- @ConditionalOnClass did not find required class 'com.google.gson.Gson' (OnClassCondition)
JmxAutoConfiguration:
Did not match:
- @ConditionalOnBooleanProperty (spring.jmx.enabled=true) did not find property 'spring.jmx.enabled' (OnPropertyCondition)
Matched:
- @ConditionalOnClass found required class 'org.springframework.jmx.export.MBeanExporter' (OnClassCondition)Đọc bốn entry đó cạnh nhau là cơ chế hết còn huyền bí. JSON chạy được vì tools.jackson.databind.json.JsonMapper có trên classpath — Boot 4 dùng Jackson 3 nằm dưới package tools.jackson, không phải com.fasterxml.jackson của Jackson 2. Phần hỗ trợ Gson vắng mặt vì lý do đối xứng. Còn JmxAutoConfiguration cho thấy một ứng viên thua ở condition property trong khi condition class vẫn qua, và đó chính là hình dạng của hầu hết câu hỏi "sao cái này không bật".
Report còn hai mục nữa, và mục cuối là lời nhắc hữu ích rằng vẫn có một nhúm auto-configuration hoàn toàn không kèm condition nào:
Exclusions:
-----------
None
Unconditional classes:
----------------------
org.springframework.boot.autoconfigure.context.ConfigurationPropertiesAutoConfiguration
org.springframework.boot.autoconfigure.ssl.SslAutoConfiguration
org.springframework.boot.autoconfigure.context.LifecycleAutoConfiguration
org.springframework.boot.autoconfigure.context.PropertyPlaceholderAutoConfiguration@ConditionalOnMissingBean: bean của bạn thắng
@ConditionalOnMissingBean là rule khiến auto-configuration sống chung được: Boot chỉ tạo bean khi bạn chưa tạo. Class AppConfig ở trên khai báo một CharacterEncodingFilter tên myEncodingFilter. Trước khi có nó, entry này nằm trong Positive matches:
HttpEncodingAutoConfiguration#characterEncodingFilter matched:
- @ConditionalOnMissingBean (types: org.springframework.web.filter.CharacterEncodingFilter; SearchStrategy: all) did not find any beans (OnBeanCondition)Chỉ thêm đúng một method @Bean đó và không đổi gì khác, cũng entry ấy chuyển sang Negative matches và gọi tên chính bean đã hất nó ra:
HttpEncodingAutoConfiguration#characterEncodingFilter:
Did not match:
- @ConditionalOnMissingBean (types: org.springframework.web.filter.CharacterEncodingFilter; SearchStrategy: all) found beans of type 'org.springframework.web.filter.CharacterEncodingFilter' myEncodingFilter (OnBeanCondition)Số bean của cả hai lần chạy vẫn là 159: bean của Boot lùi lại, bean của bạn vào chỗ đó. Chú ý condition khớp theo type chứ không theo tên — đặt tên method là myEncodingFilter thay vì characterEncodingFilter không hề tạo ra khác biệt nào.
Vòng đời của một bean
Bean không chỉ được dựng lên là xong. Nó đi qua một chuỗi có thứ tự, và biết mình đang đứng ở đâu trong chuỗi đó là thứ chặn được cả nhóm bug "sao field này vẫn null".

@Component
public class LifecycleDemo {
private final Clock clock;
private AuditLog auditLog;
public LifecycleDemo(Clock clock) {
this.clock = clock;
System.out.println("LIFECYCLE 1 constructor: clock injected = " + (clock != null)
+ ", auditLog = " + auditLog);
}
@Autowired
public void setAuditLog(AuditLog auditLog) {
this.auditLog = auditLog;
System.out.println("LIFECYCLE 2 setter injection: auditLog set");
}
@PostConstruct
public void warmUp() {
System.out.println("LIFECYCLE 3 @PostConstruct: every dependency present, zone = "
+ clock.getZone());
}
public void use() {
System.out.println("LIFECYCLE 4 in use");
}
@PreDestroy
public void shutDown() {
System.out.println("LIFECYCLE 5 @PreDestroy: releasing resources");
}
}Chạy ứng dụng rồi gửi cho nó một SIGTERM sẽ ra đủ chuỗi. Log của chính Boot xen vào giữa; con số ở dòng Started DemoApplication đã được redact như đã hứa:
INFO o.s.boot.tomcat.TomcatWebServer : Tomcat initialized with port 18437 (http)
INFO o.apache.catalina.core.StandardEngine : Starting Servlet engine: [Apache Tomcat/11.0.24]
LIFECYCLE 1 constructor: clock injected = true, auditLog = null
LIFECYCLE 2 setter injection: auditLog set
LIFECYCLE 3 @PostConstruct: every dependency present, zone = UTC
INFO com.example.demo.DemoApplication : Started DemoApplication in [redacted]
LIFECYCLE 4 in use
INFO o.s.boot.tomcat.GracefulShutdown : Commencing graceful shutdown. Waiting for active requests to complete
LIFECYCLE 5 @PreDestroy: releasing resourcesDòng 1 mới là dòng gánh cả ý. Bên trong constructor, clock đã có sẵn còn auditLog vẫn null, bởi constructor injection xảy ra trong lúc object được dựng, còn setter và field injection xảy ra sau đó. Đó là lý do phần việc động tới mọi dependency thuộc về @PostConstruct chứ không thuộc về constructor.
Singleton là scope mặc định
Mọi bean đều là singleton trừ khi bạn nói khác — một instance cho mỗi container, dùng chung cho mọi nơi cần nó. Hai service, mỗi cái inject cùng một AuditLog, cùng in System.identityHashCode của thứ chúng nhận được:
@Service
public class ServiceA {
private final AuditLog auditLog;
public ServiceA(AuditLog auditLog) {
this.auditLog = auditLog;
}
public int auditLogIdentity() {
return System.identityHashCode(auditLog);
}
}SINGLETON ServiceA sees 1387556178, ServiceB sees 1387556178, same instance = trueCùng một con số, tức đúng cùng một object. Điều này đáng khắc vào đầu, vì nó cũng là lý do một field mutable trên một @Service là bug concurrency: mọi request thread đang dùng chung field đó.
Prototype, và callback không bao giờ chạy
@Scope("prototype") cho một instance mới ở mỗi lần xin bean:
@Component
@Scope(ConfigurableBeanFactory.SCOPE_PROTOTYPE)
public class Ticket {
@PostConstruct
public void created() {
System.out.println("TICKET @PostConstruct " + System.identityHashCode(this));
}
@PreDestroy
public void destroyed() {
System.out.println("TICKET @PreDestroy " + System.identityHashCode(this));
}
}BEANS count = 159
SINGLETON ServiceA sees 1387556178, ServiceB sees 1387556178, same instance = true
TICKET @PostConstruct 1695301724
TICKET @PostConstruct 552266488
PROTOTYPE first 1695301724, second 552266488, same instance = falseCó hai chi tiết trong transcript đó dễ trôi qua. Hai dòng @PostConstruct xuất hiện sau khi số bean được in ra, chứ không phải lúc khởi động — một prototype được tạo khi có người xin, không phải khi context được dựng. Và lúc shutdown, TICKET @PreDestroy không hề xuất hiện, trái ngược hẳn với LIFECYCLE 5 của singleton.
⚠️ Spring không theo dõi các instance prototype sau khi trao đi, nên
@PreDestroykhông bao giờ được gọi trên chúng. Nếu một prototype bean giữ resource, việc đóng resource đó là phần của bạn.
Configuration: properties, YAML, profile và thứ tự ưu tiên
Cấu hình nằm ở src/main/resources/application.properties, hoặc ở application.yaml nếu bạn thích thụt lề hơn dấu chấm. Hai dạng thay thế được cho nhau; snakeyaml đi kèm sẵn trong starter nên YAML không cần thêm dependency nào.
spring.application.name=demo
app.owner=platform-team
demo.greeting.name=world
demo.greeting.locale=en
demo.greeting.exclamations=1spring:
application:
name: demo
app:
owner: platform-team
demo:
greeting:
name: world
locale: en
exclamations: 1Đổi file này lấy file kia rồi chạy lại cho ra output y hệt, và đó là nghĩa duy nhất khiến chữ "tương đương" có giá trị.
@Value và @ConfigurationProperties
@Value đọc một property và hợp cho những chỗ lẻ. Nó resolve một placeholder, nên thiếu key là hỏng ngay lúc khởi động trừ khi bạn cho giá trị mặc định bằng ${app.owner:unknown}:
public Diagnostics(@Value("${app.owner}") String owner) {
this.owner = owner;
}@ConfigurationProperties bind cả một prefix vào một object có type rõ ràng, và từ Java 16 object đó có thể là record — immutable, với constructor lo phần bind:
@ConfigurationProperties(prefix = "demo.greeting")
public record GreetingProperties(String name, String locale, int exclamations) {
}Một record bind kiểu này cần được đăng ký, bằng @EnableConfigurationProperties(GreetingProperties.class) trên một configuration class hoặc bằng @ConfigurationPropertiesScan trên application class. Sau đó nó được inject như mọi bean khác:
CONFIG name = world, locale = en, exclamations = 1
CONFIG app.owner (@Value) = platform-team
CONFIG active profiles = []Hãy ưu tiên @ConfigurationProperties khi một tính năng có quá một hai nút vặn: các giá trị được kiểm tra type và validate một lần lúc khởi động thay vì kiểm từng chuỗi tại từng injection point, và cả nhóm đi cùng nhau như một object duy nhất.
Profile và thứ tự ưu tiên
Profile là một lớp phủ có tên. application-prod.properties được nạp chồng lên application.properties khi profile prod đang bật — nó override những key nó nhắc tới và để yên phần còn lại:
demo.greeting.name=production
demo.greeting.exclamations=3java -jar target/demo-0.0.1-SNAPSHOT.jar --spring.profiles.active=prodINFO com.example.demo.DemoApplication : The following 1 profile is active: "prod"
CONFIG name = production, locale = en, exclamations = 3
CONFIG active profiles = [prod]name và exclamations đến từ file profile; locale vẫn là en từ file gốc, bởi file profile cộng thêm vào file gốc chứ không thay thế nó.
Các nguồn được tra theo một thứ tự cố định, và command-line argument nằm gần đỉnh:
| Ưu tiên | Nguồn |
|---|---|
| Cao nhất | command-line argument (--demo.greeting.name=...) |
environment variable của OS (DEMO_GREETING_NAME) | |
application-{profile}.properties ngoài jar | |
application-{profile}.properties trong jar | |
application.properties ngoài jar | |
| Thấp nhất | application.properties trong jar |
Thêm đúng một argument vào lần chạy prod ở trên, không đổi gì khác, là thấy được hai hàng đầu bảng:
java -jar target/demo-0.0.1-SNAPSHOT.jar --spring.profiles.active=prod --demo.greeting.name=from-command-lineCONFIG name = from-command-line, locale = en, exclamations = 3
CONFIG active profiles = [prod]name giờ đến từ command line, exclamations vẫn từ file profile, locale vẫn từ file gốc. Ba nguồn, một góc nhìn hợp nhất, phân định bằng thứ tự ưu tiên.
Test: context đầy đủ và slice hẹp
spring-boot-starter-test đi kèm gián tiếp qua starter test cho web và mang theo JUnit Jupiter 6.0.3, AssertJ, Mockito, Hamcrest, JSONPath cùng phần test support của chính Spring, gói trong một dependency.
@SpringBootTest khởi động application context thật — mọi bean, đúng như production sẽ dựng:
@SpringBootTest
class DemoApplicationTests {
@Autowired
private ApplicationContext context;
@Autowired
private GreetingService greetingService;
@Test
void contextLoads() {
System.out.println("FULL CONTEXT beans = " + context.getBeanDefinitionCount());
assertThat(context.containsBean("helloController")).isTrue();
assertThat(greetingService.greet("en", "test")).isEqualTo("Hello, test");
}
}Slice test thì khởi động một context cố tình thiếu. @WebMvcTest nạp tầng web và đúng controller bạn chỉ định, không nạp gì bên dưới — các collaborator được đưa vào dưới dạng mock bằng @MockitoBean, thứ đã thay cho @MockBean bị bỏ:
@WebMvcTest(HelloController.class)
class HelloControllerSliceTests {
@Autowired
private ApplicationContext context;
@MockitoBean
private GreetingService greetingService;
@Test
void loadsOnlyTheWebLayer() {
System.out.println("SLICE beans = " + context.getBeanDefinitionCount());
assertThat(context.containsBean("helloController")).isTrue();
assertThat(context.containsBean("greetingRepository")).isFalse();
}
}SLICE beans = 109
[INFO] Tests run: 1, Failures: 0, Errors: 0, Skipped: 0 -- in com.example.demo.HelloControllerSliceTests
FULL CONTEXT beans = 156
[INFO] Tests run: 1, Failures: 0, Errors: 0, Skipped: 0 -- in com.example.demo.DemoApplicationTestsCách mô tả trung thực về khác biệt này là cái gì được nạp, không phải mất bao lâu: 156 bean definition so với 109, và greetingRepository chứng minh được là không có trong slice. Ít bean cũng đồng nghĩa ít thứ có thể hỏng vì lý do chẳng liên quan gì tới controller bạn đang test. Trên Boot 4, annotation này đã đổi package — nó là org.springframework.boot.webmvc.test.autoconfigure.WebMvcTest, không phải org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest của Boot 3.
Những gì Spring Boot không làm
Boot không phải một tính năng của ngôn ngữ và nó không thay đổi gì trong cách Java hoạt động. Bên dưới, nó là quét classpath, đọc annotation metadata và reflection: SpringApplication.run đọc các file class, dựng bean definition, đánh giá condition, rồi gọi đúng những constructor mà bạn cũng có thể tự gọi. Việc đó có cái giá của nó — khởi động phải làm việc thật, tỉ lệ với số class bị quét, và một chỗ sai trong annotation lộ ra ở runtime chứ không phải lúc compile.
Hệ quả có ích là chẳng có gì bị giấu cả. Cách rẻ nhất là --debug, nó in ra conditions report ở trên và trả lời cả "vì sao bean này tồn tại" lẫn "vì sao nó không tồn tại".
Actuator phơi đúng những thông tin đó qua HTTP. Thêm spring-boot-starter-actuator, mở các endpoint ra, và /actuator/beans liệt kê từng bean kèm scope và dependency của nó:
greetingService -> singleton | com.example.demo.GreetingService | deps ['greetingRepository', 'plainFormatter']
ticket -> prototype | com.example.demo.Ticket | deps []
clock -> singleton | java.time.Clock$SystemClock | deps ['appConfig']
myEncodingFilter -> singleton | org.springframework.web.filter.CharacterEncodingFilter | deps ['appConfig']Xa hơn nữa, các auto-configuration class chỉ là Java bình thường nằm trong jar spring-boot-autoconfigure. Mở đúng class bạn đang cãi nhau với nó và đọc các annotation @Conditional của nó nhanh hơn là đi tìm một bài blog về nó.
Từ đó rút ra hai thói quen. Đừng đánh nhau với một mặc định mà bạn chưa đọc, và đừng cho rằng một bean tồn tại chỉ vì một bài hướng dẫn nói thế — report mới là thứ có thẩm quyền trên classpath của bạn, bài hướng dẫn thì không.
FAQ
Spring Boot có thay thế Spring không?
Không. Spring Boot phụ thuộc vào Spring Framework rồi thêm starter, auto-configuration và embedded server lên trên. Project này kéo spring-context và spring-webmvc 7.0.9 nằm dưới Boot 4.1.1. Mọi annotation trong các phần về container ở trên đều là annotation của Spring Framework.
Constructor còn cần @Autowired không?
Không, từ Spring 4.3, miễn là class có đúng một constructor. Với hai constructor trở lên bạn phải đánh dấu cái mà container nên dùng, nếu không nó sẽ chọn constructor không tham số nếu có và hỏng nếu không có.
Vì sao ứng dụng hỏng với "expected single matching bean but found 2"?
Hai bean cùng thoả một injection point theo type và không cái nào được ưu tiên. Đặt @Primary lên cái đóng vai mặc định, hoặc @Qualifier("beanName") ngay tại injection point muốn cái còn lại. Inject một List theo type của interface là lựa chọn thứ ba khi bạn thật sự muốn cả hai.
@Component, @Service và @Repository khác nhau thế nào?
Về mặt component scanning thì không khác — @Service và @Repository đều được meta-annotate bằng @Component. Chúng khác về ý định và khác ở một hành vi: @Repository bật cơ chế dịch exception của tầng persistence sang cây DataAccessException của Spring. Hãy dùng cái cụ thể ở nơi nó hợp, vì cả công cụ lẫn người đọc đều xem nó như dấu hiệu của tầng.
Vì sao field @Value của tôi bị null trong constructor?
Vì nó là field, mà field được điền sau khi object đã dựng xong. Hãy chuyển giá trị đó thành một constructor parameter gắn @Value, hoặc làm phần việc đó trong một method @PostConstruct, nơi mọi dạng injection đều đã kết thúc.
Làm sao xem ứng dụng thực sự đã resolve ra property nào?
Chạy với --debug để có conditions report, và thêm actuator để dùng /actuator/env và /actuator/configprops, hai endpoint này cho thấy giá trị đã resolve của từng key và key đó đến từ nguồn nào. Cách này nhanh hơn ngồi suy luận theo bảng ưu tiên khi có tới ba file cùng một environment variable cùng tham gia.
Kết luận
Container không làm gì bí ẩn cả: nó đọc annotation của bạn, dựng đúng cái object graph mà bạn sẽ dựng trong main, và phát ra một instance cho mỗi singleton bean. Auto-configuration là một danh sách class ứng viên có condition chắn phía trước, và --debug in ra phán quyết cho từng cái. Constructor injection, @Primary với @Qualifier, @PostConstruct với @PreDestroy, @ConfigurationProperties và thứ tự ưu tiên đã phủ gần hết những gì một ứng dụng Spring làm trước khi nó làm bất cứ việc gì thuộc nghiệp vụ.
Bài tiếp theo dựng một REST API thật bằng Spring Boot: @RestController và @RequestMapping, path variable và request body, status code, xử lý exception và validation cho request.