Trong quá trình phát triển phần mềm bằng Java, việc xử lý các phụ thuộc giữa các thành phần là điều không thể tránh khỏi — và đôi khi, nó khiến mã nguồn trở nên rối rắm, khó kiểm soát. Đó là lúc Dependency Injection (DI) phát huy tác dụng. Đây không chỉ là một kỹ thuật thiết kế phần mềm, mà còn là một tư duy giúp bạn tách biệt các mối quan hệ phức tạp trong hệ thống, từ đó nâng cao tính linh hoạt, dễ kiểm thử và dễ bảo trì. Bằng cách chuyển việc khởi tạo đối tượng từ thời điểm biên dịch sang thời điểm chạy, DI giúp bạn viết mã “sạch” hơn, chuyên nghiệp hơn, và quan trọng nhất là dễ mở rộng theo thời gian.
Dependency Injection trong Java
Dependency Injection có thể khó hiểu nếu chỉ tiếp cận qua lý thuyết, vì vậy hãy bắt đầu bằng một ví dụ đơn giản và sau đó áp dụng mô hình thiết kế này để đạt được sự tách biệt rõ ràng giữa các thành phần, cũng như khả năng mở rộng ứng dụng. Giả sử chúng ta sử dụng EmailService
để gửi email. Thông thường, cách triển khai sẽ như sau:
package com.journaldev.java.legacy;
public class EmailService {
public void sendEmail(String message, String receiver){
//logic to send email
System.out.println("Email sent to "+receiver+ " with Message="+message);
}
}
Lớp EmailService
chứa logic riêng gửi email tới người nhận. Mã nguồn của ứng dụng như sau:
package com.journaldev.java.legacy;
public class MyApplication {
private EmailService email = new EmailService();
public void processMessages(String msg, String rec){
//do some msg validation, manipulation logic etc
this.email.sendEmail(msg, rec);
}
}
Lớp MyApplication
sẽ sử dụng EmailService
để gửi email:
package com.journaldev.java.legacy;
public class MyLegacyTest {
public static void main(String[] args) {
MyApplication app = new MyApplication();
app.processMessages("Hi Pankaj", "pankaj@abc.com");
}
}
Thoạt nhìn, cách triển khai trên có vẻ không có vấn đề gì, nhưng thực tế có một số hạn chế nhất định:
- Phụ thuộc mã hóa cứng: Lớp
MyApplication
chịu trách nhiệm khởi tạoEmailService
và sử dụng nó. Điều này làm cho mã nguồn khó thay đổi và dẫn đến sự phụ thuộc mã hóa cứng. Nếu chúng ta muốn chuyển sang một số dịch vụ email nâng cao khác trong tương lai, sẽ cần phải thay đổi mã trong lớpMyApplication
. Kết quả khiến ứng dụng của chúng ta khó mở rộng và nếu dịch vụ email được sử dụng trong nhiều lớp thì điều đó sẽ còn khó hơn nữa. - Giảm tính mở rộng: Nếu chúng ta muốn mở rộng ứng dụng của mình để cung cấp tính năng nhắn tin bổ sung, chẳng hạn như tin nhắn SMS hoặc Facebook thì chúng ta sẽ cần phải viết một ứng dụng khác cho tính năng đó. Bởi nó liên quan đến việc thay đổi mã trong các lớp ứng dụng và trong các lớp máy khách khác.
- Khó kiểm thử: Do
MyApplication
đang trực tiếp khởi tạoEmailService
, không có cách nào chúng ta có thể mô phỏng các đối tượng này trong các lớp kiểm thử của mình.
Nhiều dev còn thường bỏ tạo phiên bản dịch vụ email khỏi lớp MyApplication
bằng cách có một hàm tạo yêu cầu dịch vụ email làm đối số.
package com.journaldev.java.legacy;
public class MyApplication {
private EmailService email = null;
public MyApplication(EmailService svc){
this.email=svc;
}
public void processMessages(String msg, String rec){
//do some msg validation, manipulation logic etc
this.email.sendEmail(msg, rec);
}
}
Tuy nhiên, thiết kế này là không tối ưu vì trong trường hợp này ta đang yêu cầu các ứng dụng khách hoặc các lớp kiểm tra khởi tạo dịch vụ email. Giờ hãy xem cách có thể áp dụng mô hình DI để giải quyết tất cả các vấn đề trên. Dependency Injection trong Java yêu cầu ít nhất 3 thành phần:
- Thành phần dịch vụ: Các dịch vụ nên được thiết kế dựa trên các lớp cơ sở hoặc giao diện ứng dụng để định nghĩa hành vi dịch vụ.
- Thành phần khách hàng: Viết code dựa trên giao diện dịch vụ.
- Lớp injector: Khởi tạo các dịch vụ và cung cấp cho thành phần khách hàng.
Các thành phần dịch vụ DI trong Java
Trong trường hợp này, chúng ta có thể sử dụng MessageService
để khai báo triển khai dịch vụ.
package com.journaldev.java.dependencyinjection.service;
public interface MessageService {
void sendMessage(String msg, String rec);
}
Giả sử có dịch vụ Email và SMS triển khai các giao diện trên.
package com.journaldev.java.dependencyinjection.service;
public class EmailServiceImpl implements MessageService {
@Override
public void sendMessage(String msg, String rec) {
//logic to send email
System.out.println("Email sent to "+rec+ " with Message="+msg);
}
}
package com.journaldev.java.dependencyinjection.service;
public class SMSServiceImpl implements MessageService {
@Override
public void sendMessage(String msg, String rec) {
//logic to send SMS
System.out.println("SMS sent to "+rec+ " with Message="+msg);
}
}
Mô hình Dependency Injection hiện giờ đã sẵn sàng và có thể bắt đầu viết lớp người dùng.
Người dùng dịch vụ Dependency Injection (Thành phần khách hàng)
Không bắt buộc phải có giao diện cơ sở cho các lớp người dùng nhưng cần có một giao diện Consumer
đã khai báo hành vi cho lớp người dùng.
package com.journaldev.java.dependencyinjection.consumer;
public interface Consumer {
void processMessages(String msg, String rec);
}
Lớp người dùng được triển khai như bên dưới.
package com.journaldev.java.dependencyinjection.consumer;
import com.journaldev.java.dependencyinjection.service.MessageService;
public class MyDIApplication implements Consumer{
private MessageService service;
public MyDIApplication(MessageService svc){
this.service=svc;
}
@Override
public void processMessages(String msg, String rec){
//do some msg validation, manipulation logic etc
this.service.sendMessage(msg, rec);
}
}
Lưu ý rằng lớp ứng dụng của chúng ta chỉ sử dụng dịch vụ. Nó không khởi tạo dịch vụ dẫn đến “phân tách các mối quan tâm” tốt hơn. Ngoài ra, việc sử dụng giao diện dịch vụ cho phép chúng ta dễ dàng kiểm tra ứng dụng bằng cách mô phỏng MessageService và liên kết các dịch vụ tại thời điểm chạy thay vì thời gian biên dịch. Bây giờ đã sẵn sàng để viết các lớp injector trong mô hình Dependency Injection, từ đó khởi tạo dịch vụ và cả các lớp người dùng.
Lớp Injector trong Java DI
Hãy nhận diện MessageServiceInjector
với phương thức khai báo trả kết quả về lớp Consumer
.
package com.journaldev.java.dependencyinjection.injector;
import com.journaldev.java.dependencyinjection.consumer.Consumer;
public interface MessageServiceInjector {
public Consumer getConsumer();
}
Tại đây với mỗi dịch vụ, chúng ta sẽ phải tạo các lớp injector như bên dưới.
package com.journaldev.java.dependencyinjection.injector;
import com.journaldev.java.dependencyinjection.consumer.Consumer;
import com.journaldev.java.dependencyinjection.consumer.MyDIApplication;
import com.journaldev.java.dependencyinjection.service.EmailServiceImpl;
public class EmailServiceInjector implements MessageServiceInjector {
@Override
public Consumer getConsumer() {
return new MyDIApplication(new EmailServiceImpl());
}
}
package com.journaldev.java.dependencyinjection.injector;
import com.journaldev.java.dependencyinjection.consumer.Consumer;
import com.journaldev.java.dependencyinjection.consumer.MyDIApplication;
import com.journaldev.java.dependencyinjection.service.SMSServiceImpl;
public class SMSServiceInjector implements MessageServiceInjector {
@Override
public Consumer getConsumer() {
return new MyDIApplication(new SMSServiceImpl());
}
}
Bây giờ chúng ta hãy quan sát các ứng dụng khách sẽ sử dụng như thế nào với một chương trình đơn giản.
package com.journaldev.java.dependencyinjection.test;
import com.journaldev.java.dependencyinjection.consumer.Consumer;
import com.journaldev.java.dependencyinjection.injector.EmailServiceInjector;
import com.journaldev.java.dependencyinjection.injector.MessageServiceInjector;
import com.journaldev.java.dependencyinjection.injector.SMSServiceInjector;
public class MyMessageDITest {
public static void main(String[] args) {
String msg = "Hi Pankaj";
String email = "pankaj@abc.com";
String phone = "4088888888";
MessageServiceInjector injector = null;
Consumer app = null;
//Send email
injector = new EmailServiceInjector();
app = injector.getConsumer();
app.processMessages(msg, email);
//Send SMS
injector = new SMSServiceInjector();
app = injector.getConsumer();
app.processMessages(msg, phone);
}
}
Như bạn có thể thấy, các lớp ứng dụng chỉ chịu trách nhiệm sử dụng dịch vụ. Các lớp dịch vụ được tạo ra trong injector. Ngoài ra, nếu chúng ta phải mở rộng thêm ứng dụng của mình để cho phép nhắn tin trên Facebook, chúng ta sẽ chỉ phải viết các lớp dịch vụ và lớp injector.
Vì vậy, việc triển khai dependency injection đã giải quyết được vấn đề với các lớp phụ thuộc (dependency) được mã hóa cứng và giúp cho ứng dụng linh hoạt và dễ mở rộng hơn. Giờ thì chúng ta có thể dễ dàng kiểm tra lớp ứng dụng của mình như thế nào bằng cách mô phỏng các lớp injector và các lớp dịch vụ.
Kiểm thử Dependency Injection bằng JUnit
package com.journaldev.java.dependencyinjection.test;
import org.junit.After;
import org.junit.Before;
import org.junit.Test;
import com.journaldev.java.dependencyinjection.consumer.Consumer;
import com.journaldev.java.dependencyinjection.consumer.MyDIApplication;
import com.journaldev.java.dependencyinjection.injector.MessageServiceInjector;
import com.journaldev.java.dependencyinjection.service.MessageService;
public class MyDIApplicationJUnitTest {
private MessageServiceInjector injector;
@Before
public void setUp(){
//mock the injector with anonymous class
injector = new MessageServiceInjector() {
@Override
public Consumer getConsumer() {
//mock the message service
return new MyDIApplication(new MessageService() {
@Override
public void sendMessage(String msg, String rec) {
System.out.println("Mock Message Service implementation");
}
});
}
};
}
@Test
public void test() {
Consumer consumer = injector.getConsumer();
consumer.processMessages("Hi Pankaj", "pankaj@abc.com");
}
@After
public void tear(){
injector = null;
}
}
Tôi đang sử dụng các lớp ẩn danh để mô phỏng các lớp injector và lớp service, từ đó kiểm tra thiết bị của mình một cách dễ dàng. Tôi sử dụng JUnit 4 cho lớp kiểm tra ở trên, vì vậy hãy đảm bảo rằng nó nằm trong đường dẫn dự án của bạn.
Chúng tôi sử dụng các hàm tạo để inject các dependency trong các lớp ứng dụng, một cách khác là sử dụng phương thức setter để “tiêm” các lớp phụ thuộc (dependency) trong các lớp ứng dụng. Đối với phương thức setter, lớp ứng dụng của chúng tôi sẽ được triển khai như bên dưới.
package com.journaldev.java.dependencyinjection.consumer;
import com.journaldev.java.dependencyinjection.service.MessageService;
public class MyDIApplication implements Consumer{
private MessageService service;
public MyDIApplication(){}
//setter dependency injection
public void setService(MessageService service) {
this.service = service;
}
@Override
public void processMessages(String msg, String rec){
//do some msg validation, manipulation logic etc
this.service.sendMessage(msg, rec);
}
}
package com.journaldev.java.dependencyinjection.injector;
import com.journaldev.java.dependencyinjection.consumer.Consumer;
import com.journaldev.java.dependencyinjection.consumer.MyDIApplication;
import com.journaldev.java.dependencyinjection.service.EmailServiceImpl;
public class EmailServiceInjector implements MessageServiceInjector {
@Override
public Consumer getConsumer() {
MyDIApplication app = new MyDIApplication();
app.setService(new EmailServiceImpl());
return app;
}
}
Một trong những ví dụ tốt nhất về phương thức Setter Injection là giao diện Struts2 Servlet API Aware. Việc sử dụng các hàm tạo (Constructor Injection) hay dựa trên setter là quyết định thiết kế phụ thuộc vào yêu cầu của bạn.
Ví dụ, nếu ứng dụng của tôi không thể hoạt động nếu không có lớp dịch vụ thì tôi sẽ thích Constructor Injection hoặc ngược lại, tôi sẽ sử dụng Setter Injection để chỉ sử dụng khi thực sự cần thiết.
Dependency Injection trong Java là cách để đạt được Inversion of control (IoC) trong ứng dụng của chúng ta bằng cách di chuyển các đối tượng liên kết từ thời gian biên dịch sang thời gian chạy. Chúng ta cũng có thể đạt được IoC thông qua các kỹ thuật như Factory Pattern, Template Method Design Pattern, Strategy Pattern và Service Locator pattern.
Một số thư viện và Framework như Spring Dependency Injection, Google Guice và Java EE CDI có thể hỗ trợ triển khai DI thông qua việc sử dụng Java Reflection API và java annotation. Tất cả những gì chúng ta cần là chú thích trường, phương thức constructor hoặc setter và xác định cấu hình chúng trong các tệp xml hoặc lớp cấu hình.
Lợi ích và hạn chế của Dependency Injection
Lợi ích:
- Phân tách/Chia nhỏ các thành phần của ứng dụng thành các đơn vị độc lập giúp việc kiểm tra dễ dàng hơn
- Giảm boilerplate code vì việc tạo ra các biến phụ thuộc đã được injector thực hiện
- Kích hoạt những kết nối cần thiết chặt chẽ hơn giúp ứng dụng dễ dàng mở rộng
- Kiểm tra Unit dễ dàng hơn với các đối tượng mô phỏng
Hạn chế:
- Nếu lạm dụng DI, nó có thể dẫn đến các vấn đề khó bảo trì vì tác động của các thay đổi được biết đến khi chạy chương trình.
- Khó phát hiện lỗi biên dịch trong thời gian chạy chương trình vì Dependency injection tiềm ẩn các thành phần phụ thuộc.
Kết luận
Bài viết trên là tổng hợp tất cả về kỹ thuật phần mềm Dependency Injection trong Java. Đây là một kỹ thuật quan trọng trong lập trình hướng đối tượng, đóng vai trò tối ưu hóa việc quản lý và kiểm soát các phụ thuộc (dependencies) trong hệ thống. Bằng cách tách biệt các thành phần và cung cấp chúng một cách linh hoạt, DI giúp mã nguồn trở nên dễ bảo trì, mở rộng và kiểm thử hơn.
Tuy nhiên, việc áp dụng DI không chỉ đơn thuần là tuân theo một mô hình có sẵn mà đòi hỏi sự cân nhắc cẩn thận để đảm bảo rằng nó thực sự phù hợp với yêu cầu của dự án. Vì vậy, trước khi quyết định sử dụng DI, bạn cần đánh giá kỹ lưỡng những lợi ích và hạn chế để có thể tận dụng tối đa sức mạnh của kỹ thuật này trong phát triển phần mềm.