Command Palette

Search for a command to run...

[Spring Boot Basics] @Configuration và @Bean trong Spring: khi nào dùng thay cho annotation stereotype

@Component và các annotation stereotype khác có một yêu cầu cứng mà ít tài liệu nói ra: bạn phải sở hữu source file. Annotation phải được viết vào trong class, nghĩa là class đó phải là thứ bạn sửa được. Ngay khi bạn cần một bean thuộc một type đến từ jar của người khác, con đường đó đóng lại, và có scan bao nhiêu package cũng không mở ra được.

@Bean sinh ra đúng để giải quyết chuyện đó, và phần còn lại của bài này là những gì kéo theo: ranh giới thật sự giữa hai cách viết nằm ở đâu, và flag proxyBeanMethods quyết định một lời gọi từ @Bean method này sang @Bean method khác sẽ trả về singleton có sẵn hay lặng lẽ tạo ra object thứ hai. Mọi con số và mọi error message bên dưới đều được copy từ terminal chạy Spring Boot 4.1.1.

Hai đường vào container: @Component và @Bean

Toolchain dùng xuyên suốt là Spring Boot 4.1.1 (Spring Framework 7.0.9) trên OpenJDK 21.0.6, build bằng Gradle 9.7.1 qua wrapper, và thư viện bên thứ ba đóng vai ví dụ là Caffeine 3.2.4.

@Component chỉ dùng được cho class bạn sở hữu

Bức tường nằm ở đây, dưới dạng bình thường nhất của nó. Ứng dụng cần một in-memory cache, nên Caffeine được thêm vào build.gradle:

build.gradle
dependencies {
	implementation 'org.springframework.boot:spring-boot-starter-webmvc'
	implementation 'com.github.ben-manes.caffeine:caffeine'
}

Không có số version — Caffeine nằm trong BOM của Spring Boot nên build tự điền:

Bash
./gradlew dependencies --configuration compileClasspath
Text
\--- com.github.ben-manes.caffeine:caffeine -> 3.2.4

Giờ thử tạo bean Cache theo đúng cách bạn vẫn tạo mọi bean khác. Type bạn cần là cái này:

Bash
unzip -l ~/.gradle/caches/modules-2/files-2.1/com.github.ben-manes.caffeine/caffeine/3.2.4/*/caffeine-3.2.4.jar | grep "cache/Cache"
Text
     1965  02-01-1980 00:00   com/github/benmanes/caffeine/cache/Cache.class

Một file .class. Không phải .java — một class đã compile, nằm trong archive chỉ đọc trong cache của Gradle. Không có chỗ nào để gõ @Component, và mọi cách lách đều tệ hơn chính vấn đề:

  • Fork thư viện rồi thêm annotation. Bạn phải nuôi một bản fork của Caffeine mãi mãi, và mỗi lần upgrade là một lần merge.
  • Kế thừa nó. Cache là interface, implementation của nó là package-private và do builder sinh ra — thực tế Caffeine.newBuilder().build() trả về BoundedLocalCache$BoundedLocalManualCache, một class không public. Kể cả khi kế thừa được, bean của bạn cũng trở thành một type mà thư viện không hề thiết kế để mở rộng.
  • Giữ trong một field static. Đó đúng là biến toàn cục mà container sinh ra để thay thế, và cũng vì vậy mà không test được.
  • Bọc lại trong @Component của riêng bạn. Đôi khi hợp lý, nhưng nghĩa là mọi nơi phải gọi qua wrapper thay vì API của thư viện, và bạn vừa viết một class mà nội dung duy nhất là một lời gọi constructor.

Câu trả lời thật sự của framework là factory method: một method do bạn viết, nằm trên class mà container đọc được, và giá trị trả về của nó trở thành bean. Bạn sở hữu method, dù không sở hữu class mà method đó trả về.

Class bạn sở hữu nhận @Component và được scan; class trong jar đi vào qua @Bean factory method — cả hai kết thúc ở cùng một registry

@Bean factory method

@Bean method là một method trên @Configuration class. Container gọi nó một lần, lấy thứ nó trả về, và đăng ký object đó thành bean. Bên trong method là Java bình thường — một builder, một câu if, một vòng lặp, một lời gọi tới factory mà thư viện cung cấp.

src/main/java/com/example/demo/cache/CacheConfig.java
package com.example.demo.cache;
 
import com.github.benmanes.caffeine.cache.Cache;
import com.github.benmanes.caffeine.cache.Caffeine;
import java.time.Duration;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
 
@Configuration
public class CacheConfig {
 
    @Bean
    public Cache<String, Product> productCache() {
        return Caffeine.newBuilder()
                .maximumSize(10_000)
                .expireAfterWrite(Duration.ofMinutes(30))
                .recordStats()
                .build();
    }
}

Ba chi tiết trong tám dòng đó:

Tên method trở thành tên bean. productCache() đăng ký một bean tên productCache. Không có bước đặt tên riêng và không có chuỗi nào phải giữ đồng bộ — đổi tên method là bean đổi tên theo.

@Bean(name = "...") ghi đè điều đó, và nó thay thế tên method chứ không cộng thêm. Method userCache() gắn @Bean(name = "userSessions") tạo ra bean tên userSessions và không có bean nào tên userCache.

Return type khai báo mới là thứ container lập index. Cache là interface; builder của Caffeine trả về một implementation không public. Khai báo method trả về Cache nghĩa là container so khớp các điểm injection theo interface, còn class implementation vẫn là chi tiết nội bộ — đúng kỷ luật "code theo interface" ở mọi nơi khác.

Inject nó thì hoàn toàn bình thường. Class sử dụng là class bạn sở hữu, nên nó nhận stereotype và constructor injection:

src/main/java/com/example/demo/cache/CatalogService.java
package com.example.demo.cache;
 
import com.github.benmanes.caffeine.cache.Cache;
import org.springframework.stereotype.Service;
 
@Service
public class CatalogService {
 
    private final Cache<String, Product> productCache;
 
    public CatalogService(Cache<String, Product> productCache) {
        this.productCache = productCache;
    }
 
    public Product find(String sku) {
        return productCache.get(sku, key -> new Product(key, "loaded from the database"));
    }
 
    public String stats() {
        return productCache.stats().toString();
    }
}

Gọi find hai lần rồi in thống kê của cache cho thấy bean này là một Caffeine cache thật, dùng chung và đã được cấu hình, chứ không phải cache mới cho mỗi lần gọi:

Text
find(A-1)      = Product[sku=A-1, name=loaded from the database]
find(A-1)      = Product[sku=A-1, name=loaded from the database]
stats          = CacheStats{hitCount=1, missCount=1, loadSuccessCount=1, loadFailureCount=0, totalLoadTime=12667, evictionCount=0, evictionWeight=0}
productCache   = true
userCache      = false
userSessions   = true

Một miss, một hit. Ba dòng cuối là kết quả containsBean và chúng xác nhận quy tắc đặt tên: productCache tồn tại, còn userCache — tên method của một method gắn @Bean(name = "userSessions") — thì không.

Đăng ký một class nhiều lần

Cache không phải chỉ có một loại. Dữ liệu sản phẩm có thể nằm lại nửa giờ; cache session thì không được. Hai @Bean method, cùng một class, cấu hình khác nhau:

Java
@Bean
public Cache<String, Product> productCache() {
    return Caffeine.newBuilder()
            .maximumSize(10_000)
            .expireAfterWrite(Duration.ofMinutes(30))
            .recordStats()
            .build();
}
 
@Bean(name = "userSessions")
public Cache<String, User> userCache() {
    return Caffeine.newBuilder()
            .maximumSize(500)
            .expireAfterWrite(Duration.ofMinutes(5))
            .build();
}

Đây là trường hợp mà stereotype không diễn đạt nổi. @Component nói "class này là một bean"; không có cách nào nói "class này là ba bean với ba bộ constructor argument khác nhau". Factory method không bị giới hạn đó, vì một method thì viết bao nhiêu lần cũng được.

Để ý hai method ở trên trả về generic type khác nhau, Cache<String, Product>Cache<String, User>, và Spring có phân giải generic khi so khớp điểm injection — nên CatalogService xin Cache<String, Product> là nhận đúng cái cần, không cần thêm gì. Khi hai bean thật sự trùng type, việc chỉ cho container biết chọn cái nào là phần của @Qualifier@Primary, đã nói ở bài trước về dependency injection.

Dùng stereotype hay @Bean?

Ranh giới này không phải chuyện sở thích. Nó suy ra từ việc mỗi cơ chế làm được gì về mặt vật lý:

Tình huốngDùngVì sao
Bạn sở hữu class và chỉ cần new@Component / @Service / @RepositoryScan tìm thấy nó, constructor là toàn bộ logic khởi tạo, factory method không thêm được gì.
Class đã compile trong jar của thư viện khác@BeanKhông có source file để annotate. Đây là trường hợp không có lựa chọn thay thế.
Class đến từ JDK — Clock, HttpClient, ObjectMapper@BeanCùng lý do. Bạn sẽ không bao giờ annotate được java.time.Clock.
Khởi tạo cần logic thật — builder, timeout, rẽ nhánh theo property@BeanThân method chứa được mọi thứ. Annotation thì không.
Một class phải thành nhiều bean với cấu hình khác nhau@BeanStereotype cho đúng một bean trên mỗi class.
Bạn đang gắn thứ gì đó vào container — FactoryBean, factory riêng của thư viện, object tạo bằng static method@BeanFactory method chính là lớp adapter giữa API khởi tạo của thư viện và container.
Bean là BeanFactoryPostProcessor hoặc hạ tầng tương tự của containerstatic @BeanNó phải tồn tại trước các bean thường. Xem bên dưới.
Class của bạn nhưng cần một giá trị chỉ có lúc runtimeCách nào cũng đượcStereotype kèm @Value trên constructor thường gọn hơn; @Bean method hợp hơn khi giá trị đó dẫn tới rẽ nhánh.

Hai ví dụ thực tế, mỗi bên một cái.

Stereotype. CatalogService ở trên. Bạn viết ra nó, constructor nhận các collaborator, và không có logic khởi tạo nào ngoài gán field. Viết @Bean method cho nó nghĩa là nuôi thêm một chỗ phải sửa mỗi lần constructor đổi — toàn chi phí, không lợi ích.

@Bean. Caffeine cache. Bạn không viết Cache, nó được dựng bằng fluent builder chứ không phải constructor, cấu hình của nó là quyết định nghiệp vụ thuộc về codebase của bạn, và bạn cần tới hai cái. Mỗi điểm đó đều là một lý do khiến đường stereotype không đi được.

Quy tắc rút ra: stereotype là lời khai báo rằng một class là bean; @Bean method là công thức tạo ra một bean. Nếu không có công thức nào đáng viết thì đừng viết.

Một @Bean method phụ thuộc vào @Bean method khác như thế nào

Bean phụ thuộc bean, nên factory method cần cách chạm tới nhau. Có hai dạng, và chúng không tương đương.

Khai báo parameter. Container phân giải nó từ context đúng như phân giải constructor parameter:

Java
@Bean
public Reporter reporter(Meter meter) {
    return new Reporter(meter);
}

Gọi thẳng method kia. Nó đọc như một lời gọi Java bình thường, vì về mặt cú pháp thì đúng là vậy:

Java
@Bean
public Reporter reporter() {
    return new Reporter(meter());
}

Dạng thứ hai mới là dạng làm người ta bất ngờ, vì việc nó có phải lời gọi Java bình thường hay không lại phụ thuộc vào một flag mà có lẽ bạn chưa từng đụng tới.

proxyBeanMethods và object thứ hai bạn không hề yêu cầu

Thí nghiệm như sau. Ba class thuần, một trong số đó in ra identity hash code của chính nó khi được tạo:

Java
public class Meter {
    public Meter() {
        System.out.println("  Meter constructed -> " + System.identityHashCode(this));
    }
}
 
public class Reporter {
    public final Meter meter;
    public Reporter(Meter meter) { this.meter = meter; }
}
 
public class Auditor {
    public final Meter meter;
    public Auditor(Meter meter) { this.meter = meter; }
}

Một configuration class trong đó hai @Bean method gọi thẳng method thứ ba:

MetricsConfig.java
@Configuration
public class MetricsConfig {
 
    @Bean
    public Meter meter() {
        return new Meter();
    }
 
    @Bean
    public Reporter reporter() {
        return new Reporter(meter());
    }
 
    @Bean
    public Auditor auditor() {
        return new Auditor(meter());
    }
}

Và một runner in ra thứ container thực sự có:

Java
@Bean
public CommandLineRunner report(MetricsConfig config, Meter meter, Reporter reporter, Auditor auditor) {
    return args -> {
        System.out.println("config class   = " + config.getClass().getName());
        System.out.println("meter bean     = " + System.identityHashCode(meter));
        System.out.println("reporter.meter = " + System.identityHashCode(reporter.meter));
        System.out.println("auditor.meter  = " + System.identityHashCode(auditor.meter));
    };
}

Đọc Java thì bạn sẽ chờ ba lời gọi new Meter(): một cho bean meter, một trong reporter(), một trong auditor(). Thực tế:

Text
  Meter constructed -> 1467977993
config class   = com.example.demo.full.MetricsConfig$$SpringCGLIB$$0
meter bean     = 1467977993
reporter.meter = 1467977993
auditor.meter  = 1467977993

Một lần khởi tạo. Một object. Ba reference trỏ vào nó.

Dòng thứ hai là cơ chế. config.getClass() không phải MetricsConfig — nó là MetricsConfig$$SpringCGLIB$$0, một subclass sinh ra lúc runtime. Đó chính là ý nghĩa mặc định của @Configuration: full mode, trong đó class bị CGLIB kế thừa và mọi @Bean method bị override bằng một interceptor. Khi reporter() gọi meter(), nó đang gọi bản override, và bản override đi hỏi container lấy bean meter thay vì chạy thân method. Thân method chỉ chạy đúng một lần, lúc container tạo bean.

Thêm hai dòng nữa vào runner là thấy subclass đó có thật:

Text
config class   = com.example.demo.full.MetricsConfig$$SpringCGLIB$$0
  extends      = com.example.demo.full.MetricsConfig
  implements   = [interface org.springframework.context.annotation.ConfigurationClassEnhancer$EnhancedConfiguration]

Marker interface EnhancedConfiguration là cách Spring nhận ra một configuration class nó đã enhance rồi. Chuỗi $$SpringCGLIB$$ trong tên thì đáng nhớ mặt — nó xuất hiện trong stack trace, trong kết quả toString() và trong log, và nó có nghĩa "bạn đang nhìn một configuration class đã bị proxy", chứ không phải một bug.

Full mode chặn lời gọi giữa các bean và trả về singleton; lite mode để thân method chạy lại

proxyBeanMethods = false

@Configuration có một flag tắt hẳn việc kế thừa đó. Ngoài ra class giữ nguyên — vẫn ba method, vẫn gọi thẳng:

MetricsConfig.java
@Configuration
@Configuration(proxyBeanMethods = false) 
public class MetricsConfig {
    // identical body
}

Cùng runner, cùng ứng dụng, khác mỗi một flag:

Text
  Meter constructed -> 1857007886
  Meter constructed -> 1179272258
  Meter constructed -> 1002911155
config class   = com.example.demo.lite.MetricsConfig
meter bean     = 1857007886
reporter.meter = 1179272258
auditor.meter  = 1002911155

Ba lần khởi tạo. Ba object khác nhau. Meter bên trong Reporter không phải bean Meter. Đây là lite mode: không có subclass — config.getClass() giờ đúng là MetricsConfig — nên meter() là lời gọi method bình thường, chạy thân method mỗi lần. Container vẫn có đúng một bean meter; hai object còn lại đơn giản là không phải bean. Không có gì lỗi, không có gì được log, và mọi trạng thái bạn tưởng đang dùng chung thì nay đã bị nhân bản.

(Hai lần chạy ở trên nằm trong package com.example.demo.fullcom.example.demo.lite để cả hai configuration cùng ở trong một project và khởi động như hai ứng dụng riêng. Ngoài cái flag ra thì không có gì khác nhau.)

Lite mode không phải cái bẫy vô cớ. Full mode tốn chi phí thật: mỗi @Configuration class phải được sinh một subclass CGLIB, load và khởi tạo lúc startup, còn mỗi lời gọi giữa các bean đều đi qua interceptor thay vì lời gọi trực tiếp của JVM. Nó cũng khiến configuration class không được final, và @Bean method cũng vậy — vướng víu trong Kotlin và là rào cản cho build ahead-of-time hay native image, vốn muốn tránh sinh class lúc runtime.

Chính Spring Boot chọn lite mode ở mọi nơi có thể. @AutoConfiguration, annotation gắn trên mọi auto-configuration class của framework, mang sẵn flag đó — javap -v trên annotation này in ra:

Text
RuntimeVisibleAnnotations:
  3: org.springframework.context.annotation.Configuration(
      proxyBeanMethods=false
     )

Nghĩa là hàng trăm @Bean method mà Boot chạy giúp bạn đều ở lite mode. (Chuyện auto-configuration thực chất chỉ là @Bean method kèm điều kiện là chủ đề của bài cuối series này.)

Quy tắc dùng lite mode an toàn gọn trong một dòng: trong một lite-mode configuration class, đừng bao giờ gọi thẳng @Bean method khác — hãy khai báo nó thành parameter. Dạng parameter đi qua container ở cả hai mode, nên nó đúng bất kể flag. Vẫn class lite mode đó, viết lại:

MetricsConfig.java
@Configuration(proxyBeanMethods = false)
public class MetricsConfig {
 
    @Bean
    public Meter meter() {
        return new Meter();
    }
 
    @Bean
    public Reporter reporter(Meter meter) {
        return new Reporter(meter);
    }
 
    @Bean
    public Auditor auditor(Meter meter) {
        return new Auditor(meter);
    }
}
Text
  Meter constructed -> 1857007886
config class   = com.example.demo.params.MetricsConfig
meter bean     = 1857007886
reporter.meter = 1857007886
auditor.meter  = 1857007886

Lại một lần khởi tạo, và không cần proxy nào. Đó là lý do dạng parameter nên là mặc định kể cả ở full mode: nó nói đúng điều nó làm, nó chạy đúng ở cả hai mode, và nó sống sót khi một năm sau có người thêm proxyBeanMethods = false vào class.

@Bean method trên một @Component class

@Bean không bị giới hạn trong @Configuration class. Đặt đúng ba method đó lên một @Component thì ứng dụng vẫn khởi động ngon lành:

MetricsBeans.java
@Component
public class MetricsBeans {
 
    @Bean
    public Meter meter() { return new Meter(); }
 
    @Bean
    public Reporter reporter() { return new Reporter(meter()); }
 
    @Bean
    public Auditor auditor() { return new Auditor(meter()); }
}
Text
  Meter constructed -> 645643802
  Meter constructed -> 949314262
  Meter constructed -> 559998250
config class   = com.example.demo.compcfg.MetricsBeans
meter bean     = 645643802
reporter.meter = 949314262
auditor.meter  = 559998250

Ba object. Một @Bean method nằm trên bất kỳ class nào không phải full-mode @Configuration class đều hành xử như lite mode — cùng kiểu nhân bản đó, và trong source không có gì cảnh báo bạn. Đó là lý do thực tế để giữ các định nghĩa bean trên class gắn @Configuration: annotation này là tín hiệu cho mọi người đọc, và cho cả Spring, rằng các lời gọi giữa bean được tôn trọng.

static @Bean method không bao giờ được proxy

Thêm một trường hợp full mode không cứu bạn. Method static không override được, nên CGLIB không chặn được — kể cả bên trong một @Configuration class mặc định:

StaticConfig.java
@Configuration
public class StaticConfig {
 
    @Bean
    public static Meter meter() { return new Meter(); }
 
    @Bean
    public Reporter reporter() { return new Reporter(meter()); }
}
Text
  Meter constructed -> 1835316563
  Meter constructed -> 490630452
meter bean     = 1835316563
reporter.meter = 490630452

Hai object, ở full mode, từ một class không bật flag nào.

Dù vậy static là bắt buộc trong đúng một tình huống: @Bean method trả về BeanFactoryPostProcessor — hay gặp nhất là PropertySourcesPlaceholderConfigurer — vì bean đó phải được tạo trước khi container xử lý được annotation trên chính configuration class khai báo nó. Bỏ static đi thì Spring nói thẳng lúc startup:

Text
INFO o.s.c.a.ConfigurationClassEnhancer : @Bean method PlaceholderConfig.placeholders is non-static
and returns an object assignable to Spring's BeanFactoryPostProcessor interface. This will result in
a failure to process annotations such as @Autowired, @Resource, and @PostConstruct within the
method's declaring @Configuration class. Add the 'static' modifier to this method to avoid these
container lifecycle issues; see @Bean javadoc for complete details.

Nó ở mức INFO nên trôi qua không ai để ý. Ngoài trường hợp hẹp đó, đừng để @Bean method là static — bạn mất interception mà chẳng được gì.

@Import, và configuration class cũng là bean

Configuration không nhất thiết phải nằm trong một class, và cũng không nhất thiết phải do scan tìm ra. @Import gọi tên một configuration class khác một cách tường minh:

CacheConfig.java
@Configuration(proxyBeanMethods = false)
@Import(ClockConfig.class)
public class CacheConfig {
    // ...
}
ClockConfig.java
@Configuration(proxyBeanMethods = false)
public class ClockConfig {
 
    @Bean
    public Clock clock() {
        return Clock.system(ZoneOffset.UTC);
    }
}
Text
clock          = java.time.Clock$SystemClock

Bean Clock được đăng ký dù ClockConfig chưa từng bị scan tới. Điều đó quan trọng ở hai chỗ: một configuration class nằm ngoài base package của bạn (thường là thư viện dùng chung), và một configuration class bạn cố tình giữ ngoài scan để chỉ kéo vào nơi nào cần. Nhân tiện, nó cũng minh hoạ lại luận điểm mở đầu bài — java.time.Clock là abstract class trong JDK và Clock.system(...) là static factory. Không có annotation nào bạn đặt lên đó được.

Cần nói thẳng một điều vì nó giải thích nhiều hành vi cùng lúc: một @Configuration class bản thân nó cũng là một bean. Đó là lý do runner ở trên nhận được parameter MetricsConfig config và in ra class của nó. Nó có thể có constructor với dependency, có thể được inject chỗ khác, và ở full mode thì bean nằm trong container là subclass CGLIB chứ không phải class bạn viết.

Ba lỗi đáng nhận mặt

@Bean method trên class không ai đăng ký

@Bean tự nó không làm gì cả. Class mang method đó phải đến được container — bằng cách bị scan, bằng @Import, hoặc bằng cách là application class. Quên điều đó thì method chỉ là một method:

src/main/java/com/example/demo/forgot/RegistryConfig.java
package com.example.demo.forgot;
 
// no annotation here at all
public class RegistryConfig {
 
    @Bean
    public Clock clock() {
        return Clock.system(ZoneOffset.UTC);
    }
}

Lỗi lại nổi lên ở chỗ hoàn toàn khác — ở nơi cố inject bean đó:

Text
***************************
APPLICATION FAILED TO START
***************************
 
Description:
 
Parameter 0 of constructor in com.example.demo.forgot.TimeService required a bean of type
'java.time.Clock' that could not be found.
 
 
Action:
 
Consider defining a bean of type 'java.time.Clock' in your configuration.

"Consider defining a bean" đọc rất lạc hướng khi bạn đã định nghĩa rồi. Chỗ cần kiểm tra luôn là một trong hai: class chứa @Bean method thiếu @Configuration, hoặc nó nằm trong package mà scan không bao giờ đi tới.

Một @Configuration class final

Full mode kế thừa class đó, mà class final thì không kế thừa được. Spring phát hiện trước khi kịp thử:

FinalConfig.java
@Configuration
public final class FinalConfig {
 
    @Bean
    public Clock clock() { return Clock.system(ZoneOffset.UTC); }
}
Text
org.springframework.beans.factory.parsing.BeanDefinitionParsingException: Configuration problem:
@Configuration class 'FinalConfig' may not be final. Remove the final modifier to continue.
Offending resource: class path resource [com/example/demo/finalcfg/FinalConfig.class]
	at org.springframework.context.annotation.ConfigurationClass.validate(ConfigurationClass.java:251)
	at org.springframework.context.annotation.ConfigurationClassPostProcessor.processConfigBeanDefinitions

Từng method cũng chịu ràng buộc y hệt, với message riêng:

Text
Configuration problem: @Bean method 'clock' must not be private or final; change the method's
modifiers to continue.

Bỏ final là cách sửa thông thường. Thêm proxyBeanMethods = false cũng chạy được — lite-mode configuration class không bao giờ bị kế thừa nên final được phép và ứng dụng khởi động — nhưng chỉ chọn đường đó nếu class không có lời gọi nào giữa các bean để mà hỏng.

Hai @Bean method trùng tên bean

Hai configuration class, mỗi cái khai báo một bean tên clock, là tai nạn rất dễ gặp: một cái của bạn, một cái đến từ thư viện, hoặc hai người thêm cùng một helper cách nhau một tuần.

Java
@Configuration
public class AuditConfig {
    @Bean
    public Clock clock() { return Clock.system(ZoneOffset.UTC); }
}
 
@Configuration
public class BillingConfig {
    @Bean
    public Clock clock() { return Clock.system(ZoneId.of("Asia/Ho_Chi_Minh")); }
}

Mặc định của Spring Boot là từ chối:

Text
***************************
APPLICATION FAILED TO START
***************************
 
Description:
 
The bean 'clock', defined in class path resource [com/example/demo/dup/BillingConfig.class], could
not be registered. A bean with that name has already been defined in class path resource
[com/example/demo/dup/AuditConfig.class] and overriding is disabled.
 
Action:
 
Consider renaming one of the beans or enabling overriding by setting
spring.main.allow-bean-definition-overriding=true

Đây là một mặc định tốt, và dòng "Action" là cái bẫy. Làm theo nó thì ứng dụng khởi động được, một definition lặng lẽ thay thế cái kia, và tất cả những gì bạn nhận được là một dòng mức INFO:

Text
INFO o.s.b.f.s.DefaultListableBeanFactory : Overriding bean definition for bean 'clock' with a
different definition: replacing [... factoryBeanName=auditConfig; factoryMethodName=clock ...]
with [... factoryBeanName=billingConfig; factoryMethodName=clock ...]
Text
clock zone = Asia/Ho_Chi_Minh

Ứng dụng giờ chạy theo definition nào tình cờ được đăng ký sau — một thứ tự phụ thuộc vào class scanning và có thể đổi khi bạn đổi tên file. Hãy đổi tên một trong hai bean thay vì bật flag.

⚠️ spring.main.allow-bean-definition-overriding=true làm biến mất một lỗi startup bằng cách đổi nó thành một hành vi phụ thuộc thứ tự scan. Hãy coi BeanDefinitionOverrideException là một lỗi đặt tên cần sửa, không phải một setting cần đổi.

FAQ

Có thể đặt @Bean method ngay trên class @SpringBootApplication không?

Được. @SpringBootApplication bao gồm @SpringBootConfiguration, mà cái này chính là @Configuration, nên application class là một full-mode configuration class và @Bean method trên đó hoạt động đúng như mô tả trong bài. Một hai bean trong service nhỏ thì không sao; nhiều hơn thì nên tách ra các configuration class có tên, để application class chỉ còn làm nhiệm vụ khởi động.

Tại sao @Bean method của tôi chạy hai lần?

Vì có chỗ nào đó gọi thẳng nó trong lite mode. Hoặc class đang là @Configuration(proxyBeanMethods = false), hoặc các method nằm trên @Component chứ không phải @Configuration class, hoặc method bị gọi là static. In System.identityHashCode ở cả hai phía để xác nhận, rồi đổi lời gọi thẳng thành method parameter — cách này đúng ở mọi mode.

Có nên đặt proxyBeanMethods = false cho mọi configuration class không?

Chỉ nên, nếu bạn giữ luôn quy tắc đi kèm: không gọi thẳng giữa các @Bean method. Trong một thư viện hay starter — nơi chi phí startup nhân lên theo từng ứng dụng phụ thuộc vào nó, và native image là chuyện quan trọng — đó là mặc định đúng, và cũng là thứ @AutoConfiguration của chính Spring Boot làm. Trong code ứng dụng thường, phần tiết kiệm không đáng kể còn rủi ro ai đó thêm một lời gọi thẳng sau này thì có, nên giữ nguyên mặc định là lựa chọn hợp lý.

@Bean method có thể là private, final hay static không?

Không được private hay final trong class ở full mode — Spring lỗi ngay lúc startup với "must not be private or final; change the method's modifiers to continue", vì nó không override được. static thì được phép nhưng không bao giờ bị chặn, nên gọi thẳng một static @Bean method sẽ tạo object mới mỗi lần, kể cả ở full mode. Chỉ dùng static cho bean loại BeanFactoryPostProcessor, vốn phải tồn tại trước khi container xử lý class khai báo nó.

@Bean có hoạt động trên class không được annotate gì không?

Không. Method đó vô hình cho tới khi bản thân class trở thành một bean definition — qua component scanning, qua @Import, hoặc bằng cách là application class. Triệu chứng không phải lỗi trên configuration class mà là required a bean of type ... that could not be found tại điểm injection, nên người ta hay đi tìm nhầm file.

Làm sao đăng ký hai bean cùng type từ một configuration class?

Viết hai method. Đặt tên khác nhau — tên method là tên bean — hoặc chỉ định @Bean(name = "..."). Nếu hai bean khác nhau ở generic parameter, ví dụ Cache<String, Product>Cache<String, User>, thì injection theo type đã phân biệt được; nếu trùng type hoàn toàn, đánh dấu một cái @Primary hoặc chọn bằng @Qualifier.

Kết luận

@Component đăng ký một class bạn sở hữu; @Bean đăng ký một object bạn tạo ra. Toàn bộ ranh giới nằm ở đó, và mọi thứ khác đều suy ra từ nó — type của thư viện khác, type của JDK, builder, logic khởi tạo có rẽ nhánh, và một class phải đăng ký nhiều lần đều là những trường hợp không có source file để annotate, hoặc không có một câu trả lời duy nhất để annotate. Tên bean lấy từ tên method trừ khi @Bean(name = ...) nói khác, @Import kéo vào configuration mà scan không thấy, và một @Configuration class bản thân nó cũng là bean.

Phần tốn thời gian nhất của người dùng là proxyBeanMethods. Mặc định @Configuration chạy full mode: class bị CGLIB kế thừa, lời gọi thẳng từ reporter() sang meter() bị chặn, và cả ba reference trỏ vào cùng một object — 1467977993, ba lần. Đặt proxyBeanMethods = false, hoặc chuyển các method sang một @Component, hoặc để một method là static, thì cùng đoạn source đó cho ra 1857007886, 11792722581002911155: ba object, không lỗi, không cảnh báo. Cách phòng thủ là đừng dựa vào interception nữa, mà khai báo dependency giữa các bean thành method parameter — dạng này hành xử y hệt ở cả hai mode.

Bài tiếp theo vẫn ở trong container và hỏi một bean sống bao lâu: bean scope và lifecycle — singleton, prototype, request và session, cùng hai callback @PostConstruct@PreDestroy chạy ở hai đầu vòng đời đó.

Bài viết liên quan

[Spring Boot Basics] @ConfigurationProperties trong Spring Boot: cấu hình type-safe kết hợp validation

Cấu hình type-safe trong Spring Boot 4.1.1 với @ConfigurationProperties, kiểm chứng bằng các lần chạy thật: bind vào record không cần @ConstructorBinding, JavaBean binding và @DefaultValue, ba cách đăng ký properties class, object lồng nhau, list, map, enum, chuyển đổi Duration và DataSize, relaxed binding và cách đặt tên environment variable, lỗi khởi động fail-fast với @Validated, metadata từ configuration processor, và bảng so sánh với @Value.

[Spring Boot Basics] Bean scope và lifecycle trong Spring: singleton, prototype, @PostConstruct và @PreDestroy

Bean scope và bean lifecycle trong Spring, chứng minh bằng cách chạy thật trên Spring Boot 4.1.1: vì sao singleton là một instance cho mỗi container chứ không phải cho cả JVM, so sánh singleton, prototype, request, session và application bằng số instance đếm được qua curl, vì sao @PreDestroy không bao giờ chạy với prototype, cái bẫy singleton giữ prototype cùng ba cách sửa ObjectProvider, @Lookup và scoped proxy, mười bốn bước lifecycle lấy từ một lần trace thật, và @Lazy thực sự đánh đổi những gì.

[Spring Boot Basics] IoC và Dependency Injection trong Spring: vì sao không còn tự new object

Ý tưởng mà cả framework dựa lên, demo bằng code chạy thật trên Spring Boot 4.1.1 và Java 21: một object graph bốn class tự new ở mọi tầng cùng ba hậu quả kéo theo, phân biệt rạch ròi Inversion of Control với Dependency Injection, nối tay cùng graph đó trong main mà không có framework nào, rồi để Spring container nối và in identity của từng instance ra để chứng minh, kèm một test JUnit 5 với stub tự viết, một lần đổi implementation mà không đụng vào class đang dùng nó, và danh sách thành thật những cái giá phải trả.

[Spring Boot Basics] Auto-configuration trong Spring Boot hoạt động ra sao: conditional, back-off và báo cáo --debug

Mổ xẻ cơ chế auto-configuration của Spring Boot 4.1.1 bằng số liệu thật: @EnableAutoConfiguration và AutoConfigurationImportSelector, các file META-INF/spring/…AutoConfiguration.imports mà Boot 4 tách ra nhiều module nhỏ, họ annotation @ConditionalOnClass / @ConditionalOnMissingBean cùng một Condition tự viết, màn demo back-off có số liệu trước và sau, và cách đọc báo cáo CONDITIONS EVALUATION REPORT từ --debug.