依赖注入模式与反模式
依赖注入模式
构造器注入
属性注入
方法注入
上下文环境注入
依赖注入反模式
控制狂
Bastard注入
受限的构造
服务查找器
DI重构
将运行时的值映射到抽象
使用短生命的依赖
解决循环依赖
处理过多的构造器依赖参数
依赖注入模式
构造器注入
最重要的DI模式。
如何工作:一个私有的只读引用指向依赖,一个带参的构造器初始化该引用。
Tip1: 保持构造器逻辑的简洁,不要包含其它的逻辑。
Tip2: 可以把构造器注入看成是静态地声明了类的依赖。明确指明了依劣势赖的类型。何时使用:应该是你默认的DI选择。
Tip1: 如果可以将构造器设计为单一的。重载的构造器会给DI容器造成误导。
优缺点:
优势 | 劣势 |
---|---|
最容易实现的DI模式 | 有些框架假定你有默认构造器,因此使用它很困难;另一个显见的劣势是在程序初始化时就需要整个依赖图,不过不用担心出现性能问题。 |
属性注入
适用于有本地默认依赖,并且希望开发扩展的场景。
如何工作: 一个可写的属性
Tip1 : 又叫做依赖设置器注入,需要为属性设置一个默认值。
Tip2 : 如果允许在类的声明周期中切换依赖,可以通过在内部引入一个flag来确保依赖只允许被设置一次。何时使用: 用于依赖是可选的情况
Tip1 : 如果你只想留一个扩展点,那么建议你使用Null-Object模式来实现本地默认属性的初始化。
Tip2 : 如果你想要保留默认的属性,还想要更多的扩展,那么可以使用观察者模式或者组合模式。优缺点:
优势 | 劣势 |
---|---|
容易理解 | 健壮地实现它不太容易,客户端可能忘记设置依赖,也可能设置null,另外客户端在类声明周期内改变依赖是也会导致不一致或不期望的行为,这些情况都需要自己处理。 |
- 示例:
private CurrencyProfileService currencyProfileService;
public CurrencyProfileService CurrencyProfileService
{
get
{
if (this.currencyProfileService == null)
{
this.CurrencyProfileService = new DefaultCurrencyProfileService(this.HttpContext);//默认值的延迟初始化
}
return this.currencyProfileService;
}
set
{
if (value == null)
{
throw new ArgumentNullException("value");
}
if (this.currencyProfileService != null)
{
throw new InvalidOperationException();//只允许依赖定义一次
}
this.currencyProfileService = value;
}
}
方法注入
每一个方法的依赖都不相同时。
如何工作:依赖做为一个方法参数
Tip1 : 首先应该确保传入的依赖非空。
Tip2 : 如果方法并不使用传入的依赖,最好将参数删去,如果是实现的接口方法,那么应该将参数验证去掉。何时使用:每个方法的依赖都不相同时
Tip : 方法注入和使用抽象工厂模式很相似,抽象工厂的抽象输入可以看作是方法注入。
优缺点:
优点 | 缺点 |
---|---|
允许方法调用提供指定的上下文环境 | 适用性不广 |
上下文环境注入
为每一个模块提供依赖,而不用关注每一个API。
- 如何工作: 通过一个静态属性或者方法
public string GetMessage()
{
return SomeContext.Current.SomeValue;
}
也就是说,上面的Current必须是静态的、抽象的、可写的。SomeContext可能的这么实现:
public abstract class SomeContext
{
public static SomeContext Current
{
get
{
var ctx = Thread.GetData(Thread.GetNamedDataSlot("SomeContext"))
as SomeContext;//1、从TLS(线程本地存储)获得上下文
if (ctx == null)
{
ctx = SomeContext.Default;
Thread.SetData(Thread.GetNamedDataSlot("SomeContext"), ctx);
}
return ctx;
}
set
{
Thread.SetData(Thread.GetNamedDataSlot("SomeContext"), value);//2、在TLS中保存上下文
}
}
public static SomeContext Default = new DefaultContext();
public abstract string SomeValue { get; }//3、上下文承载的数据
}
注意:上面的例子为了简单没有考虑线程安全。自行实现时一定要考虑。
Tip1:该模式与线程和调用上下文并没有关系,很多时候,使它在整个应用程序域中Static即可。
- 何时使用:存在会污染所有API的横切的关注点
举个例子,你可能为某个函数传入了额外的参数,因为你不知道何时可能会用到它。
public string GetSomething(SomeService service, TimeProvider timeProvider)
{
return service.GetStuff("Foo", timeProvider);
}
其实上面的GetStuff()
方法根本用不到timeProvider
,额外的参数污染了API。
public string GetStuff(string s, TimeProvider timeProvider)
{
return this.Stuff(s);
}
使用条件:
- 需要请求式的上下文: 如果只是需要一些数据(上下文中的所有方法都返回void),那么使用拦截器是更好的解决方案。常见的例子有,日志、度量性能、断言安全上下文,所有这些动作都可以使用拦截器。你只有在需要询问获得某些值时,才考虑使用上下文环境注入。|
- 存在合适的本地默认依赖: 有隐式的上下文环境存在,即使不显式的分配上下文,也可以顺利地工作。
- 必须确保上下文的可访问性: 即使存在隐式的上下文环境,还是应该确保上下文环境非Null。
优缺点
优势 | 劣势 |
---|---|
不会污染API | 含蓄(容易引入潜在的Bug),很难被正确地实现,无法通过接口定义来知道类的依赖关系,也不容易发现类的扩展点 |
总是可以获得依赖 | 在一些运行时环境中不能很好的工作(比如需要切换线程上下文时) |
- 示例
public abstract class TimeProvider
{
private static TimeProvider current;
static TimeProvider()
{
TimeProvider.current = new DefaultTimeProvider();//1、默认实现
}
public static TimeProvider Current
{
get { return TimeProvider.current; }
set
{
if (value == null)//2、确保非Null
{
throw new ArgumentNullException("value");
}
TimeProvider.current = value;
}
}
public abstract DateTime UtcNow { get; }//3、获取数据
public static void ResetToDefault()
{
TimeProvider.current = new DefaultTimeProvider();
}
}
默认实现:
public class DefaultTimeProvider : TimeProvider
{
public override DateTime UtcNow
{
get { return DateTime.UtcNow; }
}
}
依赖注入反模式
控制狂
与控制反转相反,描述一个类维护了它所有的依赖。最常见的反模式,使用了太多的new关键字,以致我们需要控制太多实例的声明周期。模块都紧紧耦合在一起。
- 反例1:直接new
private readonly ProductRepository repository;
public ProductService()
{
string connectionString = ConfigurationManager.ConnectionStrings["CommerceObjectContext"].ConnectionString;
this.repository = new SqlProductRepository(connectionString);//直接创建了实例,紧密的耦合关系。
}
反例2:工厂
最常见的想要解决new实例问题的尝试,主要是选择一些工厂模式。它们存在哪些问题呢?- 简单工厂:完全没有解决DI问题,仅仅是把它移到了具体的工厂实例。我们仍然不能在运行时更换依赖的实例。
- 抽象工厂:任然不会解决DI问题,只不过把对具体产品实例的依赖,替换为对具体工厂实例的依赖。
- 静态工厂:使原有的依赖关系更复杂了。
重构
- 首先,确保你是面向接口编程的;
- 如过你在多个地方创建了特定的依赖,把它们移到一个方法。确保该方法返回的是抽象类型。
- 是由一种DI模式改造代码,比如构造器注入。
Bastard注入
包括BCL在内,很多.NET代码都包含重载的构造器。这个重载带来了一些负面的影响——默认的构造器实现可能并不是返回本地依赖而是一个外部依赖。当你完全拥抱依赖注入时,这些重载都变为是多余的。
- 反例:默认构造函数带来的外部依赖
private readonly ProductRepository repository;
public ProductService() : this(ProductService.CreateDefaultRepository()) {}//默认构造函数
public ProductService(ProductRepository repository)//构造器注入
{
if (repository == null)
{
throw new ArgumentNullException("repository");
}
this.repository = repository;
}
private static ProductRepository CreateDefaultRepository()
{
string connectionString = ConfigurationManager
.ConnectionStrings["CommerceObjectContext"].ConnectionString;
return new SqlProductRepository(connectionString);
}
分析
这种反模式经常可见,很多开发者没有完全理解DI,为了类的可测试性选择了这种反模式。这种模式带来了一些糟糕的影响。最重要的就是外部依赖的引入使模块重用变得困难,同时并行开发也紧紧的依赖在一起。重构
受限的构造
最常见的限制是要求所有的依赖都必须有特定签名的构造器,用来从配置文件来实现延迟绑定。
- 反例:
string connectionString = ConfigurationManager.
ConnectionStrings["CommerceObjectContext"].ConnectionString;
string productRepositoryTypeName = ConfigurationManager.AppSettings["ProductRepositoryType"];
var productRepositoryType = Type.GetType(productRepositoryTypeName, true);
var repository = (ProductRepository)Activator.
CreateInstance(productRepositoryType, connectionString);//使用反射创建实例
这个例子中,从配置文件中读取了连接字符串等一系列信息,最后反射时使用了这些信息,实际上隐式地约束了被依赖项。
约束对灵活性的影响是非常大的,比如我们可能需要将一个单例注入不同的模块。
- 重构:
使用抽象工厂模式,将类型定义从核心应用中分离开来,如此一来每次重新编译的代码变成一个个程序集。虽然这是一种可行的方案,但是仍然比不上使用DI容器方便。
服务查找器
许多开发者将静态工厂上升到另一个级别——服务定位器——直接控制依赖。它是模式还是反模式是见仁见智的。DI容器和服务查找器很像,它们之间的区别是微妙的,关键不在于它是如何实现的,而在于你如何使用它。本质上讲,如果用来在代码基上处理完整的依赖图,那么它是合适的,如果在任何时候获取小颗粒的服务,那么它是反模式。
- 反例:
public static class Locator
{
private readonly static Dictionary<Type, object> services = new Dictionary<Type, object>();
public static T GetService<T>()//获取服务
{
return (T)Locator.services[typeof(T)];
}
public static void Register<T>(T service)//注册服务
{
Locator.services[typeof(T)] = service;
}
public static void Reset()//清空服务
{
Locator.services.Clear();
}
}
这个反模式看起来很不错,不过它是一个危险的模式。它唯一重要的问题是影响了它的消费者类的可重用性(它包含了冗余的依赖,它不是自描述的)。想象一下两个模块都实现了服务查找器,或者一个使用DI,另一个使用服务查找器。其实有更好的选择——比如构造器注入。
- 重构:
- 使依赖从一个方法创建;
- 引入一个
readonly
字段来保存依赖; - 引入带参数的构造器。
注意: 服务查找器和环境上下文模式很像,区别在于本地默认值的可用性上。后者能够保证总是返回一个合适的被请求的服务,通常只有一个。而前者是不能保证的,本质上它使用了弱类型的容器。
DI重构
将运行时的值映射到抽象
问题:如何处理运行时的值的依赖
使用构造器注入时,实际上要求我们在设计时明确实际的依赖,但是有些情况下是不能满足的,比如地图网站,运行时依赖哪个路径算法,最短路径,最少时间,还是最少换乘。实际的依赖在运行时才能够确定。解决:抽象工厂
抽象工厂模式解决的问题就是我们可以请求抽象的实例,它为抽象类型和具体运行时实例直接提供了一个桥接。示例:路径算法
public enum RouteType//路径类型
{
Shortest = 0,
Fastest,
Scenic
}
public interface IRouteAlgorithmFactory//工厂
{
IRouteAlgorithm CreateAlgorithm(RouteType routeType);
}
public IRoute GetRoute(RouteSpecification spec, RouteType routeType)
{
IRouteAlgorithm algorithm = this.factory.CreateAlgorithm(routeType);//映射运行时的值
return algorithm.CalculateRoute(spec);//使用映射的算法
}
使用短生命的依赖
问题:请求外部资源
典型的比如数据库连接、Web服务、资源释放等。对于ADO.NET来说,这些都已经是常识,不过对于WCF客户端来说,如果不尽快地关闭资源,服务端的压力会很大。解决方案:将连接管理隐藏在抽象后面
一方面,依赖不能运行在内存泄漏的应用中,因此我们必须尽快关闭连接。另一方面,依赖也不能处理进程外的通信,因此构造一个包含Close方法的抽象是有漏洞的。即使继承IDisposable
接口,也不过是另一种Close方法,并不能解决底层的问题。
幸运地是LINQ to SQL和LINQ to Entities为我们提供了思路——我们通过context
(包含连接的上下文)访问数据。
消费者类调用IResource
接口定义的方法,连接管理由IResource的实例进行管理。
毫无疑问,上面的方案抽象粒度比较粗,灵活性不足,有时我们需要对依赖的生命周期进行更明确地控制,以防内存泄漏。最常见的方案就是IDisposable模式——我们创建连接、使用连接、释放连接。
当然我们可以使用实现了IDisposable模式的抽象工厂,只是消费者类必须记得释放资源。
其实最佳实践是使用C#的using
关键字。
解决循环依赖
问题:不可避免的循环依赖
只有程序存在循环的依赖关系,我们是不可能满足所有的依赖的,因此程序也不可能运行。大多数情况下,应该是你程序设计的问题,某些特定的实现带来了循环依赖。如果这种实现不是必须的,你最好对程序重新设计。
典型的情况是分层应用中循环依赖。
解决方案:
解决的第一步就是打破循环:大多数分层应用中的循环依赖是结构性错误,先仔细考虑下分层是否合理。思考一下循环依赖为什么发生,有时可以改变设计,还可以使用事件、观察者模式,实在不行最后的方法是将构造器依赖注入改为属性注入。
将B的DI方式由构造器注入改为属性注入:
var b = new B();
var a = new A(b);
b.C = new C(new D(a));//属性注入
如果你不想或者不能修改B的构造方式,还可以引入一个虚拟的协议:
var lb = new LazyB();//和B实现一样的接口
var a = new A(lb);
lb.B = new B(new C(new D(a)));
- 示例:WPF MVVM模式中的例子
Window依赖于一个ViewModel,而ViewModel依赖于一个IWindow接口,这个接口由WindowAdapter实现。
MVVM是怎么做的?它使Window和ViewModel的依赖通过属性注入。
private void EnsureInitialized()
{
if (this.initialized)
{
return;
}
var vm = this.vmFactory.Create(this);//创建ViewModel
this.WpfWindow.DataContext = vm;//属性注入
this.DeclareKeyBindings(vm);
this.initialized = true;
}
可以在应用程序根部装配:
IMainWindowViewModelFactory vmFactory = new MainWindowViewModelFactory(agent);
Window mainWindow = new MainWindow();
IWindow w = new MainWindowAdapter(mainWindow, vmFactory);
处理过多的构造器依赖参数
- 问题:构造器注入非常容易实现,但是当参数过多时让人很不舒服
public MyClass(IUnitOfWorkFactory uowFactory,
CurrencyProvider currencyProvider,
IFooPolicy fooPolicy,
IBarService barService,
ICoffeeMaker coffeeMaker,
IKitchenSink kitchenSink)
不要把过错归咎于构造器注入,问题在于违反了单一职责原则。
解决方案:外观模式
外观隐藏内部的依赖,只提供可以消费的服务。如果系统非常大,可以循环使用该方法示例:一个订单服务
引入两个外观接口: