依赖注入(DI)的实践与挑战

依赖注入(DI)是一种设计模式,它允许开发者在运行时动态地为对象提供其依赖项,而不是在编译时静态地定义。这种模式可以提高代码的灵活性和可测试性。在DI中,一个类X被设计为不直接指定它依赖的其他类,而是通过接口或抽象类来访问所需的服务。这样做的好处是,X的依赖关系可以由第三方来选择,从而使得X可以在不同的上下文中重用,而无需修改其本身。

例如,如果类X需要数据库服务,它不会直接创建数据库连接,而是通过一个数据库相关的接口(如IQueryable)来引用。这样,即使X最初是为MySQL设计的,也可以在不修改X的情况下切换到PostgreSQL或Oracle。在单元测试中,甚至可以使用一个包含模拟数据的“模拟对象”,而不需要真实的数据库。

在具有多层依赖关系的应用程序中,例如文档编辑程序,可能有一个主窗口、文档编辑器、工具栏/菜单,它们依赖于文档编辑器,而文档编辑器又依赖于文档对象和各种用户界面服务。文档可能依赖于撤销/重做服务、各种数据结构和磁盘访问服务,而用户界面服务可能依赖于其他数据结构、绘图服务、空间分析算法等。

在设计DI的应用程序中,通常会在一个地方将所有不同的组件连接在一起,这样可以在一个地方查看组件是如何连接和相互依赖的。随着应用程序变得更加复杂,通过依赖注入初始化所有这些对象的代码也变得更加复杂。最终,可能需要一个IoC/DI框架,如Ninject、Windsor或Autofac,来简化所有的初始化工作。

但是,有些服务是普遍存在的,如果想要“正确”地使用DI,可能需要将这些服务传递给一百个不同的构造函数。一些例子包括本地化(提供法语和西班牙语翻译)、日志记录(几乎任何组件都可能想要写一条诊断消息)、性能分析(收集性能统计数据)以及可能的“配置选项”(以便最终用户或管理员可以通过命令行、XML文件或其他来源配置多个组件)。在编译器中,错误/警告消息服务可能在很多地方使用。在Loyc中,可能有数百个组件需要创建AST节点或使用其他“普遍”服务。

在看来,将如此多的普遍服务引用传递给构造函数比它值得的麻烦要多。如果Loyc的一个组件可能会产生警告消息,是用户可配置的,创建新的AST节点,并需要本地化支持,那么仅仅为了“普遍”服务就有4个构造函数参数,更不用说它可能需要的更重要的、“实质性”的服务,如解析器、图形算法等。如果使用构造函数注入,几十个构造函数开始变得有味道。

如何避免传递大量普遍服务引用的负担?不确定最好的方法是什么。有一个想法,但它不适用于所有情况。想法涉及到一个全局单例,可以在执行特定任务时临时替换。暂时称之为环境服务模式。

例如,在编译器中,错误/警告服务可能默认打印到控制台或输出窗口,但某些类型的分析可能是“事务性”或“试探性的”:如果发生错误,操作将被中止,并且不会打印错误,尽管如果发生警告,它将被缓冲并在操作成功时打印。这种场景的一个具体例子是C++规则SFINAE。模板替换可能会产生错误,但如果错误发生在重载解析期间,它实际上并不是错误,不应该打印消息。给定一个全局错误服务,可以通过切换到一个特殊的错误服务,执行操作,然后切换回来来模拟这个规则。

要实现这个模式,请使用线程局部变量和接口一起管理服务:

interface IService { ... } class Service { static ThreadLocalVariable _cur = new ThreadLocalVariable(); // 当前服务 public static Cur { get { return _cur.Value; } } // 用于临时安装新服务,或者在程序开始时安装初始服务 public static PushedTLV Push(IService newValue) { return new PushedTLV(_cur, newValue); } }

根据应用程序的不同,可能有不同的线程在执行独立的工作,这就是为什么需要线程局部变量的原因。

注意:如果需要支持线程分叉,有一个问题,因为.NET线程局部变量不能从父线程继承其值。为了解决Loyc中的这个问题,为线程创建制作了整个基础设施,有一个ThreadEx来包装标准Thread类,以及一个ThreadLocalVariable类来代替[ThreadStatic],它在全局弱引用集合中注册自己,以便当使用ThreadEx创建线程时,所有ThreadLocalVariables的值都可以从父线程传播到子线程(也支持自定义传播行为)。显然,这个变通方法非常麻烦,因为所有代码都必须同意使用ThreadEx,而新的.NET东西,如“并行扩展”不会传播线程局部变量。想为了正确支持.NET 4,应该尝试基于ThreadLocal找到一个新的解决方案。

PushedTLV是一个帮助类,它会在被处置时改变线程局部变量的值。像这样使用它:

using (var old = Service.Push(newService)) { // 执行将使用新服务的操作 } // 旧服务自动恢复

这个模式的一个不幸后果是,接口似乎与线程局部变量耦合。当然,仍然可以编写一个具有构造函数注入IService的类X,但这可能会让间接使用X的人感到困惑:如果有人更改了Service.Cur,他们可能会认为所有需要IService的代码都会使用Service.Cur,但X可能正在使用不同的IService。

虽然环境服务模式不像传统的依赖注入那样工作,但它仍然遵循了DI的精神,因为组件仍然独立于普遍服务的具体实现。

在这里看到的模式版本中,提供的服务充当单例。当然,如果服务是可以按需实例化的(例如数据结构),线程局部变量将持有一个工厂而不是单例。"Push()"方法将切换到一个不同的工厂而不是不同的实例,并且会有一个"New()"方法而不是"Cur"属性。

想知道.NET框架本身是否应该采用这种模式。考虑一下打开文件时会做什么:调用File.Open()或创建一个FileStream,对吧?现在,如果管理层决定“需要应用程序能够从FTP站点打开文件”,而不是更改调用File.Open()的代码(如果第三方库正在调用它怎么办?),如果能够切换到一个新的文件管理服务,它理解FTP站点(并且可能维护一个本地磁盘缓存),那不是更优雅吗?嘿,如果决定将控制台应用程序更改为使用具有图标和比例字体的图形控制台怎么办?如果决定Debug.WriteLine需要在某个地方存储日志怎么办?

/// <summary> /// 设计为在 "using" 语句中使用,以临时更改 /// </summary> public class PushedTLV : IDisposable { T _oldValue; ThreadLocalVariable _variable; public PushedTLV(ThreadLocalVariable variable, T newValue) { _variable = variable; _oldValue = variable.Value; variable.Value = newValue; } public void Dispose() { _variable.Value = _oldValue; } public T OldValue { get { return _oldValue; } } public T Value { get { return _variable.Value; } } }
沪ICP备2024098111号-1
上海秋旦网络科技中心:上海市奉贤区金大公路8218号1幢 联系电话:17898875485