Skip to content

可扩展流程怎样设计:策略、回调与责任链 ​

「让流程可扩展」至少有三种不同的意思:同一个决策点换一种算法、固定流程里换掉某一步、在请求进入之前加一道检查。它们分别对应策略、回调和责任链,经常出现在同一个请求里,但各自出错的方式完全不同。

以活动报名请求为例,一次报名要经过这些步骤:

text
租户 → 鉴权 → 限流 → 幂等 → 校验          ← 责任链:任何一个节点可以拒绝
加载活动 → 计算容量 → 保存 → 返回          ← 固定流程:「计算容量」由调用方提供
计算容量:固定座位 / 超卖 10% / 线上不限    ← 策略:按活动类型选一个

三个扩展点实测各出了一次错:两条策略都声明处理工作坊,容量是 20 还是 22 取决于注册顺序;回调换到线程池执行后,读到的租户是 null;限流排在鉴权前面,合法请求只通过了一半。本文用 JDK 21 的实测,逐个说明它们的边界。

一、三种扩展点各管一件事 ​

责任链:多个节点依次判断,任何一个可以短路租户鉴权限流幂等校验固定流程:步骤顺序不变,个别步骤由调用方提供加载活动计算容量(回调)保存返回结果固定座位超卖 10%线上不限← 策略:按类型选一个
图 1 · 同一个报名请求里三种扩展点各管一件事:责任链决定请求能否进入,固定流程保证步骤顺序,策略在某一步选出一个算法
策略固定流程 + 回调责任链
变化的是什么某一步用哪个算法某一步具体怎么做进入前要经过哪些检查
执行几个一个(或一组选中的)流程里的每一步都执行依次执行,任何一个可以短路
由谁决定路由逻辑按键选择流程的所有者调用回调链的顺序
典型例子运费、容量、渠道选择JdbcTemplate 加 RowMapperServlet Filter、Spring Security、Netty ChannelPipeline

一两个稳定的步骤不需要建框架。只有变化真实存在(新的活动类型会出现、检查会增减)时,才值得引入对应的扩展点。

二、策略:选择逻辑要能在启动时验证 ​

最常见的写法是注入所有实现,按 supports 过滤,取第一个:

java
CapacityRule rule = rules.stream().filter(r -> r.type() == type).findFirst().orElseThrow();

它在只有一个实现声明某个类型时没有问题。实测注册了两条都声明 WORKSHOP 的规则(固定座位、超卖 10%),20 个座位的工作坊在一种注册顺序下容量是 20,顺序反过来是 22。Spring 注入 List 的顺序取决于 @Order 和 Bean 定义顺序,一次无关的重构就可能改变它。

更稳妥的是在启动时建立索引,重复与缺失都立即失败:

java
static Map<ActivityType, CapacityRule> strictIndex(List<CapacityRule> rules) {
    Map<ActivityType, CapacityRule> index = new EnumMap<>(ActivityType.class);
    for (CapacityRule r : rules) {
        CapacityRule prev = index.putIfAbsent(r.type(), r);
        if (prev != null) throw new IllegalStateException("重复的容量规则 " + r.type() + ":" + prev + " 与 " + r);
    }
    Set<ActivityType> missing = EnumSet.allOf(ActivityType.class);
    missing.removeAll(index.keySet());
    if (!missing.isEmpty()) throw new IllegalStateException("缺少容量规则 " + missing);
    return index;
}

实测它报出了重复的 WORKSHOP,也报出了缺少的 WEBINAR。确实需要多条规则同时匹配时(按优先级叠加),就把优先级写成数据并检查同优先级的冲突,渐进发布规则的做法见 可复用的服务端组件。

策略选出来之后,「在多个候选里选最优」这一步本身也常写错。例如对每条优惠规则试算优惠额,选优惠最大的一条:

java
long best = Long.MIN_VALUE; Rule chosen = null;
for (Rule r : rules) {
    long discount = r.trial(order);
    if (discount < best) { best = discount; chosen = r; }   // 比较方向写反了
}

初始值是「最小」,比较却是「更小才更新」,任何候选都比不过 Long.MIN_VALUE,chosen 永远是 null。把初始值和比较方向写成一对是这类代码最容易错的地方;更稳妥的写法是让标准库表达意图:rules.stream().max(Comparator.comparingLong(r -> r.trial(order))),再明确处理「没有任何规则适用」的空结果。还要为同额的规则定一个确定的决胜顺序,否则结果取决于列表顺序,和上面「第一个匹配的策略胜出」是同一个问题。试算本身应该是没有副作用的纯计算,选定之后再由应用服务执行状态变更(扣减额度、记录使用),两者分开,试算才能放心地多次调用。

写法的选择:

  • 规则无状态、只有一个方法、不需要注入依赖 → Map<Key, Function>;
  • 规则需要配置、有多个方法或需要代理增强 → 类加容器注入;
  • 类型集合封闭、由自己掌控 → 密封类型加穷举 switch,新增类型时编译器会指出漏处理的地方,见 封装、组合与多态;
  • 开放的插件集合 → 运行时注册,并做上面的启动检查。

三、回调:写清楚在哪个线程、哪个事务里执行 ​

固定流程加回调的典型例子是 JdbcTemplate:获取连接、执行、处理异常、释放资源的流程固定,调用方只提供 SQL 和 RowMapper。它不需要继承,这是它和经典「模板方法」的区别,见 设计模式决策图。

回调的风险在于它运行在流程所有者选定的环境里。实测同一个回调读取调用方设置的租户:

text
同线程执行:    activity-1 的租户:tenant-a
线程池中执行:  activity-1 的租户:null
回调抛出 IllegalStateException:调用方收到 CompletionException,原因才是 IllegalStateException

ThreadLocal 只是一个例子,Spring 的事务、安全上下文、日志的 MDC 在换线程时都有同样的问题,事务的实测见 Spring AOP 为什么会失效。提供回调的一方要在接口上写明:

  • 回调在调用方线程还是其他线程执行;
  • 是否在事务内,事务在回调前后何时提交;
  • 回调抛出的异常以什么形式回到调用方;
  • 回调能否被调用多次(重试时)。

四、责任链:顺序与副作用 ​

鉴权 → 限流鉴权拒绝 60 个未登录限流(50)只看到 40 个合法合法请求通过 40 / 40限流 → 鉴权限流(50)前 50 个就用完配额鉴权拒绝其中 30 个未登录合法请求通过 20 / 40,超过限流 50 个
图 2 · 40 个合法请求与 60 个未登录请求交替到达,全局配额 50:先鉴权时未登录请求在第一个节点就被拒绝,合法请求全部通过;先限流时未登录请求先把配额用掉,合法请求只通过一半

责任链的每个节点返回「继续」或「拒绝」,拒绝时链条短路。实测两个结论。

顺序是业务规则。 40 个合法请求与 60 个未登录请求交替到达,全局配额 50:

顺序合法请求通过拒绝原因
鉴权 → 限流40 / 40未登录 60
限流 → 鉴权20 / 40超过限流 50、未登录 30

限流在前时,未登录请求也消耗配额。反过来,有些场景恰恰需要限流在前,比如要保护鉴权服务本身不被压垮。无论哪种,顺序都应该显式配置,并写明理由。

「继续」之前不要改状态。 幂等节点把请求的幂等键记下来,然后返回「继续」;如果它排在校验之前:

text
第一次(6 人):   auth=pass, idempotency=pass, validation=reject(同行人数必须在 1—4 之间)
改成 2 人后重试:  auth=pass, idempotency=reject(重复请求)

用户修正了参数,却因为第一次失败请求留下的幂等键被拒绝。把幂等节点放到校验之后就正常了;更一般的做法是节点只做判断,把需要持久化的状态放到请求真正成功之后再提交,或者在失败时释放。幂等键在执行失败、超时和崩溃时怎样处理,见 可复用的服务端组件。

让短路可观测。 实验里每个节点都记录了自己的决定,被拒绝的请求能直接看出停在哪个节点、原因是什么。链上节点超过五六个时,建议给每个节点记录放行、拒绝和耗时。

设计扩展点之前,先分清楚变化在哪里:是选一个算法、换一步做法,还是在入口加一道检查。策略要在启动时验证选择逻辑是完整且唯一的;回调要说明执行的线程、事务和异常;责任链要显式写出顺序,并让节点在确认成功之前不改变状态。

一次状态变化需要通知多方时,观察者、事件与消息的边界见 一次状态变化怎样通知多方。


配套实验

参考资料

文章以 CC BY-NC-SA 4.0 授权 · 代码片段以 MIT 授权