乘风破浪,遇见最佳跨平台跨终端框架.Net Core/.Net生态 - 再谈依赖注入(Dependency Injection,DI)

什么是依赖注入(DI)

https://zh.wikipedia.org/zh-cn/依赖注入

image

在软件工程中,依赖注入(Dependency Injection,缩写为DI)是一种软件设计模式,也是实现控制反转的其中一种技术。这种模式能让一个物件接收它所依赖的其他物件。“依赖”是指接收方所需的对象。“注入”是指将“依赖”传递给接收方的过程。在“注入”之后,接收方才会调用该“依赖”。此模式确保了任何想要使用给定服务的物件不需要知道如何建立这些服务。取而代之的是,连接收方物件(像是client)也不知道它存在的外部代码(注入器)提供接收方所需的服务。

解决什么问题

依赖注入(DI)该设计的目的是为了分离关注点,分离接收方和依赖,从而提供松耦合以及代码重用性

传统编程方式,客户对象自己创建一个服务实例并使用它。这带来的缺点和问题是:

  • 如果使用不同类型的服务对象,就需要修改、重新编译客户类。
  • 客户类需要通过配置来适配服务类及服务类的依赖。如果程序有多个类都使用同一个服务类,这些配置就会变得复杂并分散在程序各处。
  • 难以单元测试。本来需要使用服务类的mock或stub,在这种方式下不太可行。

依赖注入可以解决上述问题:

  • 使用接口或抽象基类,来抽象化依赖实现。
  • 依赖在一个服务容器中注册。客户类构造函数被注入服务实例。框架负责创建依赖实例并在没有使用者时销毁它。

涉及概念

依赖注入涉及四个概念:

  • 服务:任何类,提供了有用功能。
  • 客户:使用服务的类。
  • 接口:客户不应该知道服务实现的细节,只需要知道服务的名称和API。
  • 注入器:Injector,也称assembler、container、provider或factory。负责把服务引入给客户。

依赖注入把对象构建与对象注入分开。因此创建对象的new关键字也可消失了。

实现方式

  • 建构子注入:依赖由客户对象的构造函数传入。
  • Setter注入:客户对象暴露一个能接收依赖的setter方法。
  • 接口注入:依赖的接口提供一个注入器方法,该方法会把依赖注入到任意一个传给它的客户端。

在.Net中的依赖注入

.NET支持依赖关系注入(DI)软件设计模式,这是一种在类及其依赖项之间实现控制反转(IoC)的技术。.NET中的依赖关系注入是框架的内置部分,与配置、日志记录和选项模式一样。

使用前后变化

依赖项是指另一个对象所依赖的对象。使用其他类所依赖的Write方法检查以下MessageWriter类:

public class MessageWriter
{
    public void Write(string message)
    {
        Console.WriteLine($"MessageWriter.Write(message: \"{message}\")");
    }
}

Worker类可以创建MessageWriter类的实例,以便利用其Write方法。在以下示例中,MessageWriter类是Worker类的依赖项:

public class Worker : BackgroundService
{
    private readonly MessageWriter _messageWriter = new MessageWriter();

    protected override async Task ExecuteAsync(CancellationToken stoppingToken)
    {
        while (!stoppingToken.IsCancellationRequested)
        {
            _messageWriter.Write($"Worker running at: {DateTimeOffset.Now}");
            await Task.Delay(1000, stoppingToken);
        }
    }
}

该类创建并直接依赖于MessageWriter类。

硬编码的依赖项(如前面的示例)会产生问题,应避免使用,原因如下

  • 要用不同的实现替换MessageWriter,必须修改Worker类。
  • 如果MessageWriter具有依赖项,则必须由Worker类对其进行配置。在具有多个依赖于MessageWriter的类的大型项目中,配置代码将分散在整个应用中。
  • 这种实现很难进行单元测试。应用需使用模拟或存根MessageWriter类,而该类不能使用此方法。

依赖关系注入通过以下方式解决了这些问题:

  • 使用接口基类将依赖关系实现抽象化。
  • 在服务容器中注册依赖关系。.NET提供了一个内置的服务容器IServiceProvider。服务通常在应用启动时注册,并追加到IServiceCollection。添加所有服务后,可以使用BuildServiceProvider创建服务容器。
  • 将服务注入到使用它的类的构造函数中。框架负责创建依赖关系的实例,并在不再需要时将其释放

例如,IMessageWriter接口定义Write方法:

namespace DependencyInjection.Example;

public interface IMessageWriter
{
    void Write(string message);
}

此接口由具体类型MessageWriter实现:

namespace DependencyInjection.Example;

public class MessageWriter : IMessageWriter
{
    public void Write(string message)
    {
        Console.WriteLine($"MessageWriter.Write(message: \"{message}\")");
    }
}

示例代码使用具体类型MessageWriter注册IMessageWriter服务。AddScoped方法使用范围内生存期(单个请求的生存期)注册服务。

using DependencyInjection.Example;

var builder = Host.CreateDefaultBuilder(args);

builder.ConfigureServices(
    services =>
        services.AddHostedService<Worker>()
            .AddScoped<IMessageWriter, MessageWriter>());

var host = builder.Build();

host.Run();

在上面的代码中,示例应用:

  • 创建了主机生成器实例。
  • 通过注册以下内容来配置服务:
    • Worker作为托管服务。
    • IMessageWriter接口作为具有MessageWriter类的相应实现的作用域服务。
  • 生成主机并运行它。

主机包含依赖关系注入服务提供程序。它还包含自动实例化Worker并提供相应的IMessageWriter实现作为参数所需的所有其他相关服务。

namespace DependencyInjection.Example;

public class Worker : BackgroundService
{
    private readonly IMessageWriter _messageWriter;

    public Worker(IMessageWriter messageWriter) =>
        _messageWriter = messageWriter;

    protected override async Task ExecuteAsync(CancellationToken stoppingToken)
    {
        while (!stoppingToken.IsCancellationRequested)
        {
            _messageWriter.Write($"Worker running at: {DateTimeOffset.Now}");
            await Task.Delay(1000, stoppingToken);
        }
    }
}

通过使用依赖注入(DI)模式,辅助角色服务:

  • 不使用具体类型MessageWriter,只使用实现它的IMessageWriter接口。这样可以轻松地更改控制器使用的实现,而无需修改控制器。
  • 不要创建MessageWriter的实例。该实例由DI容器创建。

可以通过使用内置日志记录API来改善IMessageWriter接口的实现:

namespace DependencyInjection.Example;

public class LoggingMessageWriter : IMessageWriter
{
    private readonly ILogger<LoggingMessageWriter> _logger;

    public LoggingMessageWriter(ILogger<LoggingMessageWriter> logger) =>
        _logger = logger;

    public void Write(string message) =>
        _logger.LogInformation(message);
}

更新的ConfigureServices方法注册新的IMessageWriter实现:

static IHostBuilder CreateHostBuilder(string[] args) =>
    Host.CreateDefaultBuilder(args)
        .ConfigureServices((_, services) =>
            services.AddHostedService<Worker>()
                    .AddScoped<IMessageWriter, LoggingMessageWriter>());

LoggingMessageWriter依赖于ILogger<TCategoryName>,并在构造函数中对其进行请求。ILogger<TCategoryName>ILogger<TCategoryName>

以链式方式使用依赖关系注入并不罕见。每个请求的依赖关系相应地请求其自己的依赖关系。容器解析图中的依赖关系并返回完全解析的服务。必须被解析的依赖关系的集合通常被称为“依赖关系树”、“依赖关系图”或“对象图”。

容器通过利用(泛型)开放类型解析ILogger<TCategoryName>,而无需注册每个(泛型)构造类型。

在依赖项注入术语中,一个服务:

  • 通常是向其他对象提供服务的对象,如IMessageWriter服务。
  • 与Web服务无关,尽管服务可能使用Web服务。

框架提供可靠的日志记录系统。编写上述示例中的IMessageWriter实现来演示基本的DI,而不是来实现日志记录。大多数应用都不需要编写记录器。

下面的代码展示了如何使用默认日志记录,只需要将WorkerConfigureServices中注册为托管服务AddHostedService

public class Worker : BackgroundService
{
    private readonly ILogger<Worker> _logger;

    public Worker(ILogger<Worker> logger) =>
        _logger = logger;

    protected override async Task ExecuteAsync(CancellationToken stoppingToken)
    {
        while (!stoppingToken.IsCancellationRequested)
        {
            _logger.LogInformation("Worker running at: {time}", DateTimeOffset.Now);
            await Task.Delay(1000, stoppingToken);
        }
    }
}

使用前面的代码时,无需更新ConfigureServices,因为框架提供日志记录。

多个构造函数发现规则

当某个类型定义多个构造函数时,服务提供程序具有用于确定要使用哪个构造函数的逻辑。选择最多参数的构造函数,其中的类型是可DI解析的类型。请考虑以下示例服务:

public class ExampleService
{
    public ExampleService()
    {
    }

    public ExampleService(ILogger<ExampleService> logger)
    {
        // omitted for brevity
    }

    public ExampleService(FooService fooService, BarService barService)
    {
        // omitted for brevity
    }
}

在前面的代码中,假定已添加日志记录,并且可以从服务提供程序解析,但FooServiceBarService类型不可解析。使用ILogger<ExampleService>参数的构造函数用于解析ExampleService实例。即使有定义多个参数的构造函数,FooServiceBarService类型也不能进行DI解析。

如果发现构造函数时存在歧义,将引发异常。请考虑以下示例服务:

public class ExampleService
{
    public ExampleService()
    {
    }

    public ExampleService(ILogger<ExampleService> logger)
    {
        // omitted for brevity
    }

    public ExampleService(IOptions<ExampleOptions> options)
    {
        // omitted for brevity
    }
}

具有不明确的可DI解析的类型参数的ExampleService代码将引发异常。不要执行此操作,它旨在显示“不明确的可DI解析类型”的含义。

在前面的示例中,有三个构造函数。第一个构造函数是无参数的,不需要服务提供商提供的服务。假设日志记录和选项都已添加到DI容器,并且是可DI解析的服务。当DI容器尝试解析ExampleService类型时,将引发异常,因为这两个构造函数不明确

可通过定义一个接受DI可解析的类型的构造函数来避免歧义:

public class ExampleService
{
    public ExampleService()
    {
    }

    public ExampleService(
        ILogger<ExampleService> logger,
        IOptions<ExampleOptions> options)
    {
        // omitted for brevity
    }
}

使用扩展方法注册服务组

Microsoft Extensions使用一种约定来注册一组相关服务。约定使用单个Add{GROUP_NAME}扩展方法来注册该框架功能所需的所有服务。例如,AddOptions扩展方法会注册使用选项所需的所有服务。

框架提供的服务

ConfigureServices方法注册应用使用的服务,包括平台功能。最初,提供给ConfigureServicesIServiceCollection具有框架定义的服务(具体取决于主机配置方式)。对于基于.NET模板的应用,该框架会注册数百个服务

下表列出了框架注册的这些服务的一小部分:

服务类型 生存期
Microsoft.Extensions.DependencyInjection.IServiceScopeFactory 单例
IHostApplicationLifetime 单例
Microsoft.Extensions.Logging.ILogger<TCategoryName> 单例
Microsoft.Extensions.Logging.ILoggerFactory 单例
Microsoft.Extensions.ObjectPool.ObjectPoolProvider 单例
Microsoft.Extensions.Options.IConfigureOptions<TOptions> 暂时
Microsoft.Extensions.Options.IOptions<TOptions> 单例
System.Diagnostics.DiagnosticListener 单例
System.Diagnostics.DiagnosticSource 单例

服务生存期

可以使用以下任一生存期注册服务:

  • 暂时(Transient)
  • 作用域(Scoped)
  • 单例(Singleton)

下列各部分描述了上述每个生存期。为每个注册的服务选择适当的生存期。

暂时(Transient)

暂时(Transient)生存期服务是每次从服务容器进行请求时创建的。这种生存期适合轻量级、无状态的服务。向AddTransient注册暂时性服务。

在处理请求的应用中,在请求结束时会释放暂时服务

作用域(Scoped)

对于Web应用,指定了作用域(Scoped)的生存期指明了每个客户端请求(连接)一次服务。向AddScoped注册范围内服务。

在处理请求的应用中,在请求结束时会释放有作用域的服务。

使用EntityFrameworkCore时,默认情况下AddDbContext扩展方法使用作用域(Scoped)生存期来注册DbContext类型。

默认情况下在开发环境中,从具有较长生存期的其他服务解析服务将引发异常。

不要从单一实例解析限定范围的服务,并小心不要间接地这样做,例如通过暂时(Transient)性服务。当处理后续请求时,它可能会导致服务处于不正确的状态。可以:

  • 从作用域(Scoped)或暂时(Transient)性服务解析单一实例服务。
  • 从其他作用域(Scoped)或暂时(Transient)性服务解析范围内服务。

单例(Singleton)

创建单例生命周期服务的情况如下:

  • 在首次请求它们时进行创建
  • 在向容器直接提供实现实例时由开发人员进行创建。很少用到此方法。

来自依赖关系注入容器的服务实现的每一个后续请求都使用同一个实例。如果应用需要单一实例行为,则允许服务容器管理服务的生存期。不要实现单一实例设计模式,或提供代码来释放单一实例。服务永远不应由解析容器服务的代码释放如果类型或工厂注册为单一实例,则容器自动释放单一实例

AddSingleton注册单一实例服务。单一实例服务必须是线程安全的,并且通常在无状态服务中使用

在处理请求的应用中,当应用关闭并释放ServiceProvider时,会释放单一实例服务。由于应用关闭之前不释放内存,因此请考虑单一实例服务的内存使用

参考

posted @ 2022-08-20 14:55  TaylorShi  阅读(136)  评论(0编辑  收藏  举报