我在写《Dependency Injection in .NET》时经常碰到的一个反应是“你怎么把依赖注入写成一整本书?”这种难以置信的反应是很自然的,如果你觉得依赖注入的主要模式(构造函数注入)非常容易理解。
虽然这个主要模式易于理解,却很难成功实现依赖注入,因为这个机制只是一个更大的上下文里的一部分。DI 是对控制反转(IoC)原则的应用,想要成功实现IoC,你就要把你的思维逆转过来。这篇文章勾画了成功实现DI 所需的心智模型。
松散耦合:依赖注入 vs. 服务定位器
如果你不理解DI 的目的,就很容易把它实现错。这是我最近看到的一个例子:
private readonly ILog log;
public MyConsumer(ILog log)
{
this.log = log ?? LogManager.GetLogger(“My”);
}
从封装的角度来看,这个方案的主要问题是 MyConsumer 类好像无法确定它是否对日志程序这个依赖的创建拥有控制权。虽然这个示例很简单,但如果 LogManager 返回的 ILog 实例包装了一个非托管资源,需要在不使用的时候释放掉,那就有可能演变成一个问题。
这样的实现之所以会出现,是因为开发者把全部精力放在让 MyConsumer 可以进行单元测试。这样做的理由是开发者只想在单元测试的时候可以替换 ILog,其它情况应该使用 LogManager 返回的实例。
这实际上就是 Bastard Injection 反模式。其中一个潜在问题是它很容易违反里氏代换原则,因为某个特定的实现得到了特殊对待。
DI(Dependency Injection,依赖注入)的目的比起单纯地协助进行单元测试要广泛的多。它的目的是实现松散耦合,以便提升整个解决方案的可维护性。(如果你想知道为什么松散耦合能够增加可维护性,我的书的第一章讨论了这个话题,你可以免费下载试读。)
松散耦合可被概述为基于接口而不是具体实现进行编程的思想。但是,因为接口没有构造函数,如何创建那些接口的实例马上就成了一个问题。
根据你的编程方式,有两种完全不同的方案可以获取接口的实例:
对于前面的示例,很容易就会演变成服务定位器(Service Locator)反模式,像这样:
<span color="#0000ff">public</span> MyConsumer() { <span color="#0000ff">this</span>.log = <span color="#0080ff">Locator</span>.Resolve<<span color="#0080ff">ILog</span>>(); }
除了服务定位器的其它问题,这种方案的问题还在于 LogManager.GetLogger(“My”) 方法调用所需的参数丢失了。假设其它 consumer 对象需要的日志程序是通过不同的参数实例化的,那么这个版本的服务定位器就无法工作了。
这通常会导致定位器的 Resolve 方法有一个或多个重载版本,以便向服务定位器提供上下文信息。这样,离违反里氏代换原则就不远了。
DI 提供了一个更好的方案:
<span color="#0000ff">private readonly</span> <span color="#0080ff">ILog</span> log; <p><span color="#0000ff">public</span> MyConsumer(<span color="#0080ff">ILog</span> log)<br></br>{<br></br> <span color="#0000ff">if</span> (log == <span color="#0000ff">null</span>)<br></br> <span color="#0000ff">throw new</span> <span color="#0080ff">ArgumentNullException</span>(<span color="#c0504d">"log"</span>);<br></br> <span color="#0000ff">this</span>.log = log;<br></br>}</p>
这是控制反转的纯粹形式。ILog 的任何实现都能接受,同时通过条件语句保证这个实例不为 null。这跟使用服务定位器相反,上下文没有在构建对象图的时候丢失。
<span color="#0000ff">var</span> consumer = <span color="#0000ff">new</span> <span color="#0080ff">MyConsumer</span>( <span color="#0080ff">LogManager</span>.GetLogger(<span color="#c0504d">"My"</span>));
在创建 MyConsumer 的实例时,负责创建的代码知道这个特定的 consumer 对象使用哪个实现 ILog 接口的类,因此可以根据上下文提供正确的实现。
DI 和服务定位器是实现松散耦合的两种互斥方案。技术上,两种都是可行的,但 DI 没有服务定位器的缺点。
DI 的唯一缺点是它不像服务定位器那样易于理解。想要成功实现 DI,你需要克服一些障碍。
通往依赖注入的崎岖之路
学习 DI 的其中一个挑战,也是你首先碰到的最难的问题:如何获取一个接口的实例?好消息是一旦你理解构造函数注入只是简单地通过构造函数请求一个实例,你就跨越了这个最艰难的障碍。
接下来的挑战比较容易解决,往后一个更加容易。我喜欢把这些挑战想象成你需要攀越的山。第一个又高又陡,但下一个会比较容易,从那之后很快就会变得平坦:
在成功实现 DI 的路上,你的第一个障碍是理解通过构造函数注入把构建 consumer 对象及其依赖的责任委托给第三方。这个第三方就是对象组合的根(Composition Root),它是应用程序里的一个独立的点,整个对象图都是在这里构建的。因为对象组合的根负责构建整个对象图,所以它掌握了整个上下文,这使它可以对谁依赖谁这个问题做出明智的决定。
这可能吓跑了一些开发者,因为他们害怕这会导致性能问题,但事实并非如此。
对于大多数人来说,第二个障碍可能是某个依赖的确定需要一个运行时的值。这种情况通常发生在某个依赖要等用户在用户界面上做出特定的选择时才能确定。对于这种情况,抽象工厂通常是一个解决方案。
根据我的经验,最先的两个障碍是最难克服的。其它挑战也会出现,但一般都是个别现象。在我的书的第六章里,我收集了人们可能碰到的常见问题,以及解决它们的办法。
不管怎样,一旦你对DI 形成了正确的心智模型,任何挑战都能轻易解决的。
关于作者
Mark Seemann 是 AutoFixture 的创作者,也是《Dependency Injection in .NET》的作者。他是一个专业的软件开发者和架构师,他住在丹麦的哥本哈根,目前是一家丹麦咨询公司Commentor 的软件架构师。他喜欢阅读、画画、弹吉他、好酒以及美食。
评论