依赖倒置原则(Dependency Inversion Principle, DIP)是软件设计中一个非常重要的原则,它属于面向对象设计的SOLID原则之一。这个原则的核心在于通过抽象来降低模块间的耦合度,使得系统更加灵活和可维护。
目录
- 依赖倒置原则的基本思想
- 依赖倒置原则的好处
- 依赖倒置原则的实现方式
- 打个比方
- 不遵守的例子
- Demo
依赖倒置原则的基本思想
高层模块不应该依赖低层模块,两者都应该依赖其抽象:这意味着在软件系统中,设计上层模块(如业务逻辑层)时,不应该直接依赖于下层模块(如数据访问层)的具体实现。相反,它们都应该依赖于一个共同的抽象层(如接口或抽象类)。
抽象不应该依赖细节,细节应该依赖抽象: 抽象层(接口或抽象类)定义了高层模块和低层模块之间的通信规范,而不涉及具体的实现细节。具体的实现(细节)则依赖于这些抽象规范来实现具体的功能。
依赖倒置原则的好处
降低耦合度: 通过依赖抽象层而不是具体实现,可以减少模块间的直接依赖,从而降低系统的耦合度。
提高灵活性: 由于高层模块不直接依赖于低层模块的具体实现,因此可以更容易地更换或升级低层模块,而无需修改高层模块。
增强可测试性: 依赖倒置原则使得单元测试更加容易,因为可以通过模拟(mocking)或存根(stubbing)抽象层来测试高层模块。
依赖倒置原则的实现方式
接口和抽象类的使用: 定义清晰的接口或抽象类,并在高层模块和低层模块之间使用这些接口或抽象类进行通信。
依赖注入: 通过依赖注入(Dependency Injection, DI)技术,将低层模块的实现注入到高层模块中,而不是在高层模块中直接创建低层模块的对象。依赖注入不同于依赖倒置,因为依赖性倒置是为软件模块定义一个抽象策略,而依赖注入是依赖倒置的一种实现方式。
打个比方
正如你可以看到在上面的图像,我们有一个端口为每个外部设备,我可以关联一个外部设备,并做我们的工作。
但问题是,我不能将我的键盘连接到打印机端口,反之亦然。其他设备也会出现同样的问题。这就像一个紧密耦合,我不能在给定的接口上更改我的外部设备。
如果我有一个 USB 接口,那么我可以很容易地连接任何设备到我的机器和执行我的任务。
不遵守的例子
Public Class Customer
{
CustomerRepository CustomerRepository;
Public Customer
{
CustomerRepository = new CustomerRpository();
}
Public bool Save()
{
CustomerRepository.Save();
}
}
Public class CustomerRepository
{
Public bool Save(dattype data)
{
//Sql Connection object and Save data in Sql server
}
}
上面的代码是紧密耦合的,因为当前存储库处理 SQL 服务器。因此,如果需求是使用 Oracle 服务器,则需要对 Customer 类进行修改。
因此,为了避免这种情况,应该让客户类依赖于抽象。下面是一个类图,其中客户依赖于抽象并支持 SQL 和 Oracle 服务器。
对于软件中设计的模块也是一样的,因为依赖性反转是关于提供一组抽象策略,其细节依赖于这组抽象策略,以及在软件系统中提供灵活性的策略。
应用程序模块高度耦合,这将导致模块的可测试性变得困难,模块的并行开发变得困难,在模块中进行修改以及所依赖的模块中进行更改时,需要进行许多更改。
Demo
假设我们有一个日志系统,其中有一个ILogger接口和一个Application类。Application类需要记录日志,但它不直接依赖于具体的日志实现类(比如ConsoleLogger或FileLogger),而是依赖于ILogger接口。
public interface ILogger
{
void Log(string message);
}
然后,我们创建两个具体的日志实现类:ConsoleLogger和FileLogger,它们都实现了ILogger接口:
public class ConsoleLogger : ILogger
{
public void Log(string message)
{
Console.WriteLine(message);
}
}
public class FileLogger : ILogger
{
private string filePath;
public FileLogger(string filePath)
{
this.filePath = filePath;
}
public void Log(string message)
{
// 这里只是模拟写入文件,实际项目中应该使用文件IO操作
Console.WriteLine($"Logging to file: {filePath} - Message: {message}");
}
}
接下来,我们定义Application类,它依赖于ILogger接口而不是具体的日志实现类。我们将通过构造函数注入的方式将日志记录器的依赖注入到Application类中:
public class Application
{
private ILogger logger;
// 构造函数注入ILogger依赖
public Application(ILogger logger)
{
this.logger = logger;
}
public void Run()
{
// 执行一些业务逻辑
string message = "This is an important message.";
logger.Log(message);
// 更多的业务逻辑...
}
}
最后,在客户端代码中,我们根据需要创建具体的日志实现类,并将其注入到Application类的实例中:
class Program
{
static void Main(string[] args)
{
// 使用ConsoleLogger
ILogger consoleLogger = new ConsoleLogger();
Application appWithConsoleLogger = new Application(consoleLogger);
appWithConsoleLogger.Run();
// 或者使用FileLogger
ILogger fileLogger = new FileLogger("app.log");
Application appWithFileLogger = new Application(fileLogger);
appWithFileLogger.Run();
}
}
在这个例子中,Application类是高层模块,它依赖于ILogger接口这一抽象层,而不是依赖于具体的日志实现类(ConsoleLogger或FileLogger)。这种设计方式使得我们可以轻松地更换日志实现,而无需修改Application类的代码,从而实现了依赖倒置。同时,这种设计也提高了系统的可测试性,因为我们可以通过模拟(mocking)ILogger接口来测试Application类的行为。