跳到内容

第 6 卷 / 第 7 章 / 第 1 课

7.1 用变化方向理解 SRP、OCP 与 DIP ​

设计原则帮助团队在需求变化时追问:哪些代码会一起变化,哪些决定应该隔离,业务规则是否被基础设施牵着走。

先观察内聚与耦合 ​

内聚描述一个模块内部的元素是否围绕同一目的;耦合描述一个模块变化会把多少知识和修改传播到其他模块。

目标不是让所有模块零耦合,协作必然产生依赖,而是让依赖显式、稳定,并让经常一起变化的代码靠近,不同原因变化的代码分开。

以赛事奖励为例,运营人员调整奖励规则,财务人员调整入账与审计规则,平台团队更换数据库。若这些决定都堆进一个 PrizeCalculator,任一角色的变化都会迫使团队重新理解整个模块。

SRP:围绕同一变化来源组织模块 ​

Single Responsibility Principle 常被概括为“只有一个变化原因”。更实用的理解是:一个模块应服务于一个一致的角色/关注点,而不是同时替多个变化来源做决定。

下面的 Java 21 示例使用非现金奖励积分。每个 public 类型分别保存到同名 .java 文件;把 Reward、MatchResult、RewardTable、RewardPolicy、两种策略、RewardLedger 与 RewardService 组装后即可编译策略/服务练习。真实 PostgreSQL 适配器不在本例实现范围内。先定义值类型:

java
public record Reward(long points) {
    public Reward {
        if (points < 0) throw new IllegalArgumentException("points < 0");
    }
    public Reward multiply(int factor) {
        if (factor < 1) throw new IllegalArgumentException("factor < 1");
        return new Reward(Math.multiplyExact(points, factor));
    }
}

public record MatchResult(int rank) {
    public MatchResult {
        if (rank < 1) throw new IllegalArgumentException("rank < 1");
    }
}

public interface RewardTable {
    Reward rewardFor(int rank);
}
java
public final class RewardService {
    private final RewardPolicy policy;
    private final RewardLedger ledger;

    public RewardService(RewardPolicy policy, RewardLedger ledger) {
        this.policy = java.util.Objects.requireNonNull(policy, "policy");
        this.ledger = java.util.Objects.requireNonNull(ledger, "ledger");
    }

    public Reward grant(MatchResult result) {
        Reward reward = java.util.Objects.requireNonNull(
                policy.calculate(java.util.Objects.requireNonNull(result, "result")), "reward");
        ledger.record(reward);
        return reward;
    }
}

这里有三个不同关注点:

  • RewardService 编排一次发奖用例;
  • RewardPolicy 表达赛事运营规则;
  • RewardLedger 表达财务记录所需的端口。

SRP 不等于“一类一方法”。如果一组方法维护同一不变量、由同一类需求推动,它们放在同一模块往往更内聚。把每个私有函数都升级成类,只会制造导航成本。

判断是否应拆分,可以看实际变更历史:两部分是否由不同利益相关者提出、以不同节奏发布、需要不同测试或安全审查?如果答案都是否定的,提前拆开可能没有收益。

OCP:在已知变化轴上建立稳定边界 ​

Open–Closed Principle 常写作“对扩展开放,对修改关闭”。它不可能意味着新增能力时一个旧文件都不改;配置、装配和测试总要认识新实现。重点是保护已经验证的核心策略,让新变化通过明确扩展点进入。

java
public interface RewardPolicy {
    Reward calculate(MatchResult result);
}

public final class RankedRewardPolicy implements RewardPolicy {
    private final RewardTable table;

    public RankedRewardPolicy(RewardTable table) {
        this.table = java.util.Objects.requireNonNull(table, "table");
    }

    @Override
    public Reward calculate(MatchResult result) {
        return java.util.Objects.requireNonNull(
                table.rewardFor(java.util.Objects.requireNonNull(result, "result").rank()), "reward");
    }
}

public final class HolidayRewardPolicy implements RewardPolicy {
    private final RewardPolicy base;
    private final int multiplier;

    public HolidayRewardPolicy(RewardPolicy base, int multiplier) {
        if (multiplier < 1) throw new IllegalArgumentException("multiplier < 1");
        this.base = java.util.Objects.requireNonNull(base, "base");
        this.multiplier = multiplier;
    }

    @Override
    public Reward calculate(MatchResult result) {
        return base.calculate(result).multiply(multiplier);
    }
}

“奖励规则按赛事类型变化”是明确的变化轴,所以策略接口有依据。新节日规则可以组合已有策略,不必修改入账逻辑。

但如果系统目前只有一个固定公式,也没有第二种规则或可预见变化,先写一个普通函数更清楚。为每段代码预留任意扩展点,会把当前复杂度换成想象中的未来便利。

扩展点必须包含完整契约 ​

接口签名只说明类型,不说明语义。RewardPolicy 还需要明确:

  • 是否允许返回负数或空奖励;
  • 计算是否必须确定性;
  • 是否读取当前时间或外部状态;
  • 溢出、舍入和币种怎样处理;
  • 多条策略组合时顺序是否影响结果。

本例约定名次至少为 1,策略必须确定,返回非空、points 非负的 Reward;倍率为正整数,溢出在调用账本前抛 ArithmeticException。这里是整数非现金积分,没有币种转换或舍入。表返回 null 会违反契约,requireNonNull 用来及时发现这种违约。基础奖励 100、倍率 2 得到 200;基础值 Long.MAX_VALUE、倍率 2 则应失败且不写账本。

如果实现必须偷偷了解调用方执行顺序,扩展点只是把 if 分散到多个类,并没有真正关闭核心风险。

DIP:让源码依赖指向策略 ​

Dependency Inversion Principle 关注的是源码依赖方向。高层业务策略不应导入某个数据库驱动、HTTP SDK 或消息客户端;业务所需能力由稳定端口表达,基础设施适配它。

text
domain/application
  RewardService -> RewardPolicy
                -> RewardLedger
                         ^
                         |
infrastructure  PostgresRewardLedger
java
public interface RewardLedger {
    void record(Reward reward);
}

图中的 PostgresRewardLedger 要实现持久化和事务参与后,才满足 RewardLedger 契约;record 空方法体会默默丢掉奖励。源码依赖指向端口,运行调用仍从 RewardService 进入适配器;不能把所有运行箭头也机械翻转。

接口通常放在最能表达其契约的一侧。对用例所需的持久化能力,应用/领域侧拥有端口很自然;对第三方库本身的通用抽象,则未必应该搬进领域层。关键是业务代码不被易变实现细节反向塑形。

依赖注入只是装配手段 ​

构造器注入让依赖显式、便于替换和测试,但“用了 DI 框架”不等于遵守 DIP。如果业务类仍直接依赖 JpaRepository、云厂商 DTO 或全局 service locator,依赖方向没有改变。

装配发生在系统边缘。下面是完整的方法体练习,使用 List 支持的账本 Fake;编译上述类型后,可放入 main 方法运行:

java
RewardTable rewardTable = rank -> new Reward(rank == 1 ? 100 : 20);
RewardPolicy policy = new HolidayRewardPolicy(
        new RankedRewardPolicy(rewardTable), 2);
var recorded = new java.util.ArrayList<Reward>();
RewardLedger ledger = recorded::add;
RewardService service = new RewardService(policy, ledger);
System.out.println(service.grant(new MatchResult(1)).points()); // 200
System.out.println(recorded.size()); // 1

核心对象只看到契约,入口层决定使用哪些具体实现。该 Fake 限于单线程且不持久化,重复 grant 会再次记录;SRP、OCP、DIP 不会自动提供幂等或事务原子性。真实发奖需要业务操作标识,并在账本契约中说明重试与提交行为。

不要给每个类配一个接口 ​

以下情况通常不需要额外接口:

  • 纯值对象和不可变数据结构;
  • 稳定、局部、只有一个语义的内部实现;
  • 抽象没有减少调用者需要知道的内容;
  • 唯一理由是“以后也许有第二个实现”。

抽象的成本包括命名、跳转、装配、测试替身和契约维护。先让真实变化暴露边界,再抽取稳定接口,往往比凭猜测设计更可靠。

三个原则如何协作 ​

text
SRP:识别不同变化来源,把职责边界画出来
OCP:在高频、已知变化轴上提供受控扩展
DIP:让具体技术依赖这些稳定策略,而不是反过来

下一节检查这些抽象是否真的可替换、接口是否贴合调用者,并处理继承、组合和对象图导航。

参考资料 ​

Built with VitePress | Software Systems Atlas