Command Palette

Search for a command to run...

[Advanced Java] Spring Boot cơ bản: IoC Container, Auto-Configuration và vòng đời của Bean

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.

Chuỗi new viết tay trong main ở bên trái, cùng object graph đó do ApplicationContext lắp ráp ở bên phải

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 FrameworkSpring Boot
Cho bạnIoC container, DI, AOP, web MVC stackstarter, auto-configuration, embedded server
Bạn viếtcác bean definitiongần như không viết gì, rồi override chỗ nào không đồng ý
Thành phẩmmột library cho app dùngmột jar chạy được có main
Trong project nàyspring-context 7.0.9, spring-webmvc 7.0.9spring-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.

Bash
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 demo

Có 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:

XML
<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:

XML
<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).

Java
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:

Java
@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);
    }
}
Bash
./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"
Text
Hello, Spring

Nê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.

Component scan sinh ra bean definition, injection resolve từng constructor parameter theo type, singleton registry trao đúng một instance

Các stereotype annotation đánh dấu một class là ứng viên. @Component là cái chung nhất; @Service@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.

Java
@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:

Java
@Service
public class FinalField {
 
    @Autowired
    private final GreetingRepository repository;
 
    public String greet(String locale) {
        return repository.find(locale).orElse("Hello");
    }
}
Text
FinalField.java:10: error: variable repository not initialized in the default constructor
    private final GreetingRepository repository;
                                     ^
1 error

Class 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:

Java
@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:

Java
static class FieldInjectedGreetingService {
 
    @Autowired
    private GreetingRepository repository;
 
    String greet(String locale) {
        return repository.find(locale).orElse("Hello");
    }
}
Text
NPE MESSAGE: Cannot invoke "com.example.demo.GreetingRepository.find(String)" because "this.repository" is null

Bê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:

Java
@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:

Java
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:

Text
***************************
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 consumed

Exception gốc, được log vài dòng phía trên report, gọi tên cả type lẫn hai candidate:

Text
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,shoutFormatter

Hai 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":

Java
@Component
@Primary
public class PlainFormatter implements Formatter { /* ... */ }
Java
public Diagnostics(GreetingService greetingService,
                   @Qualifier("shoutFormatter") Formatter shout) {
    this.greetingService = greetingService;
    this.shout = shout;
}
Text
QUALIFIER primary -> Hello, world
QUALIFIER shoutFormatter -> HELLO, WORLD

Nế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.

Starter thêm jar, AutoConfiguration.imports liệt kê ứng viên, condition quyết định bean có được tạo hay khô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:

ConditionKhớp khi
@ConditionalOnClassmột class có trên classpath
@ConditionalOnMissingClassmột class không có trên classpath
@ConditionalOnBeanđã tồn tại bean theo type hoặc theo tên
@ConditionalOnMissingBeanchưa có bean nào như vậy — đây là rule back-off
@ConditionalOnPropertymột property mang giá trị cho trước
@ConditionalOnWebApplicationcontext 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:

Bash
java -jar target/demo-0.0.1-SNAPSHOT.jar --server.port=18437 --spring.output.ansi.enabled=NEVER --debug
Text
============================
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:

Text
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:

Text
   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:

Text
   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".

Năm bước có thứ tự cho singleton, cũng năm bước đó cho prototype, với bước cuối không bao giờ được gọi

Java
@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:

Text
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 resources

Dò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:

Java
@Service
public class ServiceA {
 
    private final AuditLog auditLog;
 
    public ServiceA(AuditLog auditLog) {
        this.auditLog = auditLog;
    }
 
    public int auditLogIdentity() {
        return System.identityHashCode(auditLog);
    }
}
Text
SINGLETON ServiceA sees 1387556178, ServiceB sees 1387556178, same instance = true

Cù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:

Java
@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));
    }
}
Text
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 = false

Có 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 @PreDestroy khô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=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}:

Java
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:

Java
@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:

Text
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:

Properties
demo.greeting.name=production
demo.greeting.exclamations=3
Bash
java -jar target/demo-0.0.1-SNAPSHOT.jar --spring.profiles.active=prod
Text
INFO com.example.demo.DemoApplication : The following 1 profile is active: "prod"
CONFIG name = production, locale = en, exclamations = 3
CONFIG active profiles = [prod]

nameexclamations đế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ênNguồn
Cao nhấtcommand-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ấtapplication.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:

Bash
java -jar target/demo-0.0.1-SNAPSHOT.jar --spring.profiles.active=prod --demo.greeting.name=from-command-line
Text
CONFIG 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:

Java
@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ỏ:

Java
@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();
    }
}
Text
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.DemoApplicationTests

Cá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ó:

Text
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-contextspring-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@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/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@RequestMapping, path variable và request body, status code, xử lý exception và validation cho request.

Bài viết liên quan

[Spring Boot Basics] Spring Framework và Spring Boot khác nhau thế nào: Auto-Configuration, Starter và Embedded Server

Spring Framework 7.0.9 so với Spring Boot 4.1.1 trên Java 21: một web application Spring thuần bắt bạn viết những gì, starter và BOM spring-boot-dependencies thay thế chúng ra sao, vì sao Boot 4 đổi tên web starter thành spring-boot-starter-webmvc, auto-configuration nhường chỗ thế nào, embedded Tomcat so với WAR, và bản đồ hệ sinh thái Spring.

[Advanced Java] Buffered Stream và Object Serialization trong Java

java.io nâng cao trên OpenJDK 21: bốn abstract root của stream và đúng chỗ charset được chọn, decorator chain và vì sao thứ tự bọc quan trọng, buffer đo bằng số lần gọi method chứ không đo mili giây, DataOutputStream với layout big-endian, và object serialization từ đầu tới cuối — format byte thật, transient, serialVersionUID, writeObject, Externalizable, object graph, và ObjectInputFilter tồn tại vì format này không an toàn.

[Advanced Java] Thread trong Java: Thread, Runnable và virtual thread

Thread trong Java trên OpenJDK 21: thread là gì, stack riêng và heap dùng chung, tạo thread bằng Thread, Runnable và lambda, start khác run ra sao, join, daemon thread, tên và priority, output không xác định giữa các lần chạy, virtual thread với Thread.ofVirtual, và interrupt.

[Advanced Java] Kết nối Spring Boot với Database bằng Spring Data JPA

Spring Data JPA trên Spring Boot 4.1.1 và Hibernate 7.4.5: JDBC, JPA, Hibernate và repository xếp chồng thế nào, entity mapping cùng DDL sinh ra, derived query method, vấn đề N+1 đếm bằng log SQL thật, LazyInitializationException, @Transactional dựa trên proxy, dirty checking và flush khác commit.