跳到内容

第 6 卷 / 第 7 章 / 第 2 课

7.2 可替换契约、接口隔离与组合 ​

新实现替换旧接口后,编译没有报错,调用方的行为却变了;你们必须把抽象背后的契约写清楚。

有了接口不代表实现可以安全替换。调用者依赖的除了方法名,还有前置条件、结果保证、失败语义和状态演化。LSP 与 ISP 用来检查抽象是否诚实;组合与最少知识原则帮助控制对象之间暴露的结构。

LSP:子类型必须保留调用者能依赖的性质 ​

Liskov Substitution Principle 的核心是行为子类型:如果调用者只知道父类型/接口,对象换成任何子类型后,原先能够成立的正确性推理仍应成立。

假设支付端口承诺:相同幂等键的重复扣款不会产生第二笔资金变化。

java
public interface PaymentGateway {
    /**
     * 在约定的账户/操作范围和保留窗口内,键与参数相同的重复调用
     * 返回同一业务结果,且最多产生一次资金扣减。
     */
    PaymentResult charge(
            String idempotencyKey,
            Money amount,
            PaymentMethod method);
}

一个实现即使类型检查通过,只要忽略幂等键,就不能替换其他实现:调用方的安全重试假设会被破坏。

这里是项目接口片段,Money、PaymentMethod、PaymentResult 由支付模型提供,不能直接把前面的 Money 练习原样套进来。契约还应定义金额等价、租户/商户作用域、键保留期、过期后行为与并发请求处理。本教学契约约定:保留期内以同一键换参数,抛 IllegalArgumentException 且不再扣款。提供者适配器需要把实际 API 语义映射到这份契约;把键放进 HTTP 头还不够。

常用检查方式是:

  • 子类型不能要求更严格的前置条件;
  • 子类型不能给出更弱的后置保证;
  • 必须维护父类型的不变量;
  • 失败类型、幂等性、顺序和状态历史等可观察性质不能意外改变。

“所有实现都抛 UnsupportedOperationException 处理自己不支持的参数”通常说明接口承诺过宽,或这些实现并不属于同一个可替换类型族。

若父契约已明确某项操作可选,抛异常可能完全合法。例如 Java Collection 把某些修改操作标为 optional,允许抛 UnsupportedOperationException。LSP 违约发生在子类型撤回父类型确实作出的保证时,不能只看到这个异常名就定案。

用共享契约测试验证实现族 ​

java
interface PaymentGatewayContract {
    PaymentGateway gateway();
    PaymentMethod testMethod();
    String freshKey();
    void assertSingleDebit(String key, Money amount);

    @org.junit.jupiter.api.Test
    default void repeatedKeyDoesNotChargeTwice() {
        PaymentGateway gateway = gateway();
        Money amount = Money.usd("20.00");
        PaymentMethod method = testMethod();
        String key = freshKey();

        PaymentResult first = gateway.charge(key, amount, method);
        PaymentResult second = gateway.charge(key, amount, method);

        org.junit.jupiter.api.Assertions.assertEquals(first, second);
        assertSingleDebit(key, amount);
    }

    @org.junit.jupiter.api.Test
    default void repeatedKeyWithDifferentAmountIsRejected() {
        PaymentGateway gateway = gateway();
        Money amount = Money.usd("20.00");
        PaymentMethod method = testMethod();
        String key = freshKey();
        gateway.charge(key, amount, method);

        org.junit.jupiter.api.Assertions.assertThrows(IllegalArgumentException.class,
                () -> gateway.charge(key, Money.usd("21.00"), method));
        assertSingleDebit(key, amount);
    }
}

内存 Fake 和提供者沙箱适配器可以复用同一契约。测试不能形式化证明全部 LSP,但能把重要语义从注释变成持续验证。

JUnit 5 需要具体测试类实现该接口后,才会发现其中的 default 测试方法。gateway 须使用隔离沙箱状态,freshKey 每例生成新键,assertSingleDebit 则检查同一网关的账本或提供者沙箱,不能只把返回值再读一遍。测试复用同一个 PaymentMethod,覆盖顺序重放和金额变更拒绝;并发、崩溃、键过期与真实资金结算还需相应验证。

ISP:接口按调用者需要切分 ​

Interface Segregation Principle 不是“接口方法越少越好”,而是调用者不应被迫依赖自己不需要、也无法合理实现的能力。

下面的 Java 接口声明依赖签名中的项目 ID、视图、分页和命令类型;public 接口分别保存,TournamentAudit 另需导入 java.util.List。

java
public interface TournamentQueries {
    TournamentView find(TournamentId id);
    Page<TournamentView> search(TournamentFilter filter, PageRequest page);
}

public interface TournamentCommands {
    TournamentId create(CreateTournament command);
    void close(TournamentId id);
}

public interface TournamentAudit {
    List<AuditEntry> history(TournamentId id);
}

公开查询客户端不需要依赖管理员命令;普通仓库适配器也不必为了一个巨大接口实现审计导出。接口边界应围绕客户端角色与一致契约,不是机械地把每个方法放进独立文件。

过度切分会把一次用例需要的概念散落成大量浅接口,让调用方承担组合责任。若一组操作始终由同一客户端共同使用、维护同一不变量,保留在一个“深”接口里通常更清晰。

组合优先,是为了独立替换行为 ​

继承同时承担代码复用和类型关系时,容易把两个目的绑在一起。组合允许对象在运行或装配时选择协作者:

这里是替代 7.1 的 RewardService 类声明,并依赖项目定义的 EligibilityPolicy。构造器展示组合关系,尚未实现发奖用例。

java
public final class RewardService {
    private final RewardPolicy rewardPolicy;
    private final EligibilityPolicy eligibilityPolicy;
    private final RewardLedger ledger;

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

RewardService 有奖励策略和资格策略,这些行为可以独立测试与替换。它不需要继承 HolidayRewardService、RankedRewardService、HolidayRankedRewardService 形成组合爆炸。

组合也有成本:更多对象、装配与间接调用。如果继承关系确实表达稳定的可替换类型、父类契约清晰、扩展点受控,继承仍然合适。关键问题不是语法上的 “is-a”,而是子类是否完整遵守行为契约。

实现继承与接口继承要分开看 ​

实现接口声明“我满足这份契约”;继承具体基类还会复用其状态和模板流程。后者耦合更强:父类的 protected API、调用顺序和可覆盖方法都可能成为子类依赖。

设计可扩展基类时,应明确:

  • 哪些方法允许覆盖,哪些不允许;
  • 构造期间是否调用可覆盖方法;
  • 模板方法的顺序与错误语义;
  • 子类必须维护哪些不变量;
  • 二进制和源码兼容承诺。

如果无法写清这些内容,优先使用 final 类加显式协作者。

最少知识原则不是禁止链式调用 ​

危险导航通常把外部对象图结构泄露给调用者:

java
player.getAccount().getWallet().getCurrency().getCode();

调用者同时知道 Player、Account、Wallet、Currency 的结构,任何中间关系变化都会传播。可以把真正的业务问题交给拥有相关知识的对象:

java
player.canReceive(reward);

但 builder.withName("arena").withRegion("ap").build() 并不必然违反最少知识原则;链条上的方法如果持续操作同一个 fluent abstraction,没有逐层穿透陌生对象图,耦合性质不同。

也不要为消除点号创建大量转发方法。目标是把业务决策放到拥有数据和不变量的边界,而不是追求最短调用链。

封装的是决策,不只是字段 ​

java
import java.util.Objects;

public final class Registration {
    public enum Status { PENDING, CONFIRMED }
    public record PaymentReceipt(String registrationId) {
        public PaymentReceipt {
            Objects.requireNonNull(registrationId, "registrationId");
        }
    }

    private final String id;
    private Status status = Status.PENDING;

    public Registration(String id) {
        this.id = Objects.requireNonNull(id, "id");
    }
    public String id() { return id; }
    public Status status() { return status; }

    public void confirm(PaymentReceipt receipt) {
        if (status != Status.PENDING) {
            throw new IllegalStateException("registration is not pending");
        }
        Objects.requireNonNull(receipt, "receipt");
        if (!receipt.registrationId().equals(id)) {
            throw new IllegalArgumentException("receipt belongs to another registration");
        }
        status = Status.CONFIRMED;
    }
}

完整 Java 21 示例可保存为 Registration.java。错误报名 ID 或空回执会让 PENDING 对象保持原状;匹配回执使其确认,本地契约拒绝第二次确认。这里的 PaymentReceipt 只是展示转换的结构,回执来源、支付成功、金额和币种须已在可信边界验证。该对象限于单线程,并发持久化与回调幂等还需要额外协调。

把字段设为 private 再生成任意 setter,只隐藏了内存布局。让对象控制合法状态转换,才把领域决策封装起来。

原则冲突时回到变化与风险 ​

SOLID 可能产生张力:

  • 为 OCP 增加策略接口,会提高当前理解成本;
  • 为 ISP 拆分接口,可能增加装配和跨接口一致性问题;
  • 为 DIP 增加端口,对一个稳定小脚本可能毫无收益;
  • 为 SRP 分模块,跨模块事务和协作反而更复杂。

决策依据应是实际变化频率、失败成本、团队边界和测试反馈。先写最简单且清楚的实现,在变化证据出现时重构出边界,比把五条原则当静态检查规则更可靠。

设计评审问题 ​

  1. 哪些角色会推动这段代码变化?
  2. 哪个变化轴值得稳定扩展点?
  3. 业务策略是否导入了具体技术细节?
  4. 每个实现是否遵守同一失败、幂等与状态契约?
  5. 调用者是否依赖不需要的操作?
  6. 继承是在表达可替换类型,还是只为复用代码?
  7. 调用链是否泄露了内部对象图?
  8. 新抽象解决了已知风险,还是只增加文件数量?

参考资料 ​

Built with VitePress | Software Systems Atlas