责任链模式中的上下文对象应该怎么设计?
简化版
责任链上下文对象要承载链路需要共享的数据、处理结果和执行状态,但不能变成所有处理器随便读写的“大杂烩”。设计时要区分只读输入、可变中间态、最终结果和观测信息,并为每个字段定义谁能写、什么时候写、是否允许为空。
详细版
上下文对象通常包含请求输入、用户信息、校验结果、风险结果、链路状态和错误信息。它让多个处理器不用互相直接依赖,但如果没有边界,处理器之间会通过字段暗中耦合:A 处理器写了 riskLevel,B 处理器默认它一定存在,C 处理器又覆盖它,问题会越来越隐蔽。
比较稳的做法是把上下文分层:request 放原始输入,facts 放已经计算出的事实,result 放链路输出,attributes 只放少量扩展字段,并限制使用范围。高价值字段不要全部塞进 Map<String,Object>,应该有明确类型和注释。
面试回答可以强调:上下文是责任链的共享契约,不是临时变量袋。字段越多,越要有命名、生命周期、读写权限和测试约束。
完整版教学
一、上下文对象解决的是处理器之间的数据传递
责任链里每个处理器只面向链入口和上下文,不应该直接依赖其他处理器。比如 AuthHandler 解析用户,RiskHandler 使用用户等级和 IP,StockHandler 使用商品和数量。如果没有上下文,就会出现处理器之间传参数、调用彼此方法,链式解耦就失效了。
Request -> Context
AuthHandler 写入 userId
RiskHandler 读取 userId,写入 riskLevel
StockHandler 读取 skuId/count,写入 stockPassed
SubmitHandler 读取前面结果,生成 orderId
上下文的价值在于把“共享数据”集中在一个对象里,但代价是它可能变成隐式耦合点。一个 5 节点链里,如果每个节点写 3 个字段,很快就有 15 个字段;字段生命周期不清楚时,排查成本会比直接调用还高。
心法:上下文是链路契约,不是所有处理器都能随手塞东西的临时 Map。
二、把字段分成输入、事实、结果和观测四类
一个可维护的上下文通常可以分成 4 类字段。第一类是只读输入,比如请求参数、场景、traceId;第二类是事实数据,比如用户身份、商品信息、风险等级;第三类是链路结果,比如通过、拒绝、错误码;第四类是观测信息,比如执行节点、耗时、短路原因。
class OrderContext {
private final OrderRequest request;
private final String traceId;
private UserInfo userInfo;
private RiskDecision riskDecision;
private ChainResult result = ChainResult.pass();
private final List<String> executedHandlers = new ArrayList<>();
}
| 字段类别 | 示例 | 写入规则 |
|---|---|---|
| 只读输入 | request, scene, traceId | 构造时写入,后续不改 |
| 事实数据 | userInfo, riskDecision | 指定处理器写入 |
| 链路结果 | result, errorCode | 短路或结束时写入 |
| 观测信息 | executedHandlers, costMs | 链容器统一写入 |
三、不要把所有字段都塞进 Map
Map<String,Object> 很诱人,因为新增字段不用改类。但它牺牲了类型安全、可读性和重构能力。比如 context.put("risk", "HIGH") 和 context.put("riskLevel", 3) 同时存在时,哪个是准的?IDE 也无法帮你发现拼写错误。
Map 不是不能用,而是适合放低频扩展字段。高频字段、关键业务字段、会影响短路决策的字段,应该建成明确属性。假设订单链有 8 个处理器,其中 5 个都读取 userInfo,这个字段就应该是强类型;只有某个灰度处理器临时写入 experimentBucket,才适合放扩展属性。
// 不推荐:关键字段弱类型化
Object user = context.getAttributes().get("user");
// 推荐:关键字段强类型化
UserInfo user = context.getUserInfo();
四、字段读写权限要靠约定和封装控制
如果上下文所有字段都有公开 setter,任何处理器都能改任何数据,链路就会变得不可预测。更好的做法是为关键字段提供语义化方法,比如 markRiskRejected()、attachUser()、recordHandler(),而不是暴露通用 setResult()。
class OrderContext {
private UserInfo userInfo;
private ChainResult result = ChainResult.pass();
void attachUser(UserInfo userInfo) {
if (this.userInfo != null) {
throw new IllegalStateException("userInfo already attached");
}
this.userInfo = userInfo;
}
void reject(String code, String message) {
this.result = ChainResult.stop(code, message);
}
}
这个设计的重点是让非法状态更早暴露。比如 userInfo 只能写一次,如果两个认证处理器都尝试覆盖它,就应该直接失败。否则后面的处理器拿到被覆盖的数据,问题会出现在更远的地方。
五、上下文生命周期要限定在单次请求内
责任链上下文通常不是线程安全对象,它应该只服务于单次请求或单次任务。不要把上下文放进单例处理器的成员变量,也不要在异步任务里继续引用会被复用的上下文。处理器本身可以是单例 Bean,但上下文必须每次新建。
@Component
class BadHandler implements Handler {
private OrderContext lastContext; // 错误:多线程请求会互相覆盖
public ChainResult handle(OrderContext context, Chain chain) {
this.lastContext = context;
return chain.next(context);
}
}
假设 2 个请求同时进入同一个单例处理器,请求 A 写入 lastContext=A 后还没执行完,请求 B 写入 lastContext=B,A 后续读取成员变量就可能拿到 B 的数据。责任链并没有消除并发问题,只是把请求数据集中到了上下文里。
六、短路结果要放在上下文还是返回值里
有两种常见设计:一种是处理器返回 ChainResult,链容器根据返回值决定是否继续;另一种是处理器修改上下文中的 result,再调用或不调用 next。返回值方式更显式,上下文方式更适合需要后置处理和统一收集结果的链。
| 设计方式 | 优点 | 风险 |
|---|---|---|
返回 ChainResult | 短路清晰,便于测试 | 结果和上下文字段可能重复 |
修改 context.result | 统一承载状态 | 处理器可能忘记停止后续链 |
| 抛异常 | 适合非预期错误 | 容易把业务拒绝和系统异常混在一起 |
业务拒绝: return STOP(code=RISK_REJECT)
系统错误: throw RuntimeException
正常通过: return PASS
七、上下文要支持可观测但不要污染业务
链路观测需要记录每个处理器的执行情况,但这些字段最好由链容器统一维护,不要让业务处理器自己拼日志。比如链容器在调用处理器前记录开始时间,调用后记录结果,异常时记录错误类型。处理器只负责业务决策。
executedHandlers:
1 AuthHandler PASS 3ms
2 ParamHandler PASS 1ms
3 RiskHandler STOP 15ms code=RISK_REJECT
这样设计有两个好处:第一,所有处理器都有统一日志格式;第二,新增处理器天然被观测覆盖。否则 10 个处理器里有 7 个打日志、3 个没打,线上排查会缺关键节点。
八、常见误区与追问
- 误区:上下文就是一个 Map,方便就行。 关键字段弱类型化会让拼写错误、类型转换错误和字段覆盖问题延迟到运行期。
- 误区:字段越全越好。 字段越多,处理器之间越容易产生隐式依赖,应该只放链路真正需要共享的数据。
- 误区:处理器可以随便改上下文。 关键字段要有写入时机和写入方约束,最好通过语义化方法控制。
- 追问:上下文要不要设计成不可变? 输入部分适合不可变,中间事实和结果通常需要变化,可以做成局部可变、整体单请求隔离。
- 追问:异步处理器能不能持有上下文? 可以传递必要快照,但不要长时间持有可变上下文,避免生命周期越界和并发修改。
- 追问:业务拒绝应该写上下文还是抛异常? 业务拒绝更适合明确结果,系统异常才适合抛异常,两者混用会让监控和重试策略混乱。
九、加强记忆
上下文设计记住“四分法”:输入只读,事实有主,结果明确,观测统一。只读输入保证请求基础不被改;事实字段要知道由哪个处理器写;结果字段要能表达通过、停止和错误;观测字段由链容器统一补齐。这样上下文既能传递数据,又不会把责任链变成字段互相污染的黑箱。