65.9K
CodeProject 正在变化。 阅读更多。
Home

C# 中的依赖注入模式 - 简短教程

starIconstarIconstarIconstarIcon
emptyStarIcon
starIcon

4.91/5 (33投票s)

2022年6月1日

MIT

16分钟阅读

viewsIcon

49579

downloadIcon

446

我们解释 DI 模式、DIP、IoC、DI 容器

面试生存教程

本文的目的是提供一个关于依赖注入模式及相关主题的简短、简洁的教程。对于希望学习该主题的人来说,它可以作为“初次接触教程”;对于希望复习知识的人来说,它可以作为“复习材料”。对于需要快速掌握基本概念的人来说,它可以作为面试准备材料。

本教程涵盖的主题通常是在面试高级(.NET)开发职位时会问到的。

这是一个基础教程,不涉及所有细节。人们通过“同心圆”的方式学习知识效果最好,在第一个同心圆中,他们学习一切的基础;在第二个同心圆中,他们回顾上一圆中学到的知识,并用更多细节扩展这些知识;然后在下一个同心圆中,他们再次做类似的事情,以此类推。本文旨在为感兴趣的读者提供对该主题的第一次了解。

涵盖的主题

涵盖以下主题

  • 依赖注入模式 – DI (*)
  • 依赖倒置原则 – DIP (**)
  • 控制反转 – IoC (***)
  • 依赖注入容器 (****)

依赖注入模式 – DI (*)

首先,“依赖注入模式”是一种软件设计模式。之所以称为“模式”,是因为它针对特定问题提出了低级别的具体实现方法。

该模式旨在解决的主要问题是如何创建“松耦合”的组件。它通过将组件的创建与其依赖项分离来实现。

该模式中有四个主要角色(类)

  1. Client:客户端是希望使用另一个名为 Service 的组件提供的服务的组件/类。
  2. Service-Interface:服务接口是对服务组件提供何种服务进行描述的抽象。
  3. ServiceService 组件/类根据 Service-Interface 的描述提供服务。
  4. Injector:是一个组件/类,负责创建 ClientService 组件并将它们组装在一起。

其工作方式是:Client 依赖于 Service-Interface,即 IServiceClient 依赖于 IService 接口,但并不直接依赖于 Service 本身。Service 实现 IService 接口,并提供 Client 所需的某些服务。Injector 创建 ClientService 对象,并将它们组装在一起。我们说 InjectorService “注入”到 Client 中。

这是该模式的类图

这是该模式的示例代码

public interface IService
{
    void UsefulMethod();
}

public class Service : IService
{
    void IService.UsefulMethod()
    {
        //some useful work
        Console.WriteLine("Service-UsefulMethod");
    }
}

public class Client
{
    public Client(IService injectedService = null)
    {
        //Constructor Injection
        _iService1 = injectedService;
    }

    private IService _iService1 = null;

    public void UseService()
    {
        _iService1?.UsefulMethod();
    }
}

public class Injector
{
    public Client ResolveClient()
    {
        Service service = new Service();

        Client client = new Client(service);

        return client;
    }
}

internal class Program
{
    static void Main(string[] args)
    {
        Injector injector = new Injector();

        Client cli = injector.ResolveClient();
        cli.UseService();

        Console.ReadLine();
    }
}

基于注入方法的依赖注入类型

在文献 [1] 中,经常会找到根据将服务注入客户端的方法对依赖注入的不同类型进行的分类。我认为这种区分并不重要,因为效果始终是相同的,即无论如何,服务引用都会被传递给客户端。但为了完整起见,我们还是解释一下。

因此,依赖注入的类型有

  1. 构造函数注入 – 注入在 Client 的构造函数中进行
  2. 方法注入 – 注入通过一个专用方法进行
  3. 属性注入 – 注入通过一个 public 属性进行

这是演示每种类型的代码

public interface IService
{
    void UsefulMethod();
}

public class Service : IService
{
    void IService.UsefulMethod()
    {
        //some useful work
        Console.WriteLine("Service-UsefulMethod");
    }
}

public class Client
{
    public Client(IService injectedService = null)
    {
        //1.Constructor Injection
        _iService1 = injectedService;
    }

    public void InjectService(IService injectedService)
    {
        //2.Method Injection 
        _iService1 = injectedService;
    }

    public IService Service
    {
        //3.Property Injection
        set { _iService1 = value; }
    }

    private IService _iService1 = null;

    public void UseService()
    {
        _iService1?.UsefulMethod();
    }
}

public class Injector
{
    public Client ResolveClient()
    {
        Service S = new Service();

        //NOTE: This is tutorial/demo code, normally you
        //implement only one of these three methods

        //1.Constructor Injection
        Client C = new Client(S);

        //2.Method Injection 
        C.InjectService(S);

        //3.Property Injection
        C.Service = S;

        return C;
    }
}

要点 – 客户端不知道注入的服务类型

让我们强调一下这个设计模式的关键点。那就是 Client 完全不知道被注入的 Service 的类型,它只看到 IService 接口,并且不知道注入的是哪个版本的 Service。让我们看下面的类图

Client 不知道注入的是哪个服务,无论是 Service1Service2 还是 Service3。这就是我们想要的,我们看到组件/类 ClientService1Service2Service3 是“松耦合”的。

Client 类现在更具可重用性和可测试性。一个典型的用法是:在生产环境中,Client 被注入真实的 Service1 服务;而在测试环境中,Client 被注入 Service2,这是一个仅用于测试的模拟(Mock)服务。

该模式的好处

该模式的好处有

  • 创建松耦合的组件/类 ClientService
  • Client 不依赖也不了解 Service,这使其更具可重用性和可测试性
  • 由于 IService 接口清楚地定义了它们之间的边界,因此允许不同开发人员/团队并行开发组件/类 ClientService
  • 它简化了组件的单元测试

该模式带来的缺点是

  • 规划、创建和维护接口需要更多精力
  • 依赖于 Injector 来组装组件/类

类似模式

此模式与 GoF 书中的策略模式 [2] 非常相似。类图实际上是相同的。区别在于意图

  1. 依赖注入更像结构型模式,其目的是组装松耦合的组件,一旦组装完成,它们通常在客户端生命周期内保持不变;而
  2. 策略模式是行为型模式,其目的是为问题提供不同的算法,这些算法通常在客户端生命周期内可互换。

依赖倒置原则 – DIP (**)

因此,“依赖倒置原则 (DIP)”是一种软件设计原则。之所以称为“原则”,是因为它提供了关于如何设计软件产品的高层建议。

DIP 是 Robert C. Martin [5] 推广的 SOLID [3] 首字母缩略词中的五项设计原则之一。DIP 原则指出:

  1. 高层模块不应依赖于低层模块。两者都应依赖于抽象。
  2. 抽象不应依赖于细节。细节应依赖于抽象。

解释为

虽然高层原则谈论“抽象”,我们需要将其翻译成我们特定的编程环境,在这种情况下是 C#/.NET。C# 中的抽象是通过接口和抽象类实现的。当谈论“细节”时,原则指的是“具体实现”。
所以,基本上,这意味着 DIP 提倡在 C# 中使用接口,而具体的实现(低层模块)应依赖于接口。

传统的模块依赖关系如下所示

DIP 提出了这种新的设计

正如您所见,一些依赖(箭头)的方向已反转,这就是“倒置”名称的由来。

DIP 的目标是创建“松耦合”的软件模块。传统上,高层模块依赖于低层模块。DIP 的目标是使高层模块独立于低层模块的实现细节。它通过在它们之间引入一个“抽象层”(以接口的形式)来实现。

依赖注入模式 (*) 遵循此原则,并经常被提及为与 DIP 实现密切相关。但 DIP 原则是一个更广泛的概念,并对其他设计模式产生影响。例如,当应用于工厂设计模式或单例设计模式时,它建议这些模式应返回对接口的引用,而不是对对象的引用。

控制反转 – IoC (***)

同样,“控制反转 (IoC)”是一种软件设计原则。之所以称为“原则”,是因为它提供了关于如何设计软件产品的高层建议。

在传统的编程中,自定义代码始终具有流程控制,并调用库来执行任务。
IoC 原则建议(有时)将控制流程交给库(“框架”),库将调用自定义代码来执行任务。

当他们说“框架”时,他们指的是一个专门的、任意复杂的、可重用的模块/库,它是为特定任务设计的,并且自定义代码的编写方式使其能够与该“框架”协同工作。我们说“控制流程被反转了”,因为现在“框架”调用自定义代码。

框架在控制应用程序活动方面扮演着主程序的角色。程序的整体控制被反转了,从你转移到了框架。控制反转是使框架与库不同的关键因素 [26]。

IoC 原则提倡开发和使用可重用的“软件框架”,这些框架实现了常见场景。然后,编写特定于问题的自定义代码,并使其与“框架”协同工作以解决特定任务。

虽然 IoC 原则经常在依赖注入模式 (*) 的上下文中被提及,它遵循 IoC 原则,但 IoC 是一个更广泛的概念。例如,基于事件处理程序/回调方法的“UI 框架”也遵循 IoC 原则。更多解释请参见 [26]、[25]、[8]。

依赖注入模式 (*) 遵循此原则,因为传统的常规方法是让客户端创建服务并建立依赖关系。这里控制被反转了,即服务和依赖关系的创建被委托给 Injector,在这种情况下,Injector 就是“框架”。

依赖注入容器 (****)

因此,“依赖注入容器 (DI Container)”是一个软件模块/库,它支持具有许多高级选项的自动依赖注入。

在 IoC 原则 (***) 的术语中,DI 容器扮演着“框架”的角色,所以你经常会看到它被称为“DI 框架”,但我认为“框架”这个词被过度使用了,并且会导致混淆(你有 ASP MVC 框架、DI 框架、Entity Framework 等)。

在文献中,它通常被称为“IoC 容器”,但我认为 IoC 原则 (***) 是一个比 DI 模式 (*) 更广泛的概念,而这里我们实际上是在谈论 DI 模式的大规模实现。所以,“DI 容器”是一个更好的名称,但“IoC 容器”这个名字非常流行,并且广泛用于相同的目的。

什么是 DI 容器

还记得 DI 模式 (*) 和 Injector 的角色吗?所以,DI 容器是一个高级模块/库,可以同时充当许多服务的 Injector。它支持大规模实现 DI 模式,并具有许多高级功能。DI 容器是一个非常流行的架构机制,许多流行的框架(如 ASP MVC)都计划并支持集成 DI 容器。

最流行的 DI 容器包括 Autofac [10]、Unity [15]、Ninject [16]、Castle Windsor [17] 等。

DI 容器的功能

一个 DI 容器通常会提供的功能有

注册映射。您需要告诉 DI 容器抽象(接口)与具体实现(类)之间的映射关系,以便它能够正确注入正确的类型。在这里,您向容器提供它工作所需的基本信息。

管理对象作用域和生命周期。您需要告诉容器它创建的对象的作用域和生命周期。
典型的“生命周期”模式有

  1. 单例 (Singleton):始终使用对象的单个实例。
  2. 瞬态 (Transient):每次都创建一个新的对象实例。
  3. 作用域 (Scoped):这通常是每个隐式或显式定义的作用域的单例模式。

您需要告诉容器,例如,每次解析依赖项时是创建一个新对象,还是应用单例模式。单例可以是,例如,每个进程、每个线程或每个“用户定义的作用域”。此外,您还需要指定对象的期望生命周期。例如,您可以配置对象生命周期按进程,或对象生命周期按“用户定义的作用域”,这意味着对象将在用户定义的作用域结束时被释放。容器可以强制执行所有这些,只需要精确配置。

解析方法 (Resolve Method)。这是实际创建和组装所需对象/类型的工作。容器创建一个特定类型的对象,解析所有依赖项,并将它们注入到创建的对象中。该方法递归地深入解析,直到所有依赖项都得到解析。DI 容器通过使用反射等技术来完成解析依赖项的工作。

DI 容器:Autofac – 简单示例

Autofac [10] 是 C# 世界中最常用的 DI 容器之一。我们将展示一个简单的示例来说明它的工作原理。

这是我们的类图

这是我们的示例代码

public interface IService
{
}

public class Service : IService
{
    public Service()
    {
    }
}

public class Client
{
    public IService iService;

    public Client(IService injectedService)
    {
        iService = injectedService;
    }
}

internal class Program
{
    public static string MySerialize(object obj)
    {
        string serialized = null;
        var indented = Newtonsoft.Json.Formatting.Indented;
        var settings = new JsonSerializerSettings()
        {
            TypeNameHandling = TypeNameHandling.All
        };
        serialized = JsonConvert.SerializeObject(obj, indented, settings); //(5)
        return serialized;
    }

    static void Main(string[] args)
    {
        // Register mappings
        var builder = new Autofac.ContainerBuilder();
        builder.RegisterType<Service>().As<IService>(); //(1)
        builder.RegisterType<Client>().AsSelf();   //(2)
        Autofac.IContainer Container = builder.Build();    //(3)

        //Resolve object
        var client = Container.Resolve<Client>();     //(4)  

        // Json serialize object to see what we got
        Console.WriteLine(MySerialize(client)); //(6)

        Console.ReadLine();
    }
}

这是执行结果

正如您所见,Autofac 有自己的 API 需要我们遵循。在 (1) 处,我们注册了映射 **IService** -> **Service**。然后在 (2) 处,我们注册了客户端本身。在 (3) 处,我们构建了容器,使其准备好使用。在 (4) 处,我们执行解析,此时完成了依赖项的解析和注入。
为了验证我们是否获得了想要的对象,我们在 (5) 处将其序列化,并在 (6) 处打印出来。
如果您再次查看 (*) 和其中的术语,那么我们的类 Client 扮演着 (*) 中“Client”的角色,类 Service 扮演着 (*) 中“Service”的角色,而对象 Container 扮演着 (*) 中“Injector”的角色。

只是简短说明。这是一个教程 – 概念代码演示。我们在上面的示例中显式请求依赖项解析的方式,使其在顶层有点类似于服务定位器模式 [29]。DI 容器的正确用法是将其用作框架,而不是显式请求解析依赖项。

DI 容器:Autofac – 深度依赖示例

现在我们将展示一个更复杂的、具有深度依赖树的示例。这是新的类图。

这是新的类图

这是我们的示例代码

public class C
{
    public IS obj_is = null;
    public IT obj_it = null;

    public C(IS injectIs, IT injectIT)
    {
        obj_is = injectIs;
        obj_it = injectIT;
    }
}

public interface IS
{
}

public class S : IS
{
    public IU obj_iu = null;
    public IV obj_iv = null;

    public S(IU injectIU, IV injectIV)
    {
        obj_iu = injectIU;
        obj_iv = injectIV;
    }
}

public interface IT
{
}

public class T : IT
{
    public IZ obj_iz = null;

    public T(IZ injectIZ)
    {
        obj_iz = injectIZ;
    }
}

public interface IU
{
}

public class U : IU
{
}

public interface IV
{
}

public class V : IV
{
    public IX obj_ix = null;

    public V(IX injectIX)
    {
        obj_ix = injectIX;
    }
}

public interface IZ
{
}

public class Z : IZ
{
}

public interface IX
{
}

public class X : IX
{
}

internal class Program
{
    public static string MySerialize(object obj)
    {
        string serialized = null;
        var indented = Newtonsoft.Json.Formatting.Indented;
        var settings = new JsonSerializerSettings()
        {
            TypeNameHandling = TypeNameHandling.All
        };
        serialized = JsonConvert.SerializeObject(obj, indented, settings);
        return serialized;
    }

    static void Main(string[] args)
    {
        // Register mappings
        var builder = new Autofac.ContainerBuilder();
        builder.RegisterType<S>().As<IS>();
        builder.RegisterType<T>().As<IT>();
        builder.RegisterType<U>().As<IU>();
        builder.RegisterType<V>().As<IV>();
        builder.RegisterType<Z>().As<IZ>();
        builder.RegisterType<X>().As<IX>();
        builder.RegisterType<C>().AsSelf();
        Autofac.IContainer Container = builder.Build();

        //Resolve object
        var c = Container.Resolve<C>();

        // Json serialize object to see what we got
        Console.WriteLine(MySerialize(c));

        Console.ReadLine();
    }
}

这是执行结果

从执行结果可以看出,Autofac DI 容器再次完成了它的工作。请注意,例如,类 S 在 DI 模式 (*) 的术语中,同时是“Client”和“Service”。它是类 C 的“Service”,也是类 UV 的“Client”。对象 Container 在 (*) 的术语中扮演着“Injector”的角色。

再次简短说明。这是一个教程 – 概念代码演示。我们在上面的示例中显式请求依赖项解析的方式,使其在顶层有点类似于服务定位器模式 [29]。DI 容器的正确用法是将其用作框架,而不是显式请求解析依赖项。

DI 容器:Autofac – 配置对象作用域和生命周期

如果我们查看上面的示例,会有一个问题出现。如果我们有两个类 C 的对象,即 c1c2,它们是由 DI 容器通过解析生成的,那么这些对象是不同的还是相同的?依赖的对象呢,例如类 T 的对象,我们称之为 t1t2,它们是否都是不同的还是相同的?

//let us assume we created two objects, c1 and c1
var c1 = Container.Resolve<C>();
var c2 = Container.Resolve<C>();

//are they same or different?
//what will this give us as result?
bool sameObjects=(c1 == c2);  

答案是:这是可配置的。但由于我们在上一个示例中没有配置它,我们将获得默认行为,即每次都创建一个新对象。在这种情况下,对象 c1c1 是不同的,所有依赖于类 STUVZX 的对象也都是不同的。

DI 容器 Autofac ([11]、[12]) 配置对象作用域和生命周期的典型选项有

  1. 每个依赖项实例 (Instance Per Dependency):在文献中也经常称为“瞬态 (transient)”。这是一种“每次创建新对象”的模式。基本上,这意味着每次请求对象时,都会创建一个新实例。这是默认行为。
  2. 单实例 (Single Instance):也称为“单例 (singleton)”。这基本上是“每个进程的单例”。每次在进程中请求解析时,都会获得相同的对象实例。
  3. 每个生命周期作用域实例 (Instance Per Lifetime Scope):这是“每个用户定义作用域的单例”。用户需要指定作用域,在该作用域内,他将始终获得相同的实例。
  4. 每个匹配生命周期作用域实例 (Instance Per Matching Lifetime Scope):这也是单例,但这次是“每个用户定义命名作用域的单例”。用户需要使用定义的命名作用域,并且在该作用域内每次都会获得相同的对象实例。
  5. 每个请求实例 (Instance Per Request):在 ASP 类型应用程序中,这会产生“每个请求的单例”。这实际上与 4 相同,只是命名作用域是为每个请求创建的。更多解释请参见 [12]。
  6. 每个拥有实例 (Instance Per Owned):这有点复杂,因此我们在此不进行详细介绍。更多解释请参见 [12]。
  7. 线程作用域 (Thread Scope):它不存在作为单独的配置选项,而是依赖于 3 在您的线程方法中创建一个命名作用域,并将其实现为“每个生命周期作用域实例”的解决方案。

以下是一些配置选项在代码中外观的示例

//1. Instance Per Dependency
builder.RegisterType<Worker>(); // using defualt behaviour
//explicit definition
builder.RegisterType<Worker>().InstancePerDependency(); 

//2. Single Instance
builder.RegisterType<Worker>().SingleInstance();

//3. Instance Per Lifetime Scope
builder.RegisterType<Worker>().InstancePerLifetimeScope();

using (var scope1 = container.BeginLifetimeScope())
{
    var w1 = scope1.Resolve<Worker>();
    var w2 = scope1.Resolve<Worker>();
    // w1 and w2 are the same instance 
}

//4. Instance Per Matching Lifetime Scope
builder.RegisterType<Worker>()
    .InstancePerMatchingLifetimeScope("MyScope");

using (var scope1 = container.BeginLifetimeScope("MyScope"))
{
    var w1 = scope1.Resolve<Worker>();
}

using (var scope2 = container.BeginLifetimeScope("MyScope"))
{
    var w2 = scope2.Resolve<Worker>();
    // w1 and w2 are the same instance 
}

//5. Instance Per Request
builder.RegisterType<Worker>().InstancePerRequest();

//6. Instance Per Owned
builder.RegisterType<OwnerClass>();
builder.RegisterType<SlaveClass>().InstancePerOwned<OwnerClass>();

//7. Thread Scope
//similar to 3.

我们将不再提供更多细节或代码示例,因为这对于本文来说太多了。

DI 容器 – 与应用程序框架集成

DI 容器是一个非常流行的架构机制,许多应用程序框架都计划并支持与 DI 容器的集成。

例如,ASP.NET MVC 框架公开了 IDependencyResolver 接口 [13],有前景的 DI 容器需要实现该接口。与 Autofac 集成的示例如下所示

// ASP.NET MVC and Autofac integration
// Context:
// - build Autofac DI Container
//      var builder = new Autofac.ContainerBuilder();
//      Autofac.IContainer container = builder.Build();
// - container implements interfaces Autofac.IContainer  
//      and Autofac.IComponentContext
// - new AutofacDependencyResolver(container) implements 
//      System.Web.Mvc.IDependencyResolver
// - System.Web.Mvc provides a registration point for 
//      dependency resolvers with method
//      public static void System.Web.Mvc.DependencyResolver
//      .SetResolver(System.Web.Mvc.IDependencyResolver resolver)
DependencyResolver.SetResolver(new AutofacDependencyResolver(container));

因此,关键在于 ASP.NET MVC 框架为依赖解析器提供了注册点。如果您不想使用 DI,那也没关系。但是,如果您想在 ASP.NET MVC 应用程序中使用 DI,您可以将您选择的 DI 容器与应用程序框架注册,如上所示,然后神奇地,DI 解析将在您的应用程序中开始工作。

有关如何将 Autofac DI 容器与其他应用程序集成的更多信息,请参见 [27]。本文的材料到此为止。

结论

在本文中,我们重点介绍了依赖注入模式 (DI) 及其工业应用依赖注入容器(又名 IoC 容器)。我们还解释了相关的软件设计原则:依赖倒置原则 (DIP) 和控制反转 (IoC) 原则。我们使用 Autofac 容器展示了一些示例代码。重点是读者能够理解和欣赏 DI 容器在现代应用程序中的实际用途。

DI 模式和 DI 容器是当今软件架构的主流技术,并将长期存在。

在本教程中,我们对材料进行了简洁扼要的概述,适合需要快速掌握概念的读者。建议进一步阅读相关主题。一些初学者教程是 [14]、[18] – [23]。严肃的文献有 [9]、[8]、[26]。

参考文献

历史

  • 2022年6月1日:初始版本
© . All rights reserved.