设计原则篇
一句话概述:SOLID 是一套成体系的设计原则,既指导模块设计,也可作为衡量设计有效性的尺子——其核心思想是围绕变化来组织代码结构。
知识图谱
图注:SOLID 五大原则——从关注变化来源(SRP)到隔离变化(OCP)、保证替换安全(LSP)、缩小接口(ISP)、反转依赖方向(DIP)。
5.1 单一职责原则(SRP)
概述
单一职责原则(Single Responsibility Principle)是 SOLID 原则中最基础的一条。它关注的并非"一个类只做一件事",而是关于如何分解——一个模块应该对一类且仅对一类行为者(actor)负责。理解单一职责原则的关键在于将变化纳入考量,进而考察变化的来源。
变化的原因
单一职责原则这个名称容易导致望文生义,将其理解为"一个类只干一件事"。该要求看似合理,因为几乎每位开发者都了解"高内聚、低耦合"的设计理念,都清楚应将相关代码组织在一起。
若随机选取一个模块询问其设计者该模块是否只承担一件事,答案几乎均为肯定。既然此设计原则具有普适性,以至于所有人皆可达成,那为何仍需设立这样的设计原则呢?
原因在于,上述理解本身存在偏差——将单一职责误解为有关如何组合的原则,而实际上,单一职责是关于如何分解的原则。
正如 Robert Martin 所述,单一职责的定义经历了一些演变。在《敏捷软件开发:原则、实践与模式》中,其定义为"一个模块应该有且仅有一个变化的原因";而在《架构整洁之道》中,其定义演变为"一个模块应该对一类且仅对一类行为者(actor)负责"。
单一职责原则与"一个类只干一件事"之间的核心差异在于将变化纳入了考量。
首先分析第一个定义:一个模块应该有且仅有一个变化的原因。软件设计是一门关注长期变化的学科,变化是最不愿面对却又无法回避的问题,因为变化会引入新的不确定性——既可能涉及新增功能自身的稳定性问题,也可能引发既有功能被破坏的风险。
因此,一个模块最理想的状态是不发生改变,其次是减少改变频次,这可作为衡量模块设计质量的标准。
在实际项目中,一个模块之所以频繁变更,关键原因在于触发其变更的因素过多。
以下举例说明。假设需要开发一个项目管理工具,必然涉及用户类的定义,可能设计出如下用户类:
// 用户类
class User {
// 修改密码
void changePassword(String password);
// 加入一个项目
void joinProject(Project project);
// 接管一个项目,成为管理员
void takeOverProject(Project project);
...
}上述类的设计看似合理,涵盖了用户信息管理、项目管理等功能。随后新需求提出,要求每个用户能够设置电话号码,于是新增方法:
void changePhoneNumber(PhoneNumber phoneNumber);数日后,又一新需求到来,要求查询用户已加入的项目数量:
int countProject();如此往复,各类需求接踵而至,几乎所有需求均需修改此类。该方法会导致两方面问题:其一,此类会持续膨胀;其二,内部实现将日趋复杂。依据前述衡量标准,此类变更频次显然不够理想,主要原因在于触发其变更的需求过于多样:
- 增加电话号码功能源于用户管理需求。用户管理相关需求还包括用户实名认证、用户组织归属等;
- 查询用户加入项目数量属于项目管理需求。项目管理相关需求还包括团队管理、项目权限等。
上述两类完全不同的需求却均修改同一个类,导致 User 类难以保持稳定。解决该问题的最佳方案是将不同需求引发的变更进行拆分。针对用户管理和项目管理两类不同需求,可将 User 类拆分为两个类——将用户管理相关需求置于 User 类,将项目管理相关需求置于 Member 类:
// 用户类
class User {
// 修改密码
void changePassword(String password);
...
}
// 项目成员类
class Member {
// 加入一个项目
void joinProject(Project project);
// 接管一个项目,成为管理员
void takeOverProject(Project project);
...
}经上述调整后,用户管理需求仅需修改 User 类,项目管理需求仅需修改 Member 类,二者各自的变更因素相应减少。
变化的来源
上述处理方式与前文讨论的分离关注点理念高度相似。深入理解单一职责原则的关键在于将不同关注点进行分离。在前述示例中,分离的是不同业务关注点。因此,理解单一职责原则本质上即是要理解分离关注点。
按照前述观点,分离关注点的目标是识别的关注点越多越好,粒度越细越好。若能识别更多关注点,便可构建更多类,而每个类的规模相应减小,与之相关的需求变更也会减少,其保持稳定的概率随之增大。代码库中稳定的类越多越好,这应是努力的方向。
若将该思路推演至极限,一个类应仅包含一个方法,使其受影响程度最小化。该结论固然正确,但在实际项目中,一个类通常包含多个方法,若要求所有开发者均达到极致粒度,显然不切实际。
那么应将哪些内容组织在一起?这就需要考虑单一职责原则定义的升级版本,即第二个定义:一个模块应该对一类且仅对一类行为者负责。
如果说第一个定义将变化纳入考量,则该升级版定义将变化的来源纳入考量。
需求为何会发生变更?因为存在各类提出需求的主体,不同主体提出的需求,其关注点各异。在前述关于用户的讨论中,关注用户管理和关注项目管理的可能是两批完全不同的人员,至少他们在提出需求时扮演着两种不同角色。
两类不同角色的人员,两类不同的事务,在代码层面却混合在一起,这种做法不合理。因此,分离是更优选择。负责用户管理人员讨论 User 类;负责项目管理人员讨论 Member 类。
康威定律:一个组织设计出的系统,其结构受限于其组织的沟通结构。
Robert Martin 指出,单一职责原则是基于康威定律的一个推论:一个软件系统的最佳结构高度依赖于使用该软件的组织的内部结构。若软件结构与组织结构不能对应,将会引发一系列问题,前述案例仅为其中一例。
实际上,随着对单一职责原则理解的深化,会发现其应用范围不仅限于类这一级别,亦可扩展至更大规模。
例如,曾接触过一个交易平台,其中包含一个关键模型:手续费率,即按何种比例收取交易佣金。平台可利用手续费率开展各类营销活动,例如为部分用户提供较低的手续费率以鼓励交易,不同手续费率意味着对不同交易行为的差异化激励。
对于运营人员而言,手续费率具备丰富的运营策略空间。然而对于交易系统而言,稳定高效才是核心目标。显然,频繁修改的手续费率与追求稳定的系统之间存在矛盾。
经分析发现,这是两类不同行为者的需求。因此在设计时,将手续费率配置功能置于运营子系统,而交易子系统仅负责读取手续费率。当运营子系统修改手续费率后,会将最新结果同步至交易子系统。至于各类手续费率配置策略,交易子系统无需关注。
由此可见,单一职责原则同样可用于指导不同子系统之间的职责分配。因此,单一职责原则这一看似最简单的原则,实际上蕴含诸多值得深入探讨的内容。要充分理解单一职责原则:
- 需要理解封装,明确应将哪些内容组织在一起;
- 需要理解分离关注点,明确应将哪些内容拆分出来;
- 需要理解变化的来源,明确应将不同行为者负责的代码置于不同位置。
单一职责原则同样适用于函数级别——每个函数承担的职责应保持单一,从而确保其稳定性。
User 类的职责拆分
图注:同一个 User 类承载了两类不同行为者的需求(用户管理 vs 项目管理),拆分后各自变动的理由减少,稳定性增加。
核心要点
- 单一职责原则讲的并不是"一个类只做一件事",它的关注点在于变化
- 第一版定义:"一个模块应该有且仅有一个变化的原因";第二版定义:"一个模块应该对一类且仅对一类行为者负责"
- 定义从"考虑变化"升级到"考虑变化的来源"
- SRP 本质上体现的是分离关注点——粒度越小越好
- 康威定律:软件的最佳结构高度依赖于使用这个软件的组织内部结构
- SRP 可应用于不同层次:函数 → 类 → 子系统 → 系统
- 应用单一职责原则衡量模块,粒度越小越好
5.2 开放封闭原则(OCP)
概述
开放封闭原则(Open-Closed Principle)提供了一个重要的设计方向:不修改已有代码,仅通过扩展实现新功能。其核心在于构建扩展模型——使新功能可通过扩展方式加入,而无需修改已有代码。
不修改代码
作为开发者,每接到一个需求便修改一次代码,这种方式已成为常态,甚至演变为一种下意识的反应。修改操作简便易行,只需参照既有惯例即可完成。
这是一种缺乏深思熟虑的做法,却会带来长期的负面影响。每人每次仅做少量修改,但经长期累积,当新需求到来时,改动量将显著增加。在此过程中,每位开发者似乎都无可厚非,因其仅遵循惯例进行修改。然而结果是所有人都受到了影响,代码可维护性持续下降。
既然"修改"会带来诸多问题,能否避免修改?开放封闭原则即为该问题提供了新的解决思路。
开放封闭原则的表述如下:
软件实体(类、模块、函数)应该对扩展开放,对修改封闭。
该表述由 Bertrand Meyer 在其著作《面向对象软件构造》(Object-Oriented Software Construction)中首次提出,对软件设计提出了极高要求:不修改代码。
不修改代码,如何实现新的需求?答案是通过扩展。换言之,新需求应采用新代码实现。
开放封闭原则描述的是一个理想结果——可不修改代码而仅凭扩展完成新功能。实现该结果的前提是在软件内部预留扩展点,而这正是需要精心设计的部分,因为每一个扩展点都是一个需要设计的模型。
例如,假设正在开发一个酒店预订系统,针对不同用户类型需计算不同房价。普通用户为全价,金卡会员享 8 折优惠,银卡会员享 9 折优惠,代码实现如下:
class HotelService {
public double getRoomPrice(final User user, final Room room) {
double price = room.getPrice();
if (user.getLevel() == Level.GOLD) {
return price * 0.8;
}
if (user.getLevel() == Level.SILVER) {
return price * 0.9;
}
return price;
}
}此时新需求提出,需增加白金卡会员并给予 75 折优惠,沿用原有模式的实现如下:
class HotelService {
public double getRoomPrice(final User user, final Room room) {
double price = room.getPrice();
if (user.getLevel() == UserLevel.GOLD) {
return price * 0.8;
}
if (user.getLevel() == UserLevel.SILVER) {
return price * 0.9;
}
if (user.getLevel() == UserLevel.PLATINUM) {
return price * 0.75;
}
return price;
}
}显然,上述方法即是通过修改代码实现的,每增加一种用户类型就需修改一次代码。然而,拥有多种用户级别的酒店系统中,差异不仅体现在房价方面,所提供的服务亦可能存在区别。可想而知,每增加一个用户级别,需修改的代码将遍布各处。
那么应如何处理?应考虑将其设计为可扩展的模型。在本例中,每次新增的是用户级别,且各类服务的差异均体现在用户级别上,因此需要一个用户级别模型。在前述代码中,用户级别仅为简单枚举,可对其进行丰富:
interface UserLevel {
double getRoomPrice(Room room);
}
class GoldUserLevel implements UserLevel {
public double getRoomPrice(final Room room) {
return room.getPrice() * 0.8;
}
}
class SilverUserLevel implements UserLevel {
public double getRoomPrice(final Room room) {
return room.getPrice() * 0.9;
}
}原代码可改造为:
class HotelService {
public double getRoomPrice(final User user, final Room room) {
return user.getRoomPrice(room);
}
}
class User {
private UserLevel level;
...
public double getRoomPrice(final Room room) {
return level.getRoomPrice(room);
}
}经上述改造后,再增加白金用户类型时,只需新增一个类即可:
class PlatinumUserLevel implements UserLevel {
public double getRoomPrice(final Room room) {
return room.getPrice() * 0.75;
}
}之所以能够如此处理,是因为代码中预留了扩展点:UserLevel。此处将原本仅支持枚举值的 UserLevel 升级为具备行为的 UserLevel。
经过该次改造,HotelService 的 getRoomPrice 方法得以稳定,无需根据用户级别持续调整该方法。至此,获得了一个稳定的构造块,可在后续工作中作为稳定模块使用。
当然,本例中的方法较为简单。而在实际项目中,业务方法的复杂度通常更高。
构建扩展点
现已对开放封闭原则建立了基本认识。实际上,修改代码并非良策,该道理显而易见,但在代码层面却常被忽视。类比而言:若询问正在开发的系统是否存在问题?相信多数回答都是肯定的。进一步追问是否会主动进行调整?多数情况下不会。原因何在?因为系统在线上运行正常,一旦调整失误将产生风险。该逻辑在系统层面人人皆知,而在代码层面却常被习惯性忽视。
因此,软件开发应提供一个又一个稳定的小模块,再将它们组合起来。一个频繁变更的模块必然不稳定,以此构建更大的模块等同于埋下隐患。
阻碍开发者构建稳定模块的核心障碍在于构建模型的能力。回顾前述代码,分析 UserLevel 如何被升级为具备行为的模型。
在讨论封装时曾指出,封装的核心要点是行为,数据仅为实现细节,而许多开发者的编码习惯偏向面向数据编程,这也是导致设计中缺乏扩展性思考的重要原因。
构建模型的难点,首先在于分离关注点,其次在于找到共性。
在多态相关讨论中曾指出,构建抽象需要找到事物的共同点,基于该理解,前述示例较易理解。而在实际业务处理过程中,识别共性对许多开发者而言已具有一定难度。
再看一例,以下是一个常见的报表服务,首先获取当日订单,然后生成订单统计报表,还需将统计结果发送给相关人员等:
class ReportService {
public void process() {
// 获取当天的订单
List<Order> orders = fetchDailyOrders();
// 生成统计信息
OrderStatistics statistics = generateOrderStatistics(orders);
// 生成统计报表
generateStatisticsReport(statistics);
// 发送统计邮件
sendStatisticsByMail(statistics);
}
}许多开发者在日常工作中编写的代码与此类似,但该流程较为僵化,每当出现新需求就需要调整这段代码。现有新需求:将统计信息发送给另一内部系统,该内部系统可将统计信息展示出来供外部合作伙伴查阅。应如何处理?
首先进行分析,发送给另一个系统的内容是统计信息,在原代码中,前两步分别为获取源数据和生成统计信息,后两步分别为生成报表和通过邮件发送统计信息。
换言之,后两步与即将添加的新步骤存在共同点,即均使用了统计信息,由此找到了它们的共性,故可采用统一模型进行抽象,例如 OrderStatisticsConsumer:
interface OrderStatisticsConsumer {
void consume(OrderStatistics statistics);
}
class StatisticsReporter implements OrderStatisticsConsumer {
public void consume(OrderStatistics statistics) {
generateStatisticsReport(statistics);
}
}
class StatisticsByMailer implements OrderStatisticsConsumer {
public void consume(OrderStatistics statistics) {
sendStatisticsByMail(statistics);
}
}
class ReportService {
private List<OrderStatisticsConsumer> consumers;
void process() {
// 获取当天的订单
List<Order> orders = fetchDailyOrders();
// 生成统计信息
OrderStatistics statistics = generateOrderStatistics(orders);
for (OrderStatisticsConsumer consumer: consumers) {
consumer.consume(statistics);
}
}
}经上述处理后,新需求只需添加一个新的类即可实现:
class StatisticsSender implements OrderStatisticsConsumer {
public void consume(final OrderStatistics statistics) {
sendStatisticsToOtherSystem(statistics);
}
}在该例中,第一步的工作仍是分解,即将各个步骤分离,然后找出步骤之间的相似之处,进而构建出新模型。
实际项目中的代码可能比该示例更为复杂,但其复杂性未必源于业务逻辑本身,而是代码编写方式导致的复杂性。因此,应先根据单一职责原则,将不同需求来源引发的变更拆分至不同方法,形成一个个小单元,再进行上述分析。
通过该示例可以看出,在实际项目中达到开放封闭原则的要求并非一蹴而就。此处仅因需求变动才提取出 OrderStatisticsConsumer。
未来可能还会出现其他变动,例如报表生成逻辑的调整。届时,可能会提取出新的 OrderStatisticsGenerator 接口。总体而言,每完成一次模型构建,核心类便会朝着稳定方向迈进一步。
良好的设计均会为新功能提供充足的扩展点。《Unix 编程艺术》一书中提倡的"提供机制,而不是策略"即是开放封闭原则的一种体现。
同样,许多系统具备插件机制,例如 VIM 和 Emacs,以及 Eclipse 和 Visual Studio Code 等,这些系统均体现了开放封闭原则。研究其接口设计,可了解该软件提供的各项能力,这也是一种有效的学习方式。
开放封闭原则还可用于改进自身系统——可通过查看版本控制系统,定位最频繁变更的文件,这些文件通常未满足开放封闭原则,可作为系统改进的切入点。
OCP 的落地路径
图注:OCP 的落地路径——识别变化 → 抽象接口 → 多态替换 → 新功能通过扩展加入。
核心要点
- 开放封闭原则:软件实体应该对扩展开放,对修改封闭——新需求应该用新代码实现
- OCP 的核心是构建扩展模型——让新功能可以通过扩展加入
- 面向接口编程是 OCP 的实现方式——多态是技术支撑
- OCP 的判断标准:新增一个功能时,是否需要修改已有的代码
- 扩展模型的关键:识别变化点,将变化封装为接口/抽象类
- 构建模型的难点:首先在于分离关注点,其次在于找到共性
- 不能盲目预留扩展点——只在明确需要变化的地方预留
- 好的设计提供足够的扩展点;插件机制是 OCP 的典型体现
- 设计扩展点,迈向开放封闭原则
5.3 Liskov 替换原则(LSP)
概述
Liskov 替换原则(Liskov Substitution Principle)为继承体系提供了正确的指导:子类型必须能够替换其父类型。IS-A 关系的判定基于行为的一致性,而非直觉概念。理解 LSP 需要从父类的角度进行思考,而从子类视角出发往往会破坏 LSP。
Liskov 替换原则
2008 年,图灵奖授予 Barbara Liskov,以表彰她在程序设计语言和系统设计方法方面的卓越贡献。她在设计领域影响最为深远的工作即是以她名字命名的 Liskov 替换原则(Liskov substitution principle,简称 LSP)。
1988 年,Barbara Liskov 在描述如何定义子类型时写下如下论述:
这里需要如下替换性质:若每个类型 S 的对象 o1,都存在一个类型 T 的对象 o2,使得在所有针对 T 编程的程序 P 中,用 o1 替换 o2 后,程序 P 行为保持不变,则 S 是 T 的子类型。
通俗而言,即子类型(subtype)必须能够替换其父类型(base type)。
该表述看似简洁,但违反该原则将产生严重后果。例如,父类型规定接口不得抛出异常,而子类型抛出了异常,将导致程序运行失败。
虽然该原则易于理解,但子类型不都继承自父类型吗,为何会违反 LSP?以下示例展示了部分开发者经常编写的类似代码:
void handle(final Handler handler) {
if (handler instanceof ReportHandler) {
// 生成报告
((ReportHandler)handler).report();
return;
}
if (handler instanceof NotificationHandler) {
// 发送通知
((NotificationHandler)handler).sendNotification();
}
...
}根据前一讲的内容,该段代码显然违反了 OCP。此外,在本例中,虽然定义了父类型 Handler,但在代码处理过程中,通过运行时类型识别(Run-Time Type Identification,简称 RTTI),即此处使用的 instanceof,获取子类型信息后再执行相应的业务处理。
然而,ReportHandler 和 NotificationHandler 虽然均为 Handler 的子类,但它们没有统一的处理接口,因此二者之间不存在可替换关系,该段代码同样违反了 LSP。由此可得出一项经验法则:若发现任何运行时类型识别代码,极有可能已经破坏了 LSP。
基于行为的 IS-A
若查阅关于 LSP 的资料,很可能会遇到一个经典问题,即长方形正方形问题。在常规几何认知中,正方形是一种特殊的长方形。因此,可能会编写如下代码:
class Rectangle {
private int height;
private int width;
// 设置长度
public void setHeight(int height) {
this.height = height;
}
// 设置宽度
public void setWidth(int width) {
this.width = width;
}
public int area() {
return this.height * this.width;
}
}
class Square extends Rectangle {
// 设置边长
public void setSide(int side) {
this.setHeight(side);
this.setWidth(side);
}
@Override
public void setHeight(int height) {
this.setSide(height);
}
@Override
public void setWidth(int width) {
this.setSide(width);
}
}该段代码看似完善,但实际上存在问题,因为它在以下测试中将无法通过:
Rectangle rect = new Square();
rect.setHeight(4); // 设置长度
rect.setWidth(5); // 设置宽度
assertThat(rect.area(), is(20)); // 对结果进行断言若要保证断言的正确性,Rectangle 和 Square 在此处不可互相替换。使用 Rectangle 的代码必须明确知晓其操作对象究竟是 Rectangle 还是 Square。
该问题的根源在于,构建模型时往往直接将直觉中的概念映射到代码模型。在直觉认知中,正方形确实是一种长方形。
在本例的对象体系中,边长是可以调整的。然而在几何体系中,长方形的边长不可随意更改,一旦设定即固定不变。换言之,两个体系内"长方形"的行为不一致。因此,在该对象体系中,即使正方形的边长可以调整,正方形也并非长方形,即二者之间不满足 IS-A 关系。
继承应符合 IS-A 关系,即若 A 是 B 的子类,则需满足 A is a B。但判断 A is a B 的依据是什么?
该判定显然不能依赖于直觉。从前述分析中亦可看出一些端倪,IS-A 的判定是基于行为的,只有行为一致才满足 IS-A 关系。
该原理表述简洁,但在实际工作中时常会出现偏差。例如,需要开发一个图片制作网站,创作者可在上面创作内容,还可发布创作的素材并在网站上销售。显然,该网站需提供销售能力,那么可销售的素材是否属于商品?
若从销售角度分析,它确实属于商品,需要为其定价,支持后续购买行为等。从行为角度看,素材确实是商品,但它又与创作相关,需记录作者信息、创作阶段等,这些行为与商品属性无关。
经过上述分析,问题的答案已经明晰。此处的"素材"并非单一概念,前文讲述 SRP 时已做过类似分析——虽然讨论中使用同一词汇"素材",但创作者和销售分属两个不同领域。
因此,若将"素材"进行拆分,问题即可迎刃而解。一个是"创作者素材",一个是"可销售素材"。显然,"可销售素材"属于商品范畴,而"创作者素材"不属于。
这是一种常见的概念混淆现象。产品经理在描述需求时,可能未注意到这是两个不同领域的概念,而开发者若未进行充分分析,在概念层面可能出现偏差,后续问题将层出不穷。
因此,IS-A 关系的理解并不困难,但在实际工作中,当其与其他问题交织在一起时,情况就不像表面看起来那样简单。
至此,应对 LSP 原则有了一定理解,要满足 LSP,首先对象体系需具备统一接口,而不能各行其是;其次,子类需满足 IS-A 关系。
基于对 LSP 的理解,再用其衡量一些设计,便能发现问题。例如,开发者最常用的数据结构 List,许多人习惯将其作为接口传递。在绝大多数场景下,使用目的仅是为了传递数据(读取数据),但 List 接口本身通常包含写入方法。
因此,尽管使用目的是读取,但仍有可能误用写入操作,从而导致异常问题。Google 的 Guava 库提供了 ImmutableList,在概念层面做了改进。但为了兼容现有程序,它不得不继承自 List 接口,实际上根本问题并未得到彻底解决。
还有一类常见的违反 LSP 的情况,即继承数据结构。例如,要实现包含多个学生的类,声明如下:
class Students extends ArrayList<Student> {
...
}这是一种基于直觉的设计,只要继承 ArrayList,添加、获取等方法便自动具备。但从前面讲述的内容来看,该设计显然存在问题,因为 Students 不是 ArrayList,不能满足 IS-A 关系。该方法的意图是实现继承,而前文在讨论继承时已阐述过此类做法的问题。
LSP 将注意力引导至父类层面,而当子类成为焦点时,需格外谨慎。前文讨论继承时曾指出,关注子类是实现继承的表现,而实现继承应予以摒弃,接口继承才是正确的方向,做好接口继承显然更符合 LSP。
更广泛的 LSP
若充分理解 LSP,会发现它不仅适用于类级别的设计,还适用于更广泛的接口设计场景。例如,在开发中常遇到系统集成问题,不同厂商需通过 REST 接口将统计信息上报至系统,但某大型厂商的上报消息格式无法遵循预定义格式,因其系统改造成本较高。该如何处理?
可能存在的初步想法是为该大型厂商专门设计特定接口,但一旦开启此先例,后续各类集成接口均需为其定制特殊版本;若未来其他厂商提出类似要求,是否也需要为其设计特殊接口?事实上,许多项目功能不多但接口数量庞大,正是因为在此类决策时开启了先例。请记住,公开接口是最宝贵的资源,绝不可随意添加。
若从 LSP 角度分析该问题,通用接口相当于父类接口,不同厂商的内容相当于子类。让厂商面对特定接口将导致系统难以维护。后期随人员变动,接口将持续膨胀,最终无人能清晰说明每个接口的具体用途。
决定采用统一接口后,不同消息格式该如何处理?首先需要区分不同厂商,具体方法多样,无论是通过 REST 路径还是 HTTP 头部,均可获取标识符。下一步呢?
容易想到的做法是编写 if 语句,如下所示:
if (identfier.equals("SUPER_VENDOR")) {
...
}但是,必须遏制编写 if 分支的念头,一旦开启该模式,后续代码的可维护性将严重下降。可行的方案是提供解析器接口,根据标识符查找对应的解析器,如下所示:
RequestParser parser = parsers.get(identifier);
if (parser != null) {
return parser.parse(request);
}经上述处理后,即便其他厂商因某些特殊原因要求特定格式,只需提供一个新的接口实现即可。所有代码行为保持一致性,核心代码结构亦保持稳定。
长方形-正方形问题
图注:长方形-正方形问题的核心——在可变边长的对象体系中,正方形的 setWidth/setHeight 行为与长方形不一致,因此不满足 IS-A 关系。
核心要点
- Liskov 替换原则:子类型必须能够替换其父类型
- LSP 站在父类的角度思考——从子类视角往往是破坏 LSP 的做法
- RTTI(运行时类型识别) 是违反 LSP 的信号——若使用 instanceof,很可能存在问题
- IS-A 基于行为:正方形不是(可变边长的)长方形——因为行为不一致
- 继承数据结构(Students extends ArrayList)是常见违反 LSP 的做法
- LSP 不仅适用于类设计,也适用于更广泛的接口设计
- 公开接口是最宝贵的资源,千万不能随意添加
- 接口继承才是努力方向,实现继承是要努力摒弃的
- 用父类的角度去思考,设计行为一致的子类
5.4 接口隔离原则(ISP)
概述
接口隔离原则(Interface Segregation Principle)指出:不应让使用者依赖其不需要的方法——设计小接口,使不同角色面对不同的接口。ISP 可理解为接口设计层面的 SRP,其本质为最小化接口暴露在接口层面的体现。
接口隔离原则
接口隔离原则(Interface segregation principle,简称 ISP)的表述如下:
不应强迫使用者依赖于它们不用的方法。
No client should be forced to depend on methods it does not use.
该表述易于理解,即在接口中不应放置使用者不需要的方法。从使用者角度审视,该要求十分合理。每位开发者都会认为,为何要依赖不需要的方法?作为设计者,也会认同该观点。然而,在实际设计过程中,并非所有人都能始终牢记该原则。
首先,许多开发者未能清晰区分使用者和设计者这两种不同角色。因为在许多人看来,接口的设计和使用常由同一人完成。这是角色区分意识缺失的表现,该缺失导致无法将两种角色区分开来,本质上也是分离关注点未做好的体现。
实际上,许多开发者在开发过程中并未树立上述两种角色意识——根本未曾深入思考接口问题,因为更关注的是具体的类实现。只有到了必要时刻,接口才作为语法选项被使用一次,该方法本质上是未在设计层面进行思考。
然而,未设计接口不代表不存在接口。
在进行软件设计时,经常考虑的是模型之间如何交互,接口仅为方便描述的术语,目的是将注意力从具体实现细节中抽离出来。但是,若未设计特定的接口,具体类本身就充当了接口角色。与设计不当的接口类似,此类"接口"通常也存在问题。
那么接口设计不当会产生什么问题?典型问题是接口过"胖"。
胖接口减肥
假设有一个银行系统,对外提供存款、取款和转账功能。该系统通过一个接口向外部系统暴露这些能力,不同能力的差异通过请求内容进行区分。因此,设计了如下业务请求对象:
class TransactionRequest {
// 获取操作类型
TransactionType getType() {
...
}
// 获取存款金额
double getDepositAmount() {
...
}
// 获取取款金额
double getWithdrawAmount() {
...
}
// 获取转账金额
double getTransferAmount() {
...
}
}每种操作类型对应一个业务处理模块,它们根据自身需要获取相应信息,如下所示:
interface TransactionHandler {
void handle(TransactionRequest request);
}
class DepositHandler implements TransactionHandler {
void handle(final TransactionRequest request) {
double amount = request.getDepositAmount();
...
}
}
class WithdrawHandler implements TransactionHandler {
void handle(final TransactionRequest request) {
double amount = request.getWithdrawAmount();
...
}
}
class TransferHandler implements TransactionHandler {
void handle(final TransactionRequest request) {
double amount = request.getTransferAmount();
...
}
}收到请求后,只需进行业务分发即可:
TransactionHandler handler = handlers.get(request.getType());
if (handler != null) {
handler.handle(request);
}一切看似正常,许多开发者在实际工作中也会编写类似代码。然而,在该实现中,存在一个接口过于"胖"的问题,即 TransactionRequest。
TransactionRequest 类包含了相关请求内容,这本无可厚非。但在此处,容易直观地将其作为参数传递给 TransactionHandler。于是,它作为请求对象转变为业务处理接口的一部分。
如前所述,虽未设计特定接口,但具体类可充当接口角色。然而,作为业务处理中的接口,TransactionRequest 显得过于"胖":
- getDepositAmount 方法仅在 DepositHandler 中使用;
- getWithdrawAmount 方法仅在 WithdrawHandler 中使用;
- getTransferAmount 方法仅在 TransferHandler 中使用。
然而,传递给它们的 TransactionRequest 却包含全部上述方法。
可能产生的疑问是:这有什么问题?问题在于,"胖"接口通常不稳定。例如,现需增加生活缴费功能,TransactionRequest 就需增加获取生活缴费金额的方法:
class TransactionRequest {
...
// 获取生活缴费金额
double getLivingPaymentAmount() {
...
}
}相应地,还需增加业务处理方法:
class LivingPaymentHandler implements TransactionHandler {
void handle(final TransactionRequest request) {
double amount = request.getLivingPaymentAmount();
...
}
}该方法看似符合 OCP,但实际上,由于 TransactionRequest 的修改,之前已完成的业务处理类 DepositHandler、WithdrawHandler、TransferHandler 均会受到影响。原因何在?
若使用某些现代编程语言,该问题可能不明显。假设该代码采用 C/C++ 等需要编译链接的语言编写,TransactionRequest 的修改势必导致其他几个业务处理类重新编译,因为它们均引用了 TransactionRequest。
实际上,C/C++ 程序在编译链接环节常耗费大量时间,除语言本身特性外,因设计不当导致本无需重新编译的文件也被迫重新编译的现象随处可见。
可以理解为,若某个接口发生修改,依赖它的所有代码均会受到影响,而这些代码往往又有依赖于它们的实现代码,从而形成一个修改影响的传播链。从该角度评估可知,不稳定的"胖"接口影响范围极为广泛,因此,"胖"接口属于设计缺陷。
如何修改这段代码?既然该问题由接口"胖"引起,对其进行精简即可。根据 ISP 原则,只为每个使用者提供其所需的方法。因此,可引入若干"瘦"接口:
interface TransactionRequest {
}
interface DepositRequest extends TransactionRequest {
double getDepositAmount();
}
interface WithdrawRequest extends TransactionRequest {
double getWithdrawAmount();
}
interface TransferRequest extends TransactionRequest {
double getTransferAmount();
}
class ActualTransactionRequest implements DepositRequest, WithdrawRequest, TransferRequest {
...
}此处将 TransactionRequest 改造为接口,目的是为后续业务处理提供统一接口,而 ActualTransactionRequest 则对应原来的实现类。引入了 DepositRequest、WithdrawRequest、TransferRequest 等若干"瘦"接口,分别供不同业务处理方法使用。
在此基础上,可对相应的业务处理方法进行改造:
interface TransactionHandler<T extends TransactionRequest> {
void handle(T request);
}
class DepositHandler implements TransactionHandler<DepositRequest> {
void handle(final DepositRequest request) {
double amount = request.getDepositAmount();
...
}
}
class WithdrawHandler implements TransactionHandler<WithdrawRequest> {
void handle(final WithdrawRequest request) {
double amount = request.getWithdrawAmount();
...
}
}
class TransferHandler implements TransactionHandler<TransferRequest> {
void handle(final TransferRequest request) {
double amount = request.getTransferAmount();
...
}
}经过该改造,每个业务处理方法仅关注自身相关的业务请求。那么新增生活缴费功能该如何处理?只需增加一个新接口:
interface LivingPaymentRequest extends TransactionRequest {
double getLivingPaymentAmount();
}
class ActualTransactionRequest implements DepositRequest, WithdrawRequest, TransferRequest, LivingPaymentRequest {
}然后,再增加一个新的业务处理方法:
class LivingPaymentHandler implements TransactionHandler<LivingPaymentRequest> {
void handle(final LivingPaymentRequest request) {
double amount = request.getLivingPaymentAmount();
...
}
}对比两种设计方案,仅有 ActualTransactionRequest 进行了修改,而该类表示实际的请求对象,在当前结构下无论如何都需要修改。其余部分因不存在依赖关系,不会受到本次需求增加的影响。相对于原始方案,新设计方案的影响范围显著缩小。
接口的角色
回顾该设计的改进过程,重点在于将原本庞大的 TransactionRequest 拆分为若干小接口,每个小接口仅为特定使用者服务。该做法的优势在于,每个使用者只需关注其所使用的方法,此类接口才可能保持稳定。"胖"接口不稳定的原因是其承担了过多职责。
从该讨论中或许能够体会到 SRP 的思想,甚至可以将 ISP 理解为接口设计层面的 SRP。
该改进还有一个值得注意之处:ActualTransactionRequest 实现了多个接口。在该设计中,每个接口代表与不同使用者交互的角色,Martin Fowler 将此类接口称为角色接口(Role Interface)。
这与每个人在实际生活中扮演多种角色的情形类似。在家中,是父母的子女;在公司里,是企业的员工;购物时,是顾客;出行时,是乘客,但所有这些角色最终均由同一个人承担。前文在讨论接口设计时提到,虽然是同一个个体,但常常需要同时扮演设计者和使用者两种不同角色。而在本段代码中,各种角色汇聚到了 ActualTransactionRequest 这个类上。
在一个设计中,识别出不同角色至关重要。这强调的仍是分离关注点。
在讨论多态时曾指出,接口的作用是将变化与不变的部分隔离开来。现在有了对 ISP 的理解,可知接口应尽可能稳定。接口使用者对接口形成依赖关系,被依赖方越稳定越好,而只有规模越小,才越有可能保持稳定。
还可以从更广泛的角度理解 ISP,即不依赖于任何不必要的组件。曾遇到过一个项目,其核心计算模块依赖了一个非常小众的数据库,选用理由仅是该数据库提供了某项特有功能。
然而,随着项目组人员变动,结果导致除了解该项特有功能外,对该数据库的其他能力知之甚少。该系统运行一段时间后,数据库占用的存储空间将膨胀至硬盘容量极限,而只要重新导出导入一次数据库数据,存储空间便会大幅缩减(产生该现象的根本原因是该数据库鼓励不变模式,而核心计算中有大量修改操作,产生了大量修改日志,导出导入后日志减少)。
最终只能通过增加硬盘监控、定期导出数据等方式维持系统正常运行。最后,不得不将该数据库替换掉。
之所以依赖该数据库,是因为技术选型时采用了某一特定框架,而该框架默认依赖该数据库。开发人员为实现快速开发,将框架和数据库一并引入项目中,引发了后续一系列问题。
从该实例可以看出,在高层次上依赖不必要的组件,与类依赖不需要的方法,本质上是相通的。由此可见,ISP 同样是一个适用范围广泛的设计原则。
接口隔离示意
图注:大接口让所有使用者被迫依赖全部方法;小接口让不同角色只看到自己需要的方法。
核心要点
- 接口隔离原则:不应强迫使用者依赖于它们不用的方法
- 大接口强迫使用者依赖不需要的方法 → 增加不必要的耦合
- 不同角色应该面对不同的接口(接口角色分离)
- 小接口降低耦合、提升灵活性
- ISP 可以理解为接口设计的 SRP
- ISP 的本质:最小化接口暴露在接口层面的体现
- 每个使用者面对的接口是一种角色接口(Role Interface),识别出不同角色至关重要
- 接口规模越小,越有可能稳定下来
- ISP 可以从更广泛的角度理解:不依赖于任何不需要的东西
- 接口合并需谨慎——公开接口是最宝贵的资源
- 识别对象的不同角色,设计小接口
5.5 依赖倒置原则(DIP)
概述
依赖倒置原则(Dependency Inversion Principle)指出:高层代码不应依赖底层代码,二者都应依赖抽象。抽象不应依赖细节,细节应依赖抽象。理解 DIP 的关键在于理解"倒置"——它是相对于传统自上而下解决问题的方式而言的,通过引入抽象(模型)将高层与底层解耦。
谁依赖谁
依赖倒置原则(Dependency inversion principle,简称 DIP)的表述如下:
高层模块不应依赖于低层模块,二者应依赖于抽象。
High-level modules should not depend on low-level modules. Both should depend on abstractions.
抽象不应依赖于细节,细节应依赖于抽象。
Abstractions should not depend on details. Details (concrete implementations) should depend on abstractions.
学习该原则最重要的是理解"倒置",而要理解什么是"倒置",首先需要理解所谓的"正常依赖"是什么样的。
在讨论结构化编程时曾指出,结构化编程解决问题的思路是自上而下地进行功能分解,该解决问题的思路自然地延续到许多开发者的编程习惯中。按照分解的结果进行组合,因此很自然地写出如下代码:
class CriticalFeature {
private Step1 step1;
private Step2 step2;
...
void run() {
// 执行第一步
step1.execute();
// 执行第二步
step2.execute();
...
}
}然而,这种未经审视的结构天然存在一个问题:高层模块会依赖于低层模块。在上述代码中,CriticalFeature 类即为高层类,Step1 和 Step2 即为低层模块,且 Step1 和 Step2 通常均为具体类。虽然这是一种自然而然的写法,但这种写法确实存在问题。
在实际项目中,代码经常直接耦合于具体实现。例如,使用 Kafka 作为消息中间件,就在代码中直接创建 KafkaProducer 发送消息。可能编写如下代码:
class Handler {
private KafkaProducer producer;
void execute() {
...
Message message = ...;
producer.send(new KafkaRecord<>("topic", message));
...
}
}可能产生的疑问是:使用 Kafka 发送消息,创建一个 KafkaProducer,这有什么问题?实际上,该问题在课程中已经阐述过,即需要从长期角度审视,区分哪些是变化的、哪些是不变的。Kafka 虽然优秀,但并非系统的核心组成部分,在未来存在被替换的可能性。
可能会认为,这是一个关键实现组件,怎么可能被替换?软件设计需要关注长期、放眼长远,所有不在自身掌控范围内的组件,均存在被替换的可能。在前文许多内容的讨论中也可以看到,替换中间件是经常发生的。因此,依赖一个可能变化的组件,从设计角度看并非良好实践。
那么应该如何处理?这就轮到倒置原则发挥作用了。
所谓倒置,是将这种习惯性的做法反转过来,使高层模块不再依赖低层模块。若如此,功能又该如何完成?计算机行业的一句名言给出了答案:
计算机科学中的所有问题都可以通过引入一个间接层得到解决。
All problems in computer science can be solved by another level of indirection
—— David Wheeler
是的,引入一个间接层。该间接层即 DIP 所指的抽象。不过,在课程中一直采用的术语是模型。也就是说,上述代码中缺少了一个模型,而这个模型正是低层模块在该过程中所承担的角色。
既然该模块扮演的是消息发送者角色,便可引入一个消息发送者(MessageSender)模型:
interface MessageSender {
void send(Message message);
}
class Handler {
private MessageSender sender;
void execute() {
...
Message message = ...;
sender.send(message);
...
}
}有了消息发送者模型,又该如何将 Kafka 与该模型结合?只需实现一个 Kafka 消息发送者:
class KafkaMessageSender implements MessageSender {
private KafkaProducer producer;
public void send(final Message message) {
this.producer.send(new KafkaRecord<>("topic", message));
}
}经上述处理后,高层模块不再像原来一样直接依赖低层模块,而是将依赖关系"倒置"过来,使低层模块依赖由高层定义好的接口。该方法的优势在于将高层模块与低层实现解耦。
若未来需要替换 Kafka,只需重写一个 MessageSender 即可,其他部分无需修改。这样可使高层模块保持相对稳定,不会随底层代码的改变而改变。
依赖方向的倒置
图注:传统依赖方向是高层→底层→具体实现;倒置后高层和底层都依赖抽象接口——变化被隔离在底层实现中。
依赖于抽象
理解了 DIP 的第一部分后,已建立起模型(抽象)的概念。
此前学习的所有原则均在强调尽可能将变化的部分与不变的部分分离,使不变的部分稳定下来。模型相对稳定,实现细节则是易变部分。因此,构建稳定的模型层对任何系统而言均至关重要。
接下来分析 DIP 的第二部分:抽象不应依赖于细节,细节应依赖于抽象。
实际上,该部分可更简单地理解为一点:依赖于抽象。基于该出发点,可推导出若干更具体的编码规则:
- 任何变量都不应该指向一个具体类;
- 任何类都不应继承自具体类;
- 任何方法都不应该覆写父类中已经实现的方法。
在讨论多态时曾提及 List 声明的例子,其背后遵循的即是上述第一条规则:
List<String> list = new ArrayList<>();在实际项目中,这些编码规则有时并非绝对。若某个类特别稳定,也可直接使用,例如字符串类。但请注意,这种情况极为罕见。因为大多数开发者编写的代码稳定性有限。因此,上述编码规则可作为覆盖大多数情况的指导原则,出现例外情形时需特别关注。
至此,已理解在 DIP 指导下应尽量少用具体类。但还有一个问题:最终具体类还是要使用的,毕竟代码运行不能仅依赖接口。那么具体类应在何处使用?
此前讨论的设计原则,核心关注点是业务模型。此外,还有一些代码负责将这些模型组装起来,这些组装代码需要使用具体类。
说到这里,话题显得颇为熟悉——此前曾讨论过 DI 容器的来龙去脉,在 Java 生态中,承担这些组装工作的即是 DI 容器。
因为这些组装工作几乎是标准化的,而且非常繁琐。若常用编程语言未提供 DI 容器,最好将负责组装的代码与业务模型置于不同的代码模块中。
DI 容器在最初讨论时的另一种称谓是 IoC 容器,IoC 是 Inversion of Control 的缩写,IoC 与 DIP 中的 I 均表示 inversion,二者表达的意图实质上一致。
理解了 DIP,再使用 DI 容器时会感觉顺理成章,因为依赖注入之所以可行,是由于设计遵循了 DIP。若仅知道 DI 容器而不了解 DIP,常会遇到模型组装困难的问题,根本原因是设计未做好。
关于 DIP,还有一个形象的说法称为好莱坞规则:"Don't call us, we'll call you"。放在设计语境中,应理解为"不要调用我,我会调用你"。显然,这是框架特有的说法,有了稳定的抽象后,各种具体实现均应由框架进行调用。
若计划编写框架,理解 DIP 至关重要。毫不夸张地说,不理解 DIP 的开发者只能编写功能代码,无法构建模型,难以提升至更高层次。前文在讨论程序库时建议每位开发者都应锻炼编写程序库的能力,这实际上就是锻炼构建模型的能力。
有了对 DIP 的讨论,再回顾前文遗留的疑问:为什么说一开始 TransactionRequest 将依赖方向搞反了?因为最初的 TransactionRequest 是一个具体类,而 TransactionHandler 是业务类。
后来改进的版本引入了一个模型,将 TransactionRequest 改造为接口,ActualTransactionRequest 实现该接口,TransactionHandler 仅依赖接口,而原具体类从该接口继承而来,相较原版本有所改善。
对于任何一个项目而言,了解不同模块间的依赖关系是一项重要工作。可借助工具生成项目的依赖关系图,然后以 DIP 作为评判标准,衡量项目在依赖关系方面的表现。很有可能借此找到项目改进的切入点。
理解了 DIP,再审视一些关于依赖的讨论,也能获得不同视角。例如循环依赖问题,有人会从技术角度探讨如何解决,但实际上循环依赖是设计不当的结果,依赖关系错误才可能导致循环依赖,先将设计做对,提取应有的接口,依赖就不会循环了。
至此,SOLID 的五个原则均已阐述完毕。有了此前关于分离关注点和面向对象基础知识的基础,理解这些原则的难度会有所降低。
理解这些原则,关键的步骤仍是分离关注点,将不同内容区分开来。然后运用这些原则将它们组合起来。当充分理解这些原则后,再回过头审视,也能加深对面向对象特性的认识,此刻应当更能深刻体会多态在面向对象世界中所发挥的作用。
核心要点
- 依赖倒置原则:高层模块不应依赖于低层模块,二者应依赖于抽象;抽象不应依赖于细节,细节应依赖于抽象
- 理解 DIP 的关键在于理解"倒置"——相对于传统自上而下的解决问题方式而言
- 依赖方向搞反了 → 高层模块被底层实现的变化绑架
- 正确的依赖方向:高层 → 抽象 → 底层实现(而非 高层 → 底层实现)
- 依赖倒置 = 依赖抽象而非具体 = 依赖稳定而非易变
- 由"依赖于抽象"可推导出编码规则:
- 任何变量都不应该指向一个具体类
- 任何类都不应继承自具体类
- 任何方法都不应该改写父类中已经实现的方法
- 依赖注入(DI)是 DIP 的常见落地方式
- DI 容器负责模型组装,组装代码与业务模型应分开
- DIP 的本质:稳定的依赖——让易变的部分依赖稳定的部分
- 循环依赖是设计没做好的结果,先把设计做对,依赖就不会循环
- 依赖于构建出来的抽象,而不是具体类
本篇核心概念关系
图注:SOLID 五原则围绕"变化"形成完整的设计保障链——从识别变化到隔离变化到保证安全到缩小范围到稳定方向。
与其他知识点的关联
- 关注点分离:SRP 是关注点分离在模块级别最具体的表现;ISP 的角色识别也依赖于分离关注点的能力
- 面向对象之封装:封装是 SRP 的实现基础;ISP 是"最小化接口暴露"在接口层面的体现
- 面向对象之多态:多态是 OCP 的技术支撑;OCP 要求子类可替换父类——LSP 保证替换的安全性
- 面向对象之继承:LSP 明确了继承的正确使用方式——接口继承而非实现继承
- 领域驱动设计:限界上下文的划分本质上就是 SRP 在系统级别的应用;领域模型天然符合 OCP;领域层不依赖基础设施层——DIP 的 DDD 应用
- Spring DI 容器:DI 容器是 DIP 的工程落地
- 可测试性:依赖倒置是可测试性的设计保障
- SOLID 内部关系:ISP 为 DIP 提供了接口层面的保障;LSP 和 ISP 共同指导接口设计