Hình dung phụ thuộc của một đối tượng như đồ nghề của người thợ, và có ba cách trao đồ. Spring có ba cách tiêm phụ thuộc, và bài này giải thích vì sao chỉ nên dùng một.

Tiêm qua hàm khởi tạo

@Service
class TiemHamKhoiTao {
    private final Kho kho;
    TiemHamKhoiTao(Kho kho) { this.kho = kho; }
}
  kho = KhoDB | trường có final? có

Từ Spring 4.3, không cần @Autowired khi lớp chỉ có một hàm khởi tạo. Spring tự hiểu.

Đây là trao hộp đồ nghề ngay lúc tuyển: không có hộp thì không tuyển được. Bốn tính chất, và chúng là lý do đây là cách duy nhất nên dùng:

Trường là final — bất biến sau khi tạo, an toàn luồng, và trình biên dịch bảo đảm nó được gán.

Đối tượng luôn hợp lệ. Không tồn tại trạng thái "đã tạo nhưng chưa có phụ thuộc".

Test được không cần Spring — new TiemHamKhoiTao(khoGia) là xong, đưa cho thợ một hộp đồ nghề giả là được. Không cần @SpringBootTest, không cần phản chiếu.

Phụ thuộc hiện rõ. Hàm khởi tạo tám tham số là tín hiệu lớp làm quá nhiều việc — và đó là thông tin hữu ích, không phải phiền toái.

Tiêm qua trường: cái bẫy

@Service
class TiemTruong {
    @Autowired Kho kho;      // không final được
}
  qua Spring        -> kho = KhoDB
  new TiemTruong()  -> kho = null

Dòng thứ hai là vấn đề. Tạo bằng new — trong test, hoặc do nhầm lẫn — cho một đối tượng trông hợp lệ nhưng NPE ở lần dùng đầu tiên. Như thuê thợ vào tay không rồi hứa luồn đồ nghề qua khe sau lưng sau: anh ta trông sẵn sàng, nhưng bắt tay vào việc trước khi đồ tới thì vớ phải hư không.

Ba vấn đề khác:

Không final được, nên trường thay đổi được và không có bảo đảm nào về an toàn luồng.

Che giấu phụ thuộc. Lớp mười lăm @Autowired trông vẫn gọn, nên không ai nhận ra nó đã quá tải — một người thợ đeo mười lăm món hứa-sẽ-đưa trông chẳng nặng hơn.

Che giấu phụ thuộc vòng. Với tiêm qua hàm khởi tạo, vòng phụ thuộc là lỗi lúc khởi động. Với tiêm qua trường, Spring giải được vòng bằng cách tiêm sau — nên vấn đề thiết kế bị giấu đi.

Spring Boot 2.6 trở đi cấm phụ thuộc vòng theo mặc định, và đó là quyết định đúng.

Tiêm qua setter

@Autowired void setKho(Kho kho) { this.kho = kho; }

Chỉ dùng cho phụ thuộc tuỳ chọn hoặc cần thay lúc chạy. Hiếm khi cần trong mã ứng dụng.

Nhiều cài đặt cùng interface

Đây là tình huống bạn sẽ gặp thường xuyên:

interface Thongbao { }
@Component class Email implements Thongbao { }
@Component class SMS   implements Thongbao { }

Tiêm Thongbao sẽ lỗi: expected single matching bean but found 2.

Bốn cách xử lý:

Tiêm tất cả vào List:

GomTatCa(List<Thongbao> ds) { this.ds = ds; }
  [Email, SMS, Zalo]

Rất hay khi bạn muốn chạy mọi cài đặt — gửi thông báo qua mọi kênh, chạy mọi bộ kiểm tra. Thêm một @Component mới là nó tự vào danh sách, không sửa mã cũ.

Cũng tiêm được vào Map<String, Thongbao> với khoá là tên bean.

@Qualifier để chọn đích danh:

ChonMot(@Qualifier("zalo") Thongbao t) { }
  Zalo

@Primary để đặt mặc định:

@Component @Primary class SMS implements Thongbao { }
  SMS

@Primary thắng khi không có @Qualifier. Hữu ích khi có một cài đặt "thường dùng" và vài cái đặc biệt.

@Profile để chọn theo môi trường — bài 6.

Thứ tự ưu tiên: @Qualifier > @Primary > khớp theo tên tham số > lỗi.

Phụ thuộc tuỳ chọn

DichVu(Optional<Cache> cache) { }
DichVu(@Autowired(required = false) Cache cache) { }

Optional là cách rõ ràng hơn. Không có bean Cache thì bạn nhận Optional.empty() thay vì lỗi khởi động.

Đừng dùng @Autowired trên hàm khởi tạo nữa

Mã cũ hay có:

@Autowired
DichVu(Kho kho) { }

Từ Spring 4.3, chú thích này thừa khi chỉ có một hàm khởi tạo. Xoá đi cho gọn.

Chỉ cần khi lớp có nhiều hàm khởi tạo và bạn muốn chỉ định dùng cái nào.

Lombok

@Service
@RequiredArgsConstructor
class DichVu {
    private final Kho kho;
    private final CongThanhToan ct;
}

@RequiredArgsConstructor sinh hàm khởi tạo cho mọi trường final. Đây là mẫu phổ biến nhất trong dự án Spring hiện đại, và nó giữ nguyên mọi ưu điểm của tiêm qua hàm khởi tạo mà không phải gõ.

Cân nhắc: Lombok là annotation processor, nên nó cần cấu hình IDE và làm dấu vết ngăn xếp khó đọc hơn một chút. Nhiều đội chấp nhận đánh đổi đó; một số đội không.

Nếu chỉ làm một thứ sau bài này, điểm mặt những người thợ nhận đồ nghề qua khe sau lưng, trong ba mươi giây:

grep -rn '@Autowired' --include='*.java' src/main | grep -v 'void set' | wc -l

Mỗi kết quả nằm trên một trường là một lớp không test được bằng new, và một chỗ phụ thuộc vòng có thể đang ẩn. Đổi sang tiêm qua hàm khởi tạo là việc cơ học, và IDE làm hộ được.

Mẫu số chung

Hãy làm cho một đối tượng không thể được tạo ra ở trạng thái không hợp lệ — đòi đủ phụ thuộc ngay lúc dựng, để không bao giờ có khe "đã tồn tại mà chưa ráp xong". Đây là nguyên tắc lớn hơn Spring nhiều: "biến trạng-thái-bất-hợp-lệ thành không-thể-biểu-diễn" (bài validation), RAII của C++ (giành tài nguyên ngay trong hàm dựng), trường bất biến/final. Tiêm qua hàm khởi tạo là nguyên tắc ấy áp cho việc nối dây, và phần thưởng lặp lại ở mọi nơi người ta ưa constructor DI (.NET, Guice/Dagger, Angular): một đối tượng được trao đủ cộng sự ngay lúc ra đời thì luôn hợp lệ, test được chỉ bằng cách đưa đồ giả — không framework, không phản chiếu — và an toàn luồng nhờ bất biến. Tiêm qua trường đánh đổi tất cả những cái đó lấy chút ngắn gọn, và tặng lại bạn một cái bẫy hình-null: đối tượng dựng bằng new trông ổn rồi NPE ở lần dùng đầu.

Điều thứ hai: ưu tiên cách nối dây hỏng-ồn-ào và phơi-bày-độ-phức-tạp hơn cách tiện-lợi mà giấu nó đi. Một hàm khởi tạo tám tham số là lời phản hồi trung thực rằng lớp làm quá nhiều; mười lăm trường @Autowired gọn gàng thì dập tắt đúng tín hiệu đó — một mùi thiết kế nhìn thấy được là một mùi sửa được. Lưỡi dao sắc là phụ thuộc vòng: tiêm qua hàm khởi tạo biến một vòng thành lỗi-lúc-khởi-động (hỏng-sớm, cùng cú dịch-về-sớm như bài repository soát lúc bean dựng), còn tiêm qua trường lặng lẽ gỡ vòng và chôn mất vấn đề kiến trúc. Xuyên suốt sê-ri, bản năng không đổi: làm cái đúng thành mặc định và để cái sai tự tố cáo mình, thay vì trát vữa che nó đi.

Ngày mai: vòng đời bean và scope — cùng cái bẫy tiêm prototype vào singleton.