通过理顺软件的依赖关系提高应用程序灵活性
本文讨论:
|
本文使用了以下技术: .NET Framework |
Browse the Code Online
几乎所有人都认为追求松散耦合设计不是明智之举。遗憾地是,我们所设计软件的紧密耦合程度通常都远远超过我们的预期。如何了解设计是否耦合紧密?可使用静态分析工具(如 Ndepend)来分析依赖关系,但了解应用程序中耦合程度最轻松的办法是尝试独立地实例化一个类。
从业务层中选取一个类(如 InvoiceService),然后将其代码复制到一个新的控制台项目中。尝试对它进行编译。您很可能会丢失某些依赖关系(如 Invoice、InvoiceValidator 等等)。将这些类复制到此控制台项目并重试。您又会发现缺少其他类。最终可执行编译时,您可能会发现新项目中已存在绝大部分基本代码。就像拉动毛线衫的一个松动线头就可以拆掉整件衣服一样。在您的设计中,每个类都直接或间接地与其他类耦合。更改此类系统的确非常困难,因为任何一个类中的更改都可能牵连到系统的其余部分。
解决这个问题的要点不是完全避开耦合,因为那是不可能的。例如:
string name = "James Kovacs";
我已将我的代码耦合到 Microsoft® .NET Framework 中的 System.String 类。这不妥帖吗?我不这样认为。System.String 类意外发生更改的可能性非常低,也少有可能因要求改变而必须修改我与 System.String 交互的方式。因此我对该耦合相当放心。此示例的用意在于指出并非一味地消除耦合,而是要了解它并确保明智地选用耦合。
以经常出现在许多应用程序数据层中的另一代码为例:
SqlConnection conn = new SqlConnection(connectionString);
或者以下代码:
XmlDocument settings = new XmlDocument(); settings.Load("Settings.xml");
对于数据层将仅与 SQL Server® 交互或者始终从名为 Settings.xml 的 XML 文档中加载应用程序设置而言,您有多大把握?我们的目的不是构建一个可无限扩展但极其复杂、无法使用的泛型框架。而是与可逆性相关。您能轻松地改变设计决策吗?您的应用程序体系结构能否很好地响应变化?
为何我如此关注变化?因为就是这个行业中唯一不变的就是变化。需求、技术、开发人员以及业务都在变化。您有无做好应变准备?通过创建松散耦合设计,软件就可更好地响应许多时候无法预计的必然变化。
内部依赖关系问题
让我们来研究一下常见分层式应用程序体系结构中出现的一个典型高度耦合设计(请参见图 1)。一个简单的分层架构具有一个 UI 层,它与服务(或业务)层交互,而服务(或业务)层又与存储库(或数据)层交互。这些层之间的依赖关系也以相同顺序向下传递。存储库层不了解服务层,而服务层又不了解 UI 层。
Figure 1 典型的分层式体系结构
有时您会拥有更多层(如表示层或工作流层),但每层仅了解其下一层的模式是一致的。将层作为连贯的责任群集是个不错的设计技术。然而,将上层和下层直接耦合会增加耦合并致使应用程序难以测试。
为什么我如此关注可测试性?因为可测试性是衡量耦合的一项良好标准。如果无法在测试中轻松地实例化某个类,则可能是耦合有些问题。例如,服务层非常熟悉并依赖存储库层。无法脱离存储库层测试服务层。在实际应用中,即意味着大多数测试都会访问基础数据库、文件系统或网络。这会导致大量问题,包括测试速度缓慢以及维护成本偏高。
测试速度缓慢 :如果测试可严格控制在内存中执行,则每个测试的时间只有几毫秒。如果测试访问外部资源(如数据库、文件系统或网络),则每个测试的时间通常会为 100 毫秒甚至更长时间。对于测试覆盖率良好的典型项目来说,它有成百上千个测试,这意味着是在几秒内完成测试还是在几分钟或几小时完成。
出错隔离较差 :数据层组件的故障常常导致也无法测试上层组件。不是只有几个测试失败(这种情况可快速隔离问题),而是有成百上千个测试失败,从而使得难以找出问题且更加耗时。
维护成本偏高 :大多数测试都需要一些初始数据。如果这些测试涉及数据库,则必须在每次测试前确保数据库处于已知状态。此外,必须确保每个测试的初始数据均独立于其他测试的初始数据,否则可能遇到测试排序问题,此时会由于无序而致使某些测试失败。使数据库保持正常已知状态非常耗时且容易出错。
此外,如果需要更改下层的实现,则往往不得不同时修改上层,因为这些层对下层具有隐式或显式依赖关系。尽管应用程序已分层,却仍未得到松散耦合。
让我们来看个具体示例 — 一个接受发票的服务(请参见图 2)。要使 InvoiceService.Submit 能接受发票提交,需依赖在类的构造函数中创建的 AuthorizationService、InvoiceValidator 和 InvoiceRepository。无法在缺少具体依赖关系的情况下对 InvoiceService 执行单元测试。这表示在运行单元测试前,必须确保:当 InvoiceRepository 插入新发票时,不会在数据库中产生任何主键或唯一键冲突,InvoiceValidator 也不得报告任何验证失败情况。还必须确保运行单元测试的用户具有适当的权限,这样 AuthorizationService 才会允许“提交”操作。
Figure 2 Invoice Service
public class InvoiceService { private readonly AuthorizationService authoriazationService; private readonly InvoiceValidator invoiceValidator; private readonly InvoiceRepository invoiceRepository; public InvoiceService() { authoriazationService = new AuthorizationService(); invoiceValidator = new InvoiceValidator(); invoiceRepository = new InvoiceRepository(); } public ValidationResults Submit(Invoice invoice) { ValidationResults results; CheckPermissions(invoice, InvoiceAction.Submit); results = ValidateInvoice(invoice); SaveInvoice(invoice); return results; } private void CheckPermissions(Invoice invoice, InvoiceAction action) { if(authoriazationService.IsActionAllowed(invoice, action) == false) { throw new SecurityException( "Insufficient permissions to submit this invoice"); } } private ValidationResults ValidateInvoice(Invoice invoice) { return invoiceValidator.Validate(invoice); } private void SaveInvoice(Invoice invoice) { invoiceRepository.Save(invoice); } }
这一点很难实现。如果任何此类依赖组件存在问题(代码或数据错误),InvoiceService 测试都会突然失败。即使测试通过,在数据库中设置正确数据、执行测试以及清理测试所创建的数据用掉的总执行时间将为数百毫秒。即使通过将测试分批并在批前后运行脚本来分摊掉设置和清理成本,较之在内存中运行测试而言,执行时间仍要长得多。
此外,还有个更加微妙的问题。假设您希望为 InvoiceRepository 添加审核支持,将不得不创建 AuditingInvoiceRepository 或修改 InvoiceRepository 本身。由于 InvoiceService 及其子组件之间的耦合,您在向系统引入新功能方面的选择并不多。
依赖关系反转
可从下层依赖关系分离上层组件 InvoiceService,方法是通过接口而非具体类来与依赖关系交互:
public class InvoiceService : IInvoiceService { private readonly IAuthorizationService authService; private readonly IInvoiceValidator invoiceValidator; private readonly IInvoiceRepository invoiceRepository; ... }
改为使用接口(或抽象基类)意味着可替换任何依赖关系的其他实现。除创建 InvoiceRepository 外,还可创建 AuditingInvoiceRepository(假设 AuditingInvoiceRepository 实现 IInvoiceRepository)。它还意味着可在测试期间替换虚设或模拟。此设计技术称为契约式程序设计。
在上层和下层组件分离中应用的原理称为依赖关系反转原理。正如 Robert C. Martin 在文章 (objectmentor.com/resources/articles/dip.pdf) 中所说:“上层模块不应依赖下层模块。它们都应依赖抽象。”
在本例中,InvoiceService 和 InvoiceRepository 现在都依赖 IinvoiceRepository 提供的抽象。然而,我并未完全解决问题,我只是转移了问题。尽管具体实现仅依赖接口,问题却仍是具体类如何“发现”彼此的存在。
InvoiceService 仍需要其依赖关系的具体实现。可简单地在 InvoiceService 的构造函数中实例化这些依赖关系,但这与以前相比差别不大。如果希望使用 AuditingInvoiceRepository,则还必须修改 InvoiceService 以实例化 AuditingInvoiceRepository。此外,还需修改依赖 IInvoiceRepository 的所有类以改为实例化 AuditingInvoiceRepository。并无任何简便方法可将 InvoiceRepository 全局换为 AuditingInvoiceRepository。
一个解决方案是使用工厂来创建 IInvoiceRepository 实例。这样就可通过更改工厂方法来集中转换成 AuditingInvoiceRepository。此技术的另一个名称是服务位置,负责管理实例的工厂类则称为服务定位器:
public InvoiceService() { this.authorizationService = ServiceLocator.Find<IAuthorizationService>(); this.invoiceValidator = ServiceLocator.Find<IInvoiceValidator>(); this.invoiceRepository = ServiceLocator.Find<IInvoiceRepository>(); }
ServiceLocator 中的功能可基于自配置文件或数据库读取的数据,也可直接与代码相关。在任一情况下,均可集中创建依赖关系对象。
通过使用虚设或模拟对象而非实际实现来配置服务定位器,可对单独的组件执行单元测试。因此,例如,在测试过程中,ServiceLocator.Find<IInvoiceRepository> 可返回一个 FakeInvoiceRepository,它会在保存发票时向其指定一个已知主键,但不会将发票实际保存到数据库中。从而不必执行复杂的数据库设置和清除,并会从虚设依赖关系返回已知数据(请参见侧栏“虚设依赖关系是否明智?”)。
但是,服务位置有几项缺点。首先,在更高层类中看不出依赖关系。仅通过查看代码,根本无法从其公共签名中看出 InvoiceService 是依赖 AuthorizationService、InvoiceValidator 还是 InvoiceRepository。
如果需要为同一接口提供不同的具体类型,则必须求助于重载 Find 方法。它要求您在实现工厂类时决定是否需要某个替代类型。例如,无法在部署特定 IInvoiceRepository 请求时重新配置 ServiceLocator 以替代 AuditingInvoiceRepository。但是,即使具有这些缺点,服务位置却易于理解且胜过对依赖关系进行硬编码。
依赖关系注入
在对上层组件执行单元测试时,您希望提供其依赖关系的虚设或模拟实现。但是,与其使用虚设或模拟来配置服务定位器,然后再让上层组件查找它们,不如通过参数化构造函数将依赖关系直接传递给上层组件。此技术称为依赖关系注入。图 3 显示了一个示例。
Figure 3 Dependency Injection
[Test] public void CanSubmitNewInvoice() { Invoice invoice = new Invoice(); ValidationResults validationResults = new ValidationResults(); IAuthorizationService authorizationService = mockery.CreateMock<IAuthorizationService>(); IInvoiceValidator invoiceValidator = mockery.CreateMock<IInvoiceValidator>(); IInvoiceRepository invoiceRepository = mockery.CreateMock<IInvoiceRepository>(); using(mockery.Record()) { Expect.Call(authorizationService.IsActionAllowed( invoice, InvoiceAction.Submit)).Return(true); Expect.Call(invoiceValidator.Validate(invoice)) .Return(validationResults); invoiceRepository.Save(invoice); } using(mockery.Playback()) { IInvoiceService service = new InvoiceService(authorizationService, invoiceValidator, invoiceRepository); service.Submit(invoice); } }
在此示例中,我将创建 InvoiceService 依赖关系的虚设对象,然后将它们传递给 InvoiceService 构造函数。(有关虚设对象框架的详细信息,请参见 Mark Seemann 的“单元测试:探索 Test Double 的状态集”,网址为 msdn.microsoft.com/msdnmag/issues/07/09/MockTesting。)总之,通过定义 InvoiceService 与虚设的交互方式而非在运行测试后确认 InvoiceService 的状态来指定它的行为。
通过使用依赖关系注入,可在单元测试中轻松地向上层组件提供其依赖关系。但是,在运行应用程序或执行集成测试时,如何在单元测试以外的环境中找到类的依赖关系仍是个问题。期望 UI 层向服务层提供其依赖关系或者服务层向存储库层提供其依赖关系并不明智。最终的问题会比开始时还糟糕。但是,让我们假设由 UI 层负责向服务层提供其依赖关系:
// Somewhere in UI Layer InvoiceSubmissionPresenter presenter = new InvoiceSubmissionPresenter( new InvoiceService( new AuthorizationService(), new InvoiceValidator(), new InvoiceRepository()));
如您所看到的,UI 将不得不了解自身的依赖关系,还有依赖关系的依赖关系,这样无限下去直至数据层。这显然不是个理想的解决方案。走出这一困境最轻松的办法就是使用我们称为“廉价的依赖关系注入”的技术。
“廉价的依赖关系注入”使用上层组件的默认构造函数来提供依赖关系:
public InvoiceService() : this(new AuthorizationService(), new InvoiceValidator(), new InvoiceRepository()) { }
请注意我是如何向最重载构造函数进行委托的。它确保了无论使用哪个构造函数来创建实例,类的初始化逻辑都相同。将类与具体依赖关系耦合的唯一位置是默认构造函数。由于您仍具备重载构造函数,它可在单元测试期间提供类的依赖关系,所以仍能对类进行测试。
容器
现在来介绍一下控制反转 (IoC) 容器,在那里集中管理依赖关系。实际上,与实现类型相比,容器就是一部特殊的接口字典。IoC 容器最简单的形式就是使用另一名称的服务定位器。稍后,我将研究容器比服务位置功能更多的基理。
回到目前的问题上来,您希望将 InvoiceService 从其依赖关系的具体实现中完全分离出来。就象软件中的所有问题一样,可通过添加另一中间层来解决这一问题。引入依赖关系解决程序这一概念,它会将接口映射到某个具体实现。然后使用一个泛型方法来获取接口 T 并返回实现该接口的类型:
public interface IDependencyResolver { T Resolve<T>(); }
现在我们来实现 SimpleDependencyResolver,它使用字典来存储接口与实现这些接口的对象之间的映射信息。我们需要一个方法在一开始填充字典,这里使用的是 Register<T>(object obj) 方法(请参见图 4)。请注意:Register 方法无需位于 IDependencyResolver 接口上,因为只有 SimpleDependencyResolver 的创建者会注册依赖关系。通常这是在应用程序启动期间由 Main 方法中调用的帮助程序类来完成。
Figure 4 SimpleDependencyResolver
public class SimpleDependencyResolver : IDependencyResolver { private readonly Dictionary<Type, object> m_Types = new Dictionary<Type, object>(); public T Resolve<T>() { return (T)m_Types[typeof(T)]; } public void Register<T>(object obj) { if(obj is T == false) { throw new InvalidOperationException( string.Format("The supplied instance does not implement {0}", typeof(T).FullName)); } m_Types.Add(typeof(T), obj); } }
CompanyService 如何查找 SimpleDependencyResolver 以便找到其依赖关系?可把 IDependencyResolver 传到需要它的所有类中,但这一做法很快会变得非常繁琐。最简单的解决方案是使用静态网关模式将配置好的 SimpleDependencyResolver 实例放入一个可全局访问的位置。(还可使用单例模式,但众所周知,单例很难测试。它们是紧密耦合代码难以测试的主要原因之一,因为它们与全局变量没什么差别。如果可能,尽量避免使用它们。)
接下来了解一下静态网关,我将其称为 IoC。(另一可能名称是 DependencyResolver,但 IoC 更简明扼要。)IoC 上的静态方法匹配 IdependencyResolver 上的方法。(请注意:IoC 不会实现 IdependencyResolver,因为静态类无法实现接口。)还有一个接受实际 IDependencyResolver 的 Initialize 方法。IoC 静态网关仅将所有 Resolve<T> 请求转发给配置好的 IdependencyResolver:
public class IoC { private static IDependencyResolver s_Inner; public static void Initialize(IDependencyResolver resolver) { s_Inner = resolver; } public static T Resolve<T>() { return s_Inner.Resolve<T>(); } }
在应用程序启动期间,使用配置好的 SimpleDependencyResolver 来初始化 IoC。现在,可在默认构造函数中将“廉价的依赖关系注入”替换成 IoC.Resolve:
public InvoiceService() : this(IoC.Resolve<IAuthorizationService>(), IoC.Resolve<IInvoiceValidator>(), IoC.Resolve<IInvoiceRepository>()) { }
请注意,在启动程序启动后,无需同步对 IdependencyResolver 的内部访问(因为它仅会被读取但永远不会更新)。
IoC 类还有另一好处 — 用作应用程序的防损坏层。如果要使用另一 IoC 容器,只需部署一个能实现 IdependencyResolver 的适配器即可。即使在整个应用程序中大量使用 IoC,也不会与任何特定容器产生耦合。
成熟的 IoC 容器
可使用简单的 IoC 容器(如 SimpleDependencyResolver)来集成各松散耦合的组件。但是,它缺少成熟 IoC 容器所具有的许多功能,包括:
- 广泛的配置选项(如 XML、代码或脚本)
- 生存期管理(如单例、瞬态、单个线程或池线程)
- 自动绑定依赖关系
- 绑定新功能
接下来更加深入地讨论每个功能。我将使用一个广泛应用的开源 IoC 容器 Castle Windsor 作为具体示例。许多容器都可通过外部 XML 文件进行配置。例如,可如下配置 Windsor:
<?xml version="1.0" encoding="utf-8" ?> <configuration> <components> <component id="Foo" service="JamesKovacs.IoCArticle.IFoo, JamesKovacs.IoCArticle" type="JamesKovacs.IoCArticle.Foo, JamesKovacs.IoCArticle"/> </components> </configuration>
由于修改后无需重新编译应用程序,因此 XML 配置的优势十分明显 — 尽管常常需要重新启动应用程序更改才能生效。但它也并非没有缺点:XML 配置很容易变得非常冗长、直到运行时才检测到错误以及使用 CLR 的反引号表示法(而非更常见的 C# 泛型表示法)来声明泛型类型。(Company.Application.IValidatorOf<Invoice> 编写成 Company.Application.IValidatorOf`1[[Company.Application.Invoice, Company.Application]], Company.Application。)
除 XML 外,还可使用 C# 或其他与 Microsoft .NET Framework 兼容的语言来配置 Windsor。如果将配置代码隔离到一个单独的程序集中,更改配置即意味着仅重新编译配置程序集并重新启动应用程序。
可使用 Binsor 这一专用于配置 Windsor 的域特定语言 (DSL) 来编写 Windsor 配置的脚本。Binsor 允许使用 Boo 来编写配置文件。(Boo 是一种注重语言和编译器扩展性的静态类型 CLR 语言,因而非常适合于编写 DSL。)在 Binsor 中,之前的 XML 配置文件可重新编写为:
import JamesKovacs.IoCArticle Component("Foo", IFoo, Foo)
当您发现 Boo 是一种成熟的编程语言后就更加有趣了,即意味着无需手动添加组件注册即可使用 Binsor 在 Windsor 中自动注册类型,就像使用基于 XML 的配置一样:
import System.Reflection serviceAssembly = Assembly.Load("JamesKovacs.IoCArticle.IoCContainer") for type in serviceAssembly.GetTypes(): continue if type.IsInterface or type.IsAbstract or type.GetInterfaces().Length == 0 Component(type.FullName, type.GetInterfaces()[0], type)
即使并不熟悉 Boo,代码的用意仍是一清二楚。只需将新服务添加到 JamesKovacs.IoCArticle.Services 命名空间,该服务就会自动注册为其服务接口的默认实现。假设创建以下类:
public class AuthorizationService : IAuthorizationService { ... }
如果任何其他类通过将 IAuthorizationService 作为其构造函数的参数来声明与它的依赖关系,Binsor 将自动绑定,而无需在配置文件中明确指定该依赖关系!可在 ayende.com/Blog/category/451.aspx 上找到有关 Binsor 的更多信息,在 boo.codehaus.org 上找到有关 Boo 的更多信息。
生存期管理
SimpleDependencyResolver 始终返回为接口注册的同一实例,从而有效使得该实例成为单例。可修改 SimpleDependencyResolver 来注册具体类型而非实例。然后,可使用各种工厂来创建具体类型的实例。单例工厂将始终返回同一实例。瞬态工厂将始终返回一个新实例。单个线程工厂则针对每个请求线程维护一个实例。
可实现您想得到的所有实例化策略。这就是 Windsor 所提供的功能。通过在 XML 配置文件中应用属性,可选择将哪种类型的工厂用于创建特定具体类型的实例。默认情况下,Windsor 使用单例实例。如果希望每次从容器请求 IFoo 时均返回一个新的 Foo,只需将配置更改为:
<component id="Foo" service="JamesKovacs.IoCArticle.IFoo, JamesKovacs.IoCArticle" type="JamesKovacs.IoCArticle.Foo, JamesKovacs.IoCArticle" lifestyle="transient"/>
自动绑定依赖关系
自动绑定依赖关系表示容器可检查请求类型的依赖关系并创建这些依赖关系,而无需开发人员提供默认构造函数。
public InvoiceService(IAuthorizationService authorizationService, IInvoiceValidator invoiceValidator, IInvoiceRepository invoiceRepository) { ... }
当客户端向容器请求 IInvoiceService 时,容器会注意到具体类型需要 IauthorizationService、IInvoiceValidator 和 IinvoiceRepository 的具体实现。它会查找适当的具体类型,解析它们可能拥有的所有依赖关系并构造这些关系。然后使用这些依赖关系来创建 InvoiceService。自动绑定免去了维护默认构造函数这一要求,因而简化了代码并删除了许多类对 IoC 静态网关的依赖关系。
通过编码约定而非具体实现并使用容器,您的体系结构将更加灵活且易于更改。如何为 InvoiceRepository 实现可配置审核日志记录?在紧密耦合体系结构中,必须修改 InvoiceRepository。还需要一些应用程序配置设置来指明是否打开审核日志记录。
在松散耦合体系结构中,有没有更好的方法呢?您可实现 AuditingInvoiceRepositoryAuditor,它会实现 IinvoiceRepository。审核程序仅实现审核功能,然后委托给其构造函数中提供的实际 InvoiceRepository。此模式称为装饰程序(请参见图 5)。
Figure 5 Using the Decorator Pattern
public class AuditingInvoiceRepository : IInvoiceRepository { private readonly IInvoiceRepository invoiceRepository; private readonly IAuditWriter auditWriter; public AuditingInvoiceRepository(IInvoiceRepository invoiceRepository, IAuditWriter auditWriter) { this.invoiceRepository = invoiceRepository; this.auditWriter = auditWriter; } public void Save(Invoice invoice) { auditWriter.WriteEntry("Invoice was written by a user."); invoiceRepository.Save(invoice); } }
要打开审核,可将容器配置为在请求 IInvoiceRepository 时,返回 AuditingInvoiceRepository 所装饰的 InvoiceRepository。客户端并不知晓其中内情,因为它们仍与 IinvoiceRepository 交互。这种方法具有许多好处:
- 由于并未修改 InvoiceRepository,因此不可能会破坏其代码。
- 可独立于 InvoiceRepository 实现和测试 AuditingInvoiceRepository。因此无论有没有实际数据库均可确保审核功能可正常运行。
- 可出于审核、安全性、缓存或其他目的构建多个装饰程序,这不会增加 InvoiceRepository 的复杂性。换句话说,在添加新功能时,松散耦合系统中的装饰程序方法扩展性更好。
- 容器提供了有趣的应用程序可扩展性机制。不必在与 InvoiceRepository 或 IInvoiceRepository 相同的程序集中实现 AuditingInvoiceRepository。可在配置文件引用的第三方程序集中轻松地实现它。
轻松实现更改
即使软件体系结构已划分层次,各层之间仍可能紧密耦合,从而妨碍应用程序的测试和发展演变。但是,您可取消设计的耦合。通过使用依赖关系反转和依赖关系注入,即可从编码约定(而非具体实现)中受益。通过引入控制反转容器,可增加体系结构的灵活性。最终,您的松散耦合设计将更加适应变化。