介绍
在本文中,我们将讨论SOLID原则的其中一个支柱,即依赖反转原则。我们将讨论其背后的工作原理,以及如何将其应用于工作示例。
1.概念
什么是DIP?
原则指出:
- 高级模块不应依赖于低级模块。两者都应依赖抽象。
- 抽象不应依赖细节。细节应依赖于抽象。
例如,下面的代码不符合上述原则:
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#世界中,有几种方法可以做到这一点:
- 使用接口
- 使用抽象类
- 使用委托
首先,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.使用依赖注入
使用依赖注入(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或只是一个只包含属性的类。