Spring Core & Boot/@Bean & @Configuration — cơ chế CGLIB proxy và composition
23/41
Bài 23 / 41~12 phútConfiguration & Bean DeclarationMiễn phí lượt xem

@Bean & @Configuration — cơ chế CGLIB proxy và composition

Tại sao @Configuration phải dùng CGLIB proxy, full mode vs lite mode khác nhau ra sao, @Bean method cho class third-party, và @Import để ghép config theo nhóm.

TL;DR: @Bean method là cách khai báo bean cho class third-party (không thể annotate @Component). @Configuration không chỉ là container của @Bean — Spring enhance class này bằng CGLIB subclass để intercept mọi lời gọi @Bean method, đảm bảo method gọi nhau trả cùng singleton instance ("full mode"). Tắt CGLIB bằng proxyBeanMethods = false ("lite mode") để startup nhanh hơn, nhưng phải kỷ luật: mọi dependency qua parameter, không gọi method @Bean khác trong body. @Import cho phép ghép nhiều config class mà không cần @ComponentScan — Spring Boot autoconfig dùng cơ chế này.

1. Tại sao cần @Bean — vấn đề với class third-party

@Component yêu cầu bạn annotate class nguồn. Với class third-party như HikariDataSource, ObjectMapper, hay RestTemplate, bạn không sửa được source. @Bean method giải quyết bài toán này: method trả về object, Spring lấy object đó đăng ký vào container.

@Configuration
public class InfrastructureConfig {

    @Bean
    public DataSource dataSource(
            @Value("${db.url}") String url,
            @Value("${db.user}") String user,
            @Value("${db.pass}") String pass) {
        var ds = new HikariDataSource();
        ds.setJdbcUrl(url);
        ds.setUsername(user);
        ds.setPassword(pass);
        ds.setMaximumPoolSize(20);
        return ds;
    }

    @Bean
    public ObjectMapper objectMapper() {
        return new ObjectMapper()
            .registerModule(new JavaTimeModule())
            .disable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS);
    }
}

Phân tích:

  • Bean name mặc định = method name (dataSource, objectMapper).
  • Bean class = return type khai báo.
  • Method parameter được Spring inject@Value resolve từ Environment.
  • Toàn bộ logic cấu hình nằm trong method body: set pool size, register module, disable feature.

Bảng so sánh hai cách khai báo:

CáchĐiều kiệnCơ chế
@Component / stereotypeClass do bạn viếtComponent scan tìm class, gọi constructor
@Bean methodClass bất kỳ — third-party hoặc cần config phức tạpSpring gọi method, lấy return value

2. Vấn đề khi @Bean method gọi nhau — cần hiểu trước khi gặp bug

Giả sử bạn có hai bean: ConnectionPoolTransactionManagerTransactionManager cần đúng instance ConnectionPool đã đăng ký:

// KHONG co @Configuration
public class DatabaseConfig {

    @Bean
    public ConnectionPool connectionPool() {
        return new HikariConnectionPool(/* config */);
    }

    @Bean
    public TransactionManager transactionManager() {
        return new DataSourceTransactionManager(connectionPool()); // goi method
    }
}

Ý định của lập trình viên: transactionManager dùng cùng instance connectionPool đã đăng ký. Thực tế khi không có @Configuration: connectionPool() trong body transactionManager()lời gọi Java thuần — không qua Spring. Kết quả: Spring tạo 1 ConnectionPool bean, rồi transactionManager() tạo thêm 1 ConnectionPool mới khi được gọi. Bạn có hai pool riêng biệt trong app — pool bean không có transaction nào đi qua, pool trong TransactionManager không ai giám sát. Bug cực kỳ khó trace.

flowchart LR
    subgraph NoConfig["Khong @Configuration -- lite mode"]
        CP1["ConnectionPool bean<br/>(do Spring quan ly)"]
        CP2["ConnectionPool instance<br/>(trong TransactionManager)"]
        TM["TransactionManager bean"]
    end
    TM -- "giu ref toi" --> CP2
    CP1 -- "bi bo qua" --> CP1

Đây chính là lý do @Configuration tồn tại.

3. Cơ chế CGLIB trong @Configuration — full mode

Khi class được annotate @Configuration, Spring không dùng class gốc của bạn — nó tạo một CGLIB subclass (tên dạng DatabaseConfig$$EnhancerBySpringCGLIB$$abc123) với các @Bean method được override. Override logic: mỗi lần method @Bean được gọi, proxy kiểm tra container trước — bean đó đã tồn tại chưa? Nếu có, trả cached instance; chưa có thì gọi method gốc và đăng ký kết quả.

@Configuration
public class DatabaseConfig {

    @Bean
    public ConnectionPool connectionPool() {
        return new HikariConnectionPool(/* config */);
    }

    @Bean
    public TransactionManager transactionManager() {
        return new DataSourceTransactionManager(connectionPool()); // bi intercept
    }
}

Bây giờ connectionPool() trong body transactionManager() không phải lời gọi Java thuần. Nó đi qua override method của CGLIB proxy, proxy tra container thấy connectionPool bean đã có, trả về instance cached. Kết quả: TransactionManager và phần còn lại của app đều giữ cùng một ConnectionPool object.

sequenceDiagram
    participant Ctx as Container
    participant CGLIB as DatabaseConfig$$CGLIB
    participant Real as DatabaseConfig (real)

    Ctx->>CGLIB: connectionPool()
    CGLIB->>Real: super.connectionPool()
    Real-->>CGLIB: new HikariConnectionPool
    CGLIB->>Ctx: register bean connectionPool
    Ctx->>CGLIB: transactionManager()
    CGLIB->>CGLIB: intercept connectionPool()
    alt bean da co trong cache
        CGLIB-->>CGLIB: return cached ConnectionPool
    end
    CGLIB->>Real: super.transactionManager(cachedPool)
    Real-->>Ctx: new DataSourceTransactionManager

ConfigurationClassPostProcessor — một BeanFactoryPostProcessor — là thành phần xử lý toàn bộ @Configuration class trước khi container tạo bean. Nó parse @Bean method, đăng ký BeanDefinition, và áp dụng CGLIB enhancement. Xem thêm về BeanFactoryPostProcessor tại BeanDefinition & BeanFactoryPostProcessor.

Constraints của CGLIB enhancement

Vì CGLIB cần subclass class của bạn và override method:

  • @Configuration class không được final — CGLIB không subclass được → startup fail với BeanDefinitionParsingException.
  • Method @Bean không được private hoặc final — không override được → method bị xử lý ở "lite mode" ngay cả khi class có @Configuration.
  • Method public hoặc protected đều OK.

4. Lite mode — proxyBeanMethods = false

Spring 5.2 thêm attribute proxyBeanMethods trên @Configuration. Khi đặt false, Spring không sinh CGLIB subclass — class của bạn được dùng trực tiếp. @Bean method vẫn được Spring gọi để tạo bean, nhưng các lời gọi method từ trong body không bị intercept.

@Configuration(proxyBeanMethods = false)
public class WebClientConfig {

    @Bean
    public WebClient.Builder webClientBuilder() {
        return WebClient.builder()
            .defaultHeader(HttpHeaders.CONTENT_TYPE, MediaType.APPLICATION_JSON_VALUE);
    }

    @Bean
    public UserApiClient userApiClient(WebClient.Builder builder) { // inject qua parameter
        return new UserApiClient(builder.baseUrl("https://users.api").build());
    }

    @Bean
    public OrderApiClient orderApiClient(WebClient.Builder builder) { // inject qua parameter
        return new OrderApiClient(builder.baseUrl("https://orders.api").build());
    }
}

Để lite mode hoạt động đúng, toàn bộ dependency phải đến qua method parameter — Spring inject vào khi gọi method. Không gọi method @Bean khác trong body.

Lợi ích của lite mode:

  • Startup nhanh hơn — không sinh CGLIB subclass cho mỗi config class.
  • Tương thích tốt hơn với GraalVM native image — CGLIB sinh proxy class tại runtime, không native-friendly.
  • Spring Boot 2.2+ tự annotate proxyBeanMethods = false cho phần lớn autoconfig class.

Bảng so sánh:

Full mode (default)Lite mode (proxyBeanMethods=false)
CGLIB subclassKhông
@Bean gọi nhau trong bodyTrả cùng singletonTạo instance mới mỗi lần
Startup speedChậm hơn một chútNhanh hơn
Native imageKhó hơnThân thiện hơn
Khi nào dùngCode gọi method nhau, legacyCode disciplined — chỉ inject qua param

5. Pitfall lite mode — hai instance tưởng là một

Đây là lỗi phổ biến nhất khi chuyển sang proxyBeanMethods = false mà không refactor dependency:

// ❌ SAI — lite mode nhưng goi method trong body
@Configuration(proxyBeanMethods = false)
public class CacheConfig {

    @Bean
    public CacheKeyGenerator keyGenerator() {
        return new DefaultCacheKeyGenerator();
    }

    @Bean
    public LocalCache localCache() {
        return new LocalCache(keyGenerator()); // tao CacheKeyGenerator MOI, khong phai bean
    }

    @Bean
    public DistributedCache distributedCache() {
        return new DistributedCache(keyGenerator()); // lai tao CacheKeyGenerator MOI
    }
}

Kết quả: app có 3 instance CacheKeyGenerator — 1 bean do Spring quản lý, 1 trong localCache, 1 trong distributedCache. Ba instance này độc lập, state không đồng bộ nếu CacheKeyGenerator có state.

// ✅ DUNG — inject qua parameter
@Configuration(proxyBeanMethods = false)
public class CacheConfig {

    @Bean
    public CacheKeyGenerator keyGenerator() {
        return new DefaultCacheKeyGenerator();
    }

    @Bean
    public LocalCache localCache(CacheKeyGenerator keyGenerator) { // Spring inject
        return new LocalCache(keyGenerator);
    }

    @Bean
    public DistributedCache distributedCache(CacheKeyGenerator keyGenerator) { // cung instance
        return new DistributedCache(keyGenerator);
    }
}

6. @Import — ghép config theo nhóm

@Import cho phép một config class kéo thêm config class khác vào container — không cần component scan:

@Configuration
@Import({DatabaseConfig.class, SecurityConfig.class, MessagingConfig.class})
public class AppConfig {
    // Bean rieng cua app
}

Khác biệt với @ComponentScan:

@ComponentScan@Import
Phạm viTự động quét packageLiệt kê explicit
Tốc độChậm hơn (scan classpath)Nhanh hơn (không scan)
Dùng choApplication codeLibrary config, modular setup
Spring Boot autoconfigKhông dùngDùng — qua AutoConfiguration.imports

Spring Boot không dùng @ComponentScan cho autoconfig. Mỗi starter khai báo config class trong file META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports, và Spring Boot @Import chúng tại startup. Lý do: autoconfig phải explicit và không phụ thuộc package layout của user.

@Import cũng nhận ImportSelector (chọn class config theo điều kiện) và ImportBeanDefinitionRegistrar (đăng ký bean định nghĩa programmatically) — cơ chế của @EnableJpaRepositories, @EnableScheduling, và các @Enable* annotation.

Pattern thực tế

Khi viết library hoặc starter nội bộ: nhóm bean theo concern vào @Configuration riêng biệt (DatabaseConfig, SecurityConfig, MessagingConfig), rồi dùng @Import trong 1 entry config để user chỉ cần import 1 class. Tránh expose nhiều config class ra public API của library.

7. Lifecycle qua @BeaninitMethoddestroyMethod

@Bean cho phép khai báo lifecycle method của class third-party không implement InitializingBean hay DisposableBean:

@Bean(initMethod = "start", destroyMethod = "stop")
public ConnectionPool connectionPool() {
    return new HikariConnectionPool(config);
}

@Bean(destroyMethod = "") // tat auto-infer destroy method
public DataSource managedDataSource() {
    return externallyManagedDataSource; // lifecycle do he thong khac quan
}

Spring tự suy ra destroyMethod cho close() (Closeable/AutoCloseable) và shutdown(). Nếu lifecycle được quản lý bởi hệ thống khác (container, connection registry), đặt destroyMethod = "" để Spring không gọi auto.

Liên hệ các bài khác

  • BeanDefinition & BeanFactoryPostProcessor: ConfigurationClassPostProcessorBeanFactoryPostProcessor xử lý @Configuration — parse @Bean method, đăng ký BeanDefinition, kích hoạt CGLIB enhancement. Hiểu BFPP giải thích tại sao @Configuration class được xử lý trước khi bất kỳ bean nào được tạo.
  • Stereotypes & @ComponentScan: bài trước giải thích @Component và scan — bài này bổ sung @Bean@Configuration cho trường hợp scan không dùng được (third-party class).
  • @Conditional — nền auto-configuration: @Bean + @ConditionalOnClass / @ConditionalOnMissingBean là cặp đôi của Spring Boot autoconfig. Hiểu @Bean trước, rồi bài tiếp theo đào sâu condition.

Tóm tắt

  • @Bean method khai báo bean từ bất kỳ class nào — giải quyết bài toán class third-party không thể annotate @Component.
  • @Configuration không chỉ là marker — Spring sinh CGLIB subclass (full mode) để intercept @Bean method call, đảm bảo method gọi nhau trả cùng singleton instance.
  • Full mode (proxyBeanMethods = true, mặc định): CGLIB proxy, method gọi nhau an toàn. Class và method không được final.
  • Lite mode (proxyBeanMethods = false): không proxy, startup nhanh hơn, native-friendly. Bắt buộc: mọi dependency qua method parameter, không gọi method @Bean trong body.
  • Pitfall lite mode: gọi method @Bean khác trong body tạo instance mới (không phải bean cache) → nhiều instance không đồng bộ.
  • @Import kéo config class explicit — không scan, nhanh hơn, dùng cho library config và Spring Boot autoconfig.
  • ConfigurationClassPostProcessor (một BeanFactoryPostProcessor) chịu trách nhiệm xử lý toàn bộ @Configuration trước khi container instantiate bean.

Tự kiểm tra

Tự kiểm tra
Q1
Đoạn code sau in ra true hay false? Giải thích cơ chế bên dưới.
@Configuration
public class AppConfig {
  @Bean
  public Validator validator() { return new HibernateValidator(); }

  @Bean
  public UserService userService() {
      return new UserService(validator()); // goi method
  }

  @Bean
  public OrderService orderService() {
      return new OrderService(validator()); // goi method
  }
}

// Trong main:
var userValidator = ctx.getBean(UserService.class).getValidator();
var orderValidator = ctx.getBean(OrderService.class).getValidator();
System.out.println(userValidator == orderValidator);

In true.

Class AppConfig@Configuration, nên Spring sinh CGLIB subclass. Mọi lời gọi method @Bean đều bị intercept bởi proxy. Khi userService() chạy và gọi validator(), proxy kiểm tra container — bean validator đã có chưa? Lần đầu chưa có, gọi method gốc và đăng ký. Khi orderService() chạy và cũng gọi validator(), proxy thấy bean đã có trong singletonObjects, trả cached instance ngay.

Kết quả: userServiceorderService giữ cùng một object HibernateValidator. Phép so sánh == trả true.

Nếu bỏ @Configuration (chỉ để class trần, lite mode): hai lời gọi validator() là Java thuần, mỗi lần tạo new HibernateValidator() mới. Kết quả in false — hai validator instance khác nhau, bug tinh tế.

Q2
Bạn chuyển config sau sang proxyBeanMethods = false để tối ưu native image. Code có bug không? Nếu có, fix thế nào?
@Configuration(proxyBeanMethods = false)
public class MetricsConfig {
  @Bean
  public MeterRegistry meterRegistry() {
      return new PrometheusMeterRegistry(PrometheusConfig.DEFAULT);
  }

  @Bean
  public HttpMetricsFilter httpFilter() {
      return new HttpMetricsFilter(meterRegistry()); // goi method
  }

  @Bean
  public DatabaseMetrics dbMetrics() {
      return new DatabaseMetrics(meterRegistry()); // goi method
  }
}

Có bug. Lite mode không có CGLIB proxy. Mỗi lời gọi meterRegistry() trong body httpFilter()dbMetrics() là lời gọi Java thuần — mỗi lần tạo new PrometheusMeterRegistry(...) mới. App sẽ có 3 instance MeterRegistry: 1 bean thật, 1 trong httpFilter, 1 trong dbMetrics. Metrics từ HTTP và database không đi vào cùng registry — dashboard Prometheus trống.

Fix: inject qua method parameter thay vì gọi method:

@Configuration(proxyBeanMethods = false)
public class MetricsConfig {
  @Bean
  public MeterRegistry meterRegistry() {
      return new PrometheusMeterRegistry(PrometheusConfig.DEFAULT);
  }

  @Bean
  public HttpMetricsFilter httpFilter(MeterRegistry registry) { // inject
      return new HttpMetricsFilter(registry);
  }

  @Bean
  public DatabaseMetrics dbMetrics(MeterRegistry registry) { // inject
      return new DatabaseMetrics(registry);
  }
}

Spring inject cùng instance meterRegistry bean vào cả hai method. Không cần proxy — dependency rõ ràng qua signature.

Q3
Tại sao class @Configuration không được khai báo final, và method @Bean không được private? Giải thích từ góc độ cơ chế CGLIB.

CGLIB tạo singleton instance bằng cách **subclass** class của bạn (tạo YourConfig$$EnhancerByCGLIB extends YourConfig) và **override** từng method @Bean để thêm logic intercept.

Hai ràng buộc Java ngăn cản điều này:

  • Class final: Java không cho phép subclass class final. CGLIB không thể tạo subclass, nên Spring ném BeanDefinitionParsingException ngay lúc startup.
  • Method private hoặc final: method private không thể override (không kế thừa được); method final bị Java chặn override. Cả hai đều khiến CGLIB không thể inject intercept logic. Spring xử lý method đó ở "lite mode" — không có proxy — ngay cả khi class có @Configuration.

Fix: bỏ final khỏi class, dùng public hoặc protected cho method @Bean. Hoặc dùng proxyBeanMethods = false để không cần CGLIB — nhưng phải đảm bảo không gọi method nhau trong body.

Q4
Tại sao Spring Boot autoconfig dùng @Import thay vì @ComponentScan để load các autoconfig class?

Có ba lý do chính:

  • Explicit và an toàn: @ComponentScan quét theo package — nếu user project có package trùng tên với starter, có thể scan nhầm. @Import liệt kê class cụ thể, không phụ thuộc package layout của user.
  • Performance: scan classpath để tìm @Component trong jar của tất cả dependency tốn thời gian O(classpath size). @Import qua file META-INF/spring/...AutoConfiguration.imports đọc danh sách có sẵn — O(1) lookup.
  • Isolation: autoconfig bean chỉ được load khi điều kiện @ConditionalOn* thoả mãn. Với @ComponentScan, bean sẽ scan và đăng ký vô điều kiện (trừ khi thêm filter phức tạp). @Import kết hợp với @Conditional cho phép lazy evaluation sạch hơn.

Quy tắc thực dụng: khi viết library hay starter nội bộ, dùng @Import để expose config — đừng yêu cầu user thêm package vào @ComponentScan.

Q5
Bạn thấy config class sau trong codebase legacy. Phân tích mọi vấn đề tiềm ẩn và đề xuất fix:
@Configuration
public final class AppConfig {

  @Bean
  private DataSource dataSource() {
      return new HikariDataSource();
  }

  @Bean
  public JdbcTemplate jdbcTemplate() {
      return new JdbcTemplate(dataSource()); // goi method
  }

  @Bean
  public TransactionTemplate transactionTemplate() {
      return new TransactionTemplate(dataSource()); // goi method
  }
}

hai vấn đề nghiêm trọng:

Vấn đề 1 — Class final: CGLIB không thể subclass final class. App sẽ ném exception lúc startup. Fix: bỏ final.

Vấn đề 2 — Method dataSource()private: ngay cả khi sửa final, CGLIB không override được method private. Spring xử lý method đó ở lite mode — dataSource() trong body jdbcTemplate()transactionTemplate() là lời gọi Java thuần, mỗi lần tạo HikariDataSource mới. Kết quả: 3 connection pool trong app, không cái nào share transaction với nhau.

Fix đúng:

@Configuration // bo final
public class AppConfig {

  @Bean // doi thanh public hoac protected
  public DataSource dataSource() {
      return new HikariDataSource();
  }

  @Bean
  public JdbcTemplate jdbcTemplate() {
      return new JdbcTemplate(dataSource()); // gio duoc intercept boi CGLIB
  }

  @Bean
  public TransactionTemplate transactionTemplate() {
      return new TransactionTemplate(dataSource()); // tra cung pool
  }
}

Hoặc dùng lite mode với parameter injection nếu muốn native-friendly:

@Configuration(proxyBeanMethods = false)
public class AppConfig {
  @Bean
  public DataSource dataSource() { return new HikariDataSource(); }

  @Bean
  public JdbcTemplate jdbcTemplate(DataSource ds) { return new JdbcTemplate(ds); }

  @Bean
  public TransactionTemplate transactionTemplate(DataSource ds) {
      return new TransactionTemplate(ds);
  }
}

Bài tiếp theo: @Conditional — nền auto-configuration

Bài này có giúp bạn hiểu bản chất không?

Hỏi đáp về bài này

Chưa có câu hỏi

Đặt câu hỏi

Có gì chưa rõ trong bài? Đặt câu hỏi đầu tiên — câu trả lời từ cộng đồng giúp bạn (và người sau).

Đặt câu hỏi đầu tiên