在 C# 中使用Ninject进行依赖注入的分步指南

714 阅读10分钟

最近,我一直在尝试Ninject依赖注入框架。在这篇文章中,我想给你一些关于如何使用它的知识。我还将简要解释依赖注入的目的。

什么是依赖注入?

依赖注入 (DI) 是一种设计模式,通过在运行时(而不是在设计时)注入这些依赖关系来减少类之间的硬编码依赖关系。从技术上讲,依赖注入是一种机制,允许实施另一种称为"控制反转"(IoC)的设计模式。这两种模式的目的是减少类之间的硬编码依赖关系(或"耦合")。

什么是依赖关系?

假设你正在构建一个 Web 应用程序,该应用程序将向已输入表单的访客发送电子邮件。在面向对象的代码中,区分责任非常重要。因此,你最终可能会获得一个处理表单输入(FormHandler)的类和负责发送电子邮件(MailSender)的类。MailSender类看起来像这样:

public class MailSender
{
   public void Send(string toAddress, string subject)
   {
      Console.WriteLine("Sending mail to [{0}] with subject [{1}]", toAddress, subject);
   }
}

如果你不使用Inversion of Control,你的FormHandler类将看起来像这样:

public class FormHandler
{
    public void Handle(string toAddress)
    {
        MailSender mailSender = new MailSender();
        mailSender.Send(toAddress, "This is non-Ninject example");
    }
}

虽然此代码没有问题,但它正在FormHandler和MailSender类之间的依赖关系。依赖创建于第 5 行。使用代码中的新关键字来实例化类意味着你正在创建依赖关系。从实际的角度来看,你告诉你的FormHandler类使用MailSender类的具体实施。没有灵活性。"但是为什么,"你问,"这是件坏事吗?让我们来看看一些原因...

为什么依赖关系是件坏事?

你不能使用MaillSender类的多个实现:如果你像上面所示编写代码,你将失去面向对象代码的好处之一。你不能轻易地将MailSender的实现换成另一个实现。也许你希望避免发送真实邮件并将其记录为应用程序的过渡环境。或者你希望以纯特性发送邮件,而不是用HTML发送邮件。在这些情况下,你只能通过更改MailSender类和FormHandler类来更改邮件发送器的实现。结果:你失去了灵活性:

它使编写松散的代码变得更加容易:如果你的类紧密相连,那么更容易发生职责混合。如果你使用依赖注入,你必须投入更多时间为你的类,特别是他们的接口设计一个好的设计。因此,使用控制反转或依赖注入的将提高代码的质量。

它使单元测试(几乎)变得不可能:当你为一个类编写单元测试时,你只想测试该特定类的行为。如果你要为FormHandler编写单元测试,你最终也会测MailSender类。毕竟,MailSender在"Handle"方法的里面使用,对此我们无能为力。这使得编写单元测试几乎是不可能的。如果你正在编写单元测试,则通常需要依赖注入。

结果是,你的FormHandler不应该知道使用了MailSender的新实例。具体实例化的是什么,应该在类外确定。这样,如果需要,你可以将邮件发送者换成另一个实例。例如,如果你正在为 FormHandler 编写单元测试,则可以将MailSender类换成模拟版本。

那么,如何摆脱依赖关系呢?

这样做的一种方法是使用依赖注射框架,如Spring,Unity或Ninject。这些框架允许你与类封面开配置应用使用的具体实现。我更喜欢 Ninject, 因为它是轻量级的, 易于使用并且几乎不需要更改代码。

第 1 步:下载Ninject

转到Ninject网站并下载针对你使用的 .NET 平台的最新版本。如果你不确定,请使用.NET 4.5。你可以通过Visual Studio中的 NuGet 做到这一点。我比较喜欢手动方法,将程序集(ninject.dll)放入我的解决方案中被称为 "Assemblies"的文件夹中。

第 2 步:准备代码

在深入研究 Ninject 之前,我们要做的第一件事就是使用接口来重写我们的代码。对于依赖注入这是必须的,但无论如何这都是好的习惯。接口基本上是一个类之间的契约,迫使一个类的行为完全按照接口描述进行操作。我们为MailSender类创建一个非常简单的界面:

public interface IMailSender
{
   void Send(string toAddress, string subject);
}

我们还重写MailSender类以实现 IMailSender接口:

public class MailSender : IMailSender
{
    public void Send(string toAddress, string subject)
    {
      Console.WriteLine("Sending mail to [{0}] with subject [{1}]", toAddress, subject);
    }
}

我们基本上已经告诉C#,我们的MailSender的具体实施遵循IMailSender接口。现在可以创建其他具体遵循相同的接口的实现,例如:

public class MockMailSender : IMailSender
{
    public void Send(string toAddress, string subject)
    {
        Console.WriteLine("Mocking mail to [{0}] with subject [{1}]", toAddress, subject);
    }
}

现在,我们可以通过重写实例化该类的代码来更改我们的FormHandler使用的具体实现:

public class FormHandler
{
    public void Handle(string toAddress)
    {
       IMailSender mailSender = new MockMailSender();
       mailSender.Send(toAddress, "This is still a non-Ninject example");
    }
}

当然,这已经为我们的代码增加了很多灵活性,因为我们可以通过更改第5行来交换哪种IMailSender的具体实现。下一步是实施手动依赖注入。

第 3 步:实施手动依赖注入

在使用 Ninject 注入依赖项之前,手动操作以了解基础知识是很有用的。基本上,我们将通过FormHandler类的构造函数传递依赖。通过这种方式,使用FormHandler的代码可以确定要使用的 IMailSender 的具体实现:

public class FormHandler
{
    private readonly IMailSender mailSender;

    public FormHandler(IMailSender mailSender)
    {
        this.mailSender = mailSender;
    }

    public void Handle(string toAddress)
    {
        mailSender.Send(toAddress, "This is non-Ninject example");
    }

创建FormHandler的代码必须首先传入IMailSender的具体实现。这意味着控制权现在被反转了。现在不是由FormHandler决定使用哪个实现,而是由调用代码决定。这就是控制反转的全部意义,依赖注入只是其中一种方式。代码如下:

class Program
{
    static void Main(string[] args)
    {
       IMailSender mailSender = new MockMailSender();
       FormHandler formHandler = new FormHandler(mailSender);
       formHandler.Handle("test@test.com");

       Console.ReadLine();
    }
}

这是手动依赖注入的一个例子,因为我们不依赖于任何框架来为我们做繁重的工作。上面的代码会很好地执行。但随着依赖数量的增加,这种方法将变得越来越困难。毕竟,你必须为正在实例化的类中的每一个新依赖项添加一个构造参数。这可能是相当令人气愤的。因此,我们需要一个框架来解决这个问题。这就是Ninject或其他任何DI框架存在的意义。

第 4 步:使用Ninject为我们实现依赖注入

Ninject是注入的方式是相当多的, 但我会坚持最简单 (和最常用) 依赖注入方式, 即构造函数注入。关于Ninject的好处是,你不必修改MailSender,IMailSender或FormHandler。你只需要在项目中添加对 Ninject .dll程序集的引用,并在项目中创建一个单独的类,用于在Ninject 运行时配置依赖项:

using Ninject.Modules;  
using Ninject;

public class Bindings : NinjectModule
{
    public override void Load()
    {
        Bind<IMailSender>().To<MockMailSender>();
    }
}

类的名称可以是任何你喜欢的:只要它从Ninject模块继承,Ninject就会找到它。你的调用代码(Program.cs)必须使用Ninject来确定要使用哪些具体实现:

using Ninject;  

class Program
{
    static void Main(string[] args)
    {
        var kernel = new StandardKernel();
        kernel.Load(Assembly.GetExecutingAssembly());
        var mailSender = kernel.Get<IMailSender>();

        var formHandler = new FormHandler(mailSender);
        formHandler.Handle("test@test.com");

        Console.ReadLine();
    }
}

行此代码时,控制台将会显示"向...发送邮件",这也是我们所期望的。依赖注入正在工作!该代码创建了一个ninjectKernel 解决我们整个依赖链。我们告诉Ninject从正在执行的程序集中加载绑定。这是最常见的情况。在这种情况下,你的绑定类应位于执行项目中包含的程序集之中。实际上,这意味着你的绑定类通常将存在于你的网站,Web服务,Windows服务,控制台应用程序或单位测试项目,因为他们位于执行代码链的顶部。对于每个链/上下文(网站、单元测试、控制台),你可以创建具有不同配置的不同绑定类。例如,你可以更改绑定类,以便在使用IMailSender的任何地方使用MailSender:

public class Bindings : NinjectModule
{
    public override void Load()
    {
        Bind<IMailSender>().To<MailSender>();
    }
}

现在,运行相同的代码将在你的控制台中显示“将邮件发送到...”,这是我们所期望的。

第 5 步:更多程度的依赖关系,以及威力真正展示的位置

以上示例有效,但它没有展示出Ninject真正的力量。当你的项目不断变大,依赖数量增加时,Ninject 会根据绑定数自动找出哪些具体实现需要传递给构造函数。假设我们的MailSender将调用一个单独的类用来记录异常。如果没有依赖注入,我们的表单手现在将依赖于MailSender,而MailSender又依赖于Logging类。因此,你的MailSender类可能看起来像这样:

public class MailSender : IMailSender
{
    private readonly ILogging logging;

    public MailSender(ILogging logging)
    {
       this.logging = logging;
    }

    public void Send(string toAddress, string subject)
    {
       logging.Debug("Sending mail");
       Console.WriteLine(string.Format("Sending mail to [{0}] with subject [{1}]", toAddress, subject));
    }
}

你的绑定将看起来像这样:

public class Bindings : NinjectModule
{
    public override void Load()
    {
        Bind<IMailSender>().To<MockMailSender>();
        Bind<ILogging>().To<MockLogging>();
    }
}

现在,如果你从控制台应用程序调用FormHandler,将进行两次依赖注入。我们已经看到的第一个;我们要求Ninject给我们一个具体的MailSender实现,并将其传递给FormHandler的构造函数。当Ninject实例化MailSender时,它就会明白此类需要ILogging。它将检查其绑定并自动加载指定的具体实现。好消息是,如果你忘记在绑定中添加 ILogging 的配置,Ninject 将抛出一个友好的异常,解释你必须做什么。

因此,一旦你通过创建内核设置了执行等级的顶层,Ninject 将为你处理其余部分。这与其他大多数依赖注入框架有很大的不同。它们通常要求在包含依赖项的所有类中更改代码。

避免使用服务定位器反模式

我最初犯的一个错误是,我使用容器或 IoC 管理器作为服务定位器(另一种设计模式)。基本上,我创建了一个以单例状态生存的类,并且从所有有依赖关系的类中调用该类来解决依赖项问题(IoCManager.Resolve())。许多依赖注入框架都使用了这种方法,如 Unity。这是一个常见的策略,但它会导致对 IoC 容器_itself的依赖,因此没有必要。我上面显示的代码不需要任何自定义服务定位器模式就可以工作。事实上,自手动入射方法以来,MailSender和FormHandler类并没有更改。

结论

在你从来没有使用过依赖注入之前,它会是一个很难理解的概念。但是,一旦你开始尝试,你就会看到它让你的代码变得多么灵活。特别是当你写单位测试时,你会很快看到好处。在这种情况下,你可以轻松地交换依赖项的模拟实现。它使你的代码更清洁,并迫使你写出更好的代码。