资讯动态

用责任链模式重构业务校验:告别if-else堆砌,构建可复用校验流水线

发布时间:2026/10/4 3:37:45 来源:尧图企业网站定制
这大概是每个写业务代码的人都会经历的瞬间一开始你的接口只有三个校验用户名非空、密码非空、做个简单的格式匹配顺手写三个if世界很干净。过了两周产品说密码至少八位你往上叠一个else if。又过了一个月前后端联调时甲方说手机号要支持最新号段你捏着鼻子加了一段正则。等哪天线上告警你点开那个createUser方法发现校验逻辑早就长成了一坨七十多行的if-else嵌套你甚至不敢挪动其中任何一段因为你不知道它被哪个分支悄悄依赖着。这个场景我太熟了。不止一个项目里见过类似的“校验屎山”而且它不会消失只会随着业务演进越来越壮。问题的症结不在于要不要校验而在于我们把“校验规则”这种本来就适合沉淀和复用的东西焊死在了service层的一长串判断里。如果你正在被这种代码折磨今天我分享的责任链模式值得你认真看一眼——它能把一条条散落的校验规则串成流水线让十个甚至二十个规则在业务方法里只留下一行调用。后面我会用可复现的代码把整套思路和踩坑点完整拆开。1. 先从真正的痛点开始if-else不是不好是太容易长残1.1 业务代码里最常见的“校验膨胀”长什么样随便打开一个真实的订单创建、用户注册、表单提交接口大概率能看到这类代码public void createUser(UserRequest req) { if (req.getName() null || req.getName().isEmpty()) { throw new IllegalArgumentException(用户名不能为空); } if (req.getName().length() 20) { throw new IllegalArgumentException(用户名长度不能超过20个字符); } if (req.getPassword() null || req.getPassword().length() 8) { throw new IllegalArgumentException(密码长度不能少于8位); } if (req.getPassword().length() 32) { throw new IllegalArgumentException(密码长度不能超过32位); } if (req.getEmail() ! null !EMAIL_REGEX.matcher(req.getEmail()).matches()) { throw new IllegalArgumentException(邮箱格式不正确); } if (req.getPhone() ! null !PHONE_REGEX.matcher(req.getPhone()).matches()) { throw new IllegalArgumentException(手机号格式不正确); } if (userService.countByLoginName(req.getLoginName()) 0) { throw new IllegalArgumentException(登录名已被占用); } // 到这里才算真正进入业务逻辑 userService.save(req); }写的时候倒是不难问题全在“之后”。刚上线时需求稳定还好怕的是业务方每隔几周扔来一个新校验注册渠道限制、邀请码有效期、密码不能和用户名相同、内部账号跳过部分校验……每来一条你就得在这个方法中间找个位置再插一段。功能是正常跑但代码越来越像一个没有抽屉的衣柜所有东西都堆在同一个隔层里找什么都得翻半天。1.2 为什么说if-else校验代码有自己的“膨胀规律”校验逻辑膨胀是有规律的参数数量线性增长时分支组合是近似指数增长的。一个新字段往往意味着“为空校验”“长度校验”“格式校验”“重复校验”中的好几个分支一起进来更别说字段之间还会互相影响比如编辑场景下部分字段允许为空、创建场景下又必须必填。这种互相纠缠的条件写一次两次是直觉写多了就是在给自己埋雷。更麻烦的是if-else校验和真正的业务逻辑是“绵”在一起的。读代码的人想搞清楚“这个接口到底做了什么”却要先穿透那几十行校验分支想改校验规则的人又随时担心破坏掉包裹在里面的业务步骤。久而久之谁都不敢碰这个方法新人接手更是直接怀疑人生。1.3 校验规则其实是“资产”不是“临时代码”我在重构这类代码时有一个强烈感受大部分校验规则并不只属于某一个接口。手机号格式、用户名长度、密码强度、日期范围这类规则在创建和更新场景、在不同接口里往往会被反复用到。如果用if-else把它们写成每个方法里的私有逻辑就等于把一笔可复用的资产变成了沉没成本。所以核心思路应该倒过来把每个校验规则从接口方法里提出来变成能够独立命名、独立测试、独立组装的对象。在这种思路下哪个接口需要哪几条规则就按需组装哪条规则要调整就单独改那一个对象。这就是后文要聊的“责任链模式”所要解决的核心问题。2. 责任链模式凭什么能把校验变成“流水线”2.1 先理解责任链模式最朴素的形态责任链模式简单说就是让多个处理器对象按顺序排成一条链数据从链头进入逐个经过每个处理器直到某个处理器决定终止流程或者所有处理器都处理完。这个概念听起来有点绕但放到工厂车间的流水线上一秒就懂每个质检工位就是一个处理器产品从传送带进入先过尺寸检测再过外观检测最后过功能检测。任何一个工位发现不合格直接扣下不再流向下一站。这不就是我们想要的校验逻辑吗参数进来以后依次走过“非空校验”“长度校验”“格式校验”“唯一性校验”这些工位任何一站不过立刻抛出异常结束请求全部通过才放行进业务层。相比一长串if-else这种形式最大的区别在于规则与规则之间有了明确的边界和顺序每个规则可以单独维护、单独测试。2.2 为什么“责任链”天然就是一条“流水线”很多人分不清责任链和普通for循环其实关键在于“是否需要提前终止”。普通for循环遍历规则时你也可以用break模拟短路但代码会显得很死板你依然需要在一个方法里维护规则数组在循环里写各种判断。责任链模式的精髓在于把“执行规则”和“编排规则”分离——链上的每个节点只知道自己的检查逻辑和检查结果完全不关心前后是谁编排器只负责顺序执行并在失败时中止。这与流水线的建模方式完全同构规则节点是工位数据是待检产品执行过程是传送带。当你听到“流水线”这个词脑子里应该浮现出的就是这种模型输入进入按顺序流过一系列处理单元每个单元要么放行要么拦截。是的我们完全可以把校验扩展成更广义的处理流水线比如先做数据清洗再做格式转换再做业务校验再做落库前检查——每个环节都是独立的处理节点。2.3 常见误区选择责任链不等于彻底扔掉if-else需要先说句实在话责任链不是银弹也不该被神话。如果接口只有一两个校验直接写if反而更直观。我个人的判断标准是校验规则稳定在4条以上或者规则可能频繁调整或者多个接口要复用同一套规则这时候用责任链才有明显收益。也不要以为用了责任链就再也见不到if-else。链上的每个节点内部还是会根据业务做条件判断只是这些判断被收口到了单一职责的类里不会再随着需求膨胀把整个业务方法塞满。总的目的不是消灭if-else而是消灭“一长串难以维护、难以测试的if-else”。3. 实战一行代码把10条校验规则串进Pipeline3.1 先说清楚第一种实现思路List容器 顺序执行责任链的经典教科书实现是用next指针把节点串成链表但在业务代码里我更推荐用List容器加顺序遍历。原因有三个用链表结构时每个节点都要持有下一个节点的引用调试链路关系时要一格格找复杂度高。List容器天然支持来自不同代码块的节点组合想从几十个规则里挑十个组装比手动改指针引用方便得多。后续要在节点间插入日志、耗时统计只需要在容器遍历的统一入口做不需要改动每个节点。所以我常说模式的精神比模式的教条代码更重要。下面的实现会以List容器为主这也是我在项目里最常用的做法。3.2 定义处理器接口明确“一个节点该输出什么”首先定义一个通用的处理器接口我习惯叫Processor它接收待校验数据返回一个结果对象public interface ProcessorT { CheckResult process(T data); }注意返回的是CheckResult而不是boolean这一点在复杂业务里很重要public class CheckResult { private boolean success; private String message; public static CheckResult ok() { return new CheckResult(true, null); } public static CheckResult fail(String message) { return new CheckResult(false, message); } // 省略 getter/setter }只返回true/false看起来更简单但一旦失败调用方根本不知道原因是什么最后还是得回原方法里找是哪个if抛的错。把message带在结果里链路也好、页面提示也好都能直接复用。为了减少样板代码我还会提供一个抽象父类把公共逻辑收拢public abstract class AbstractProcessorT implements ProcessorT { Override public CheckResult process(T data) { if (!support(data)) { return CheckResult.ok(); } return doProcess(data); } // 当前节点是否支持处理这条数据默认都支持 protected boolean support(T data) { return true; } protected abstract CheckResult doProcess(T data); }support方法给了节点一个自我豁免的机会。比如某个规则只对“创建场景”生效可以在support里判断场景字段不满足时直接放行不用跑到doProcess里去写复杂条件。这个设计在多个接口复用同一套规则链时特别有用。3.3 用具体规则节点把“校验规则”变成流水线工位理论说完了直接看节点怎么落地。以用户注册场景为例我可以抽出这样一个Processor实现public class NotNullProcessor extends AbstractProcessorUserRequest { Override protected CheckResult doProcess(UserRequest data) { if (data.getName() null || data.getName().isEmpty()) { return CheckResult.fail(用户名不能为空); } return CheckResult.ok(); } } public class MaxLengthProcessor extends AbstractProcessorUserRequest { Override protected CheckResult doProcess(UserRequest data) { if (data.getName().length() 20) { return CheckResult.fail(用户名长度不能超过20个字符); } return CheckResult.ok(); } } public class PasswordStrengthProcessor extends AbstractProcessorUserRequest { Override protected CheckResult doProcess(UserRequest data) { if (data.getPassword() null || data.getPassword().length() 8) { return CheckResult.fail(密码不能少于8位); } return CheckResult.ok(); } } public class EmailFormatProcessor extends AbstractProcessorUserRequest { private static final Pattern EMAIL_PATTERN Pattern.compile(^[\\w.-][\\w.-]\\.\\w$); Override protected CheckResult doProcess(UserRequest data) { if (data.getEmail() ! null !EMAIL_PATTERN.matcher(data.getEmail()).matches()) { return CheckResult.fail(邮箱格式不正确); } return CheckResult.ok(); } } public class LoginNameUniqueProcessor extends AbstractProcessorUserRequest { private final UserService userService; public LoginNameUniqueProcessor(UserService userService) { this.userService userService; } Override protected CheckResult doProcess(UserRequest data) { if (userService.countByLoginName(data.getLoginName()) 0) { return CheckResult.fail(登录名已被占用); } return CheckResult.ok(); } }看到没每个节点只关心一件事。以后要调整密码长度只改PasswordStrengthProcessor要加“密码不能包含用户名”再新增一个节点。节点之间无依赖改哪个都不影响旁边的工位。如果规则到了十个以上你可能会问难道要写十个类没错而且这正是好事。十个类各自承担一个明确职责总比一个方法里堆十条if清晰。你可以继续补上手机号格式校验、地址长度校验、年龄范围校验、邀请码有效性校验、内部账号白名单校验等节点每个都是十几行的规模加起来不复杂。在IDE里看到类变多时不用慌你可以按“规则节点”建一个package统一管理类名本身就是最好的注释。更关键的是每个类都能独立写单元测试再也不需要为了测一条邮箱规则去构造一个完整合法的用户请求了。3.4 写一个流水线编排器把节点串起来节点有了还差一个组装者。这个角色我通常命名为Pipeline它只做两件事往队列里添加节点按顺序执行public class PipelineT { private final ListProcessorT processors new ArrayList(); public PipelineT add(ProcessorT processor) { processors.add(processor); return this; } public void execute(T data) { for (ProcessorT processor : processors) { CheckResult result processor.process(data); if (!result.isSuccess()) { throw new BizException(result.getMessage()); } } } }这里我选择“失败即抛业务异常”的终止策略。这样调用方写起来最干净业务代码里不用处理返回值异常统一交给全局异常处理器转成用户提示。如果你的团队不喜欢异常控制流也可以让execute返回第一个失败的CheckResult由调用方判断是否终止属于风格取舍后文会展开聊。为了让调用方代码更简介我还会给Pipeline加一个静态入口public static T PipelineT start() { return new Pipeline(); }这样组装规则链的时候就可以这样写PipelineUserRequest pipeline Pipeline.start() .add(new NotNullProcessor()) .add(new MaxLengthProcessor()) .add(new PasswordStrengthProcessor()) .add(new EmailFormatProcessor()) .add(new LoginNameUniqueProcessor(userService));3.5 业务代码终于只剩一行调用组装工作可以在Controller或Service的初始化阶段完成然后保持为一个成员字段。比如注册接口里public void createUser(UserRequest req) { userCreatePipeline.execute(req); userService.save(req); }你看原来那十几条if横在那儿现在只剩一行pipeline.execute(req)。后面产品再来十个新规则你最多做的事情就是新写十个Processor类然后在组装处加十行add调用createUser方法一行不动。这其实才是责任链模式真正值钱的地方它改变了“加需求就必须改老代码”的默认节奏。新校验是增量式扩展不是对已有逻辑的侵入式修改这也正好呼应了设计原则里的开闭原则。下一个接手的人看这个方法时会很轻松因为这里已经没有任何复杂分支了。3.6 别忘了给流水线配上“状态”和“透传”很多人用责任链只做“校验”其实远可以更进一步。我在处理step验证时习惯让CheckResult携带透传数据让前面的处理节点把中间结果传给后面节点使用。例如第一个节点做数据清洗把手机号里的空格和横杠去掉。第二个节点做格式校验验证清洗后的手机号是否符合规范。第三个节点做重复校验用清洗后的手机号去数据库查询。在这种场景里每个节点的process方法返回的CheckResult里可以带一个processedData字段Pipeline把上一次处理后的数据作为下一次的输入。这样一来流水线就从“只拦截”升级成了“清洗转换拦截落库”价值又大了一截。4. 流水线不是玩具规则冲突、顺序意志与边界设计4.1 节点顺序不只是一个风格问题它直接影响正确性用责任链之后很多人第一反应是“规则随便排”。如果规则之间完全独立顺序确实无所谓但真实业务里规则往往有隐藏依赖。最典型的例子空值校验必须排在格式校验之前。你把手机号格式规则排在前面当手机号字段为空时Pattern.matches(null)直接就抛NullPointerException了而用户真正应该看到的是“手机号不能为空”。再比如登录名的唯一性查询依赖前面的格式规则。如果格式都不合法就没必要去数据库里查一遍提前查不仅白耗一次IO还可能因为字符串太长导致SQL异常。所以节点顺序从来不是好看不好看的事它是一条有向链路上的执行顺序排错了就是线上事故。我自己的实践是在组装Pipeline时把顺序策略写成一个独立的构建方法并加上注释说明每段顺序的理由。改顺序的人必须同时更新注释否则review时我会直接打回。4.2 所谓“流水线中的冲突”互斥规则和分组规则“流水线与流水线中的冲突”这个词放在责任链里指的是什么我理解有两层意思第一层单个流水线内的节点可能互相冲突。比如同一个接口里既要求“密码不能等于用户名”又要求“密码长度至少8位”这两个规则单独看都没问题合在一起就要求密码必须在兼容用户名的情况下另选一个够长的组合。这种业务歧义必须在组装链路前说清楚否则节点按顺序执行时第二个节点的判断条件会把第一个节点的合法结果拦下来。每次遇到这种情况我会把所有规则里涉及“互斥词”的业务描述单独整理出来先找产品经理确认再写进节点注释。第二层一个接口里可能跑着不止一条流水线。比如“创建用户”要三个校验“更新密码”要另外五个校验如果把它们全部塞进同一条链链会变得臃肿且难以理解。更合理的方案是定义基础规则节点然后按业务场景组装成多条Pipeline。每个场景维护自己的链而不是让一条链承载所有历史规则。这一点正好能解释为什么责任链模式能优雅地面对规则组合爆炸。4.3 节点之间的“短路”与“额外透传”怎么设计标准做法里只要某个节点返回失败整条链就终止后面的节点不再执行。这个“短路”逻辑在Pipeline.execute里就是一行if判断但有几个细节值得推敲。一是失败信息要不要包装。我建议在execute里统一包一层“第几个规则不通过”之类的上下文而不是直接把节点返回的message抛出去。否则线上看到“登录名已被占用”时你根本不知道这个提示是在哪个流程的哪个位置出来的。二是要不要支持节点自己决定“跳过后续节点”。有些业务里出现了某类特殊账号后不再继续执行注册相关校验但普通账号必须全量走完。这时就需要在CheckResult里增加一个skipChain标记表示“当前请求不再参与后续处理”。这个不是责任链模式标配但业务里非常常见属于按需演进。4.4 责任链、策略模式、规则引擎之间的边界有同事问过我责任链和策略模式看起来都是组合多个类区别在哪策略模式的核心是“选择一条算法路径”比如下单时根据用户等级选择不同折扣策略是互斥的多选一责任链的核心是“按序经过多个处理器”节点之间是顺序与短路关系是可能一个都不选的接力赛。责任链又和规则引擎有重叠很多规则引擎比如Drools也是把规则做成节点顺序执行。不过规则引擎更偏重“外部化”和“复杂事实推理”适合几百上千条动态规则而责任链更适合几十条以内、由开发人员直接维护的中型校验场景。引入规则引擎意味着多一层学习成本和运行时依赖用责任链则是纯代码层面的轻量重构。我通常在规则数量增长到“改一个节点都要小心翼翼”之前先用责任链撑着等真的出现大规模动态配置需求才考虑换成规则引擎。5. 踩坑实录与排查技巧5.1 我踩过的坑空指针、重复执行与顺序颠倒刚开始用责任链时我在顺序上翻过车。把邮箱格式校验放在了非空校验前面结果前端传了空邮箱链上第一个节点直接抛NPE返回给用户的提示变成了系统异常产品差点以为是线上故障。这之后我学乖了凡是可能为空的字段所有格式类节点里都必须自己做好空值保护不能寄希望于链路顺序。另一个容易踩的坑是一条Pipeline被多个线程共用时节点里有可变的临时状态。这里必须敲黑板责任链的Processor节点是无状态的如果有字段一定要用final定义且逻辑上不可变。真要存临时结果应该通过CheckResult透传而不是塞在节点成员变量里。否则并发请求一多数据互相串排查起来非常痛苦。5.2 失败时抛异常还是返回结果怎么选更稳这是个团队风格问题我没有唯一答案但说说我的取舍如果项目里已经有成熟的全局异常体系用抛异常最省事如果没有统一拦截或者调用方需要针对不同失败执行不同兜底逻辑让execute返回失败结果更稳妥。复用之前的结果对象时可以把失败信息封装成FailResult或者Optional 。关键是全团队必须只用一种方案不要有的节点抛异常有的节点返回结果那样会把你逼疯。我见过混用的项目线上日志里一会儿是业务错误码一会儿是NullPointerException兜了一圈才搞明白是不同人写了不同风格。5.3 性能、日志与远程校验的优化心得一条链十个节点每个节点纯内存计算性能开销完全可以忽略。但如果有些节点会查数据库、调外部服务那就要注意了。我的做法是给Pipeline的execute统一加个执行耗时统计每个节点名前后的耗时落日志线上用日志定位哪一环节慢。如果发现远程校验是瓶颈优先考虑加缓存或者把多个远程校验合并成一次批量调用。另外推荐在开发阶段加一个“链路执行清单”日志请求进来后把每个通过的节点和每个失败的节点都打印出来。这张清单在联调和排查问题时简直就是救命的。有一次线上用户反馈“注册时总说登录名占用”日志里看到前一个节点已经通过了格式检查到了Unique节点才失败说明不是格式问题是数据库里真有了排查范围瞬间缩小。5.4 常见问题的排查速查表现象可能原因处理思路空字段报正则或NPE格式校验排到了空值校验前面调整链路顺序或在格式节点内做空值保护某个新规则没有生效组装Pipeline时漏了add调用检查Pipeline构建方法与测试用例覆盖同一个链在并发下结果错乱节点里有可变成员变量把节点改成无状态临时数据通过参数透传改了某条规则后别的接口也变了两个接口复用了同一个节点对象确认节点语义是否通用不通用则拆成两个子类失败时用户看到的提示和预期不符节点message写错或异常被提前捕获统一在execute里包装失败信息打日志核对5.5 让流水线变得可测试、可观测责任链模式最让我满意的一点就是测试成本被压到了最低。每个节点是独立类单测时构造一个UserRequest就能测完一个规则链路测试也很简单组装一条Pipeline用异常数据断言抛出的BizException即可。再分享一个小习惯我在Pipeline里除了execute还会额外提供executeWithListeners方法支持传入一个回调接口在每个节点执行前后收到通知。开发环境里我用它打印每个节点的耗时和结果生产环境则可以切到安静模式。这个扩展不到二十行但能让链路状态时刻可见排查问题时不再靠猜。收个尾聊几句实在的后来我接手过几个被if-else包围的老项目凡是用责任链重构过校验逻辑的改动成本都肉眼可见地降了下来。印象最深的一次需求方一口气提了六条新的注册限制我只花了一下午全部做了新增节点和add调用原业务方法一行没动。交付的时候既没影响线上也没踩到老的逻辑那种感觉就像把一抽屉乱线终于收进了有卡扣的理线器里。如果你现在正对着一个大方法里密密麻麻的校验判断发愁我建议别急着推翻全部重写先从最乱的一个接口试水抽出三五个规则节点组一条最小Pipeline跑通流程后再逐步扩大。责任链这个模式本身不难难的是迈出第一步时克制住“我来写个更复杂的框架”的冲动。记住它本质上就是给校验规则建一条流水线而任何流水线都从第一个工位开始。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑