Dependency Injection
Xem thửCheckoutService cần một cổng thanh toán. Cổng thật là Stripe, nhưng trong kiểm thử bạn muốn một bản giả. Một lập trình viên mới viết @Autowired private PaymentGateway gateway; cho nhanh. Sáu tháng sau lớp đó có chín field @Autowired, không ai nhận ra nó làm quá nhiều việc (vì không có constructor dài để cảnh báo), unit test phải dùng reflection hoặc khởi động cả context để gán dependency, và một lần refactor thêm vòng phụ thuộc giữa hai service mà ứng dụng vẫn chạy bình thường, cho tới khi một người nâng cấp cấu hình spring.main.allow-circular-references và cả hệ thống ngừng khởi động.
Bài này so sánh ba kiểu inject bằng thí nghiệm trên Spring Boot 4.1.1: constructor, setter và field; rồi đo bảng sự thật của vòng phụ thuộc (constructor, field, có và không có cờ cho phép, có và không có @Lazy), thông báo lỗi của Boot khi có vòng, cách Spring chọn constructor, và cách inject tập hợp, Optional và ObjectProvider.
Sau bài này, bạn có thể
- So sánh ba kiểu inject (constructor, setter, field) và giải thích vì sao constructor được khuyến nghị cho dependency bắt buộc.
- Đọc báo cáo vòng phụ thuộc của Spring Boot và biết cách phá vòng bằng thiết kế.
- Biết khi nào
@Autowiredbắt buộc trên constructor, và lỗi khi có hai constructor. - Inject tập hợp có thứ tự, dependency tùy chọn bằng
OptionalvàObjectProvider.
Ba kiểu inject
Cách đọc hình
Hàng trên, constructor injection: container giải quyết PaymentGateway trước rồi gọi new CheckoutService(gateway), nên đối tượng đầy đủ ngay khi tồn tại và field có thể là final. Hàng giữa, setter injection: container gọi constructor rỗng rồi mới gọi setGateway(...); giữa hai thời điểm đó gateway là null. Hàng dưới, field injection: container ghi vào field private bằng reflection, nên lớp không thể tạo ra đúng trạng thái nếu không có Spring, và dependency bị giấu khỏi constructor.
| Kiểu | Ưu | Nhược |
|---|---|---|
| Constructor | Bất biến, dependency bắt buộc hiện rõ, dễ kiểm thử | Constructor dài là dấu hiệu lớp làm quá nhiều việc |
| Setter | Hợp với dependency tùy chọn, có thể cấu hình lại | Đối tượng có thể ở trạng thái chưa đủ |
Field @Autowired | Viết nhanh | Khó kiểm thử, che giấu coupling, không thể final |
Tài liệu Spring nêu quy tắc: dùng constructor cho dependency bắt buộc, setter cho dependency tùy chọn; nhóm Spring nghiêng về constructor vì nó cho phép tạo thành phần bất biến và bảo đảm dependency bắt buộc không null.
Thí nghiệm 1: khoảng null của setter và cái bẫy của field
Lab dựng ba kiểu cùng nhận một Gateway và ghi vết lúc constructor chạy:
@Component
static class CtorStyle {
private final Gateway gateway;
CtorStyle(Gateway gateway) {
this.gateway = gateway;
trace.add("CtorStyle constructed, gateway=" + (gateway != null));
}
}
@Component
static class SetterStyle {
private Gateway gateway;
SetterStyle() {
trace.add("SetterStyle constructed, gateway=" + (gateway != null));
}
@Autowired
void setGateway(Gateway gateway) {
this.gateway = gateway;
trace.add("SetterStyle setter called");
}
}
@Component
static class FieldStyle {
@Autowired
private Gateway gateway;
String run() {
return gateway.toString();
}
}
DI constructor vs setter: [Gateway, CtorStyle constructed, gateway=true, SetterStyle constructed, gateway=false, SetterStyle setter called]
DI field-injected class outside Spring: NullPointerException when used
Constructor: ngay trong constructor gateway=true, đối tượng luôn đầy đủ. Setter: trong constructor gateway=false, và chỉ sau đó setter mới được gọi; mọi mã chạy trong khoảng giữa (kể cả constructor) thấy null. Field: dùng ngoài Spring (new FieldStyle().run()) ném NullPointerException: không có cách nào dựng đúng lớp này trong unit test thường nếu không có reflection hay context của Spring. Với field private, test phải dùng ReflectionTestUtils hoặc khởi động Spring, đó là cái giá ẩn của sự tiện lợi.
Spring chọn constructor và tìm dependency thế nào
- Xác định constructor cần dùng. Một constructor duy nhất thì không cần
@Autowired. - Với mỗi tham số, tìm bean có kiểu phù hợp trong context.
- Nếu có nhiều ứng viên, dùng
@Primaryhoặc@Qualifier(bài Autowiring). - Tạo đối tượng và truyền các bean đã tìm được.
Khi lớp có hai constructor mà không có @Autowired, Spring không biết chọn cái nào và quay về constructor không tham số, thứ không tồn tại:
DI two constructors without @Autowired: NoSuchMethodException: lab.L09Test$Two.<init>()
Sửa: đánh dấu constructor muốn dùng bằng @Autowired, hoặc để lại một constructor duy nhất.
@Service
public class CheckoutService {
private final PaymentGateway gateway;
private final InventoryService inventory;
public CheckoutService(PaymentGateway gateway, InventoryService inventory) {
this.gateway = gateway;
this.inventory = inventory;
}
}
Hãy inject interface (PaymentGateway) thay vì lớp cụ thể, để thay cài đặt và tạo bản giả trong test. Một dấu hiệu thiết kế: nếu constructor cần quá sáu hay bảy tham số, lớp đang làm quá nhiều việc; hãy tách trách nhiệm thay vì chuyển sang field injection để "giấu" danh sách.
Thí nghiệm 2: bảng sự thật của vòng phụ thuộc
Lab thử bốn cấu hình vòng (hai bean cần nhau) trong Spring Boot 4.1.1:
DI constructor cycle (default): BeanCurrentlyInCreationException: Error creating bean with name 'l09Test.A': Requested bean is currently in creation: Is there an unresolvable circular reference or an asynchronous initialization dependency?
DI constructor cycle, allow-circular-references=true: BeanCurrentlyInCreationException: ...
DI field cycle (default): BeanCurrentlyInCreationException: Error creating bean with name 'l09Test.FA': ...
DI field cycle, allow-circular-references=true: started
DI constructor cycle with @Lazy parameter (default): started
| Loại vòng | Mặc định | allow-circular-references=true |
|---|---|---|
| Constructor | Lỗi | Vẫn lỗi |
| Field hoặc setter | Lỗi | Khởi động được |
Constructor với @Lazy ở một tham số | Khởi động được | Khởi động được |
Spring Boot cấm tham chiếu vòng theo mặc định (spring.main.allow-circular-references là false), và báo cáo lỗi vẽ luôn chu trình:
APPLICATION FAILED TO START
---------------------------
Description:
The dependencies of some of the beans in the application context form a cycle:
┌─────┐
| l09Test.A
↑ ↓
| l09Test.B
└─────┘
Hãy đọc nó như sau: A cần B và B cần A; container không thể tạo bean nào trước vì cả hai đều cần đối tượng đã hoàn chỉnh của nhau.
Đang tải Animation…
Thử và quan sát
Dự đoán trước: hai bean cần nhau qua constructor, container tạo bean nào trước? Chạy ở trạng thái hai bean cần nhau và quan sát ai chờ ai, sau đó chuyển sang biến thể tách PricingPolicy. Hãy để ý rằng cách sửa nằm ở thiết kế: phần chung được chuyển ra một bean riêng để cả hai cùng phụ thuộc vào nó, không còn vòng.
Có ba cách phá vòng, xếp theo thứ tự nên chọn:
- Sửa thiết kế: tách trách nhiệm chung ra bean thứ ba (ví dụ
PricingPolicydùng chung, như animation), hoặc đổi chiều phụ thuộc bằng sự kiện (ApplicationEventPublisher). @Lazytrên tham số constructor: Spring inject một proxy lười thay vì bean thật, nên không cần bean thật lúc khởi tạo (lab: khởi động được). Đó là biện pháp tạm thời; vòng vẫn còn, chỉ bị che đi.spring.main.allow-circular-references=true: cho phép vòng qua field hoặc setter (lab: khởi động được), nhưng tài liệu Spring ghi rõ không khuyến nghị, và constructor vẫn lỗi. Đừng bật nó chỉ để ứng dụng chạy.
Inject tập hợp và dependency tùy chọn
@Component
static class Collector {
final List<Rule> rules;
final Optional<Missing> optional;
final ObjectProvider<Missing> provider;
@Autowired(required = false)
Missing notRequired;
Collector(List<Rule> rules, Optional<Missing> optional, ObjectProvider<Missing> provider) {
this.rules = rules;
this.optional = optional;
this.provider = provider;
}
}
DI List<Rule> order=[first, second] Optional present=false provider.getIfAvailable=null required=false field=null
List<Rule>nhận mọi bean kiểuRule, và thứ tự theo@Order(Firstcó@Order(1)đứng trướcSecondcó@Order(2)dù chữ cái đảo). Đây là cách chuẩn để xây chuỗi quy tắc, bộ xử lý, bộ kiểm tra mà không cần danh sách thủ công.Optional<Missing>,ObjectProvider<Missing>và@Autowired(required = false)đều cho phép không có bean: kết quảempty,nullvànullkhi không có beanMissing.ObjectProviderthêm khả năng lấy bean muộn (mỗi lần gọigetObject()), thứ cần cho prototype (bài Singleton và Prototype).
Ba bean vòng nhau và hình dạng của thứ được inject
Báo cáo chu trình ở trên chỉ có hai bean. Khi vòng dài hơn, người đọc cần thấy cả vòng; Boot vẽ đúng điều đó. Lab dựng X cần Y, Y cần Z, Z cần X (cả ba qua constructor):
The dependencies of some of the beans in the application context form a cycle:
┌─────┐
| LExtra09Test.X
↑ ↓
| LExtra09Test.Y
↑ ↓
| LExtra09Test.Z
└─────┘
Báo cáo liệt kê cả ba theo thứ tự phụ thuộc, nên bạn không phải đoán. Cách phá vòng ba bean không khác vòng hai (tách phần chung, đổi hướng phụ thuộc bằng sự kiện), nhưng cần nhìn cả vòng để chọn cạnh nào đáng cắt: thường là cạnh biểu diễn phụ thuộc "ngược dòng" về mặt kiến trúc (một bean ở lớp thấp gọi lên lớp cao), không phải cạnh nào cũng được.
Phần trước đã đo List<T>. Spring còn inject nhiều hình dạng khác của tập hợp, và thứ tự của chúng không đồng nhất. Lab inject hai bean Handler tên alpha (@Order(2)) và beta (@Order(1)) vào một constructor theo bốn hình dạng:
Map<String, Handler> keys = [alpha, beta] (khóa là tên bean, theo thứ tự đăng ký)
Set<Handler> order = [a, b] (theo thứ tự đăng ký, KHÔNG theo @Order)
Handler[] order = [b, a] (theo @Order)
@Nullable Missing = null (bean không tồn tại, không lỗi)
Điểm cần nhớ: mảng và List được sắp theo @Order, còn Set và Map thì không (giữ thứ tự đăng ký). Nếu thứ tự quan trọng (chuỗi quy tắc, bộ lọc), dùng List hoặc mảng, không dùng Set. @Nullable trên tham số constructor là cách biểu đạt "tùy chọn" gọn nhất, cùng nghĩa với Optional và required = false đã bàn ở trên, và có lợi thế là công cụ phân tích tĩnh hiểu được.
Chi phí kiểm thử của từng kiểu inject, đo bằng mã
Khuyến nghị "dùng constructor injection" thường được biện minh bằng lý thuyết; hãy xem chi phí thật khi viết test đơn vị. Lab so sánh hai lớp cùng phụ thuộc vào Y:
Fielded f = new Fielded();
f.has();
ReflectionTestUtils.setField(f, "y", new Y(null));
f.has();
new Constructed(new Y(null)).has();
field injection trước khi dùng reflection: has=false
field injection sau ReflectionTestUtils.setField: has=true
constructor injection: has=true
Lớp dùng field injection phải đi qua ReflectionTestUtils.setField(f, "y", ...), truyền tên trường bằng chuỗi: đổi tên trường là test hỏng mà trình biên dịch không báo, và test phụ thuộc vào chi tiết cài đặt (tên trường private). Lớp dùng constructor chỉ cần new Constructed(dependency): không reflection, không chuỗi tên, trình biên dịch kiểm tra, và quan trọng hơn, không thể quên truyền dependency (thiếu tham số là lỗi biên dịch). Với một lớp có 5 dependency, test field-injection thành 5 lời gọi reflection dễ lệch; test constructor thành một dòng. Đây là lý do thực dụng đầu tiên để chọn constructor, trước cả lập luận về bất biến.
Chọn kiểu inject: bảng quyết định rút gọn
Gom các kết quả đo của bài thành một bảng để dùng khi review mã: (1) dependency bắt buộc thì constructor, vì đối tượng đầy đủ ngay khi tồn tại, test không cần reflection và quên truyền là lỗi biên dịch; (2) dependency tùy chọn thì @Nullable trên tham số constructor hoặc ObjectProvider/Optional, và mã xử lý vắng mặt; (3) cần tất cả cài đặt thì List<T> hoặc mảng nếu có thứ tự, Map<String, T> nếu cần tra theo tên, và tránh Set<T> khi thứ tự quan trọng; (4) cần lấy muộn hoặc mỗi lần một bản (prototype) thì ObjectProvider; (5) phụ thuộc vòng: sửa thiết kế trước, @Lazy chỉ là biện pháp tạm; (6) setter injection chỉ khi cần cấu hình lại được sau khi tạo. Trong review, mỗi @Autowired trên field là một câu hỏi: vì sao không phải constructor?
Quyết định thiết kế và đánh đổi
| Nhu cầu | Cách | Đánh đổi |
|---|---|---|
| Dependency bắt buộc | Constructor, field final | Danh sách dài lộ ra lớp làm nhiều việc |
| Dependency tùy chọn | Optional, ObjectProvider hoặc setter | Mã phải xử lý vắng mặt |
| Nhiều cài đặt cùng kiểu | List<T> với @Order | Thứ tự phải tường minh |
| Vòng phụ thuộc | Sửa thiết kế | Cần tái cấu trúc |
| Vòng tạm thời | @Lazy trên tham số | Che vòng, thêm proxy |
Cờ allow-circular-references | Không bật | Ứng dụng có thể chạy với thiết kế xấu |
| Hai constructor | @Autowired trên một cái | Hoặc giữ một constructor |
| Kiểm thử | new + bản giả | Field injection phá điều này |
Sổ tay xử lý sự cố
- Triệu chứng:
BeanCurrentlyInCreationExceptionvà hình vẽ chu trình. Nguyên nhân: vòng phụ thuộc. Sửa: tách bean chung hoặc đổi sang sự kiện. - Triệu chứng:
NoSuchMethodException: ...<init>(). Nguyên nhân: nhiều constructor không có@Autowired. Sửa: gắn@Autowiredlên một constructor. - Triệu chứng:
NullPointerExceptiontrong unit test. Nguyên nhân: field injection. Sửa: chuyển sang constructor. - Triệu chứng: field
nulltrong constructor hay@PostConstructsớm. Nguyên nhân: setter hoặc field injection chưa chạy. Sửa: constructor injection. - Triệu chứng: ứng dụng chạy nhưng thứ tự quy tắc sai. Nguyên nhân: thiếu
@Order. Sửa:@OrderhoặcOrdered. - Triệu chứng: chỉ cần một cài đặt nhưng nhận cả danh sách. Nguyên nhân: kiểu
List<T>. Sửa:@Primaryhoặc@Qualifier(bài Autowiring). - Triệu chứng: bean tùy chọn gây lỗi khởi động. Nguyên nhân: dependency bắt buộc nhưng không có bean. Sửa:
OptionalhoặcObjectProvider. - Triệu chứng: constructor dài mười tham số. Nguyên nhân: lớp có nhiều trách nhiệm. Sửa: tách lớp, gom dependency liên quan vào một bean mới.
Hiệu năng, bảo mật và vận hành
- Constructor injection cho phép đối tượng bất biến, an toàn luồng hơn vì không có khoảng null và không ai đổi dependency sau khi tạo.
- Field injection phụ thuộc reflection để ghi vào field
private, và khó kiểm tra tĩnh trong lúc build; constructor cho trình biên dịch và IDE thấy mọi phụ thuộc. - Bật
allow-circular-referencesche đi lỗi thiết kế, và khiến thứ tự khởi tạo bắt đầu phụ thuộc vào thứ tự đăng ký bean, thứ có thể thay đổi sau một lần refactor vô hại. - Dependency
@Lazycó thể che lỗi cấu hình đến lần dùng đầu tiên (bài BeanFactory và ApplicationContext). - Thêm test "context khởi động được" để bắt vòng phụ thuộc trong CI.
Bài thực hành
- Viết ba lớp cùng nhận
Gatewaybằng constructor, setter và field; ghi vết trạng tháigatewaytrong constructor của từng lớp. - Gọi
new FieldStyle().run()ngoài Spring và ghi kết quả. - Tạo hai bean cần nhau qua constructor và đọc báo cáo
APPLICATION FAILED TO START. - Bật
spring.main.allow-circular-references=truecho vòng constructor, rồi cho vòng field; ghi cái nào chạy. - Thêm
@Lazyvào một tham số của vòng constructor và xác nhận ứng dụng khởi động được. - Tạo lớp có hai constructor không có
@Autowiredvà ghi lỗi; sửa bằng@Autowired. - Inject
List<Rule>với@Orderđảo thứ tự chữ cái; thêmOptionalvàObjectProvidercho bean không tồn tại.
Tiêu chí hoàn thành: bước 1 cho gateway=true (constructor) và false (setter); bước 2 cho NullPointerException; bước 3 vẽ chu trình A và B; bước 4 cho constructor vẫn lỗi và field chạy được; bước 5 cho khởi động được; bước 6 cho NoSuchMethodException; bước 7 cho [first, second] và Optional.empty. Gợi ý nếu bước 4 vòng field không chạy: kiểm tra cờ truyền đúng qua --spring.main.allow-circular-references=true (hoặc application.properties).
Tự kiểm tra
- Vì sao field có thể là
finalkhi dùng constructor injection nhưng không thể khi dùng field injection? - Khi nào một dependency nên inject qua setter?
- Hai service cần nhau qua constructor. Bạn thay đổi thiết kế thế nào, và thử bật
allow-circular-referencesthì sao? - Vì sao lớp có hai constructor không có
@AutowiredgâyNoSuchMethodException? @Lazytrên tham số constructor làm gì để khởi động được?List<Rule>được sắp xếp theo gì?Optional<T>,ObjectProvider<T>vàrequired = falsegiống và khác nhau thế nào?- Vì sao constructor có mười tham số là tín hiệu thiết kế?
Gợi ý đáp án. (1) final bắt buộc gán trong constructor; field injection gán sau bằng reflection. (2) Khi dependency là tùy chọn và có giá trị mặc định hợp lý, hoặc cần cấu hình lại sau. (3) Tách trách nhiệm chung ra bean thứ ba hoặc dùng sự kiện; bật cờ vẫn không cứu vòng constructor. (4) Spring quay về constructor không tham số, thứ không tồn tại. (5) Inject proxy lười thay vì bean thật nên không cần bean thật lúc khởi tạo. (6) Theo @Order hoặc Ordered. (7) Cả ba cho phép vắng mặt; ObjectProvider còn cho lấy muộn mỗi lần gọi. (8) Lớp có quá nhiều trách nhiệm.
Điều cần nhớ
- Constructor cho dependency bắt buộc; setter,
OptionalhoặcObjectProvidercho tùy chọn; tránh field injection làm mặc định. - Vòng constructor luôn lỗi; vòng field chỉ chạy khi bật cờ không khuyến nghị;
@Lazychỉ che vòng. - Sửa vòng bằng thiết kế, không bằng cờ.
- Hai constructor cần
@Autowiredtrên một cái. List<T>với@Orderlà cách chuẩn để xây chuỗi cài đặt.
Học liệu chọn lọc
- Spring Framework Reference: Dependency Injection
- Spring Framework Reference: Using @Autowired
- Spring Framework Reference: Using @Lazy on injection points
- Spring Boot Reference: Circular References (SpringApplication)
- Oliver Drotbohm: Why field injection is evil
- The Java Tutorials (Oracle): Declaring Member Variables
- The Java Tutorials (Oracle): Constructors
- Sách: Dependency Injection Principles, Practices, and Patterns (Mark Seemann, Steven van Deursen).