依赖注入(DI)友好的库

236
我正在考虑设计一个C#库,它将具有几个不同的高级函数。当然,这些高级函数尽可能地使用SOLID类设计原则来实现。因此,可能会有用于消费者直接定期使用的类,以及那些更常见的“最终用户”类所依赖的“支持类”。
问题是,设计库的最佳方式是什么?
  • DI Agnostic - 虽然对于一两个常见的DI库(StructureMap,Ninject等)添加基本的“支持”似乎是合理的,但我希望消费者能够使用任何DI框架来使用库。
  • Non-DI usable - 如果库的消费者不使用DI,则该库仍应尽可能易于使用,减少用户必须做的所有这些“不重要”的依赖项的创建量,以便到达他们想要使用的“真正”类。
我目前的想法是为常见的DI库提供几个“DI注册模块”(例如StructureMap注册表,Ninject模块),以及一组非DI的工厂类或工厂集合,并包含与这些少数工厂的耦合。
您有什么想法?

新的关于破损链接文章(SOLID)的参考:http://butunclebob.com/ArticleS.UncleBob.PrinciplesOfOod - M.Hassan
4个回答

373

理解了DI是关于模式和原则而不是技术,实际上这很简单。

要以与DI容器无关的方式设计API,请遵循以下一般原则:

针对接口编程,而不是实现

这个原则实际上是摘自《设计模式》(尽管是从记忆中摘录的),但它应该始终是您的真正目标 DI只是实现该目标的方法

应用好莱坞原则

在DI方面,好莱坞原则的意思是:不要调用DI容器,容器会调用你

永远不要直接通过从代码内部调用容器来请求依赖项。通过使用构造函数注入隐式地请求依赖项。

使用构造函数注入

当需要依赖项时,通过构造函数静态地请求它:

public class Service : IService
{
    private readonly ISomeDependency dep;

    public Service(ISomeDependency dep)
    {
        if (dep == null)
        {
            throw new ArgumentNullException("dep");
        }

        this.dep = dep;
    }

    public ISomeDependency Dependency
    {
        get { return this.dep; }
    }
}

注意Service类如何保证其不变式。一旦创建实例,由于Guard Clause和readonly关键字的组合,依赖项保证可用。

如果需要短暂的对象,请使用抽象工厂

使用构造函数注入的依赖项往往具有长生命周期,但有时需要一个短生命周期的对象或根据仅在运行时已知的值构建依赖项。

请参见此处以获取更多信息。

仅在最后负责时构成

将对象保持解耦,直到最后才组合。通常,您可以等待并在应用程序的入口点中连接所有内容。这称为组合根

更多详细信息请看:

使用Facade简化

如果您觉得为初学者用户提供的API变得太复杂,可以始终提供一些封装常用依赖项组合的Facade类。

要提供灵活的Facade和高度可发现性,您可以考虑提供流畅构建器。例如:

public class MyFacade
{
    private IMyDependency dep;

    public MyFacade()
    {
        this.dep = new DefaultDependency();
    }

    public MyFacade WithDependency(IMyDependency dependency)
    {
        this.dep = dependency;
        return this;
    }

    public Foo CreateFoo()
    {
        return new Foo(this.dep);
    }
}

这将允许用户通过编写以下内容来创建默认的Foo

var foo = new MyFacade().CreateFoo();

然而,很容易发现可以提供自定义依赖项,您可以编写

var foo = new MyFacade().WithDependency(new CustomDependency()).CreateFoo();

如果你想象一下MyFacade类封装了很多不同的依赖关系,希望现在清楚了如何提供适当的默认值,同时仍然使可扩展性易于发现。


顺便说一句,在撰写这篇答案后很久,我进一步阐述了这里的概念,并撰写了一篇更长的博客DI-Friendly Libraries,以及一篇关于DI-Friendly Frameworks的配套文章。


24
虽然从理论上来看听起来很不错,但根据我的经验,一旦有许多内部组件以复杂的方式进行交互,你最终会有许多需要管理的工厂,这使得维护变得更加困难。此外,工厂还必须管理它们所创建的组件的生命周期,一旦您在真实容器中安装库,这将与容器自己的生命周期管理发生冲突。工厂和门面妨碍了真正的容器。 - Mauricio Scheffer
4
我还没有找到一个项目能够完全做到这一点。 - Mauricio Scheffer
31
这就是我们在Safewhere开发软件的方式,所以我们不了解你的经历... - Mark Seemann
19
我认为立面应该手动编码,因为它们代表已知(且可能常见)的组件组合。不需要使用 DI 容器,因为一切都可以手动连接(类似于“贫穷人的 DI”)。请记住,外观只是 API 用户可选的方便类。高级用户仍然可能要绕过外观,按照自己的喜好连接组件。他们可能希望使用自己的 DI 容器,因此如果他们不打算使用它,强制施加特定的 DI 容器可能是不友善的。虽然可能,但不建议这样做。 - Mark Seemann
8
这可能是我在SO上看到的最佳答案。 - Nick Hodges
显示剩余5条评论

43
术语“依赖注入”与IoC容器并没有特定的关系,即使你通常会看到它们一起提到。它只是意味着不要像这样编写代码:
public class Service
{
    public Service()
    {
    }

    public void DoSomething()
    {
        SqlConnection connection = new SqlConnection("some connection string");
        WindowsIdentity identity = WindowsIdentity.GetCurrent();
        // Do something with connection and identity variables
    }
}

你可以这样写:

像这样编写:

public class Service
{
    public Service(IDbConnection connection, IIdentity identity)
    {
        this.Connection = connection;
        this.Identity = identity;
    }

    public void DoSomething()
    {
        // Do something with Connection and Identity properties
    }

    protected IDbConnection Connection { get; private set; }
    protected IIdentity Identity { get; private set; }
}

也就是说,在编写代码时,您需要做两件事情:
  1. 如果您认为实现可能需要更改,则应该依赖于接口而不是类;

  2. 在类内部不应创建这些接口的实例,而是将它们作为构造函数参数传递(或者将它们分配给公共属性;前者是构造函数注入,后者是属性注入)。

这并不预设任何 DI 库的存在,并且即使没有 DI 库,这也不会使编码变得更加困难。
如果您正在寻找此类示例,请参考 .NET Framework 本身:
  • List<T> 实现了 IList<T>。如果您设计您的类使用 IList<T>(或者 IEnumerable<T>),则可以利用懒加载等概念,如 Linq to SQL、Linq to Entities 和 NHibernate 在幕后执行的操作,通常通过属性注入实现。有些框架类实际上接受 IList<T> 作为构造函数参数,例如用于多个数据绑定特性的 BindingList<T>

  • Linq to SQL 和 EF 完全基于 IDbConnection 和相关接口构建,可以通过公共构造函数传入。不过,您也可以使用默认构造函数,并在某个配置文件中设置连接字符串。

  • 如果您曾经使用过 WinForms 组件,则会涉及“服务”,例如 INameCreationServiceIExtenderProviderService。您甚至真的不知道这些具体类是什么。 .NET 实际上有自己的 IoC 容器 IContainer,用于此目的,而 Component 类则有一个 GetService 方法,它是实际的服务定位器。当然,您可以在没有 IContainer 或该特定定位器的情况下使用任何或所有这些接口。这些服务本身与容器的耦合度很低。

  • WCF 中的协议完全基于接口构建。实际的具体服务类通常在配置文件中以名称引用,这本质上就是 DI。许多人并没有意识到这一点,但完全有可能使用另一个 IoC 容器来替换此配置系统。更有趣的是,服务行为都是 IServiceBehavior 的实例,可以稍后添加。同样,您可以轻松地将其连接到 IoC 容器并让其选择相关的行为,但是即使没有 IoC 容器,这个特性也可以完全使用。

等等等等。您将在 .NET 中各处发现 DI,只是通常它被无缝地执行,以至于您甚至不会将其视为 DI。
如果您希望为 DI 启用的库设计最大可用性,则最好提供自己的默认 IoC 实现,使用轻量级容器。使用 IContainer 是一个不错的选择,因为它是 .NET Framework 的一部分。

2
容器的真正抽象是IServiceProvider,而不是IContainer。 - Mauricio Scheffer
2
@Mauricio:你当然是对的,但是试着向从未使用过LWC系统的人解释为什么IContainer实际上不是容器,这可能需要一个段落。 ;) - Aaronaught
Aaron,我只是好奇为什么你使用private set而不是将字段指定为readonly? - jr3
@Jreeter:你的意思是为什么它们不是“private readonly”字段?如果它们只被声明类使用,那就很好,但OP指定这是框架/库级别的代码,这意味着子类化。在这种情况下,通常希望将重要依赖项暴露给子类。我可以编写一个“private readonly”字段和一个具有显式getter/setter的属性,但是……在示例中浪费空间,在实践中需要维护更多的代码,而没有真正的好处。 - Aaronaught
谢谢您澄清!我以为只读字段可以被子类访问。 - jr3
@jr3 这也与提供给子类的接口(受保护+公共成员)应该像公共接口一样受到同等关注和细心对待的概念有关。更多信息请参见此处此处。因此,出于与公共字段相同的原因,受保护的字段很少是一个好主意。 - tne

4
编辑于2015年:时间已经过去,我现在意识到这整件事情都是一个巨大的错误。IoC容器很糟糕,DI是处理副作用的一种非常糟糕的方式。实际上,所有这里的答案(以及问题本身)都应该避免。只需注意副作用,将其与纯代码分离,其他所有内容都会落到位或者是不相关和不必要的复杂性。

以下是原始答案:


在开发SolrNet时,我不得不面临同样的决定。我最初的目标是友好的依赖注入和容器无关性,但随着我添加更多的内部组件,内部工厂很快就变得难以管理,结果导致库缺乏灵活性。

最终,我写了一个非常简单的嵌入式IoC容器,同时提供了一个Windsor facility和一个Ninject module。将库与其他容器集成只需要正确地连接组件,因此我可以轻松地将其与Autofac、Unity、StructureMap等集成。

这样做的缺点是,我失去了只需new服务的能力。我还依赖于CommonServiceLocator,本来可以避免的(未来可能进行重构),以使嵌入式容器更易于实现。
更多细节请参见此博客文章

MassTransit 似乎依赖于类似的东西。它有一个 IObjectBuilder 接口,实际上是 CommonServiceLocator 的 IServiceLocator 带有几个更多的方法,然后为每个容器实现这个接口,即 NinjectObjectBuilder 和一个常规的模块/设施,即 MassTransitModule。然后它 依赖于 IObjectBuilder 来实例化所需的内容。当然,这是一种有效的方法,但个人不太喜欢它,因为它实际上过度传递了容器,并将其用作服务定位器。

MonoRail 实现了自己的容器,该容器实现了老旧的IServiceProvider。这个容器通过一个公开已知服务的接口在整个框架中使用。为了获取具体的容器,它有一个内置的服务提供程序定位器Windsor facility 将此服务提供程序定位器指向 Windsor,使其成为所选的服务提供程序。

底线是:没有完美的解决方案。与任何设计决策一样,这个问题需要在灵活性、可维护性和便利性之间取得平衡。

13
尊重您的观点,但我认为您过于笼统地表示其他回答过时且应该避免这一点非常天真。如果我们从中学到了什么,那就是依赖注入是解决语言限制的答案。如果不进化或放弃我们现有的语言,似乎我们将需要DI来扁平化我们的架构并实现可重用性。IoC作为一个通用概念只是这些目标的一个结果,绝对是一个值得追求的目标。对于IoC或DI,IoC 容器 绝对不是必需品,它们的有用性取决于我们所做的模块组合。 - tne
@tne 你说我“极其天真”是一种不受欢迎的人身攻击。我在C#、VB.NET、Java等语言中编写了复杂的应用程序,没有使用DI或IoC(根据你的描述,这些语言似乎需要IoC),这绝对不是关于语言限制的问题,而是概念上的限制。我邀请你学习函数式编程,而不是诉诸人身攻击和无力的论点。 - Mauricio Scheffer
20
我提到我认为你的陈述很幼稚;这并不涉及你个人,只是我的观点。请不要因一个陌生人的话而感到受辱。暗示我对所有相关的函数式编程方法都无知也没有帮助;你不知道那些,而且你会错。我知道你在那方面很有知识(迅速查看了你的博客),这就是为什么我觉得一个看起来很有见识的人会说像“我们不需要IoC”这样的话让人烦恼。IoC在函数式编程中随处可见...... - tne
4
在高级软件中,事实上函数式语言(以及许多其他风格)确实证明了它们在不使用依赖注入的情况下解决了IoC问题,我想我们都同意这一点,这就是我想说的全部。如果您在提到的语言中没有使用依赖注入,那么您是如何做到的呢?我假设您没有使用“服务定位器”。如果您应用函数式技术,则最终会得到等效于闭包的对象,这些对象是依赖注入的类(其中依赖项是封闭的变量)。还有其他方法吗?(我真的很好奇,因为我也不喜欢依赖注入。) - tne
1
IoC容器是全局可变字典,剥夺了参数化作为推理工具的能力,因此应该避免使用。在技术上,每当您将函数作为值传递时,都会应用IoC(概念),就像“不要打电话给我们,我们会打电话给你”一样。与其使用DI,不如使用ADT来建模您的领域,然后编写解释器(参见Free)。 - Mauricio Scheffer
显示剩余3条评论

1
我会设计我的库,使其与 DI 容器无关,尽可能减少对容器的依赖。这样可以在需要时更换 DI 容器。
然后将 DI 逻辑上面的层暴露给库的用户,以便他们通过您的接口使用您选择的任何框架。这样,他们仍然可以使用您公开的 DI 功能,并且可以自由地使用任何其他框架来实现自己的目的。
让库的用户插入自己的 DI 框架似乎有些不妥,因为这会大大增加维护量。这也变成了一个插件环境,而不是纯粹的 DI。

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接