整理一些有关使用 DI 容器的一些建议事项,主要的参考数据源是 Jimmy Board 的文章:Container Usage Guidelines。
1.容器设定
避免对同一个组件(DLL)重复扫描两次或更多次
扫描组件的目的是为了自动注册类型对应关系,故其过程涉及了探索组件内含之类型信息。依应用程序所包含的组件与类型数量而定,扫描组件与探索类型的动作可能在毫秒内完成,亦可能需要花费数十秒。因此,当你在应用程序中使用 DI 容器的自动扫描功能来注册类型时,应注意避免对同一个组件重复扫描两次以上,以免拖慢应用程序的执行性能(通常是影响应用程序激活的时间,因为注册类型的动作大多集中写在 Composition Root)。
为了能够自动找到类型对应关系,类型的命名通常会遵循特定惯例,比如说,在扫描组件过程中,自动把 Foo
注册成为 IFoo
的实现类型。
有关 Unity 自动注册的用法可参考 Unity Configuration - Registration by Convention 或《.NET 依赖注入》的第七章。
使用不同类型来注册不同用途的组件
例如,你可能有一个 WebApiConfig
类型负责注册 ASP.NET Web API 相关类型,以及用一个 DalConfig
类型来注册数据存取层(Data Access Layer)的相关类型。然后,在应用程序的组合根(Composition Root)呼叫这些类型的注册类型方法。一种常见的写法是把组合根放在一个叫做Bootstrapper
的类型里,像这样:
public static class Bootstrapper { private static readonly IUnityContainer _container = new UnityContainer(); public static void RegisterDependencies() { WebApiConfig.RegisterTypes(_container); DalConfig.RegisterTypes(_container); BllConfig.RegisterTypes(_container); } }
此范例使用了一个静态(static
)类型 Bootstrapper
来总管容器对象的建立与类型注册的工作,而此作法对于某些需要更大弹性的场合来说可能不适用,此时可参考下一个建议。
使用非静态类型来建立与设定 DI 容器
有时候,应用程序可能需要在不同时机建立多个 DI 容器,而这些 DI 容器的生命周期可能不完全相同。若应用程序只有一个全局共享的静态 DI 容器,便无法满足上述需求。提供非静态的 API,例如提供 instance 层级的方法和属性(而不是 class 层级的 static 方法和属性),可提供客户端程序更多弹性,同时也更方便测试时抽换特定组件。
不要另外建立一个 DLL 项目来集中处理相依关系的解析
DI 容器的设定应该写在需要解析那些相依类型的应用程序里面,而不要把它们集中放在一个单独的 DLL 项目(例如 DependencyResolver.csproj)。
为个别组件加入一个初始化类型来设定相依关系
如果你的 DLL 组件会给多个项目共享,而且它依赖某些外部类型,此时最好为该组件提供一个初始化类型(如前面提过的 Bootstrapper
)来设定相依关系。若该组件需要扫描其他组件来寻找相依类型,此动作也是写在初始化类型里。
扫描组件时,尽量避免指定组件名称
组件名称可能会变,所以在扫描组件时,最好避免指定特定名称的组件。
2. 生命周期管理
多数 DI 容器都有提供对象生命周期管理的功能。一般而言,应用程序比较常用的是 Transient(每次要求解析时都建立新的对象)、Singleton(全都共享同一个对象)、以及 Per-HTTP request(每一个 HTTP 请求范围内共享同一个对象),而只在某些少数场合才需要用到特殊的自定义生命周期。
优先使用 DI 容器来管理对象的生命周期
若无特殊原因,最好使用 DI 容器来管理对象的生命周期,而不要自行撰写对象工厂来管理相依对象的生命周期。这是因为,目前大家喊得出名号的 DI 容器都经过多年的实务考验与多方测试,不仅在设计上考虑得更全面,发生 bug 的机率也比自己写的组件来得低。有时候,对象生灭的时机比较特殊,或者需要更细致地管理对象的生命周期,这些情况则不妨自行撰写对象工厂来管理。
考虑使用子容器来管理 Per-Request 类型的对象
对于 Per HTTP Request 或类似的场合,一种常见的作法是把对象保存在当前的 HttpContext
对象里,例如在 HTTP Request 建立之初便一并建立相依对象并将它们保存于 HttpContext.Current.Items
集合里,然后在 Request 结束前一并释放这些相依对象。DI 容器提供了另一个选择来处理这类需求:子容器(child container)。
所有透过子容器解析(建立)的对象,其寿命不会超过子容器。也就是说,子容器一旦消失,它所管理的对象亦随之消失。基于此特性,我们可以利用子容器来管理如 Per HTTP Request 或其他类似性质的对象生命周期。
在适当时机呼叫容器的 Dispose 方法
DI 容器通常有实现 IDisposable
接口,亦即提供了 Dispose
方法。当你呼叫某容器对象的 Dispose
方法来释放该容器时,它会找出内部管理的所有对象,只要是支持 IDisposable
的对象,就呼叫它的 Dispose
方法,以确保相依对象的资源得以释放。
3. 组件设计相关建议
避免建立深层的巢状对象
虽然我们偏好对象组合而不是类型继承,但如果相依对象的巢状关系太多且深,这样的程序架构仍然不好维护。DI 容器的一个方便却也危险之处,是它具备自动解析巢状相依对象的能力。于是,我们甚至只要写一行代码就能解析所有深层的相依对象。这的确减轻了开发人员的负担,但同时也隐藏了背后的复杂性,因而导致开发人员更容易忽略设计的缺陷:太多零碎的小接口,组合出非常复杂的对象图。对此设计面的问题,DI 容器没法帮忙,唯有靠开发人员自己多加注意。
考虑使用泛型来封装抽象概念
当你发觉整个程序架构太过笨重,可试着从现有的组件中找出同性质的功能,并为它们定义基础的泛型接口,例如ICommand<T>
、IValidator<T>
、IRequestHandler<TRequest, TResponse>
等等。
考虑使用 Adapter 或 Facade 来封装 3rd-party 组件
开发应用程序时,难免会使用一些现成的外来(third-party)组件。有时候,外来组件可能会因为版本更新而造成既有应用程序无法运行或产生错误的结果。为了避免这种状况,我们可以利用 Adapter、Facade、若 Proxy 等模式来为自己的应用程序建立一个反腐败层(anti-corruption layer)。
不要一律为每个组件定义一个接口
对 S.O.L.I.D. 设计原则的一个常见误解是:每个组件都应该要有一个相应的接口。当组件本身确实具有某个抽象概念的意涵,那么为它定义一个抽象接口是合理的;但如果它不具备抽象概念,就不要硬为它生出一个接口。此外,你也不应该因为在使用 DI 容器时想要一致透过接口来解析,而为特定组件定义接口。DI 容器可以直接解析具象类型(concrete type),故从技术上而言,直接使用具象类型并不是问题。
看一下应用程序中的组件,如果许多组件的类名正好就是它所实现的接口名称去掉 'I'(例如 Foo
与 IFoo
),这可能是一个讯号:有些接口可能只是单纯因为「每个组件都要有个接口」的想法而产生的。
对于同一层(layer)的组件,可依赖其具象类型
如果某组件与其所依赖的其他组件都位在应用程序的同一层(layer),而且没有抽换组件实现的需要(例如单元测试),这种情况,可直接依赖具象类型无妨。这就好像在同一间办公室里面工作的同事,通常是直接当面沟通;除非有特殊原因,否则没必要透过中间人传话,或透过即时消息软件的方式沟通。
4. 动态解析
虽然组件或服务的类型大多能够在应用程序初始化的时候决定,但有时候还是需要依执行时期的特定条件来动态决定使用哪个类型。比如说,在 ASP.NET MVC 应用程序中,可能会需要根据每一次前端网页发出请求的动作参数来决定该绑定哪一个 model 类型,而无法在建立 controller 时预先得知欲绑定的 model 类型。
尽量避免把 DI 容器直接当成 Service Locator 来使用
如需动态解析组件类型,比较偷懒的办法是把 DI 容器当成全局共享的 Service Locator 来使用。像这样:
MyApp.Container.Resolve<IValidationProvider>();
这种用法很方便,只要在程序的某处向 DI 容器预先注册组件类型,之后在程序中的任何地方都可以透过 DI 容器来解析组件。正因为它方便,所以容易滥用,使得组件之间的相依关系变得复杂紊乱,增加代码阅读与维护的困难。
因此,若有其他选项,例如 Constructor Injection、Factory 模式 等,应优先考虑。若都没有合适的办法,最后才使用 Service Locator。
此外,如果应用程序框架本身已经有提供组件解析的机制,也应该优先采用。例如 ASP.NET MVC 和 Web API 提供的 IDependencyResolver
及其相关机制。
考虑使用对象工厂或 Func<T>
来处理晚期绑定
有时候,必须等到程序执行时,依当下的某些变量值来动态决定该建立何种对象。例如:
class NotificationService { private IMessageService _emailService; // 邮件服务 private IMessageService _smsService; // 简讯服务 public NotificationService(IMessageService emailService, IMessage smsService) { _emailService = emailService; _smsService = smsService; } public void Notify(string to, string msg) { if (当前某些变量值符合特定条件) { _emailService.SendMessage(to, msg); } else { _smsService.SendMessage(to, msg); } } }
其中「当前某些变量值符合特定条件」所涉及的相关信息如果不存在 NotificationService
类型里面,则可以考虑将「建立对象」的工作委外,亦即由呼叫端或其他特定类型来负责建立所需之对象。一种常见的委外方式是撰写特定用途的对象工厂,把建立对象的逻辑包在工厂类型里,例如:
class MessageServiceFactory : IMessageServiceFactory { public IMessageService GetService() { if (当前某些变量值符合特定条件) { return new EmailService(); } else { return new SmsService(); } } } class NotificationService { public NotificationService(IMessageServiceFactory msgServiceFactory) { _msgServiceFactory = msgServiceFactory; } public void Notify(string to, msg) { using (var msgService = _msgServiceFactory.GetService()) { msgService.SendMessage(to, msg); } } }
于是客户端可以这么写:
var notySvc = new NotificationService(new MessageServiceFactory());
notySvc.Notify("Michael", "DI Example");
另一种可行的作法,是利用 Func<T>
来让外界提供建立对象的函式。像这样:
class NotificationService { private Func<IMessageService> _msgServiceFactory; public NotificationService(Func<IMessageService> svcFactory) { _msgServiceFactory = svcFactory; } public void SendMessage(string to, string msg) { using (var msgService = _msgServiceFactory()) { msgService.Send(to, msg); } } }
如果已经了解上述两种做法,还可以试着再进一步,使用 DI 框架提供的「自动工厂」(automatic factory)来达到相同目的。