天天看点

依赖倒置原则(DIP)介绍1.概念2.抽象方法3.依赖倒置方法

介绍

在本文中,我们将讨论SOLID原则的其中一个支柱,即依赖反转原则。我们将讨论其背后的工作原理,以及如何将其应用于工作示例。

1.概念

什么是DIP?

原则指出:

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

例如,下面的代码不符合上述原则:

public class HighLevelModule
{
  private readonly LowLevelModule _lowLowelModule;
 
  public HighLevelModule()
  {
    _lowLevelModule = new LowLevelModule();   
  }

  public void Call()
  {
    _lowLevelModule.Initiate();
    _lowLevelModule.Send();
  }
}

public class LowLevelModule
{
  public void Initiate()
  {
    //do initiation before sending
  }
  
  public void Send()
  {
    //perform sending operation
  }
}
           

在上面的代码中,HighLevelModule直接依赖于LowLevelModule并且不遵循DIP的第一点。为什么这么重要?两者之间的直接且紧密耦合的关系使得在HighLevelModule上独立于LowLevelModule创建单元测试变得更加困难。你不得不在同一时间测试HighLevelModule和LowLevelModule,因为它们是紧密耦合。

请注意,仍然可以在HighLevelModule上使用执行.NET CLR侦听的测试框架(例如TypeMock Isolator)来隔离进行单元测试。使用此框架,可以更改测试LowLevelModule行为。但是,出于两个原因,我不推荐这种做法。首先,在测试中使用CLR拦截违反了代码的现实: HighLevelModule对LowLevelModule的依赖。在最坏的情况下,测试会产生假阳性结果。其次,这种做法可能会阻止我们学习编写干净且可测试的代码的技能。

我们如何应用DIP?

DIP的第一点建议我们对代码同时应用两件事:

  • 抽象化
  • 依赖倒置或控制反转

首先,LowLevelModule需要被抽象,而HighLevelModule将依赖于抽象。下一节将讨论不同的抽象方法。对于下面的示例,我将使用interface进行抽象。一个IOperation接口用于抽象LowLevelModule。

public interface IOperation
{
  void Initiate();
  void Send();
}

public class LowLevelModule: IOperation
{
  public void Initiate()
  {
    //do initiation before sending
  }
  
  public void Send()
  {
    //perform sending operation
  }
}
           

其次,由于HighLevelModule将仅依赖IOperation抽象,因此我们不能再在HighLevelModule类内部使用new LowLevelModule()。LowLevelModule需要从调用者上下文中注入到HighLevelModule类中。依赖项LowLevelModule需要反转。这就是术语“依赖倒置”和“控制反转”的来源。

需要从HighLevelModule外部传递LowLevelModule抽象或行为的实现,并将其从类的内部移至外部的过程称为反转。我将在第3节中讨论依赖倒置的不同方法。在下面的示例中,将使用通过构造函数的依赖注入。

public class HighLevelModule
{
  private readonly IOperation _operation;

  public HighLevelModule(IOperation operation)
  {
    _operation = operation;
  }

   public void Call()
  {
    _operation.Initiate();
    _operation.Send();
  }
}
           

我们已经将 HighLevelModule和LowLevelModule彼此分离,现在两者都依赖于抽象IOperation。Send方法的行为可以从类之外,通过使用任何的IOperation选择实现来控制,例如LowLevelModule

但是,尚未完成。该代码仍然不符合DIP的第二点。抽象不应依赖于细节或实现。实际上,IOperation内的Initiate方法是的LowLevelModule实现细节,用于在执行Send操作之前准备好LowLevelModule。

我要做的是从 IOperation抽象中删除它,并将其视为LowLevelModule实现细节的一部分。我可以在LowLevelModule构造函数中包含该Initiate操作。这使操作成为一种private方法,从而限制了对类的访问。

public interface IOperation
{
  void Send();
}

public class LowLevelModule: IOperation
{
  public LowLevelModule()
  {
    Initiate();
  }

  private void Initiate()
  {
    //do initiation before sending
  }
  
  public void Send()
  {
    //perform sending operation
  }
}

public class HighLevelModule
{
  private readonly IOperation _operation;

  public HighLevelModule(IOperation operation)
  {
    _operation = operation;
  }

  public void Call()
  {
    _operation.Send();
  }
}
           

2.抽象方法

实现DIP的第一个活动是将抽象应用于代码的各个部分。在C#世界中,有几种方法可以做到这一点:

  1. 使用接口
  2. 使用抽象类
  3. 使用委托

首先,interface仅用于提供抽象,而abstract class也可以用于提供一些共享的实现细节。最后,委托为一个特定的函数或方法提供了抽象。

附带说明一下,将方法标记为虚方法是一种常见的做法,因此在为调用类编写单元测试时可以模拟该方法。但是,这与应用抽象不同。将方法标记为virtual只会使其可重写,因此可以模拟该方法,这对于测试目的很有用。

我的偏好是将interface用于抽象目的。仅当两个或多个类之间共享实现细节时才使用abstract类。即便如此,我也将确保abstract类实现了实际抽象的interface。在第1节中,我已经给出了使用interfaces进行抽象应用的示例。在本节中,我将使用abstract类和委托给出其他示例。

使用抽象类

使用在第1节的例子中,我只需要更改接口IOperation为abstract类,OperationBase。

public abstract class OperationBase
{
  public abstract void Send();
}

public class LowLevelModule: OperationBase
{
  public LowLevelModule()
  {
    Initiate();
  }

  private void Initiate()
  {
    //do initiation before sending
  }
  
  public void Send()
  {
    //perform sending operation
  }
}

public class HighLevelModule
{
  private readonly OperationBase _operation;

  public HighLevelModule(OperationBase operation)
  {
    _operation = operation;
  }

  public void Call()
  {
    _operation.Send();
  }
}
           

上面的代码等效于使用interface。通常,只有在共享实现细节的情况下,我才使用abstract类。例如,如果HighLevelModule可以使用LowLevelModule或AnotherLowLevelModule,并且两个类都具有共享的实现细节,那么我将使用一个abstract类作为两者的基类。基类将实现IOperation,这是实际的抽象。

public interface IOperation
{
  void Send();
}

public abstract class OperationBase: IOperation
{
  public OperationBase()
  {
    Initiate();
  }

  private void Initiate()
  {
    //do initiation before sending, also shared implementation in this example
  }

  public abstract void Send();
}

public class LowLevelModule: OperationBase
{
  public void Send()
  {
    //perform sending operation
  }
}

public class AnotherLowLevelModule: OperationBase
{
  public void Send()
  {
    //perform another sending operation
  }
}

public class HighLevelModule
{
  private readonly IOperation _operation;

  public HighLevelModule(IOperation operation)
  {
    _operation = operation;
  }

  public void Call()
  {
    _operation.Send();
  }
}
           

使用委托

可以使用委托来抽象单个方法或函数。通用委托Func<T>或Action可用于此目的。

public class Caller
{ 
  public void CallerMethod()
  {
    var module = new HighLevelModule(Send);
    ...
  }

  public void Send()
  {
    //this is the method injected into HighLevelModule
  }
}

public class HighLevelModule
{
  private readonly Action _sendOperation;

  public HighLevelModule(Action sendOperation)
  {
    _sendOperation = sendOperation;
  }

  public void Call()
  {
    _sendOperation();
  }
}
           

或者,您可以创建自己的委托并为其赋予一个有意义的名称。

public delegate void SendOperation();

public class Caller
{ 
  public void CallerMethod()
  {
    var module = new HighLevelModule(Send);
    ...
  }

  public void Send()
  {
    //this is the method injected into HighLevelModule
  }
}

public class HighLevelModule
{
  private readonly SendOperation _sendOperation;

  public HighLevelModule(SendOperation sendOperation)
  {
    _sendOperation = sendOperation;
  }

  public void Call()
  {
    _sendOperation();
  }
}
           

使用泛型委托的好处是我们不需要为依赖项创建或实现一种类型,例如接口和类。我们可以从调用者上下文或其他任何地方使用任何方法或函数。

3.依赖倒置方法

在第一节中,我将构造函数依赖项注入用作依赖倒置方法。在本节中,我将讨论依赖倒置方法中的各种方法。

这里是依赖倒置方法的列表:

  1. 使用依赖注入
  2. 使用全局状态
  3. 使用间接

下面,我将解释每种方法。

1.使用依赖注入

使用依赖注入(DI)是将依赖项通过其公共成员直接注入到类中的。可以将依赖项注入到类的构造函数(构造函数注入)、set属性(Setter注入)、方法(方法注入)、事件,索引属性、字段以及基本上是public类的任何成员中。我一般不建议使用字段,因为在面向对象的编程中,不建议将字段公开是一个好习惯,因为使用属性可以实现相同的目的。使用索引属性进行依赖项注入也是一种罕见的情况,因此我将不做进一步解释。

构造函数注入

我主要使用构造函数注入。使用构造函数注入还可以利用IoC容器中的某些功能,例如自动装配或类型发现。稍后我将在第5节中讨论IoC容器。以下是构造注入的示例:

public class HighLevelModule
{
  private readonly IOperation _operation;

  public HighLevelModule(IOperation operation)
  {
    _operation = operation;
  }

  public void Call()
  {
    _operation.Send();
  }
}
           

Setter 注入

Setter和Method注入用于在构造对象之后注入依赖项。与IoC容器一起使用时,这可以看作是不利条件(将在第5节中进行讨论)。但是,如果您不使用IoC容器,它们将实现与构造函数注入相同的功能。Setter或Method注入的另一个好处是允许您更改对运行时的依赖关系,它们可以用于构造函数注入的补充。下面是一个Setter注入示例,它允许您一次注入一个依赖项:

public class HighLevelModule
{
  public IOperation Operation { get; set; }

  public void Call()
  {
    Operation.Send();
  }
}
           

方法注入

使用方法注入,您可以同时设置多个依赖项。下面是方法注入的示例:

public class HighLevelModule
{
  private readonly IOperation _operationOne;
  private readonly IOperation _operationTwo;

  public void SetOperations(IOperation operationOne, IOperation operationTwo)
  {
    _operationOne = operationOne;
    _operationTwo = operationTwo;
  }

  public void Call()
  {
    _operationOne.Send();
    _operationTwo.Send();
  }
}
           

使用方法注入时,作为参数传递的依赖项将保留在类中,例如作为字段或属性,以备后用。在方法中传递某些类或接口并仅在方法中使用时,这不算作方法注入。

使用事件

仅在委托类型注入中使用事件才受限制,并且仅在需要订阅和通知模型的情况下才适用,并且委托不得返回任何值,或仅返回void。调用者将向实现该事件的类订阅一个委托,并且可以有多个订阅者。事件注入可以在对象构造之后执行。通过构造函数注入事件并不常见。以下是事件注入的示例。

public class Caller
{ 
  public void CallerMethod()
  {
    var module = new HighLevelModule();
    module.SendEvent += Send ;

    ...
  }

  public void Send()
  {
    //this is the method injected into HighLevelModule
  }
}

public class HighLevelModule
{
  public event Action SendEvent = delegate {};

  public void Call()
  {
    SendEvent();
  }
}
           

通常,我的口头禅始终是使用构造函数注入,如果没有什么可迫使您使用Setter或Method注入的话,这也使我们能够在以后使用IoC容器。

2.使用全局状态

可以从类内部的全局状态中检索依赖关系,而不必直接注入到类中。可以将依赖项注入全局状态,然后从类内部进行访问。

public class Helper
  {
    public static IOperation GlobalStateOperation { get; set;}
  }

  public class HighLevelModule
  {
    public void Call()
    {
       Helper.GlobalStateOperation.Send();
    }
  }
   
  public class Caller
  {
    public void CallerMethod()
    {
      Helper.GlobalStateOperation = new LowLevelModule();

      var highLevelModule = new HighLevelModule();
      highLevelModule.Call();
    }
  }  
}
           

全局状态可以表示为属性、方法甚至字段。重要的一点是,基础值具有公共setter和getter。setter和getter可以采用方法而不是属性的形式。

如果全局状态只有getter(例如,单例),则依赖性不会反转。不建议使用全局状态来反转依赖关系,因为它会使依赖关系变得不那么明显,并将它们隐藏在类中。

3.使用间接

如果使用的是Indirect,则不会直接将依赖项传递给类。而是传递一个能够为您创建或传递抽象实现的对象。这也意味着您为该类创建了另一个依赖关系。您传递给类的对象的类型可以是:

  • 注册表/容器对象
  • 工厂对象

您可以选择是直接传递对象(依赖注入)还是使用全局状态。

注册表/容器对象

如果使用注册表(通常称为服务定位器模式),则可以查询注册表以返回抽象的实现(例如接口)。但是,您将需要先从类外部注册实现。您也可以像许多IoC容器框架一样使用容器来包装注册表。容器通常具有其他类型的发现或自动装配功能,因此,在注册容器interface及其实现时,无需指定实现类的依赖项。当查询接口时,容器将能够通过首先解决其所有依赖关系来返回实现类实例。当然,您将需要首先注册所有依赖项。

在IoC容器框架如雨后春笋般出现的早期,该容器通常被实现为Global状态或Singleton,而不是将其显式传递给类,因此现在被视为反模式。这是使用容器类型对象的示例:

public interface IOperation
{
  void Send();
}

public class LowLevelModule: IOperation
{
  public LowLevelModule()
  {
    Initiate();
  }

  private void Initiate()
  {
    //do initiation before sending
  }
  
  public void Send()
  {
    //perform sending operation
  }
}

public class HighLevelModule
{
  private readonly Container _container;

  public HighLevelModule(Container container)
  {
    _container = container;
  }

  public void Call()
  {
    IOperation operation = _container.Resolvel<IOperation>();
    operation.Send();
  }
}

public class Caller
{
  public void UsingContainerObject()
  {
     //registry the LowLevelModule as implementation of IOperation
     var register  = new Registry();
     registry.For<IOperation>.Use<LowLevelModule>();

     //wrap-up registry in a container
     var container = new Container(registry);
      
     //inject the container into HighLevelModule
     var highLevelModule = new HighLevelModule(container);
     highLevelModule.Call();     
  }
}
           

您甚至可以使HighLevelModule依赖于容器的抽象,但是此步骤不是必需的。

而且,在类中的任何地方使用容器或注册表可能不是一个好主意,因为这使类的依赖关系变得不那么明显。

工厂对象

使用注册表/容器和工厂对象之间的区别在于,使用注册表/容器时,需要先注册实现类才能查询它,而使用工厂时则不需要这样做,因为实例化是在工厂实现中进行了硬编码。工厂对象不必将“工厂”作为其名称的一部分。它可以只是返回抽象(例如,接口)的普通类。

此外,由于LowLevelModule实例化是在工厂实现中进行硬编码的,因此HighLevelModule依赖工厂不会导致LowLevelModule依赖关系反转。为了反转依赖关系,HighLevelModule需要依赖于工厂抽象,而工厂对象需要实现该抽象。这是使用工厂对象的示例:

public interface IOperation
{
  void Send();
}

public class LowLevelModule: IOperation
{
  public LowLevelModule()
  {
    Initiate();
  }

  private void Initiate()
  {
    //do initiation before sending
  }
  
  public void Send()
  {
    //perform sending operation
  }
}

public interface IModuleFactory
{
   IOperation CreateModule();
}

public class ModuleFactory: IModuleFactory
{
  public IOperation CreateModule()
  {
      //LowLevelModule is the implementation of the IOperation, 
      //and it is hardcoded in the factory. 
      return new LowLevelModule();
  }
}

public class HighLevelModule
{
  private readonly IModuleFactory _moduleFactory;

  public HighLevelModule(IModuleFactory moduleFactory)
  {
    _moduleFactory = moduleFactory;
  }

  public void Call()
  {
    IOperation operation = _moduleFactory.CreateModule();
    operation.Send();
  }
}

public class Caller
{
  public void CallerMethod()
  {
     //create the factory as the implementation of abstract factory
     IModuleFactory moduleFactory = new ModuleFactory();
      
     //inject the factory into HighLevelModule
     var highLevelModule = new HighLevelModule(moduleFactory);   
     highLevelModule.Call();  
  }
}
           

我的建议是谨慎使用间接。服务定位器模式,如今被视为反模式。但是,有时可能需要使用工厂对象为您创建依赖关系。我的理想是避免使用间接,除非证明有必要。

除了抽象(接口、抽象类或代理)的执行情况,我们通常可以还注入依赖于原始类型,诸如布尔,int,double,string或只是一个只包含属性的类。

继续阅读