TungDaDev's Blog

Spring IoC & Dependency Injection

Spring di.jpg
Published on
/10 mins read/

Trong thế giới phát triển ứng dụng Java, Spring Framework là cái tên thống trị tuyệt đối. Và nếu ví toàn bộ hệ sinh thái Spring như một cỗ xe đồ sộ, thì IoC Container (Inversion of Control) và Dependency Injection (DI) chính là khối động cơ nguyên khối vận hành toàn bộ cỗ xe đó.

Mặc dù hầu hết các kỹ sư backend đều sử dụng @Component, @Service, và @Autowired hàng ngày, nhưng khi bước vào các bài toán kiến trúc hệ thống quy mô lớn, nhiều câu hỏi bản chất vẫn là điểm mù:

  • DIP, IoC và DI khác nhau như thế nào về mặt lý thuyết và kiến trúc?
  • Một Spring Bean thực sự trải qua những giai đoạn nào trong vòng đời từ khi nạp class đến lúc bị hủy? Tại sao @Transactional hay Spring Security Proxy chỉ được sinh ra ở giai đoạn BeanPostProcessor?
  • Tại sao Field Injection (@Autowired trên private field) bị coi là một anti-pattern nguy hiểm và chính thức bị khai tử trong các tiêu chuẩn code hiện đại?
  • Spring giải quyết vấn đề phụ thuộc vòng (Circular Dependency) bên dưới mã nguồn DefaultSingletonBeanRegistry thông qua bộ nhớ đệm 3 tầng (Three-Level Cache) như thế nào?

Bài viết này sẽ phân tích chi tiết cơ chế hoạt động của Spring IoC Container từ góc nhìn của một Kỹ sư phần mềm cao cấp và Kiến trúc sư giải pháp.


# phân biệt bộ ba dip vs ioc vs di

Rất nhiều lập trình viên sử dụng ba khái niệm này thay thế cho nhau một cách tùy tiện. Về mặt khoa học máy tính, chúng nằm ở 3 tầng trừu tượng hoàn toàn khác biệt:

  • DIP: Là tôn chỉ thiết kế phần mềm trừu tượng.
  • IoC: Là mẫu kiến trúc đảo ngược luồng điều khiển. Thay vì code của bạn chủ động new OrderRepository(), bạn nhường quyền đó cho Spring Container.
  • DI: Là hành động cụ thể của Spring: tạo ra instance OrderRepository và "bơm" (inject) vào OrderService.

# vòng đời 12 giai đoạn của spring bean

Một Spring Bean từ khi được khai báo cho đến khi sẵn sàng phục vụ HTTP requests phải trải qua một quy trình kiểm duyệt và biến đổi nghiêm ngặt bên trong ApplicationContext:

# tại sao beanPostProcessor afterInitialization là then chốt

Khi bạn đánh dấu một method là @Transactional hoặc @Secured, đối tượng thực sự được Spring tiêm vào các Controller/Service khác không phải là class nguyên bản của bạn!

  • Tại bước này, Spring AOP chặn lại và bọc đối tượng bằng một CGLIB Dynamic Proxy Class (ví dụ: OrderService$$SpringCGLIB$$0).
  • Nếu bạn gọi hàm nội bộ this.methodWithTransactional(), cuộc gọi diễn ra bên trong instance gốc mà không đi qua Proxy, dẫn đến việc Transaction bị bypass hoàn toàn!

TIP

Để tránh bẫy self-invocation của Spring AOP, hãy tách các method cần @Transactional hoặc @Async sang một Service riêng biệt, hoặc inject chính Service đó thông qua lazy lookup để đi qua CGLIB Proxy.


# vì sao field injection bị khai tử

Trong các tutorial cũ, bạn luôn thấy kiểu viết này:

@Service
public class OrderService {
    @Autowired // FIELD INJECTION: ANTI-PATTERN NGUY HIỂM!
    private PaymentClient paymentClient;
    @Autowired
    private OrderRepository orderRepository;
}

Mặc dù ngắn gọn, Field Injection chính thức bị cấm trong các tiêu chuẩn code Enterprise vì 4 lý do chết người:

# constructor injection chuẩn mực với requiredArgsConstructor

@Service
@RequiredArgsConstructor // Tự động sinh Constructor cho toàn bộ các trường 'final'
public class OrderService {
 
    // Tuyệt đối bất biến (Immutable), an toàn đa luồng 100%
    private final PaymentClient paymentClient;
    private final OrderRepository orderRepository;
 
    // Dễ dàng viết Unit Test trong 0.1ms mà không cần Mockito Reflection:
    // OrderService service = new OrderService(mockPayment, mockRepo);
}

# bộ nhớ đệm 3 tầng xử lý circular dependency

Một bài toán kinh điển: Class A cần inject B, và Class B lại cần inject A.

Nếu chỉ khởi tạo thông thường: Để tạo A, Spring phải có B. Để tạo B, Spring lại phải có A -> Rơi vào vòng lặp vô tận và ném ra BeanCurrentlyInCreationException!

Để hóa giải điều này cho các singleton bean, lớp DefaultSingletonBeanRegistry của Spring duy trì bộ nhớ đệm 3 tầng (Three-Level Cache):

WARNING

Thay đổi kiến trúc từ Spring Boot 2.6+: Mặc dù bộ nhớ đệm 3 tầng giải quyết được phụ thuộc vòng cho setter/field injection, circular dependency luôn là dấu hiệu của kiến trúc bị chia tách sai trách nhiệm. Kể từ Spring Boot 2.6, Spring mặc định cấm hoàn toàn circular dependency (spring.main.allow-circular-references: false). Ứng dụng sẽ fail-fast ngay khi khởi động!


# scope mismatch: hiểm họa inject prototype vào singleton

Theo mặc định, mọi bean trong Spring đều là singleton (chỉ có duy nhất một instance trong ApplicationContext).

Giả sử bạn có một ReportGenerator (scope: prototype - mỗi lần gọi tạo một object mới độc lập) được inject vào một BillingService (scope: singleton):

@Component
@Scope(ConfigurableBeanFactory.SCOPE_PROTOTYPE)
public class ReportGenerator {
    private String reportId = UUID.randomUUID().toString();
}
 
@Service // Singleton (Mặc định)
@RequiredArgsConstructor
public class BillingService {
    private final ReportGenerator reportGenerator; // Bẫy Scope Mismatch!
 
    public void generate() {
        System.out.println(reportGenerator.getReportId());
    }
}

# tại sao code bị lỗi logic?

Do BillingService là singleton, nó chỉ được khởi tạo đúng 1 lần duy nhất lúc Spring startup. Tại thời điểm đó, một instance ReportGenerator được inject vào.

  • Trong toàn bộ vòng đời của ứng dụng, mọi lời gọi billingService.generate() sẽ tái sử dụng mãi mãi đúng 1 instance ReportGenerator ban đầu.
  • Đặc tính prototype bị vô hiệu hóa hoàn toàn!

# giải pháp kiến trúc

# cách 1: sử dụng ObjectProvider
@Service
@RequiredArgsConstructor
public class BillingService {
    private final ObjectProvider<ReportGenerator> reportGeneratorProvider;
 
    public void generate() {
        // Mỗi lần gọi getObject() sẽ kích hoạt container sinh ra một prototype mới
        ReportGenerator generator = reportGeneratorProvider.getObject();
        System.out.println(generator.getReportId());
    }
}
# cách 2: sử dụng scoped proxy

Spring sẽ inject một CGLIB Proxy thay cho object thật. Khi method của proxy được gọi, nó sẽ định tuyến động tới instance prototype tương ứng (@Scope(value = ConfigurableBeanFactory.SCOPE_PROTOTYPE, proxyMode = ScopedProxyMode.TARGET_CLASS)).

TIP

Lựa chọn thực chiến: Ưu tiên dùng ObjectProvider<T> vì cú pháp tường minh, dễ viết unit test giả lập (mocking) và không làm phát sinh thêm overhead của dynamic proxy CGLIB trong runtime.


# so sánh các kiểu dependency injection

Tiêu chíConstructor Injection (Khuyên dùng)Setter InjectionField Injection (Anti-Pattern)
Tính bất biến (final)🟢 Hỗ trợ 100%🔴 Không thể🔴 Không thể
Độ dễ dàng unit test🟢 Cực dễ (Không cần Spring)🟡 Trung bình (gọi setter)🔴 Khó (Cần Reflection / Mockito Runner)
Ngăn chặn khớp nối chặt🟢 Cảnh báo ngay nếu constructor quá dài🔴 Dễ bị lạm dụng🔴 Hoàn toàn che giấu mùi code
Xử lý null safety🟢 Tránh hoàn toàn NPE🔴 Dễ dính NPE nếu quên set🔴 Dễ dính NPE ngoài Spring context
Phát hiện circular dep🟢 Fail-Fast ngay lúc startup🟡 Tự động hóa giải ngầm🟡 Tự động hóa giải ngầm

# tổng kết và khuyến nghị

Hiểu sâu về Spring IoC & Dependency Injection là lằn ranh phân biệt giữa việc chỉ sử dụng framework theo thói quen và một kỹ sư làm chủ toàn bộ hành vi của hệ thống:

  1. Dẹp bỏ field injection: Luôn sử dụng constructor injection với final fields để đảm bảo tính bất biến và dễ dàng viết unit test không phụ thuộc container.
  2. Nắm vững vòng đời 12 bước của bean: Để biết chính xác thời điểm proxy AOP được hình thành và tránh các bẫy tự gọi method nội bộ (this).
  3. Cảnh giác với scope mismatch: Luôn dùng ObjectProvider khi inject các bean có vòng đời ngắn hơn (prototype, request scope) vào singleton bean.

Chỉ là những ghi chép cá nhân với hy vọng mang lại chút giá trị. Nếu thấy hữu ích, đừng ngại chia sẻ cho bạn bè & đồng nghiệp nhé!

Happy coding 😎 👍🏻 🚀 🔥.

← Previous postjava records