Scope quyết định Spring tạo bao nhiêu thực thể của một bean. Hình dung singleton như cái máy pha cà phê chung cả văn phòng dùng, còn prototype như cái cốc giấy phát mới mỗi lần bạn xin. Bài này về sáu scope và một cái bẫy nằm ngay chỗ giao nhau của hai thứ đó.
Singleton là mặc định
singleton: hai lần getBean cùng đối tượng? true
prototype: hai lần getBean cùng đối tượng? false
Mặc định, mỗi bean chỉ có một thực thể cho cả ứng dụng.
Chú ý đây là "một thực thể mỗi container", không phải mẫu Singleton của GoF — không có getInstance() tĩnh, không có hàm khởi tạo private, và bạn vẫn new được bình thường.
Hệ quả đã nói ở bài 3 và đáng nhắc lại: bean singleton phục vụ mọi request đồng thời, nên không được có trạng thái thay đổi được — cái máy pha chung phải an toàn khi mười người bấm cùng lúc.
Cái bẫy: prototype tiêm vào singleton
@Component @Scope("prototype")
class KhoPrototype { }
@Service
class DichVu {
private final KhoPrototype kho;
DichVu(KhoPrototype kho) { this.kho = kho; }
int lay() { return System.identityHashCode(kho); }
}
gọi 3 lần, hashCode của prototype được tiêm:
863366099
863366099
863366099
Cùng một đối tượng.
Lý do rất đơn giản khi nghĩ ra: DichVu là singleton, nên hàm khởi tạo của nó chạy đúng một lần. Tại thời điểm đó Spring tạo một KhoPrototype và tiêm vào. Từ đó về sau không có gì kích hoạt việc tạo thêm — cái thiết bị lắp cố định lên tường được đưa đúng một cái cốc lúc lắp, và nó dùng lại cái cốc ấy mãi.
Đây là lỗi rất khó phát hiện vì mã trông hoàn toàn đúng, và nó chỉ lộ ra khi bạn để ý trạng thái bị chia sẻ giữa các request.
Bốn cách chữa
ObjectProvider — cách tôi khuyên:
@Service
class DichVu {
private final ObjectProvider<KhoPrototype> nhaCungCap;
DichVu(ObjectProvider<KhoPrototype> p) { this.nhaCungCap = p; }
void lam() {
KhoPrototype k = nhaCungCap.getObject(); // MỚI mỗi lần gọi
}
}
Rõ ràng, không cần proxy, và ObjectProvider còn có getIfAvailable() cho phụ thuộc tuỳ chọn. Đây là lắp cho cái thiết bị một hộp phát cốc thay vì đưa nó một cái cốc — mỗi lần dùng rút một cái mới.
@Lookup — Spring ghi đè phương thức bằng CGLib:
@Lookup
protected KhoPrototype tao() { return null; } // Spring viết lại thân
Hoạt động nhưng khó hiểu với người đọc, và không dùng được với lớp final.
Scoped proxy:
@Component
@Scope(value = "prototype", proxyMode = ScopedProxyMode.TARGET_CLASS)
class KhoPrototype { }
Spring tiêm một proxy, và mỗi lời gọi method tạo thực thể mới. Tiện nhưng che giấu việc gì đang xảy ra — người đọc thấy một trường bình thường.
Đừng dùng prototype. Đây là câu trả lời đúng trong đa số trường hợp: nếu bạn cần một đối tượng mới mỗi lần, hãy new nó. Không phải mọi thứ đều cần là bean.
Sáu scope
singleton — mặc định, một thực thể cho container.
prototype — thực thể mới mỗi lần yêu cầu. Chú ý Spring không quản vòng đời của prototype: @PreDestroy không bao giờ chạy. Bạn tự chịu trách nhiệm dọn dẹp — văn phòng không đếm cốc giấy, phát ra rồi thì vứt đi là việc của bạn.
request — một thực thể mỗi HTTP request.
session — một thực thể mỗi phiên người dùng.
application — một thực thể mỗi ServletContext.
websocket — một thực thể mỗi phiên WebSocket.
Bốn scope cuối chỉ có trong ứng dụng web, và đều cần scoped proxy khi tiêm vào singleton:
@Component
@Scope(value = WebApplicationContext.SCOPE_REQUEST, proxyMode = ScopedProxyMode.TARGET_CLASS)
class NguoiDungHienTai { }
Nên dùng scope web không
Lời khuyên thẳng: hiếm khi.
request scope nghe hợp lý cho "người dùng hiện tại", nhưng nó tạo ra trạng thái ẩn — phương thức nhận được dữ liệu mà không có tham số nào nói điều đó. Đây đúng vấn đề của ThreadLocal ở bài 78 sê-ri Java, và scope request của Spring cài đặt bằng ThreadLocal nên mang theo mọi cảnh báo ở đó.
Với @Async hay luồng ảo, bean scope request không lan sang luồng khác, và bạn nhận lỗi lúc chạy — cái cốc "của khách đang đứng ở quầy" không đi theo khi việc chuyển sang người làm ở phòng trong.
Cách rõ ràng hơn: truyền dữ liệu qua tham số phương thức. Dài hơn vài ký tự, nhưng luồng dữ liệu nhìn thấy được.
@PostConstruct và @PreDestroy
Bài 3 đã đo thứ tự. Vài lưu ý thêm:
Đừng làm việc nặng trong @PostConstruct. Nó chạy lúc khởi động, nên mọi thứ chậm ở đây đều cộng vào thời gian khởi động. Kết nối mạng, nạp dữ liệu lớn — nên làm lười hoặc bất đồng bộ.
Đừng dựa vào thứ tự giữa các bean. Spring bảo đảm phụ thuộc của bạn sẵn sàng trước, nhưng không bảo đảm thứ tự giữa hai bean độc lập. Cần thứ tự thì dùng @DependsOn, hoặc tốt hơn là làm cho nó không cần thứ tự.
InitializingBean và DisposableBean là hai interface cũ làm cùng việc. Đừng dùng — chúng buộc lớp của bạn phụ thuộc vào Spring, còn chú thích thì không.
Nếu chỉ soi một thứ sau bài này, điểm mặt những cái cốc bị giữ lại, trong ba mươi giây:
grep -rn '@Scope' --include='*.java' src/main
Với mỗi kết quả không phải singleton, kiểm xem nó được tiêm vào đâu. Nếu bean chứa nó là singleton và không có ObjectProvider hay proxy, bạn vừa tìm ra một bean prototype chỉ được tạo một lần.
Mẫu số chung
Khi một đối tượng giữ một đối tượng khác mà hai bên có vòng đời khác nhau, vòng đời của kẻ giữ thắng — một đối tượng sống lâu bắt lấy phụ thuộc của nó đúng một lần lúc dựng, nên tiêm một thứ mới-mỗi-lần vào một thứ sống-mãi thì bạn lặng lẽ chỉ có đúng một. Cái lệch-vòng-đời này là một hiểm hoạ DI có tên ở mọi nơi: .NET gọi nó là "captive dependency" và container còn phát hiện việc tiêm một service scoped vào một singleton; Dagger và Angular cùng luật. Thuốc chữa là tiêm một cách để TẠO, không phải một thực thể — ObjectProvider, một Provider<T>, một hàm factory — và bản năng là: khớp vòng đời, và khi một thứ sống-lâu thật sự cần những thứ sống-ngắn, hãy đưa nó một cái factory, không phải một đối tượng đã bắt cứng.
Điều thứ hai: một thực thể dùng chung thì phải phi trạng thái, vì "dùng chung" nghĩa là "phục vụ mọi người gọi và mọi luồng cùng lúc" — luật singleton-phi-trạng-thái chính là hiểm hoạ trạng-thái-chung-ghi-được của cả chặng đồng thời (một bộ đếm chung cần khoá). Và hãy ưu tiên vòng đời đơn giản nhất mà đủ dùng: không phải thứ gì cũng cần là bean của framework với một scope kỳ lạ — đôi khi một new trơn mới là câu trả lời trung thực, còn scope request/session lén đưa vào trạng thái ẩn (dựng trên ThreadLocal) mà lặng lẽ không băng qua nổi một cú nhảy luồng. Mặc định chọn chung-phi-trạng-thái hoặc cục-bộ-trơn, coi một scope không-phải-singleton là một mùi cần biện minh, và đừng bao giờ để một tiện lợi che mất trạng thái nằm ở đâu và sống bao lâu.
Ngày mai: cấu hình ứng dụng — application.yml, thứ tự ưu tiên và profile.