跳到内容

第 6 卷 / 第 10 章 / 第 1 课

10.1 可替换策略、流程骨架、责任链与命令 ​

同一场比赛可以采用不同积分算法,批量导入必须先校验再写入,报名检查要在拒绝后停止,而“关闭赛事”可能排队到稍后执行。这些问题关心的都是一次工作怎样向前推进。

行为型模式关注“谁决定下一步、请求怎样流动、变化被放在哪里”。这一节处理四类常见协作:替换算法、固定流程骨架、逐站处理请求,以及把一次请求变成可保存或调度的值。

Strategy:替换一个完整决策 ​

本节为 Java 21 项目片段;输入、报告、仓储等领域类型由项目提供,每个 public 类型单独存放。示例不共享前文同名类型的所有字段,也没有暗中接入数据库或队列。

当多个算法满足同一输入输出契约,并且调用者需要按配置或上下文选择时,使用 Strategy。

java
@FunctionalInterface
public interface RankingPolicy {
    RatingChange calculate(RatingSnapshot before, MatchResult result);
}

public final class RankingService {
    private final RankingPolicy policy;

    public RankingService(RankingPolicy policy) {
        this.policy = java.util.Objects.requireNonNull(policy, "policy");
    }

    public RatingChange rank(RatingSnapshot before, MatchResult result) {
        return policy.calculate(before, result);
    }
}

实现可以是类、lambda 或方法引用:

java
RankingPolicy fixedPoints = (before, result) ->
        RatingChange.fixed(result.winnerId(), 10);

策略契约要说明确定性、舍入、边界和失败语义。两个实现虽然签名相同,如果一个会访问当前时间、另一个是纯函数,替换时仍可能让缓存与重放失效。

不要把每个 if 都拆成 Strategy。分支少、集合封闭且规则一起变化时,一个穷尽 switch 更直观。Strategy 适用于算法族确实独立演化或需要运行期替换的场景。

Template Method:固定流程,只开放少数步骤 ​

Template Method 用基类定义稳定算法骨架,让子类覆盖受控步骤:

java
public abstract class BatchImport {
    public final ImportReport execute(Source source) {
        ParsedBatch parsed = parse(source);
        ValidationResult validation = validate(parsed);
        if (!validation.accepted()) {
            return ImportReport.rejected(validation.errors());
        }
        return persist(parsed);
    }

    protected abstract ParsedBatch parse(Source source);

    protected abstract ValidationResult validate(ParsedBatch batch);

    protected abstract ImportReport persist(ParsedBatch batch);
}

execute() 设为 final,固定 parse → validate → persist 的调用顺序;校验返回拒绝时不调用 persist。这里强制子类实现校验,避免默认“全部通过”。final 不能证明校验正确,也阻止不了违规 hook 偷偷写库。契约须要求解析和校验无持久副作用,并测试拒绝路径的写入次数为 0;异常在此直接传播,写入原子性由 persist 负责。

Template Method 的代价是继承耦合:子类依赖父类调用顺序,hook 增多后会出现“覆盖哪几个方法才能工作”的隐式协议。如果步骤需要自由组合,改用多个 Strategy:Parser + Validator + Persister 通常更清楚。

Strategy 与 Template Method ​

text
Strategy:通过组合替换整个算法/步骤,调用者或装配层选择
Template Method:通过继承固定大流程,只让子类填少数钩子

二者可以协作:模板固定事务和审计流程,某一步委托给注入的策略。不要为了模式纯度拒绝简单组合。

Chain of Responsibility:让请求经过有序处理站 ​

责任链适合一组处理器按顺序判断、增强或截断请求。先定义传播结果,避免每个 handler 偷偷决定是否调用下一个:

java
public sealed interface CheckResult {
    record Continue(RegistrationContext context) implements CheckResult {}
    record Reject(String code, String reason) implements CheckResult {}
}

@FunctionalInterface
public interface RegistrationCheck {
    CheckResult check(RegistrationContext context);
}

public final class RegistrationChecks {
    private final java.util.List<RegistrationCheck> checks;

    public RegistrationChecks(java.util.List<RegistrationCheck> checks) {
        this.checks = java.util.List.copyOf(checks);
    }

    public CheckResult run(RegistrationContext initial) {
        RegistrationContext current = java.util.Objects.requireNonNull(initial);
        for (RegistrationCheck check : checks) {
            CheckResult result = java.util.Objects.requireNonNull(
                    check.check(current), "check result");
            if (result instanceof CheckResult.Reject rejected) {
                return rejected;
            }
            current = java.util.Objects.requireNonNull(
                    ((CheckResult.Continue) result).context(), "context");
        }
        return new CheckResult.Continue(current);
    }
}

这条链用于资格校验,约定先身份、再禁赛、再等级、最后容量。Continue 把可能更新后的上下文传给下一站,Reject 立即返回。空列表会直接放行,重复处理器会执行两次;List.copyOf 只固定顺序并拒绝空元素,不会证明必需的校验都已装配。

例如第二站拒绝,执行轨迹必须是 [identity, ban],等级和容量检查都不应运行。初始上下文、处理结果或继续上下文为 null 时本例立即失败;处理器异常直接传播。容量检查只是观察,最后占用名额仍需原子条件更新。

责任链有多种语义,不能混用:

  • 第一个能处理者结束;
  • 所有处理器依次增强;
  • 任一拒绝即短路;
  • 收集全部错误后统一返回。

异常传播、异步步骤和补偿也要统一规定。把数据库写入放进一条可能中途失败的链,会产生部分副作用;校验链优先保持纯或明确事务边界。

Command:把一次意图变成值 ​

Command 把接收者、参数和执行入口封装起来,使请求可以排队、记录、重试或组合:

java
public record CloseTournament(
        CommandId commandId,
        TournamentId tournamentId,
        UserId requestedBy,
        java.time.Instant requestedAt) {}

public final class CloseTournamentHandler {
    private final TournamentRepository tournaments;

    public CloseTournamentHandler(TournamentRepository tournaments) {
        this.tournaments = java.util.Objects.requireNonNull(tournaments);
    }

    public CommandResult handle(CloseTournament command) {
        Tournament tournament = tournaments.getRequired(command.tournamentId());
        tournament.close(command.requestedAt(), command.requestedBy());
        tournaments.save(tournament);
        return CommandResult.accepted(command.commandId());
    }
}

在应用架构中,命令通常是不可变数据,handler 执行用例;不必照搬一个带 execute() 的类层次。本例只展示单次调用,没有按 commandId 去重。requestedBy 必须由认证上下文确定或核验,客户端写进字段的名字不是权限凭据;业务若按实际处理时间判定截止,不能直接信任 requestedAt。

可重试命令需要幂等设计 ​

采用至少一次投递时,要处理这些可能情况:

  • 同一命令执行两次;
  • 执行成功但确认丢失;
  • 旧版本消费者读取新命令;
  • 命令过期后才到达。

因此需要稳定 commandId、去重/状态记录、schema 版本、超时与死信策略。去重记录与业务写入若不在同一事务,崩溃仍可能落在两者之间;外部副作用还需要幂等键或 Outbox 等协调。仅仅“把对象序列化进队列”不构成可靠 Command 系统。

Undo 不是通用能力 ​

文本编辑可以保存逆操作;支付、邮件和外部 API 往往不可真正撤销,只能执行补偿。Undo 必须保存足够前状态,并处理逆操作自身失败。不要在接口上统一加 undo(),然后让多数命令抛不支持异常。

四种模式选择表 ​

变化/协作问题模式
同一契约下替换完整算法Strategy
固定骨架,只开放少数继承钩子Template Method
请求按有序处理站传播或短路Chain of Responsibility
请求需要记录、排队、重放或调度Command

参考资料 ​

Built with VitePress | Software Systems Atlas