自己动手实现依赖注入






4.52/5 (17投票s)
依赖注入框架是糟糕的做法。那么我们如何才能继续使用依赖注入呢?自己动手吧!
引言
一段时间以来,IOC 和依赖注入被视为最重要的编码设计模式。你在每次代码面试中都会遇到关于它们的问题。开发者会在简历上列出他们使用过的 DI 框架。招聘人员会询问你是否有特定 DI 框架的经验。大多数人的观点是,为了遵循这个非常重要的代码架构原则,你需要使用某种框架。令人遗憾的是,大多数人都错了。这个简单而常被误解的事实是,实现 DI 和使用 DI 框架并非一回事。
推广这种观点非常困难。我发现无论我在做什么项目,都必须一遍又一遍地解释自己。有经验的工程师会像看疯子一样看着我。你可能会觉得我告诉他们他们活在矩阵里。但我不是唯一一个,还有其他人。一小群坚定的人拒绝接受这种潮流。
如果你是一名程序员,凭直觉就觉得 DI 框架有问题,那么这篇文章就是为你准备的。我将为你介绍我个人实现所有实际 DI 方面(至少是我遇到过并认为必要的方面)的配方。请注意,阅读后你可能会感到一丝震惊。这个解决方案是如此简单,你可能会忍不住想知道围绕 DI 框架的所有炒作究竟是怎么来的。
背景
以下简短的几点几乎总结了你不使用 DI 框架的理由:
- 依赖注入用于鼓励代码的松耦合。讽刺的是,使用 DI 框架会将你的整个应用程序耦合到框架本身。
- 大量使用 DI 框架可能会导致难以解决的性能问题。对象之间意想不到的关联可能导致整个对象链被不必要地创建。
- 使用 DI 框架并不能保证“连接”对象的正确性。检查“连接”是否正确的唯一方法是在运行时,这会浪费宝贵的开发时间。
- 一些 DI 框架使用注解,将行为引入了本不该有的接口和类中,违反了一些 OOP 原则和最佳实践。
- 一些 DI 框架会推广其他反模式,如 setter 注入。
- 手动实现 DI 在编译时利用了自然的类型安全。
- 手动 DI 比使用框架性能更高。
- 使用手动 DI,你可以完全控制你的应用程序、对象创建和生命周期管理,并可以使用标准开发工具进行调试和测试。
更多阅读,你可能会发现以下在线文章很有用:
- http://www.yegor256.com/2014/10/03/di-containers-are-evil.html
- http://stackoverflow.com/questions/2407540/what-are-the-downsides-to-using-dependency-injection
- https://sites.google.com/site/unclebobconsultingllc/blogs-by-robert-martin/dependency-injection-inversion
- https://drew.thecsillags.com/Dependency-Injection/
- https://dzone.com/articles/how-i-learned-avoid-magical
代码
让我们列出 DI 框架提供的我们需要实现的重要功能:
- 将接口映射到其合适的具体类实现。
- 创建实例,并为它们提供所有必需的依赖项。
- 控制对象生命周期。将对象绑定到应用程序范围,或使其成为单例。
- 提供一个占位符,用于正确清理和处理资源。
考虑以下典型的类和接口:
public interface DB extends AutoCloseable {
List<Record> fetchRecords(String someParameter);
}
public class DBImpl implements DB {
private final String connectionString;
public DBImpl(String connectionString) {
this.connectionString = connectionString;
}
@Override
public List<Record> fetchRecords(String someParameter) {
// fetch records from database, transform to POJOSs and return results
return new ArrayList<>();
}
@Override
public void close() throws Exception {
// typically close the db connection here
}
}
public interface ReportService {
void generateReport(String filename,String otherParameter);
}
public class ReportServiceImpl implements ReportService {
private final DB db;
public ReportServiceImpl(DB db) {
this.db=db;
}
@Override
public void generateReport(String filename, String otherParameter) {
List<Record> records=db.fetchRecords(otherParameter);
// create the report from the records
}
}
我们手动实现 DI 的第一个尝试是:
public interface DIYDI1 {
DB getDB();
ReportService getReportService();
}
public class DIYDI1Impl implements DIYDI1 {
private static DIYDI1 instance=new DIYDI1Impl();
public static DIYDI1 getInstance() {
return instance;
}
@Override
public DB getDB() {
return new DBImpl("db connection string");
}
@Override
public ReportService getReportService() {
return new ReportServiceImpl(getDB());
}
}
要获取 ReportService
的实例,我们可以简单地使用:
DIYDI1Impl.getInstance().getReportService();
这清楚地满足了要求 1 和 2。然而,这个解决方案非常不实用,因为每次需要一个对象时,它都会从头开始重新创建,包括它所有的依赖项。例如,如果我们有另一个使用数据库的服务,就无法在服务之间共享数据库连接。如果创建连接成本很高,可能会导致严重的性能损失。
为了克服这个限制,我们需要引入某种对象生命周期管理。我们将考虑支持两种生命周期:
- 单例
- 作用域
单例是一个熟悉的生命周期。通常它们在应用程序启动时初始化。有时会使用惰性初始化,在第一次访问单例时。
作用域生命周期有点棘手。它只是在某个应用程序事件的开始和结束之间执行的一段代码。一些作用域的例子是:处理 HTTP 请求、Quartz 作业或从 JMS 队列读取消息。我们需要能够将对象的生命周期绑定到作用域,这样它在作用域内最多只会被创建一次。
这对于数据库连接来说很常见,因为创建数据库连接相对昂贵。在整个作用域中重复使用同一个连接可以显著提高性能。在作用域结束时关闭连接也很常见。我们的解决方案需要支持这一点。它应该跟踪在整个作用域内创建的所有对象,并在作用域结束时清理或关闭资源。
另一个有趣的作用域属性是,每个作用域通常只涉及一个线程。在我们的实现中,我们将在每个作用域开始时创建一个新的 DIYDI 对象。它应该只由作用域线程访问,因此不需要是线程安全的。
让我们在代码中添加更多服务示例:
public interface InMemoryDataService {
Record getData(String recordID);
}
public class InMemoryDataServiceImpl implements InMemoryDataService {
private final ConcurrentMap <String, Record> _cachedRecords;
public InMemoryDataServiceImpl(DB db) {
_cachedRecords=new ConcurrentHashMap<>();
//this is where we would access the database and fill our map with cached records
//small note about this inMemory data service. Only expose Record Object to client
// code if they are immutable
}
@Override
public Record getData(String recordID) {
return _cachedRecords.get(recordID);
}
}
public interface AlertService {
void sendAlert(String customerID,String recordID);
}
public class AlertServiceImpl implements AlertService {
private final DB db;
private final InMemoryDataService inMemoryDataService;
public AlertServiceImpl(DB db,InMemoryDataService inMemoryDataService) {
this.db=db;
this.inMemoryDataService=inMemoryDataService;
}
@Override
public void sendAlert(String customerID, String recordID) {
Record record=inMemoryDataService.getData(recordID);
Customer customer=getCustomer(customerID,db);
sendAlert(customer, record);
}
private Customer getCustomer(String customerID, DB db2) {
//use the database to retrieve customer data
return null;
}
private void sendAlert(Customer customer, Record record) {
//now that we have actual customer data and record data we can send it to the
//customer
}
}
另外,通过声明一个配置接口来保存我们的连接字符串,为数据库访问方法增加一个抽象层。然后,我们的数据库访问类可以依赖于配置来获取连接字符串。
public interface Configuration {
String getConnectionString();
}
public class ConfigurationImpl implements Configuration {
public ConfigurationImpl(Object externalArgs) {
//read the connection string from a file or command line
}
private String connectionString;
@Override
public String getConnectionString() {
return connectionString;
}
}
public class DBImpl implements DB {
public DBImpl(Configuration configuration) {
this(configuration.getConnectionString());
}
}
最后,我们修订后的 DIYDI 接口和实现,包括对象生命周期管理:
public interface DIYDI2 extends AutoCloseable {
void initSingletons(Object externalArgs);
Configuration getConfiguration();
DB getDB();
ReportService getReportService();
InMemoryDataService getInMemoryDataService();
AlertService getAlertService();
}
public class DIYDI2Impl implements DIYDI2 {
private static Configuration configuration;
private static InMemoryDataService inMemoryDataService;
private DB db;
private ReportService reportSerivce;
private AlertService alertService;
@Override
public void initSingletons(Object externalArgs) {
configuration=new ConfigurationImpl(externalArgs);
inMemoryDataService=new InMemoryDataServiceImpl(getDB());
}
@Override
public Configuration getConfiguration() {
return configuration;
}
@Override
public InMemoryDataService getInMemoryDataService() {
return inMemoryDataService;
}
@Override
public DB getDB() {
if (db==null)
db=new DBImpl(getConfiguration());
return db;
}
@Override
public ReportService getReportService() {
if (reportSerivce==null)
reportSerivce=new ReportServiceImpl(getDB());
return reportSerivce;
}
@Override
public AlertService getAlertService() {
if (alertService==null)
alertService=new AlertServiceImpl(getDB(), getInMemoryDataService());
return alertService;
}
@Override
public void close() {
if (db!=null)
try {
db.close();
} catch (Exception e) {
e.printStackTrace();
}
}
}
我们可以看到,单例服务由一个静态字段支持。它们将在应用程序启动期间通过 initSingletons
方法进行初始化。作用域服务由成员字段支持,这确保了服务在作用域内只会创建一次。它们仅在作用域内使用时才会创建。DIYDI
对象还实现了 AutoCloseable
。close
方法将在作用域结束时调用,如果数据库连接已打开,它将关闭数据库连接,并可能释放其他作用域资源。
这是一个类如何在单个作用域内使用的示例:
try (DIYDI2 diydi2=new DIYDI2Impl()) {
ReportService reportService=diydi2.getReportService();
reportService.generateReport("SomeFile.txt", "paramValue");
AlertService alertService=diydi2.getAlertService();
alertService.sendAlert("YourName", "1234");
}
在此作用域内,ReportService
和 AlertService
将共享数据库连接。通常情况下,你不会这样使用 DIYDI
对象。细节取决于你应用程序的自然作用域和暴露的 API。通常会有一个基类,可以由应用程序的自然作用域工厂实例化。它将负责创建和关闭 DIYDI
对象。然后你可以继承它或将其与其它执行器类组合来控制应用程序的流程。
结论
希望到目前为止,你已经体会到自己实现依赖注入是多么容易。在软件开发中,一个重要的指导原则是“保持简单”。我在这里展示的配方只使用了 Java 语言的自然能力。这比引入一个用于创建所有对象的框架要简单得多。它还消除了当我们不能总是完全理解我们正在使用的 DI 框架时可能发生的复杂问题,这些问题通常在开发周期的后期才被发现。但最重要的是,它将应用程序的控制权交还给了它应该属于的地方:开发者。