第三章 功能设计原则:单一职责、开闭原则、接口隔离

设计原则这东西,刚入行时觉得是“玄学”。写代码能跑就行,管那么多干嘛?

后来我被现实狠狠教育了一回。一个模块改了三次,每次都要牵连五六个地方一起改。测试同学看到我的代码就头疼,说“你改一行,我得测十行”。

嗯,从那以后我才真正重视起这些原则。说白了,它们不是束缚,是帮你省时间的。

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 要求职责单一——三个原则一起上,代码反而变得碎片化。

我的做法是:

  1. 先保证 SRP:这是基础。职责不清,后面都是白搭。
  2. 再考虑 OCP:只在变化点做抽象。不要为了“未来可能的变化”提前设计。
  3. 最后用 ISP 收尾:接口暴露给外部时,按调用方裁剪。内部实现可以大,但对外接口要小。

实战口诀:

  • 一个类只改一个原因(SRP)
  • 加功能不删老代码(OCP)
  • 接口只给需要的(ISP)

3.5 知识结构图

下面这张图展示了三个原则的关系和权衡点:

功能设计三原则关系图 功能设计三原则:关系与权衡 单一职责 (SRP) 一个类只改一个原因 开闭原则 (OCP) 对扩展开放,对修改关闭 接口隔离 (ISP) 不依赖不需要的方法 权衡点 SRP 拆太细 → 类爆炸,调用复杂 OCP 过度抽象 → 接口层级深,理解成本高 ISP 切太碎 → 调用方需要组合多个接口 实践建议 1. 先保证 SRP,职责清晰是基础 2. 只在变化点做 OCP 抽象 3. 对外接口用 ISP 裁剪,对内实现可大

3.6 避坑指南

我曾经踩过的坑:

  • 过度拆分:一个订单模块拆了 30 个类,每个类就一个方法。结果新人看不懂,改一个功能要改 5 个文件。后来我合并了一些,反而更好维护。
  • 提前抽象:项目刚开始就搞策略模式、工厂模式,结果业务还没定型,抽象层改来改去。我的建议是:至少等同一个逻辑被改过两次,再考虑抽象。
  • 接口膨胀:接口越加越多,最后调用方要注入五六个接口才能完成一个功能。这时候要考虑是否该用门面模式(Facade)做一层封装。

3.7 小结

三个原则不是教条,是工具。用得好,代码改起来轻松;用得不好,反而增加复杂度。

我个人习惯是:写代码时先问自己三个问题——

  • 这个类以后会因为什么原因改?
  • 加新功能需要改老代码吗?
  • 调用方真的需要这个接口里的所有方法吗?

想清楚这三个问题,原则自然就用对了。