什么是依赖倒置原则,为什么它很重要?
16 回答
敏捷软件开发、原则、模式和实践以及 C# 中的敏捷原则、模式和实践这本书是充分理解依赖倒置原则背后的原始目标和动机的最佳资源。文章“依赖倒置原则”也是一个很好的资源,但由于它是最终进入前面提到的书籍的草稿的浓缩版本,它遗漏了一些关于“依赖倒置”概念的重要讨论。包和接口所有权是区分这一原则与更一般的建议的关键,即在设计模式(Gamma 等人)一书中找到的“编程到接口,而不是实现”。
总结一下,依赖倒置原则主要是关于将传统的依赖方向从“高级”组件反转到“低级”组件,这样“低级”组件依赖于“高级”组件拥有的接口. (注意:这里的“更高级别”组件是指需要外部依赖/服务的组件,不一定是它在分层架构中的概念位置。)这样做,耦合并没有减少太多,而是从理论上的组件转移对理论上更有价值的组件价值较低。
这是通过设计组件来实现的,这些组件的外部依赖关系用接口表示,组件的使用者必须为其提供实现。换句话说,定义的接口表达了组件需要什么,而不是你如何使用组件(例如“INeedSomething”,而不是“IDoSomething”)。
依赖倒置原则没有提到的是通过使用接口来抽象依赖的简单实践(例如MyService → [ILogger ⇐ Logger])。虽然这将组件与依赖项的特定实现细节解耦,但它并没有颠倒消费者和依赖项之间的关系(例如 [MyService → IMyServiceLogger] ⇐ Logger.
依赖倒置原则的重要性可以归结为一个单一的目标,即能够重用依赖外部依赖来实现部分功能(日志记录、验证等)的软件组件。
在这个重用的总体目标中,我们可以描述两种重用的子类型:
在具有子依赖实现的多个应用程序中使用软件组件(例如,您已经开发了一个 DI 容器并希望提供日志记录,但不想将您的容器耦合到特定的记录器,这样使用您容器的每个人都必须也使用您选择的日志库)。
在不断发展的环境中使用软件组件(例如,您开发的业务逻辑组件在实现细节不断发展的应用程序的多个版本中保持不变)。
对于跨多个应用程序重用组件的第一种情况,例如使用基础架构库,目标是为您的消费者提供核心基础架构需求,而不会将您的消费者耦合到您自己库的子依赖项,因为对此类依赖项进行依赖需要您的消费者也需要相同的依赖项。当您的库的使用者选择使用不同的库来满足相同的基础架构需求(例如 NLog 与 log4net),或者如果他们选择使用与该版本不向后兼容的所需库的更高版本时,这可能会出现问题您的图书馆需要。
对于重用业务逻辑组件(即“更高级别的组件”)的第二种情况,目标是将应用程序的核心域实现与实现细节的不断变化的需求(即更改/升级持久性库、消息传递库)隔离开来、加密策略等)。理想情况下,更改应用程序的实现细节不应破坏封装应用程序业务逻辑的组件。
注意:有些人可能反对将第二种情况描述为实际重用,理由是在单个不断发展的应用程序中使用的诸如业务逻辑组件之类的组件仅代表一次使用。然而,这里的想法是,对应用程序实现细节的每次更改都会呈现一个新的上下文,因此会呈现一个不同的用例,尽管最终目标可以区分为隔离与可移植性。
虽然在第二种情况下遵循依赖倒置原则可以提供一些好处,但应该注意的是,它应用于现代语言(如 Java 和 C#)的价值大大降低,可能到了无关紧要的地步。如前所述,DIP 涉及将实现细节完全分离到单独的包中。然而,在不断发展的应用程序的情况下,简单地使用根据业务域定义的接口将防止由于实现细节组件的需求变化而需要修改更高级别的组件,即使实现细节最终位于同一个包中. 原则的这一部分反映了与新语言无关的原则被编纂时所考虑的语言相关的方面(即 C++)。也就是说,
可以在此处找到对该原则的更长讨论,因为它涉及接口的简单使用、依赖注入和分离接口模式。此外,可以在此处找到有关该原理如何与 JavaScript 等动态类型语言相关的讨论。
查看此文档:依赖倒置原则。
它基本上说:
- 高级模块不应依赖于低级模块。两者都应该依赖于抽象。
- 抽象永远不应该依赖于细节。细节应该取决于抽象。
至于为什么它很重要,简而言之:更改是有风险的,并且通过依赖于概念而不是实现,您可以减少呼叫站点的更改需求。
有效地,DIP 减少了不同代码段之间的耦合。这个想法是,虽然有很多方法可以实现,比如一个日志工具,但你使用它的方式应该在时间上是相对稳定的。如果您可以提取一个表示日志记录概念的接口,则该接口在时间上应该比其实现更稳定,并且调用站点应该受到您在维护或扩展该日志记录机制时所做的更改的影响要小得多。
通过使实现也依赖于接口,您可以在运行时选择更适合您的特定环境的实现。根据具体情况,这也可能很有趣。
当我们设计软件应用程序时,我们可以考虑实现基本和主要操作(磁盘访问、网络协议……)的低级类和封装复杂逻辑(业务流程……)的类。
最后一个依赖于低级类。实现这种结构的一种自然方式是编写低级类,一旦我们让它们编写复杂的高级类。由于高级类是根据其他类定义的,这似乎是合乎逻辑的方法。但这不是一个灵活的设计。如果我们需要替换一个低级类会发生什么?
依赖倒置原则指出:
- 高级模块不应依赖于低级模块。两者都应该依赖于抽象。
- 抽象不应依赖于细节。细节应该取决于抽象。
该原则旨在“颠倒”软件中高级模块应依赖于低级模块的传统观念。这里高层模块拥有由低层模块实现的抽象(例如,决定接口的方法)。从而使较低级别的模块依赖于较高级别的模块。
良好应用的依赖倒置在应用程序的整个架构级别提供了灵活性和稳定性。它将允许您的应用程序更安全和稳定地发展。
传统分层架构
传统上,分层架构 UI 依赖于业务层,而这又依赖于数据访问层。
您必须了解层、包或库。让我们看看代码是怎样的。
我们将有一个用于数据访问层的库或包。
// DataAccessLayer.dll
public class ProductDAO {
}
还有另一个依赖于数据访问层的库或包层业务逻辑。
// BusinessLogicLayer.dll
using DataAccessLayer;
public class ProductBO {
private ProductDAO productDAO;
}
依赖倒置的分层架构
依赖倒置表示以下内容:
高级模块不应该依赖于低级模块。两者都应该依赖于抽象。
抽象不应该依赖于细节。细节应该取决于抽象。
什么是高级模块和低级模块?考虑诸如库或包之类的模块,高级模块将是那些传统上具有依赖关系和它们所依赖的低级模块。
换句话说,模块高层是调用动作的地方,而低层是执行动作的地方。
从这个原则得出的一个合理的结论是,混凝土之间不应该有依赖关系,但必须依赖抽象。但是根据我们采用的方法,我们可能会误用投资依赖依赖,而是一种抽象。
想象一下,我们调整我们的代码如下:
我们将有一个用于定义抽象的数据访问层的库或包。
// DataAccessLayer.dll
public interface IProductDAO
public class ProductDAO : IProductDAO{
}
还有另一个依赖于数据访问层的库或包层业务逻辑。
// BusinessLogicLayer.dll
using DataAccessLayer;
public class ProductBO {
private IProductDAO productDAO;
}
尽管我们依赖于业务和数据访问之间的抽象依赖关系保持不变。
要获得依赖倒置,必须在此高级逻辑或域所在的模块或包中定义持久性接口,而不是在低级模块中。
首先定义领域层是什么,其通信的抽象定义为持久性。
// Domain.dll
public interface IProductRepository;
using DataAccessLayer;
public class ProductBO {
private IProductRepository productRepository;
}
在持久层依赖于域之后,如果定义了依赖关系,现在就开始反转。
// Persistence.dll
public class ProductDAO : IProductRepository{
}
(来源:xurxodev.com)
深化原则
重要的是要很好地吸收这个概念,深化目的和好处。如果我们停留在机械地学习典型案例库,我们将无法确定在哪里可以应用依赖原则。
但是为什么我们要反转依赖呢?除了具体例子之外,主要目标是什么?
这样通常允许最稳定的事物,不依赖于不太稳定的事物,更频繁地改变。
更改持久性类型(数据库或访问同一数据库的技术)比更改域逻辑或旨在与持久性通信的操作更容易。因此,依赖关系被反转,因为如果发生这种变化,更容易改变持久性。这样,我们就不必更改域。领域层是最稳定的,这就是为什么它不应该依赖任何东西。
但不仅仅是这个存储库示例。这个原则适用的场景很多,也有基于这个原则的架构。
架构
在某些架构中,依赖倒置是其定义的关键。在所有领域中,它是最重要的,它是指示领域与定义的其余包或库之间的通信协议的抽象。
清洁架构
在Clean 架构中,域位于中心,如果您沿着指示依赖关系的箭头方向看,很明显哪些是最重要和最稳定的层。外层被认为是不稳定的工具,因此请避免依赖它们。
(来源:8thlight.com)
六边形结构
六边形架构也是如此,其中域也位于中心部分,端口是从多米诺骨牌向外通信的抽象。在这里,很明显域是最稳定的,并且传统的依赖是倒置的。
(来源:pragprog.com)
对我来说,官方文章中描述的依赖倒置原则实际上是一种误导性的尝试,旨在提高本质上可重用性较低的模块的可重用性,也是一种解决 C++ 语言问题的方法。
C++ 中的问题是头文件通常包含私有字段和方法的声明。因此,如果高级 C++ 模块包含低级模块的头文件,它将取决于该模块的实际实现细节。显然,这不是一件好事。但这在当今常用的更现代的语言中不是问题。
高级模块本质上不如低级模块可重用,因为前者通常比后者更特定于应用程序/上下文。例如,实现 UI 屏幕的组件是最高级别的,并且非常(完全?)特定于应用程序。试图在不同的应用程序中重用这样的组件会适得其反,并且只会导致过度设计。
因此,只有在组件 A 对在不同的应用程序或上下文中重用真正有用的情况下,才能在组件 A 的同一级别创建依赖于组件 B(不依赖于 A)的单独抽象。如果不是这样,那么应用 DIP 将是糟糕的设计。
基本上它说:
类应该依赖于抽象(例如接口、抽象类),而不是具体的细节(实现)。
说明依赖倒置原则的更清晰的方法是:
封装复杂业务逻辑的模块不应直接依赖于封装业务逻辑的其他模块。相反,它们应该只依赖于简单数据的接口。
即,而不是Logic
像人们通常那样实现你的类:
class Dependency { ... }
class Logic {
private Dependency dep;
int doSomething() {
// Business logic using dep here
}
}
您应该执行以下操作:
class Dependency { ... }
interface Data { ... }
class DataFromDependency implements Data {
private Dependency dep;
...
}
class Logic {
int doSomething(Data data) {
// compute something with data
}
}
Data
并且DataFromDependency
应该与 位于同一模块中Logic
,而不是与Dependency
.
为什么要这样做?
- 这两个业务逻辑模块现在解耦了。更改时
Dependency
,您无需更改Logic
. - 了解什么
Logic
是一个更简单的任务:它只在看起来像 ADT 的东西上运行。 Logic
现在可以更容易地进行测试。您现在可以直接Data
使用假数据进行实例化并将其传入。无需模拟或复杂的测试脚手架。
其他人已经在这里给出了很好的答案和很好的例子。
DIP之所以重要,是因为它确保了 OO 原则“松散耦合设计”。
软件中的对象不应进入层次结构,其中某些对象是顶级对象,依赖于低级对象。然后,低级对象的更改会波及到您的顶级对象,这使得软件对更改非常脆弱。
您希望您的“顶级”对象非常稳定且不易更改,因此您需要反转依赖关系。
控制反转(IoC) 是一种设计模式,其中对象由外部框架传递其依赖关系,而不是向框架询问其依赖关系。
使用传统查找的伪代码示例:
class Service {
Database database;
init() {
database = FrameworkSingleton.getService("database");
}
}
使用 IoC 的类似代码:
class Service {
Database database;
init(database) {
this.database = database;
}
}
IoC 的好处是:
- 您不依赖于中央框架,因此可以根据需要进行更改。
- 由于对象是通过注入创建的,最好使用接口,因此很容易创建单元测试,用模拟版本替换依赖项。
- 解耦代码。
依赖倒置原则(DIP)
它是 SOLID [About]的一部分,它是 OOD 的一部分,由 Bob 大叔介绍。它是关于类(层......)之间的松散耦合。类不应该依赖于具体实现,类应该依赖于抽象/接口
问题:
//A -> B
class A {
B b
func foo() {
b = B();
}
}
解决方案:
//A -> IB <|- B
//client[A -> IB] <|- B is the Inversion
class A {
IB ib // An abstraction between High level module A and low level module B
func foo() {
ib = B()
}
}
现在A
不依赖B
(一对一),现在A
依赖于IB
由实现的接口B
,这意味着A依赖于IB
(一对多)的多个实现
依赖倒置的重点是制作可重用的软件。
这个想法是,它们不是依赖于彼此的两段代码,而是依赖于一些抽象的接口。然后,您可以重复使用其中的任何一个而无需另一个。
最常见的实现方式是通过控制反转 (IoC) 容器,如 Java 中的 Spring。在这个模型中,对象的属性是通过 XML 配置来设置的,而不是对象走出去寻找它们的依赖关系。
想象一下这个伪代码......
public class MyClass
{
public Service myService = ServiceLocator.service;
}
MyClass 直接依赖于 Service 类和 ServiceLocator 类。如果您想在另一个应用程序中使用它,它需要这两个。现在想象这个...
public class MyClass
{
public IService myService;
}
现在,MyClass 依赖于一个接口,即 IService 接口。我们会让 IoC 容器实际设置该变量的值。
所以现在,MyClass 可以很容易地在其他项目中重用,而不会带来其他两个类的依赖关系。
更好的是,您不必拖动 MyService 的依赖项,以及这些依赖项的依赖项,而且……嗯,你明白了。
如果我们可以认为公司的“高级”员工因执行他们的计划而获得报酬,并且这些计划是由许多“低级”员工计划的总执行来交付的,那么我们可以说如果高层员工的计划描述以任何方式与任何低层员工的具体计划相结合,这通常是一个糟糕的计划。
如果一个高层管理人员有一个“改善交货时间”的计划,并指出航运公司的员工必须每天早上喝咖啡并做伸展运动,那么该计划是高度耦合的,凝聚力低。但是,如果计划没有提及任何特定的员工,而实际上只是要求“一个可以执行工作的实体准备工作”,那么该计划就是松散耦合且更具凝聚力:计划不会重叠并且可以很容易地被替代. 承包商或机器人可以轻松取代员工,高层的计划保持不变。
依赖倒置原则中的“高级”意味着“更重要”。
依赖倒置:依赖于抽象,而不是具体。
控制反转:主要与抽象,以及主要如何成为系统的粘合剂。
这些是一些谈论这个的好帖子:
https://coderstower.com/2019/03/26/dependency-inversion-why-you-shouldnt-avoid-it/
https://coderstower.com/2019/04/02/main-and-abstraction-the-decoupled-peers/
https://coderstower.com/2019/04/09/inversion-of-control-putting-all-together/
我可以看到上面的答案已经给出了很好的解释。但是我想用简单的例子提供一些简单的解释。
依赖倒置原则允许程序员去除硬编码的依赖,使应用程序变得松散耦合和可扩展。
如何实现:通过抽象
没有依赖倒置:
class Student {
private Address address;
public Student() {
this.address = new Address();
}
}
class Address{
private String perminentAddress;
private String currentAdrress;
public Address() {
}
}
在上面的代码片段中,地址对象是硬编码的。相反,如果我们可以使用依赖倒置并通过构造函数或 setter 方法注入地址对象。让我们来看看。
依赖倒置:
class Student{
private Address address;
public Student(Address address) {
this.address = address;
}
//or
public void setAddress(Address address) {
this.address = address;
}
}
依赖倒置原则(DIP)说
i) 高级模块不应依赖于低级模块。两者都应该依赖于抽象。
ii) 抽象不应该依赖于细节。细节应该取决于抽象。
例子:
public interface ICustomer
{
string GetCustomerNameById(int id);
}
public class Customer : ICustomer
{
//ctor
public Customer(){}
public string GetCustomerNameById(int id)
{
return "Dummy Customer Name";
}
}
public class CustomerFactory
{
public static ICustomer GetCustomerData()
{
return new Customer();
}
}
public class CustomerBLL
{
ICustomer _customer;
public CustomerBLL()
{
_customer = CustomerFactory.GetCustomerData();
}
public string GetCustomerNameById(int id)
{
return _customer.GetCustomerNameById(id);
}
}
public class Program
{
static void Main()
{
CustomerBLL customerBLL = new CustomerBLL();
int customerId = 25;
string customerName = customerBLL.GetCustomerNameById(customerId);
Console.WriteLine(customerName);
Console.ReadKey();
}
}
注意:类应该依赖于接口或抽象类之类的抽象,而不是具体的细节(接口的实现)。
除了一连串通常很好的答案之外,我想添加一个我自己的小样本来展示好与坏的做法。是的,我不是扔石头的人!
假设您想要一个小程序通过控制台 I/O将字符串转换为 base64 格式。这是天真的方法:
class Program
{
static void Main(string[] args)
{
/*
* BadEncoder: High-level class *contains* low-level I/O functionality.
* Hence, you'll have to fiddle with BadEncoder whenever you want to change
* the I/O mode or details. Not good. A good encoder should be I/O-agnostic --
* problems with I/O shouldn't break the encoder!
*/
BadEncoder.Run();
}
}
public static class BadEncoder
{
public static void Run()
{
Console.WriteLine(Convert.ToBase64String(Encoding.UTF8.GetBytes(Console.ReadLine())));
}
}
DIP 基本上说高级组件不应该依赖于低级实现,其中“级别”是根据 Robert C. Martin(“清洁架构”)与 I/O 的距离。但是你如何摆脱这种困境呢?只需使中央编码器仅依赖于接口,而无需打扰这些接口是如何实现的:
class Program
{
static void Main(string[] args)
{
/* Demo of the Dependency Inversion Principle (= "High-level functionality
* should not depend upon low-level implementations"):
* You can easily implement new I/O methods like
* ConsoleReader, ConsoleWriter without ever touching the high-level
* Encoder class!!!
*/
GoodEncoder.Run(new ConsoleReader(), new ConsoleWriter()); }
}
public static class GoodEncoder
{
public static void Run(IReadable input, IWriteable output)
{
output.WriteOutput(Convert.ToBase64String(Encoding.ASCII.GetBytes(input.ReadInput())));
}
}
public interface IReadable
{
string ReadInput();
}
public interface IWriteable
{
void WriteOutput(string txt);
}
public class ConsoleReader : IReadable
{
public string ReadInput()
{
return Console.ReadLine();
}
}
public class ConsoleWriter : IWriteable
{
public void WriteOutput(string txt)
{
Console.WriteLine(txt);
}
}
请注意,您无需触摸GoodEncoder
即可更改 I/O 模式——该类对它知道的 I/O 接口感到满意;任何低级实现,IReadable
并且IWriteable
永远不会打扰它。