Dependency Injection 筆記 (1)

.NET 依賴注入》連載 (1)

本文从一个基本的问题开始,点出软件需求变动的常态,以说明为什么我们需要学习「依赖注入」(dependency injection;简称 DI)来改善设计的质量。接着以一个简单的入门范例来比较没有使用 DI 和改写成 DI 版本之后的差异,并讨论使用 DI 的时机。目的是让读者先对相关的基础概念有个概括的理解,包括可维护性(maintainability)、宽松耦合(loose coupling)、控制反转(inversion of control)、动态绑定、单元测试等等。


为什么需要依赖注入?

或许你也曾在某个网络论坛上看过有人提出类似问题:「如何利用字符串来来建立对象?」

欲了解此问题的动机与解法,得先来看一下平常的程序写法可能产生什么问题。一般来说,建立对象时通常都是用 new 运算符,例如:

ZipCompressor obj = new ZipCompressor();

上面这行代码的作用是建立一个 ZipCompressor 对象,或者说,建立 ZipCompressor 类型的实例(instance)。从类名不难看出,ZipCompressor 类型会提供压缩数据的功能,而且是采用 Zip 压缩算法。假如有一天,软件系统已经部署至客户端,后来却因为某种原因无法再使用 ZipCompressor 了(例如发现它有严重 bug 或授权问题),必须改用别的类型,比如说 RarCompressor 或 GZip。那么,项目中所有用到 ZipCompressor 的代码全都必须修改一遍,并且重新编译、测试,导致维护成本太高。

为了降低日后的维护成本,我们可以在设计软件时,针对「将来很可能需要替换的组件」,在程序中预留适度的弹性。简单地说,就是一种应付未来变化的设计策略。回到刚才的范例,如果改写成这样:

var className = ConfigurationManager.AppSettings["CompressorClassName"];
Type aType = Type.GetType(className);
ICompressor obj = (ICompressor) System.Activator.CreateInstance(aType);

亦即建立对象时,是先从应用程序配置文件中读取欲使用的类名,然后通过 Activator.CreateInstance 方法来建立该类型的实例,并转型成各压缩器共同实现的接口 ICompressor。于是,我们就可以将类名写在配置文件中:

    <appSettings>
        <add key="CompressorClassName" value="MyLib.ZipCompressor, MyLib" />
    <appSettings>

将来若需要换成其他压缩器,便无须修改和重新编译代码,而只要修改配置文件中的参数值,就能切换程序执行时所使用的类型,进而达到改变应用程序行为的目的。这里使用了动态绑定的程序技巧。

举这个例子,重点不在于「以字符串来建立对象」的程序技巧,而是想要点出一个问题:当我们在写程序时,可能因为很习惯使用 new 运算符而不经意地在程序中加入太多紧密的依赖关系——即「依赖性」(dependency)。进一步说,每当我们在程序中使用 new 来建立第三方(third party)组件的实例,我们的代码就在编译时期跟那个类型固定绑(bind)在一起了;这层依赖关系有可能是单向依赖,也可能是彼此相互依赖,形成更紧密的「耦合」(coupling),增加日后维护程序的困难。

可维护性

就对软件系统而言,「可维护性」(maintainability)指的是将来需要修改程序时需要花费的工夫;如果改起来很费劲,我们就说它是很难维护的、可维护性很低的。

有软件开发实践经验的人应该会同意:软件需求的变动几乎无可避免。如果你的代码在完成第一个版本之后就不会再有任何改动,自然可以不用考虑日后维护的问题。但这种情况非常少见。实际上,即使刚开始只是个小型应用程序,将来亦有可能演变成大型的复杂系统;而最初看似单纯的需求,在实现完第一个版本之后,很快就会出现新的需求或变更现有规格,而必须修改原先的设计。这样的修改,往往不只是改动几个函式或类型这么单纯,还得算上重新跑过一遍完整测试的成本。这就难怪,修改程序所衍生的整体维护成本总是超出预期;这也难怪,软件系统交付之后,开发团队都很怕客户又要改东改西。

此外,我想大多数人都是比较喜欢写新程序,享受创新、创造的过程,而鲜少人喜欢接手维护别人的程序,尤其是难以修改的代码。然而,代码一旦写成,某种程度上它就已经算是进入维护模式了1。换言之,代码大多是处于维护的状态。既然如此,身为开发人员,我们都有责任写出容易维护的代码,让自己和别人的日子好过一些。就如 Damian Conway 在《Perl Best Practices》书中建议的:

「編程时,请想象最后维护此代碼的人,是个有暴力倾向的精神病患,而且他知道你住哪里。」

宽松耦合

在 .NET (或某些面向对象程序语言)的世界里,任何东西都是「对象」,而应用程序的各项功能便是由各种对象彼此相互合作所达成,例如:对象 A 调用对象 B,对象 B 又去调用 C。像这样由类型之间相互调用而令彼此有所牵连,便是耦合(coupling)。对象之间的关系越紧密,耦合度即越高,代码也就越难维护;因为一旦有任何变动,便容易引发连锁反应,非得修改多处代码不可,导致维护成本提高。为了提高可维护性,一个常见且有效的策略是采用「宽松耦合」(loose coupling),亦即让应用程序的各部组件适度隔离,不让它们彼此绑得太紧。一般而言,软件系统越庞大复杂,就越需要考虑采取宽松耦合的设计方式。

当你在设计过程中试着落实宽松耦合原则,刚开始可能会看不太出来这样的设计方式有什么好处,反而会发现要写更多代码,觉得挺麻烦。但是当你开始维护这些现有的程序,你会发现自己修复 bug的速度变快了,而且那些类型都比以往紧密耦合的写法要来得更容易进行独立测试。此外,修改程序所导致的「牵一发动全身」的现象也可能获得改善,因而降低你对客户需求变更的恐惧感。

基本上,「可维护性」与「宽松耦合」便是我们学习「依赖注入」的主要原因。不过,这项技术还有其他附带好处,例如有助于单元测试与平行开发,这里也一并讨论。

 

可測試性

I've focused almost entirely on the value of Dependency Injection for unit testing and I've even bitterly referred to using DI as “Mock Driven Design.” That's not the whole story though.2 —— Jeremy Miller
(对于依赖注入所带来的价值,我把重点几乎全摆在单元测试上面,有人甚至挖苦我是以「模仿驱动设计」的方式来使用 DI。但那只是事实的一部分而已。)


当我们说某软件系统是「可测试的」(testable),指的是有「单元测试」(unit test),而不是类似用鼠标在窗口或网页上东点西点那种测试方式。单元测试有「写一次,不断重复使用」的优点,而且能够利用工具来自动执行测试。不过,编写单元测试所需要付出的成本也不低,甚至不亚于编写应用程序本身。有些方法论特别强调单元测试,例如「测试驱动开发」(Test-Driven Devcelopment;TDD),它建议开发人员养成先写测试的习惯,并尽量扩大单元测试所能涵盖的范围,以达到改善软件质量的目的。

有些情况,要实施「先写测试」的确有些困难。比如说,有些应用程序是属于分布式多层架构,其中某些组件或服务需要运行于远程的数台服务器上。此时,若为了先写测试而必须先把这些服务部署到远程机器上,光是部署的成本与时间可能就让开发人员打退堂鼓。像这种情况,我们可以先用「测试替身」(test doubles)来暂时充当真正的组件;如此一来,便可以针对个别模块进行单元测试了。「依赖注入」与宽松耦合原则在这里也能派上用场,协助我们实现测试替身的机制。


平行开发

分而治之,是对付复杂系统的一个有效方法。实际上,软件系统也常被划分成多个部分,交给多名团队成员同时分头进行各部组件的开发工作,然后持续进行集成,将它们互相衔接起来,成为一个完整的系统。要能够做到这点,各部分的组件必须事先订出明确的接口,就好像插座与插头,将彼此连接的接口规格先订出来,等到各部分实现完成时,便能顺利接上。「依赖注入」的一项基本原则就是针对接口编程(program to an interface),而此特性亦有助于团队分工合作,平行开发。

了解其优点与目的之后,接着要来谈谈什么是「依赖注入」。


什么是依赖注入?

如果说「容易维护」是设计软件时的一个主要质量目标,「宽松耦合」是达成此目标的战略原则,那么,「依赖注入」(dependency injection;DI)就是属于战术层次;它包含一组设计模式与原则,能够协助我们设计出更容易维护的程序。

DI 经常与「控制反转」(Inversion of Control;简称 IoC)相提并论、交替使用,但两者并不完全相等。比较精确地说,IoC 涵盖的范围比较广,其中包含 DI,但不只是 DI。换个方式说,DI 其实是 IoC 的一种形式。那么,IoC 所谓的控制反转,究竟是什么意思呢?反转什么样的控制呢?如何反转?对此问题,我想先引用著名的软件技术问答网站 Stack Overflow 上面的一个妙答,然后再以范例代码来说明。

该帖的问题是:「如何向五岁的孩童解释 DI?」在众多回答中,有位名叫 John Munch 的仁兄假设提问者就是那五岁的孩童而给了如下答案:

当你自己去开冰箱拿东西时,很可能会闯祸。你可能忘了关冰箱门、可能会拿了爸妈不想让你碰的东西,甚至冰箱里根本没有你想要找的食物,又或者它们早已过了保存期限。
你应该把自己需要的东西说出来就好,例如:「我想要一些可以搭配午餐的饮料。」然后,当你坐下用餐时,我们会准备好这些东西。


如此精准到位的比喻,自然获得了网友普遍的好评。

接下来,依惯例,我打算用一个 Hello World 等级的 DI 入门范例来说明。常见的 Hello World 范例只有短短几行代码,但 DI 没办法那么单纯;即使是最简单的 DI 范例,也很难只用两三行代码来体现其精神。因此,接下来会有更多代码和专有名词,请读者稍微耐心一点。我们会先实现一个非 DI 的范例程序,然后再将它改成 DI 版本。


入門範例—非 DI 版本

这里使用的范例场景是应用程序的登录功能必须提供两步骤验证(two-factor authentication)机制,其登录流程大致有以下几个步骤:

  1. 用户输入账号密码之后,系统检查账号密码是否正确。
  2. 账号密码无误,系统会立刻发送一组随机验证码至使用者的信箱。
  3. 使用者收信,获得验证码之后,回到登录页面继续输入验证码。
  4. 验证码确认无误,让用户登录系统。

依此描述,我們可以設計一個類別來提供雙因素驗證的服務:AuthenticationService。底下是簡化過的程式碼:

class AuthenticationService
{
    private EmailService msgService;
    public AuthenticationService()
    {
        msgSevice = new EmailService(); // 建立用来发送验证码的对象
    }

    public bool TwoFactorLogin(string userId, string pwd)
    {
        // 检查账号密码,若正确,则返回一个包含用户信息的对象。           
        User user = CheckPassword(userId, pwd);
        if (user != null)
        {
            // 接着发送验证码给使用者,假设随机产生的验证码为 "123456"。
            this.msgService.Send(user, "您的登录验证码为 123456");
            return true;
        }
        return false;
    }
}

AuthenticationService 的构造函数会建立一个 EmailService 对象,用来发送验证码。TwoFactorLogin 方法会检查用户输入的账号密码,若正确,就调用 EmailService 对象的 Send 方法来将验证码发送至使用者的 e-mail 信箱。EmailService 的 Send 方法就是单纯发送电子邮件而已,这部分的实现细节并不重要,故未列出代码;CheckPassword 方法以及 User 类型的代码也是基于同样的理由省略(User 对象会包含用户的基本联系信息,如 e-mail 地址、手机号码等等)。

主程序的部分则是利用 AuthenticationService 来处理用户登录程序。这里用一个简化过的 MainApp 类型来表示,代码如下,我想应该不用多做解释。

class MainApp
{
    public void Login(string userId, string password)
    {
        var authService = new AuthenticationService();
        if (authService.TwoFactorLogin(userId, password)) 
        {
            if (authService.VerifyToken("使用者输入的验证码"))
            {
                // 登录成功。
            }
        }
        // 登录失败。
    }
}

 

此范例目前涉及四个类型:MainApp、AuthenticationService、User、EmailService。它们的依赖关系如下图所示,图中的箭头代表依赖关系的依赖方向。

 

 

通过这张图可以很容易看出来,代表主程序的 MainApp 类型需要使用 AuthenticationService 提供的验证服务,而该服务又依赖 User 和 EmailService 类型。就它们之间的角色关系来说,AuthenticationService 对 MainApp 而言是个「服务端」(service)的角色,对于 User 和 EmailService 而言则是「客户端」(client;有时也说「调用端」)的角色。

目前的设计,基本上可以满足功能需求。但有个问题:万一将来使用者想要改用手机简讯来接收验证码,怎么办?稍后你会看到,此问题凸显了目前设计上的一个缺陷:它违反了「开放/封闭原则」。

 

开放/封闭原则

「开放/封闭原则」(Open/Close Principle;OCP)指的是软件程序的单元(类型、模块、函式等等)应该要够开放,以便扩充功能,同时要够封闭,以避免修改现有的代码。换言之,此原则的目的是希望能在不修改现有代码的前提下增加新的功能。须注意的是,遵循开放/封闭原则通常会引入新的抽象层,使代码不易阅读,并增加代码的复杂度。在设计时,应该将此原则运用在将来最有可能变动的地方,而非试图让整个系统都符合此原则。

一般公认最早提出 OCP 的是 Bertrand Meyer,后来由 Robert C. Martin(又名鲍伯[Uncle Bob])重新诠释,成为目前为人熟知的面向对象设计原则之一。鲍伯在他的《Agile Software Development: Principles, Patterns, and Practices》书中详细介绍了五项设计原则,并且给它们一个好记的缩写:S.O.L.I.D.。它们分别是:

  • SRP(Single Responsibility Principle):单一责任原则。一个类型应该只有一个责任。
  • OCP(Open/Closed Principle):开放/封闭原则。对开放扩充,对修改封闭。
  • LSP(Liskov Substitution Principle):里氏替换原则。对象应该要可以被它的子类型的对象替换,且完全不影响程序的现有行为。
  • ISP(Interface Segregation Principle):接口隔离原则。多个规格明确的小接口要比一个包山包海的大型接口好。
  • DIP(Dependency Inversion Principle):依赖倒置原则。依赖抽象类型,而不是具象类型。

 

后续章节若再碰到这些原则,将会进一步说明。您也可以参考刚才提到的书籍,繁体中文版书名为《敏捷软件开发:原则、样式及实务》 。出版社:碁峰。译者:林昆颖、吴京子。

针对此需求变动,一个天真而快速的解法,是增加一个提供发送简讯服务的类型:ShortMessageService,然后修改 AuthenticationService,把原本用到 EmailService 的代码换成新的类型,像这样:

class AuthenticationService
{
    private ShortMessageService msgService;

    public AuthenticationService()
    {
        msgSevice = new ShortMessageService(); // 建立用来发送验证码的对象
    }

    public bool TwoFactorLogin(string userId, string pwd)
    {
        // 没有变动,故省略。
    }
}

其中的 TwoFactorLogin 方法的实现完全没变,是因为 ShortMessageService 类型也有一个 Send 方法,而且这方法跟 EmailService 的 Send 方法长得一模一样:接受两个传入参数,一个是 User 对象,另一个是讯息内容。底下同时列出两个类型的源代码。

class EmailService
{
    public void Send(User user, string msg)
    {
        // 发送电子邮件给指定的 user (略)
    }
}

class ShortMessageService
{
    public void Send(User user, string msg)
    {
        // 发送简讯给指定的 user (略)
    }
}

 

你可以看到,这种解法仅仅改动了 AuthenticationService 类型的两个地方:

  • 私有成员 msgService 的类型。
  • 构造函数中,原本是建立 EmailService 对象,现在改为 ShortMessageService。

剩下要做的,就只是编译整个项目,然后部署新版本的应用程序。这种解法的确改得很快,代码变动也很少,但是却没有解决根本问题。于是,麻烦很快就来了:使用者反映,他们有时想要用 e-mail 接收登录验证码,有时想要用手机简讯。这表示应用程序得在登录画面中提供一个选项,让用户选择以何种方式接收验证码。这也意味着程序内部实现必须要能够支持执行时期动态切换「提供发送验证码服务的类型」。为了达到执行时期动态切换实现类型,相依类型之间的绑定就不能在编译时期决定,而必须采用动态绑定。

接着要说明如何运用 DI 来让刚才的范例程序具备执行时期切换实现类型的能力。

摘自電子書:《.NET 依賴注入

下一集:Dependency Injection 筆記 (2)

posted on 2014-08-12 20:09  MichaelTsai  阅读(1026)  评论(9编辑  收藏  举报

导航