10.1 可替换策略、流程骨架、责任链与命令
同一场比赛可以采用不同积分算法,批量导入必须先校验再写入,报名检查要在拒绝后停止,而“关闭赛事”可能排队到稍后执行。这些问题关心的都是一次工作怎样向前推进。
行为型模式关注“谁决定下一步、请求怎样流动、变化被放在哪里”。这一节处理四类常见协作:替换算法、固定流程骨架、逐站处理请求,以及把一次请求变成可保存或调度的值。
Strategy:替换一个完整决策
本节为 Java 21 项目片段;输入、报告、仓储等领域类型由项目提供,每个 public 类型单独存放。示例不共享前文同名类型的所有字段,也没有暗中接入数据库或队列。
当多个算法满足同一输入输出契约,并且调用者需要按配置或上下文选择时,使用 Strategy。
@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 或方法引用:
RankingPolicy fixedPoints = (before, result) ->
RatingChange.fixed(result.winnerId(), 10);策略契约要说明确定性、舍入、边界和失败语义。两个实现虽然签名相同,如果一个会访问当前时间、另一个是纯函数,替换时仍可能让缓存与重放失效。
不要把每个 if 都拆成 Strategy。分支少、集合封闭且规则一起变化时,一个穷尽 switch 更直观。Strategy 适用于算法族确实独立演化或需要运行期替换的场景。
Template Method:固定流程,只开放少数步骤
Template Method 用基类定义稳定算法骨架,让子类覆盖受控步骤:
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
Strategy:通过组合替换整个算法/步骤,调用者或装配层选择
Template Method:通过继承固定大流程,只让子类填少数钩子二者可以协作:模板固定事务和审计流程,某一步委托给注入的策略。不要为了模式纯度拒绝简单组合。
Chain of Responsibility:让请求经过有序处理站
责任链适合一组处理器按顺序判断、增强或截断请求。先定义传播结果,避免每个 handler 偷偷决定是否调用下一个:
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 把接收者、参数和执行入口封装起来,使请求可以排队、记录、重试或组合:
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 |