Dependency Injection 筆記 (2)
续上集,接着要说明如何运用 DI 来让刚才的范例程序具备执行时期切换实现类型的能力。
(本文摘自電子書《.NET 依賴注入》)
入门范例—DI 版本
为了让 AuthenticationService 类型能够在执行时期才决定要使用 EmailService 还是 ShortMessageService 来发送验证码,我们必须对这些类型动点小手术,把它们之间原本紧密耦合的关系松开——或者说「解耦合」。有一个很有效的工具可以用来解耦合:接口(interface)。
说得更明白些,原本 AuthenticationService 是相依于特定实现类型来发送验证码(如 EmailService),现在我们要让它相依于某个接口,而此接口会定义发送验证码的工作必须包含那些操作。由于接口只是一份规格,并未包含任何实现,故任何类型只要实现了这份规格,便能够与 AuthenticationService 衔接,完成发送验证码的工作。有了中间这层接口,开发人员便能够「针对接口、而非针对实现来编写程序。」(program to an interface, not an implementation)3,使应用程序中的各部组件保持「有点黏、又不会太黏」的适当距离,从而达成宽松耦合的目标。
提炼接口(Extract Interface)
开始动手修改吧!首先要对 EmailService 和 ShortMessageService 进行抽象化(abstraction),亦即将它们的共通特性抽离出来,放在一个接口中,使这些共通特性成为一份规格,然后再分别由具象类型来实现这份规格。以下代码是重构之后的结果,包含一个接口,两个实现类型。我在个别的 Send 方法中使用 Console.WriteLine 方法来输出不同的讯息字符串,方便观察实验结果(此范例是个 Console 类型的应用程序项目)。
interface IMessageService { void Send(User user, string msg); } class EmailService : IMessageService { public void Send(User user, string msg) { // 发送电子邮件给指定的 user (略) Console.WriteLine("发送电子邮件给用户,讯息内容:" + msg); } } class ShortMessageService : IMessageService { public void Send(User user, string msg) { // 发送短信给指定的 user (略) Console.WriteLine("发送短信给用户,讯息内容:" + msg); } }
看图可能会更清楚些:
图 1-2:抽离出共通接口之后的类型图 |
接口抽离出来之后,如先前提过的,AuthenticationService 就可以依赖此接口,而不用再依赖特定实现类型。为了方便比对差异,我将修改前后的代码都一并列出来:
class AuthenticationService { // 原本是这样: private ShortMessageService msgService; public AuthenticationService() { this.msgSevice = new ShortMessageService(); } // 現在改成这样: private IMessageService msgService; public AuthenticationService(IMessageService service) { this.msgService = service; } }
修改前后的差异如下:
- 私有成员 msgService 的类型:修改前是特定类型(EmailService 或 ShortMessageService),修改后是 IMessageService 接口。
- 构造函数:修改前是直接建立特定类型的实例,并将对象参考指定给私有成员 msgService; 修改后则需要由外界传入一个 IMessageService 接口参考,并将此参考指定给私有成员 msgService。
控制反轉(IoC)
现在 AuthenticationService 已经不依赖特定实现了,而只依赖 IMessageService 接口。然而,接口只是规格,没有实现,亦即我们不能这么写(无法通过编译):
IMessageService msgService = new IMessageService();
那么对象从何而来呢?答案是由外界通过 AuthenticationService 的构造函数传进来。请注意这里有个重要意涵:非 DI 版本的 AuthenticationService 类型使用 new 运算符来建立特定讯息服务的对象,并控制该对象的生命周期;DI 版本的 AuthenticationService 则将此控制权交给外层调用端(主程序)来负责——换言之,依赖性被移出去了,「控制反转了」。
最后要修改的是主程序(MainApp):
class MainApp { public void Login(string userId, string pwd, string messageServiceType) { IMessageService msgService = null; // 用字符串比对的方式来决定该建立哪一种讯息服务对象。 switch (messageServiceType) { case "EmailService": msgService = new EmailService(); break; case "ShortMessageService": msgService = new ShortMessageService(); break; default: throw new ArgumentException("无效的讯息服务类型!"); } var authService = new AuthenticationService(msgService); // 注入相依对象 if (authService.TwoFactorLogin(userId, pwd)) { // 此处没有变动,故省略. } } }
现在主程序会负责建立讯息服务对象,然后在建立 AuthenticationService 对象时将讯息服务对象传入其构造函数。这种由调用端将相依对象通过构造函数注入至另一个对象的作法是 DI 的一种常见写法,而这写法也有个名称,叫做「构造函数注入」(Constructor Injection)。「构造函数注入」是实现 DI 的一种方法,第 2 章会进一步介绍。
现在各类型之间的依赖关系如下图所示。请与上一集的第一张图比较一下两者的差异(为了避免图中箭头过于复杂交错,我把无关紧要的配角 User 类型拿掉了) 。
圖 1-3:改成 DI 版本之后的类型依赖关系图 |
你会发现,上一集的图中的依赖关系,是上层依赖下层的方式;或者说,高阶模块依赖低阶模块。这只符合了先前提过的 S.O.L.I.D. 五项原则中的「依赖倒置原则」(Dependency Inversion Principle;DIP)的其中一小部分的要求。DIP 指的是:
- 高阶模块不应依赖低阶模块;他们都应该依赖抽象层(abstractions)。
- 抽象层不应依赖实现细节;实现细节应该依赖抽象层。
而从图 1-3 可以发现,DI 版本的范例程序已经符合「依赖倒置原则」。其中的 IMessageService 接口即属于抽象层,而高阶模块 AuthenticationService 和低阶模块皆依赖中间这个抽象层。
此 DI 版本的范例程序有个好处,即万一将来使用者又提出新的需求,希望传送验证码的方式除了 e-mail 和简讯之外,还要增加移动设备平台的信息推送服务(push notification),以便将验证码推送至行动 app。此时只要加入一个新的类型(可能命名为 PushMessageService),并让此类型实现 IMessageService,然后稍微改一下 MainApp 便大致完工,AuthenticationService 完全不需要修改。简单地说,应用程序更容易维护了。
当然,这个范例的程序写法还是有个缺点:它是用字符串比对的方式来决定该建立哪一种讯息服务对象。想象一下,如果欲支持的讯息服务类型有十几种,那个 switch...case 区块不显得太冗长累赘吗?如果有一个专属的对象能够帮我们简化这些类型对应以及建立对象的工作,那就更理想了。这个部分会在第 3 章〈DI 容器〉中进一步说明。
何时该用 DI?
一旦你开始感受到宽松耦合的好处,在设计应用程序时,可能会倾向于让所有类型之间的耦合都保持宽松。换言之,碰到任何需求都想要先定义接口,然后通过依赖注入的方式(例如先前范例中使用的「构造函数注入」)来建立对象之间的依赖关系。然而,天下没有白吃的午餐,宽松耦合也不例外。每当你将类型之间的依赖关系抽离出来,放到另一个抽象层,再于特定时机注入相依对象,这样的动作其实多少都会产生一些额外成本。不管三七二十一,将所有对象都设计成可任意替换、随时插拔,并不是个好主意。
以 .NET 基础类库(Base Class Library;简称 BCL)为例,此类库包含许多组件,各组件又包含许多现成的类型,方便我们直接取用。每当你在程序中使用 BCL 的类型,例如 String、DateTime、Hashtable 等等,就等于在程序中加入了对这些类型的依赖。此时,你会担心有一天自己的程序可能需要把这些 BCL 类型替换成别的类型吗?如果是从网络上找到的开放源代码呢?答案往往取决于你对于特定类型/组件是否会经常变动的信心程度;而所谓的「经常变动」,也会依应用程序的类型、大小而有不同的定义。
相较于其他在网络上找到或购买的第三方组件,我想多数人都会觉得 .NET BCL 里面的类型应该会相对稳定得多,亦即不会随便改来改去,导致现有程序无法编译或正常执行。这样的认知,有一部分可能来自于我们对该类型的提供者(微软)有相当程度的信心,另一部分则是来自以往的经验。无论如何,在为应用程序加入第三方组件时,最好还是审慎评估之后再做决定。
以下是几个可能需要使用或了解 DI 技术的场合:
- 如果你正在设计一套框架(framework)或可重复使用的类库,DI 会是很好用的技术。
- 如果你正在开发应用程序,需要在执行时其动态加载、动态切换某些组件,DI 也能派上用场。
- 希望自已的代码在将来需求变动时,能够更容易替换掉其中一部份不稳定的组件(例如第三方组件,此时可能搭配 Adapter 模式使用)。
- 你正在接手维护一个应用程序,想要在重构(refactor)代码的时候降低对某些组件的依赖,方便测试以及让代码更好维护。
以下是一些可能不适合、或应该更谨慎使用 DI 的场合:
- 在小型的、需求非常单纯的应用程序中使用 DI,恐有杀鸡用牛刀之嫌。
- 在大型且复杂的应用程序中,如果到处都是宽松耦合的接口、到处都用 DI 注入相依对象,对于后续接手维护的新进成员来说可能会有点辛苦。在阅读代码的过程中,他可能会因为无法确定某处使用的对象究竟是哪个类型而感到挫折。比如说,看到代码中调用 IMessageService 接口的 Send 方法,却没法追进去该方法的实现来了解接着会发生什么事,因为接口并没有提供任何实现。若无人指点、也没有文件,每次碰到疑问时就只能以单步调试的方式来了解程序实际运行的逻辑,这确实需要一些耐心。
- 对老旧程序进行重构(refactoring)时,可能会因为现有设计完全没考虑宽松耦合,使得引入 DI 困难重重。
总之,不加思索地使用任何技术总是不好的;没有银弹。
posted on 2014-08-12 22:10 MichaelTsai 阅读(449) 评论(1) 编辑 收藏 举报