这篇文章聊一下我在做设备上报数据处理模块重构时,面对"显式编排器"和"经典责任链"这两种模式的选型过程。不是教科书式的概念科普,而是结合实际需求分析为什么最终选了编排器。
需求背景
简单说一下场景:传感器设备通过 MQTT 上报 JSON 格式的数据,系统收到后需要依次完成——消息解析、数据校验、身份标准化、写入 InfluxDB、写入 Redis 实时缓存、告警判断、串口联动、历史归档。一共 8 个处理步骤,每个步骤对应一种设备数据的处理环节。
最初这些逻辑写在一个方法里,代码量上去之后不好维护,需要拆分。拆分的时候就要考虑用哪种设计模式来组织这些处理步骤。
第一反应:责任链
提到"多个步骤依次处理",大部分人的第一反应都是责任链模式。确实,大学课本里讲的就是这个——每个 Handler 处理自己的逻辑,然后传给下一个 Handler,链条式调用。
代码结构大概是这样的:
publicabstractclassHandler{protectedHandlernext;publicvoidhandle(Requestreq){if(canHandle(req)){doProcess(req);}if(next!=null){next.handle(req);}}}看起来很优雅,但实际往项目里套的时候,有几个需求不太好处理。
责任链不太好处理的几个点
1. 节点需要运行时动态开关
项目有一个运维需求:可以在不重启服务的情况下,开启或关闭任意一个处理节点。比如告警模块出了问题,运维需要临时关掉告警节点,其他节点继续正常运行。
在责任链里,这个需求意味着每个 Handler 在执行前都要检查自己的开关状态:
publicclassParseHandlerextendsHandler{publicvoidhandle(Requestreq){if(!isEnabled("PARSE")){// 跳过自己,但要记得传给下一个if(next!=null)next.handle(req);return;}doProcess(req);if(next!=null)next.handle(req);}}每个 Handler 都要写这段开关逻辑,要么抽到基类里,要么重复写。而且"跳过但继续传递"和"跳过且终止整个链条"是两种不同的行为,混在一起容易出错。
2. 需要快速失败
消息解析失败或者数据校验不通过的时候,后续步骤没有必要继续执行,应该直接终止。
在责任链里要终止整条链条,要么抛异常(不太优雅),要么在 context 里加一个isAborted标记让每个 Handler 都去检查。两种方式都不够干净。
3. 需要精确的节点级耗时统计
项目要求记录每个节点的执行耗时,并且能按节点维度做 P99 统计。
在责任链里,计时逻辑要么写在每个 Handler 里(重复代码),要么用一个装饰器/AOP 来统一处理(多了一层抽象)。不管哪种,都不是责任链本身能直接提供的能力。
4. 不同区段需要不同的容错策略
实际编码中,前两个节点(解析、校验)失败就直接 return,不需要 try-catch;而后面的节点涉及外部系统调用(InfluxDB、Redis),需要用 try-catch 兜底防止线程崩溃。
这种"链条的前半段和后半段用不同容错策略"的需求,在责任链里很难自然地表达出来。
最终选择:显式编排器
显式编排器的思路很简单:不让节点之间互相传递,而是由一个 Orchestrator 按顺序显式调用每个节点。
publicvoidexecute(Contextctx){// 全局开关if(!runtime.isRunning())return;// 节点1:解析if(!runtime.isNodeEnabled("PARSE")){log.skip("PARSE","disabled");}else{longstart=System.currentTimeMillis();booleanok=parser.parse(ctx);log.record("PARSE",System.currentTimeMillis()-start,ok);if(!ok)return;// 快速失败}// 节点2:校验(同样的模式)if(!runtime.isNodeEnabled("VALIDATE")){log.skip("VALIDATE","disabled");}else{longstart=System.currentTimeMillis();booleanok=validator.validate(ctx);log.record("VALIDATE",System.currentTimeMillis()-start,ok);if(!ok)return;}// 节点3~7:用 try-catch 兜底try{normalize.doProcess(ctx);influx.write(ctx);realtime.process(ctx);}catch(Exceptione){log.error(ctx.getTraceId(),e);}}前面提到的四个需求,在编排器里的处理方式:
动态开关:调用前一行 if 判断,每个节点都是统一模板。
快速失败:直接 return,不需要通知任何人,也不需要额外的标记。
耗时统计:调用前后取时间差,统一写在 Orchestrator 里,格式天然一致。
分段容错:前两个节点不用 try-catch,后面的节点用 try-catch 包起来。想在哪分段就在哪分段,完全由 Orchestrator 的代码结构决定。
两种模式的对比
| 对比维度 | 显式编排器 | 经典责任链 |
|---|---|---|
| 控制权 | 集中在 Orchestrator | 分散在每个 Handler |
| 运行时开关 | 调用前 if 判断,统一模板 | 每个 Handler 内部自行处理 |
| 快速失败 | 直接 return | 需要 isAborted 标记或抛异常 |
| 节点耗时 | 统一模板记录 | 需要装饰器或 AOP |
| 分段容错 | try-catch 灵活包裹 | 难以在链条中间插入不同策略 |
| 全链路日志 | 每次调用后统一记录 | 需要额外拦截器 |
| 处理者动态增减 | 需要改代码 | 天然支持 |
| 节点间耦合 | 节点互不感知 | 每个节点持有 next 引用 |
责任链适合什么场景
说了这么多编排器的好处,也要说一下责任链的优势,避免产生"编排器一定比责任链好"的误解。
责任链的核心优势在于处理者数量不确定、可以动态增减的场景。典型的例子:
Servlet Filter 链——用户可以在 web.xml 里配置任意数量的 Filter,框架不需要知道具体有几个,chain.doFilter() 一路传下去就行。
Spring HandlerInterceptor——拦截器的数量和顺序由配置决定,框架提供的是注册机制而不是硬编码调用。
Netty ChannelPipeline——网络处理的 Handler 数量完全取决于用户配置,编码器、解码器、业务处理器可以自由组合。
这些场景的共同点是:框架设计者不知道用户会加多少个处理者,所以用链式结构来保持灵活性。
而我的项目里,处理步骤是固定的(8 个节点,不太会增减),但需要在这些固定步骤之上叠加开关、计时、日志、容错等控制逻辑。这种"节点固定、控制复杂"的场景,编排器更合适。
总结
选型的核心判断标准其实就一句话:你的场景更需要"灵活性"还是"控制力"?
处理者数量不确定、需要动态增减 → 责任链。处理者固定、但需要开关/计时/日志/容错等控制逻辑 → 编排器。
两者没有高下之分,关键看需求。