1.5 依赖倒置原则 (Dependency Inversion Principle, DIP)
核心定义
依赖倒置原则(Dependency Inversion Principle, DIP)指出:
- 高层模块不应该依赖于低层模块。两者都应该依赖于抽象。
- 抽象不应该依赖于细节。细节应该依赖于抽象。
简单来说,DIP要求我们在设计系统时,要依赖于抽象接口或抽象类,而不是依赖于具体的实现类。这种“倒置”指的是传统软件设计中高层模块调用低层模块,依赖关系是自上而下的;而DIP则通过引入抽象层,使得高层和低层都依赖于这个抽象层,依赖关系的方向发生了“倒置”。
深层解读与目的
DIP是实现松耦合系统架构的关键原则之一,其核心目的是减少模块间的直接依赖,特别是高层业务逻辑对低层实现细节的依赖,从而提高系统的灵活性、可维护性和可测试性。
- 高层模块 vs. 低层模块:高层模块通常包含系统的核心业务逻辑和策略,它们定义了系统应该做什么。低层模块则包含实现这些策略所需的具体机制和细节,例如数据库访问、文件操作、网络通信等。
- 依赖于抽象:抽象通常指接口(Interface)或抽象类(Abstract Class)。高层模块定义它所需要的服务接口,而低层模块则提供这些接口的具体实现。这样,高层模块只知道它需要一个符合某种契约的服务,而不需要关心这个服务是如何实现的。
- 控制反转 (Inversion of Control, IoC):DIP是IoC思想的一个具体体现。传统的流程控制是高层模块主动创建和调用低层模块。而在IoC中,创建和管理低层模块(依赖对象)的控制权从高层模块中移出,通常由外部容器或框架(如Spring的DI容器)来负责,或者通过构造函数注入、Setter注入等方式将依赖传递给高层模块。
- 面向接口编程:DIP鼓励“面向接口编程,而不是面向实现编程”。
遵循DIP的好处:
- 降低耦合度:高层模块和低层模块之间的耦合通过抽象层解开,修改低层模块的实现细节不会影响到高层模块。
- 提高系统的灵活性和可扩展性:可以方便地替换低层模块的具体实现,只要新的实现遵循相同的抽象接口即可。
- 增强代码的可测试性:在测试高层模块时,可以很容易地使用模拟对象(Mock Objects)或存根(Stubs)来替代真实的低层模块,从而实现单元测试的隔离。
- 促进并行开发:一旦抽象接口定义好,高层模块和低层模块的开发可以并行进行。
生活化类比
- 电源插座和电器 (再次出现):电器(高层模块,如台灯、电脑)并不直接依赖于某个特定的发电厂(低层模块)。它们都依赖于一个标准的电源插座接口和电压标准(抽象)。只要发电厂能提供符合标准的电力并通过插座输出,任何电器都可以使用。发电厂的实现方式(火电、水电、核电)对电器来说是透明的。
- 汽车和轮胎:汽车制造商(高层模块)在设计汽车时,不会指定必须使用米其林轮胎或普利司通轮胎(低层模块的具体实现)。相反,他们会定义轮胎的规格标准(如尺寸、承重、速度级别等——抽象接口)。任何符合这些标准的轮胎都可以安装到汽车上。车主可以根据自己的需求更换不同品牌的轮胎。
- 老板和员工:老板(高层模块)需要完成一项任务(如市场调研)。老板不会直接去执行具体的调研步骤(低层细节),而是定义任务的需求和期望结果(抽象),然后将任务分配给具备相应能力的员工或团队(低层模块的实现)。员工如何完成任务(具体方法)对老板来说不重要,只要结果符合要求。
实际应用场景
- 业务逻辑层与数据访问层:业务逻辑层(高层)不应该直接依赖于具体的数据库访问技术(如直接使用JDBC API或特定ORM框架的API)。应该定义一个数据访问接口(如
UserRepository接口),业务逻辑层依赖这个接口。而具体的数据访问实现(如JdbcUserRepositoryImpl或JpaUserRepositoryImpl)则实现该接口。这样,如果将来需要更换数据库或ORM框架,只需提供新的接口实现,业务逻辑层代码无需修改。 - 通知服务:一个应用需要发送通知,可能通过邮件、短信或App推送。应用的核心逻辑(高层)不应直接调用
EmailSender或SmsSender。应定义一个NotificationService接口,核心逻辑依赖此接口。然后提供EmailNotificationService、SmsNotificationService等具体实现。 - 插件化系统:主应用程序(高层)定义插件需要遵循的接口(抽象)。各种插件(低层)实现这些接口来提供特定功能。主应用程序通过加载和调用这些插件接口来扩展功能,而无需知道插件的具体实现细节。
- MVC/MVP/MVVM架构模式:在这些UI架构模式中,通常Presenter/ViewModel(高层逻辑)会依赖于View的抽象接口,而不是具体的View实现。View实现这个接口,并将用户操作通知给Presenter/ViewModel。这种方式也体现了DIP。
作用与价值
| 作用维度 | 具体表现 |
|---|---|
| 降低耦合度 | 模块间的依赖关系通过抽象建立,减少了直接依赖,使得系统更加松耦合。 |
| 提高系统灵活性 | 可以轻松替换或修改低层模块的具体实现,而不影响高层模块。 |
| 增强可扩展性 | 新的功能或实现可以通过实现已有的抽象接口来加入系统。 |
| 提高代码可测试性 | 易于对高层模块进行单元测试,可以使用Mock对象替代真实的依赖。 |
| 促进并行开发 | 接口定义后,高层和低层模块可以独立开发和测试。 |
| 使架构更清晰 | 强调面向接口编程,使得系统的层次和模块边界更加清晰。 |
代码示例 (Kotlin)
场景:一个报告生成服务需要从数据源获取数据。
违反DIP的例子:
// 低层模块:具体的数据源实现
class MySqlDatabase_Violates_DIP {
fun queryData(): List<String> {
println("Querying data from MySQL Database...")
return listOf("Data1_MySQL", "Data2_MySQL")
}
}
// 高层模块:报告生成服务直接依赖于具体的MySqlDatabase
class ReportGenerator_Violates_DIP {
private val database = MySqlDatabase_Violates_DIP() // 直接依赖具体实现
fun generateReport(): String {
val data = database.queryData()
return "Report based on: ${data.joinToString()}"
}
}
// 使用
// val reportGenBad = ReportGenerator_Violates_DIP()
// println(reportGenBad.generateReport())
// 如果要换成Oracle数据库,ReportGenerator_Violates_DIP类就需要修改
在这个例子中,ReportGenerator_Violates_DIP(高层模块)直接依赖于MySqlDatabase_Violates_DIP(低层模块的具体实现)。如果将来需要从其他数据源(如Oracle数据库或CSV文件)获取数据,就必须修改ReportGenerator_Violates_DIP类。
遵循DIP的例子:
// 步骤1: 定义抽象接口(数据源接口)
interface DataSource_DIP {
fun fetchData(): List<String>
}
// 步骤2: 低层模块实现这个抽象接口
class MySqlDatabase_DIP : DataSource_DIP {
override fun fetchData(): List<String> {
println("Fetching data from MySQL Database (DIP compliant)...")
return listOf("MySQL_Data1", "MySQL_Data2")
}
}
class OracleDatabase_DIP : DataSource_DIP {
override fun fetchData(): List<String> {
println("Fetching data from Oracle Database (DIP compliant)...")
return listOf("Oracle_DataA", "Oracle_DataB")
}
}
class CsvFileSource_DIP : DataSource_DIP {
override fun fetchData(): List<String> {
println("Fetching data from CSV File (DIP compliant)...")
return listOf("CSV_RecordX", "CSV_RecordY")
}
}
// 步骤3: 高层模块依赖于抽象接口
// 通常通过构造函数注入、Setter注入或接口注入来提供具体实现
class ReportGenerator_DIP(private val dataSource: DataSource_DIP) { // 依赖于抽象
fun generateReport(): String {
val data = dataSource.fetchData() // 调用抽象接口的方法
return "Report (DIP compliant) based on: ${data.joinToString()}"
}
}
// 使用
// val mySqlDataSource = MySqlDatabase_DIP()
// val reportGenMySql = ReportGenerator_DIP(mySqlDataSource)
// println(reportGenMySql.generateReport())
// val oracleDataSource = OracleDatabase_DIP()
// val reportGenOracle = ReportGenerator_DIP(oracleDataSource)
// println(reportGenOracle.generateReport())
// val csvDataSource = CsvFileSource_DIP()
// val reportGenCsv = ReportGenerator_DIP(csvDataSource)
// println(reportGenCsv.generateReport())
// ReportGenerator_DIP类无需任何修改即可适应不同的数据源
在这个遵循DIP的例子中,ReportGenerator_DIP(高层模块)依赖于DataSource_DIP接口(抽象)。具体的数据库实现(如MySqlDatabase_DIP、OracleDatabase_DIP)都实现了这个接口。通过依赖注入(这里是构造函数注入),我们可以向ReportGenerator_DIP提供不同的数据源实现,而ReportGenerator_DIP本身的代码不需要任何修改。
优缺点
| 优点 | 缺点 |
|---|---|
| 显著降低模块间的耦合度。 | 可能增加类的数量和系统复杂度:引入抽象层和依赖注入机制可能会使得类的数量增多,理解系统的调用链可能需要更多步骤。 |
| 提高系统的灵活性、可扩展性和可维护性。 | 需要更周全的设计:定义稳定且合适的抽象接口需要仔细的分析和设计。如果抽象设计不当,反而可能成为系统的瓶颈。 |
| 非常有利于单元测试。 | 对于非常简单或变化极少的系统,可能显得过度设计。 |
| 促进团队并行开发。 |
最佳实践与应用指南
- 每个类都应该有接口或抽象类,或者说,每个类都应该针对接口编程。 (这是一个理想化的说法,实践中对于一些简单的数据对象或工具类可能不需要严格遵循)。
- 类间的依赖关系应该建立在接口或抽象类(抽象)之上,而不是具体类(细节)之上。
- 抽象不应该依赖于细节。 即接口或抽象类的设计不应该过多地考虑其具体实现类的细节。
- 细节应该依赖于抽象。 具体实现类应该实现或继承抽象层定义的接口或方法。
- 使用依赖注入(Dependency Injection, DI):DI是实现DIP的重要手段。常见的DI方式有构造函数注入、Setter方法注入、接口注入。DI容器(如Spring, Guice, Dagger, Koin)可以自动化依赖对象的创建和注入过程。
- 工厂模式(Factory Pattern)或服务定位器模式(Service Locator Pattern) 也可以用来帮助解耦高层模块对具体实现的依赖,但DI通常被认为是更优的方式。
- 避免在代码中直接使用
new关键字创建具体依赖的实例 (除非是在工厂或DI容器的配置中)。高层模块应该从外部获取其依赖的实例。 - 定义稳定且职责清晰的抽象接口:接口一旦发布,应尽量保持稳定。接口的设计应遵循单一职责原则和接口隔离原则。
依赖倒置原则是构建高质量、松耦合、易于演进的软件系统的核心思想。通过依赖于抽象而非具体实现,我们可以有效地隔离变化,提高系统的整体灵活性和可维护性。
添加公众号第一时间了解最新内容。
欢迎入群交流QQ:276097690