第三章 功能设计原则:单一职责、开闭原则、接口隔离
设计原则这东西,刚入行时觉得是“玄学”。写代码能跑就行,管那么多干嘛?
后来我被现实狠狠教育了一回。一个模块改了三次,每次都要牵连五六个地方一起改。测试同学看到我的代码就头疼,说“你改一行,我得测十行”。
嗯,从那以后我才真正重视起这些原则。说白了,它们不是束缚,是帮你省时间的。
3.1 单一职责原则(SRP)
单一职责,字面意思就是“一个类只做一件事”。但我在项目中见过太多人把它理解成“一个类只有一个方法”。
不对。职责指的是“变化的原因”。一个类如果有多个变化原因,就该拆。
核心判断标准:问自己“这个类为什么要修改?”如果答案不止一个,就违反了 SRP。
举个例子。我曾经接手过一个订单处理类,它负责:
- 校验订单数据
- 计算价格
- 保存到数据库
- 发送通知邮件
- 生成日志
你看,五个职责。后来业务方说“邮件模板要改”,我改了邮件逻辑。结果测试发现价格计算也出了问题——因为两个方法用了同一个全局变量。
这就是典型的“牵一发动全身”。
3.1.1 如何拆分?
我个人习惯按“变化频率”和“变化原因”来切。上面那个例子,我拆成了:
| 职责 | 类名 | 变化原因 |
|---|---|---|
| 校验 | OrderValidator | 业务规则变更 |
| 计算 | PriceCalculator | 促销策略调整 |
| 持久化 | OrderRepository | 数据库表结构变化 |
| 通知 | NotificationService | 邮件/短信渠道切换 |
| 日志 | Logger | 日志格式或存储变更 |
拆完之后,改邮件模板再也不用担心价格算错了。每个类只对自己的变化负责。
小技巧:写代码前先画个“职责清单”,把每个方法对应的变化原因写出来。如果某个类有超过两个不同的原因,就拆。
3.2 开闭原则(OCP)
开闭原则说:对扩展开放,对修改关闭。
你想想看,如果每次加新功能都要改老代码,那测试成本得多高?改一行,全回归。项目越大越不敢动。
我在项目中遇到过最典型的场景是支付渠道对接。一开始只支持微信支付,后来要加支付宝、银联、PayPal。
3.2.1 错误做法
public class PaymentService {
public void pay(String type, double amount) {
if ("wechat".equals(type)) {
// 微信支付逻辑
} else if ("alipay".equals(type)) {
// 支付宝支付逻辑
}
// 每加一个渠道,这里就多一个 else if
}
}
这种代码,加一个新渠道就要改这个类。改一次测一次,风险极高。
3.2.2 正确做法
用策略模式 + 接口抽象:
public interface PaymentStrategy {
void pay(double amount);
}
public class WechatPay implements PaymentStrategy {
public void pay(double amount) {
// 微信支付逻辑
}
}
public class AlipayPay implements PaymentStrategy {
public void pay(double amount) {
// 支付宝支付逻辑
}
}
public class PaymentService {
private PaymentStrategy strategy;
public PaymentService(PaymentStrategy strategy) {
this.strategy = strategy;
}
public void pay(double amount) {
strategy.pay(amount);
}
}
加新渠道?新建一个类实现接口就行。PaymentService 不用改。这就是“对修改关闭,对扩展开放”。
注意:开闭原则不是让你把所有东西都抽象化。过度抽象反而增加复杂度。我见过有人为了“开闭”,一个简单功能搞了五层接口,最后自己都看不懂。
我的经验是:当同一个地方被修改超过两次,才考虑抽象。
3.3 接口隔离原则(ISP)
接口隔离原则,说白了就是“不要强迫调用方依赖它不需要的方法”。
我曾经接手过一个项目,有个接口叫 UserService,里面定义了十几个方法:
- 创建用户
- 删除用户
- 更新用户
- 查询用户
- 发送欢迎邮件
- 生成报表
- 导出Excel
- ……
结果呢?前端页面只想展示用户列表,却要依赖一个包含“发送邮件”和“导出Excel”的接口。每次改接口,前端都得跟着重新编译部署。
3.3.1 如何隔离?
按调用方的需求拆分:
| 调用方 | 需要的功能 | 接口名 |
|---|---|---|
| 管理后台 | 增删改查 | UserCrudService |
| 前端展示 | 查询用户 | UserQueryService |
| 通知模块 | 发送邮件 | NotificationService |
| 数据分析 | 生成报表、导出 | ReportService |
每个接口只暴露调用方真正需要的方法。这样改报表逻辑不会影响前端,改邮件逻辑不会影响管理后台。
判断标准:如果一个接口的实现类中有方法抛出 UnsupportedOperationException,或者方法体是空的,大概率违反了 ISP。
3.4 三个原则如何权衡?
你可能会问:这三个原则有时候会冲突,怎么办?
我举个例子。一个订单模块,按 SRP 拆成了十几个小类。但每个小类只有一个方法,调用方要组合使用,反而更复杂。这时候 ISP 要求接口要小,OCP 要求扩展性好,SRP 要求职责单一——三个原则一起上,代码反而变得碎片化。
我的做法是:
- 先保证 SRP:这是基础。职责不清,后面都是白搭。
- 再考虑 OCP:只在变化点做抽象。不要为了“未来可能的变化”提前设计。
- 最后用 ISP 收尾:接口暴露给外部时,按调用方裁剪。内部实现可以大,但对外接口要小。
实战口诀:
- 一个类只改一个原因(SRP)
- 加功能不删老代码(OCP)
- 接口只给需要的(ISP)
3.5 知识结构图
下面这张图展示了三个原则的关系和权衡点:
3.6 避坑指南
我曾经踩过的坑:
- 过度拆分:一个订单模块拆了 30 个类,每个类就一个方法。结果新人看不懂,改一个功能要改 5 个文件。后来我合并了一些,反而更好维护。
- 提前抽象:项目刚开始就搞策略模式、工厂模式,结果业务还没定型,抽象层改来改去。我的建议是:至少等同一个逻辑被改过两次,再考虑抽象。
- 接口膨胀:接口越加越多,最后调用方要注入五六个接口才能完成一个功能。这时候要考虑是否该用门面模式(Facade)做一层封装。
3.7 小结
三个原则不是教条,是工具。用得好,代码改起来轻松;用得不好,反而增加复杂度。
我个人习惯是:写代码时先问自己三个问题——
- 这个类以后会因为什么原因改?
- 加新功能需要改老代码吗?
- 调用方真的需要这个接口里的所有方法吗?
想清楚这三个问题,原则自然就用对了。