依赖注入

《Dependency Injection Principles, Practices, and Patterns》学习笔记

前言

分层架构的优点:

  1. 职责清晰,方便分工
  2. 代码复用,扩展性良好
  3. 体积小,易维护

依赖倒置原则(Dependency Inversion Principle):

  1. 高层模块(high-level modules)不要直接依赖低层模块(low-level);
  2. 高层模块和低层模块应该通过抽象(abstractions)来互相依赖
  3. 抽象(abstractions)不要依赖具体实现细节(details),具体实现细节(details)依赖抽象(abstractions)。

高层本来依赖低层,但是可以通过工厂(容器)来决定细节,去掉对低层的依赖

DI

Stack Overflow Q#A:

Q:How to explain Dependency Injection to a 5-year old?

When you go and get things out of the refrigerator for yourself, you can cause problems. You
might leave the door open, you might get something Mommy or Daddy doesn’t want you to
have. You might even be looking for something we don’t even have or which has expired.
What you should be doing is stating a need, “I need something to drink with lunch,” and
then we will make sure you have something when you sit down to eat.--John Munsch

书写可维护的代码

“面向接口编程,而不是面向实现”。松耦合让代码更具有扩展性和可持续性,即可维护。

Dependency Injection(DI)依赖注入是一种软件设计原则帮助人开发出松耦合的代码.

关于依赖注入常见误区

  • DI只和延迟绑定相关

  • DI只和单元测试相关

  • DI是抽象工厂的一种

  • DI需要DI容器

延迟绑定:延迟绑定是指不通过重新编译代码就可以替换应用程序部分功能的能力。延迟绑定只是DI的众多功能中的一部分。

单元测试:同上,DI是支持单元测试的一个重要组成,但它不仅如此。

抽象工厂:不能将DI错误的认为是程序中用力啊给依赖对象创建实例的抽象工厂。服务定位器模式通常被称为一种抽象工厂,它的基本思想就是有一个对象定位器知晓如何控制该程序需要的所有服务。

public interface IServiceLocator
{
    object GetService(Type serviceType);
}

DI是服务定位器的对立面; 这是构建代码的一种方法,因此你不必要求依赖项。 相反,您要求消费者提供它们。

DI容器:不能简单地认为DI容器可以承担上述服务定位器的职责。DI容器是一种可选库帮助我们开发应用时更便捷的创建类,但它并不是必须项。

DI的目的

DI不是最终目标,而是达到目的的一种手段。DI可以松耦合,松耦合让程序变得可维护。

一些设计模式图解

image-20220808113626343

通过插座和插头使得吹风机与墙上的插座松耦合

image-20220808113931691

通过插座和插头可以替换吹风机为电脑。这对应里氏转换原则(Liskov Substitution Principle )。

image-20220808114454677

在替换插座为儿童安全插座时拔下电脑插头,电脑和墙上的插座都没有因此而爆炸QAQ

image-20220808114823369

UPS可以保证电脑在断电时继续工作。这对应着装饰器模式

image-20220808115144111

通过插排可以使得多个电器通过一个墙上的插座供电。这对应着组合模式

image-20220808115452668

旅行时,人们常常需要带着适配器已接入不同标准的国外插口。这对应着适配器模式

软件设计中松耦合的优点与物理插座和插头模型相同:一旦基础架构到位,任何人就可以使用它,并适应不断变化的需求和不可预见的需求,而无需对应用程序进行大规模更改代码基础和基础架构。 这意味着理想情况下,新要求只需要增加新类,而不会更改系统的其他类别。这又恰合开闭原则

一种方便的解耦方式就是通过接口代替实现。但是不能直接通过接口本身作为接口自身的实例。DI则解决了这个问题。

IMessageWriter writer = new IMessageWriter();//无法编译

简例:

Hello DI!

UML

image-20220808145650446

Main

private static void Main()
{
    IMessageWriter writer = new ConsoleMessageWriter();
    var salutation = new Salutation(writer);
    salutation.Exclaim();
}

Salutaition

salutation类依赖接口IMessageWriter,通过结构体来提供具体的实例,这种方式称为结构体注入(Constructor Injection)。最终,我们通过内部Exclaim方法调用外部注入的writer的实例的Writer方法。

public class Salutation
{
    private readonly IMessageWriter writer;
    
    //使用构造注入来提供 IMessageWriter依赖
    public Salutation(IMessageWriter writer)          
    {
        if (writer == null) //卫语句(Guard clause)进行空值检验                          
            throw new ArgumentNullException("writer");
        this.writer = writer;
    }
    
    //调用依赖
    public void Exclaim()
    {
        this.writer.Write("Hello DI!");               
    }
}

IMessageWriter

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

ConsoleMessageWriter

该类实现了IMessageWriter接口

public class ConsoleMessageWriter : IMessageWriter
{
    public void Write(string message)
    {
        Console.WriteLine(message);
    }
}

DI的益处

益处 描述 何时奏效
延迟绑定 服务可以被替换而不通过重新编译代码 在标准软件中很有价值,但在运行时环境往往得到很好的定义的企业应用程序中较少。
扩展性 代码可以在未明确的计划中扩展和重复使用 一直有
并行开发 代码可以并行开发 在复杂庞大的软件中有效,小而简的软件中没那么重要
可维护性 类可以被明确的定义职责易于维护 一直
测试 类可以被单元测试 一直

延迟绑定

比如可以将具体注入的实例类型通过配置文件来绑定

{
  "messageWriter":
    "Ploeh.Samples.HelloDI.Console.ConsoleMessageWriter, HelloDI.Console"
}
//读取配置项
IConfigurationRoot configuration = new ConfigurationBuilder()
    .SetBasePath(Directory.GetCurrentDirectory())
    .AddJsonFile("appsettings.json")
    .Build();
//获取配置项中的类型
string typeName = configuration["messageWriter"];
//通过反射来得到具体实例
Type type = Type.GetType(typeName, throwOnError: true);
IMessageWriter writer = (IMessageWriter)Activator.CreateInstance(type);

扩展性

假设我们让上述程序中的消息只能通过 授权用户写入。这里新增了IIdentity来进行权限管理。

public class SecureMessageWriter : IMessageWriter      
{
    private readonly IMessageWriter writer;
    private readonly IIdentity identity;
    
    //构造器注入实例
    public SecureMessageWriter(
IMessageWriter writer,                         
        IIdentity identity)
    {
        if (writer == null)
            throw new ArgumentNullException("writer");
        if (identity == null)
            throw new ArgumentNullException("identity");
        this.writer = writer;
        this.identity = identity;
    }
    public void Write(string message)
    {
        //权限检测
        if (this.identity.IsAuthenticated)             
        {
            this.writer.Write(message);                
        }
    }
}

Main方法仅需要替换IMessagerWriter到新的构造器类型
... ...
    IMessageWriter writer =
    new SecureMessageWriter(                 
        new ConsoleMessageWriter(),
        WindowsIdentity.GetCurrent());
... ...

并行开发

每个团队通常对整个项目的某个领域负责。 为了划定职责,每个团队都会开发一个或多个模块,需要将其整体化为完成的应用程序。 除非每个团队的领域真正独立,否则一些团队可能取决于其他团队开发的功能。在上一个示例中,由于SecureMessageWriter和ConsolemessageWriter并不直接依赖彼此,因此可以由并行团队开发它们。 他们只需共享IMessagerWriter接口。

可维护性

每个类都有明确的职责和定义使整体维护变得容易,这也是单一职责原则。新的功能添加也变得加单,无需修改现有的代码,通过添加新类来组建应用程序。这也符合开闭原则。bug排除也更加方便,可以快速定位问题点范围。

单元测试

TestDouble 简单理解就是测试替身,在多数情况下,我们的系统能够正常运行,不仅仅依托系统本身,还需要依赖一些外部服务,比如其他系统提供的 http、rpc 服务,系统自身以来的像 redis 缓存服务或者 mysql 这类数据库服务。在微服务场景下,业务按照业务领域将一个系统拆分为多个系统,系统之间的交互不仅仅是简单的 A->B,可能是 A ->B -> C ->D,对于编写单元测试的开发者来说,当我需要编写系统A 的测试用例时,不可能去构建完整的调用链路,那么在测试工程中,通常会以 “测试替身” 来解决外部依赖所带来的测试复杂性问题。在进行单元测试时,使用 Test Double 最主要的目的就是减少被测试对象的依赖,使得测试更加单一,只需要关注在被测系统本身的一些测试场景;除此之外, Test Double 从某种角度来说,可以让测试案例执行的测试时间更短,运行也更稳定(替代了真实的外部依赖)。Test Double 和实际交付使用的实际对象还是存在本质差别的,所以在实际的测试过程中,不建议 Test Double 的过度使用,因为可能会造成测试场景和实际场景脱节。
————————————————
版权声明:本文为CSDN博主「阿星君」的原创文章,遵循CC 4.0 BY-SA版权协议,转载请附上原文出处链接及本声明。
原文链接:https://blog.csdn.net/dingyu002/article/details/118337643

重要的部分是提供特定于测试的iMessageWriter实现,以确保您一次仅测试一件事,所以这里使用了自定义的SpyMessageWriter,而不是原系统的ConsoleMessageWriter,因为我们不想让系统中自己的其他依赖干扰我们的测试,比如这里如果ConsoleMessageWriter自身就有bug,就会将问题传播到Salutaion.Exclaim。也正是因为DI松耦合为可测试性带来了提升。

[Fact]
public void ExclaimWillWriteCorrectMessageToMessageWriter()
{
    //这里使用SpyMessageWriter测试替身来作为实例
    var writer = new SpyMessageWriter();
    var sut = new Salutation(writer);                  
    sut.Exclaim();
    Assert.Equal(
        expected: "Hello DI!",
        actual: writer.WrittenMessage);
}

public class SpyMessageWriter : IMessageWriter
{
    public string WrittenMessage { get; private set; }
    public void Write(string message)
    {
        this.WrittenMessage += message;
    }
}

那些地方需要注入,那些不需要

接缝:接缝是一个应用程序从其组成部分组装的地方,类似于在其接缝处缝制一件衣服的方式。它也是一个可以拆卸应用程序并与模块隔离一起工作的地方。以上面 Hello DI为例,接缝存在于Salutation和ConsoleMessageWriter。Salutation没有直接依赖于ConsoleMessageWriter,它仅仅通过IMessagerWriter接口来写信息。我们可以从这里拆分和组装应用程序,为其接入不同的messagewriter。

image-20220809101724075

稳定依赖

默认情况下,您可以将基础类库BCL中定义的大多数(但不是全部)类型视为安全或稳定的依赖性。 我们之所以称它们为稳定,是因为它们已经在那里,它们往往向后兼容,并调用它们具有确定性的结果。 大多数稳定的依赖性是BCL类型,但其他依赖性也可以稳定。 稳定依赖性的标准如下:

  • 类或模块已经存在
  • 你认为新版本中不会出现破坏性修改
  • 所讨论的类型包含确定的算法
  • 你永远不会去替换,封装,装饰或终止的类或模块

其他的例子比如某些实现专门需要的库,这些库与你的应用程序相关的算法封装。比开发处理化学应用程序中引入的第三方化学特定功能的库,应用中用来进行矩阵运算的库等等。

DI Container既可以被认为是稳定依赖,也可被认为是可变依赖,取决于你是否想去替换他们。

易变依赖

一些依赖并没有为应用提供充足稳定的基础被称之为易变依赖,下面为易变依赖的标准:

  • 对应用程序设置和运行时有要求的依赖

数据库是易变依赖性的BCL类型的好示例,关系型数据库就是原型。 如果没有在接缝后面隐藏关系数据库,则永远无法用任何其他技术替代它。 这也使设置并运行自动单位测试变得困难。 (即使Microsoft SQL Server客户端库是BCL中包含的一项技术,其用法也意味着一个关系数据库。)其他流行的资源(例如消息队列,Web服务,甚至文件系统)属于此类别。 这种类型的依赖性的特点是缺乏延迟绑定,可扩展性以及可检验性。

  • 那些还未存在或还在开发中的依赖
  • 那些还未在开发环境伤部署的依赖。可能是比较昂贵无法在所有系统上部署的依赖或第三方库。最大的的通病就是缺乏可测试性
  • 那些包含不确定行为的依赖。这在单元测试中尤为重要,因为所有的测试都必须有确定性。典型的例子就是一些取决于当前时间和日期的随机数和算法。

易变依赖是DI的切入点。通过易变依赖而不是稳定依赖来为应用引入接缝SEAMS。同时,这也意味着你需要用DI来创建他们

DI范围

DI的一个重要职责就是将各种责任分给单独的类。其中一个职责就是将创建依赖实例的任务从类中分离出来。以Hello DI为例,我们将其创建依赖上移到了Main方法中。通过这种方式我们只是将具体的实现放到其他地方,而不是失去对其控制。我们应该移除类对其依赖的控制,这也是单一职责应用,类不必处理器依赖的创建。

image-20220809111918791

DI使你有机会以统一的方式管理依赖关系。 消费者直接创建并设置依赖项实例时,每个都可以有自己的实现方式。 这与其他消费者的做法可能不一致,你无法管理依赖关系,也没有简单的方法来解决交叉切割(CROSS-CUTTING CONCERNS)问题。 使用DI,您将获得拦截每个依赖性实例并在传递给消费者之前采取行动的能力。 这提供了应用程序的可扩展性。
使用DI,您可以在拦截依赖关系并控制其生命的同时撰写应用程序。 组合(OBJECT COMPSITION),拦截(INTERCEPTION)和生命周期管理是(LIFE-TIME MANAGEMENT)DI的三个维度。

对象组合

组合(is a part of),组合关系的对象具有相同的生命周期,同生死,共存在,强于聚合,弱于继承。

聚合(is a member of),聚合关系的对象整体与部分间没有必然的生命周期联系。

对象组合通常是将DI引入应用程序的主要动机。 实际上,最初,di是对象组合的代名词。你可以通过多种方式将类组成为应用程序。 当我们讨论延迟绑定时,我们使用了一个配置文件和一些动态对象(dynamic object)实例化来从可用模块中构成应用程序。 我们还可以使用DI容器将配置用作代码。

对象生命周期

一个失去对其依赖控制的类放弃了不仅仅是对抽象的特定实现,他也放弃了对实例创建和控制其什么时候失效。.NET垃圾回收器帮我们照看很多东西。消费者可以将其依赖注入其中并且随心所欲的使用它们。结束的时候,如果没有其他类引用他们,他们将会被垃圾回收器回收。

如果两个消费者共享同一类型的依赖将如何处置?如下代码所示,可以将不同的实例注入给消费者。消费者也可以使用相同的实例。根据里氏转换,消费者对实现统一接口的对象一视同仁。

//不同实例注入给不同的消费者
MessageWriter writer1 = new ConsoleMessageWriter();   
IMessageWriter writer2 = new ConsoleMessageWriter();   
var salutation = new Salutation(writer1);              
var valediction = new Valediction(writer2);

//同一实例注入给不同的消费者
IMessageWriter writer = new ConsoleMessageWriter();    
var salutation = new Salutation(writer);               
var valediction = new Valediction(writer);

由于可以共享依赖,因此单一的消费者无法控制其生命周期。 尽管托管对象可以超出范围并收集垃圾,但这不是什么大问题。 但当依赖关系实现IDisposable的接口时,事情变得更加复杂。 总体而言,生命时间管理是DI的单独维度,并且很重要。

拦截

拦截是对装饰器模式的应用。当我们将依赖的控制委托给第三方时,我们也需要具有在类中使用它们之前修改他们的能力。Hello DI例子中我们将ConsoleMessageWriter实例注入到Salutation实例。接下来我们添加了显得安全机制功能,创建了新的SecureMessagerWriter来检测用户的权限,通过权限检测后将后续任务进一步委托给ConsoleMessageWriter。通过这种方式来保证单一职责原则。正因为面向接口编程也使之成为可能。对于Salutation而言,它并不关心IMessagerWriter时ConsoleMessagerWriter还是SecureMessageWriter。

image-20220809153709151

这种拦截机制也有益于ASPECT-ORIENTED PROGRAMMING(AOP面向切面编程)。通过拦截和AOP,您可以解决交叉切割(CROSS-CUTTING CONCERNS)问题,例如日志 ,以结构良好的方式进行审核,访问控制,验证等,使您保持关注点的分离。

DI的三个方面

组合主导了这些,正因为组合的存在使得拦截和对象生命周期出现。对象组合提供了基础,生命周期管理解决了一些重要的副作用。 但是,主要是在拦截方面,我们可以从中受益。

IOC(INVERSION OF CONTROL)

框架整体或运行时控制程序流。DI是IOC的一个子集。把高层对低层的依赖转移到第三方,以免高层直接依赖低层,是一种目的。

Unity中常见IOC库

StrangeIOC:https://github.com/strangeioc/strangeioc

Zenject:https://github.com/modesttree/Zenject

VContainer:https://github.com/hadashiA/VContainer

posted @ 2022-08-08 16:29  世纪末の魔术师  阅读(91)  评论(0编辑  收藏  举报