ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

责任链模式实战:用校验链重构订单校验,终结失控的if-else

责任链模式实战:用校验链重构订单校验,终结失控的if-else 1. 为什么if-else在校验场景里越写越失控先说说我的实际感受。做了七八年后端最怕的不是业务逻辑复杂而是那种“看起来很简单、改起来要命”的校验模块。尤其是订单创建、用户注册、支付下单这类入口方法校验规则一多代码就开始失控。你回想一下是不是这样刚开始就一个参数非空校验很简单。后来加了手机号格式校验再加白名单校验再加库存校验再加风控校验。每一个新需求过来就是往原来的方法里再塞一个if-else。等到业务跑了两年打开那个方法一看好家伙十几个if-else嵌套叠加整个方法两三百行光看都费劲。改动的时候更是提心吊胆——你不知道哪个规则在前哪个在后万一新规则要插在中间你还得小心翼翼地挪位置。更别说某些规则之间有先后依赖改错一个顺序线上直接出一单异常订单。这种代码我当时给它起了个名字叫“手抽筋式写法”。因为每次加需求你就得在两个if之间抠出一行代码来跟做针线活似的手能不抽筋么。后来我接触到责任链模式才意识到一个问题校验逻辑本身是有“顺序”、“可插拔”、“可复用”这三个天然属性的。它本质上就应该像流水线一样一个规则一个工位走完一道再过下一道。把它写成堆在一起的if-else等于把所有工位挤在同一个房间里人挤人、工具乱放效率当然低。责任链模式用一个比喻就能讲清楚你进一家餐厅点完菜后厨是一条流水线——切菜的只管切菜炒菜的只管炒菜装盘的只管装盘。哪怕你以后新增一道“摆盘工序”也不需要把炒菜师傅的锅掀了重来只需要在流水线上加一个工位。而那个点菜的人调用方他根本不需要知道后厨到底有几道工序他只管把菜单递进去。放到校验代码里这个比喻完全成立。调用方只需要一次入口校验链内部按顺序执行多个独立的校验节点每个节点只干一件事。新增规则、调整顺序、甚至临时停用某个规则都不会影响到调用方。这就是责任链模式对校验场景最大的价值。但是话又说回来网上讲责任链模式的文章不少大多数都在讲理论——什么是Handler、什么是next指针、UML图怎么画。真正讲怎么落地到具体业务场景、怎么拆节点、怎么管理顺序、怎么解决调试困难的反而很少。这篇文章我想把这些实操层面的东西完整地分享出来用一套订单校验的代码来展示整个过程。我首先声明一个观点责任链模式不是银弹它不适合所有场景。比如两个规则之间逻辑高度耦合拆开反而别扭或者规则总共就两三个硬套责任链属于过度设计。但当你面对的场景是“5个规则以上、规则经常新增、顺序经常调整、不同业务渠道还要不同规则组合”那责任链模式就是非常合适的选择。2. 核心思路拆解校验流水线到底怎么设计2.1 三种对象明确分工节点、链条、上下文在设计之前先把责任链模式里的三个核心对象搞清楚这是整个方案的骨架。第一个是校验节点Handler。这是流水线上的一个工位每个节点只负责一项校验。它的核心特征有两个一是知道自己要校验什么二是知道自己下一道工序是谁。注意节点本身的代码里不涉及任何“业务编排”的痕迹——它不知道当前是第几步也不知道后面还有几个兄弟节点。这种“无状态”的设计能保证每个节点独立测试、独立复用。第二个是校验链条Chain。这是流水线的传送带负责把节点按顺序串起来。链条维护一个节点列表执行的时候从第一个节点开始挨个传递。链条本身不实现具体的校验逻辑它只做“调度”。所以链条的代码是稳定的——不管未来新增多少个节点Chain的代码几乎不需要改动。第三个是校验上下文Context。这是流水线上跟着订单一起走的“工单流转卡”上面记载了当前这笔请求的所有数据。比如用户ID、商品ID、数量、金额、渠道来源以及校验过程中产生的中间结果。节点与节点之间不能直接传参传递因为参数一旦多起来十个八个字段方法签名会变得极其臃肿。用Context把所有数据打包传递是责任链模式落地中最关键的决策之一。我见过有人把上下文定义成MapString, Object图省事。短期看确实方便但时间一长你就知道疼了——你根本不知道Map里存了哪些key取出来还要强转类型安全完全没有保障。我的建议是定义一个强类型的Context类字段明确即使字段多一些代码可读性和可维护性也远胜于Map。2.2 为什么责任链天然适配“流水线”这句比喻把校验规则比作流水线很多人会觉得就是打个比方没啥深意。但我实际操作下来发现这个比喻其实点中了责任链模式的核心运行机制请求在链条上是“单向流动”的。你不需要回退不需要跳跃不需要并行至少基础版不需要。校验规则之间天然是“顺序执行、任一失败则中断”的关系。这一点跟流水线的物理特性是完全一致的。有个细节容易忽略责任链模式有两种变体——一种是“纯责任链”请求要么被某个节点处理并结束要么一直流传到链尾另一种是“非纯责任链”也就是每个节点都执行一遍最终由链条末尾统一收口。我对校验场景的建议是默认使用“中断式链条”——某个节点校验失败立刻返错误结果不再继续往下走。这样可以避免无效计算还能快速定位到具体是哪个规则拦截了请求。不过如果你的场景里有些校验规则是互相独立的、不拦截请求的比如校验通过后顺手记一笔日志你可以把这些节点设计成“非中断式节点”。这个灵活度可以用一个布尔标记来控制后面我会在代码示例里具体讲。2.3 链式顺序为什么如此重要校验的“前置依赖”逻辑接下来讲一个容易翻车的点规则的先后顺序。很多时候顺序不是随便定的它是业务逻辑的一部分。举个例子订单参数校验里有一个规则是“用户必须在白名单内”另一个规则是“用户积分必须大于100”。这两个规则本身没有依赖关系谁先谁后理论上都行。但问题来了如果“积分校验”跑在“白名单校验”前面而一个非白名单用户积分又不满足条件那返回的错误信息会是“积分不足”不是“不在白名单”。这两种错误含义不同前端弹的提示也完全不同用户感受差异很大。另一个更典型的例子是先校验“该商品是否在售卖中”再校验“该商品库存是否充足”。如果商品都已经下架了你还去查库存干什么完全是在浪费资源而且返回的错误信息优先级也是错的。所以设计链条的时候经验法则是基础格式校验在前业务状态校验在中重资源校验在后错误优先级高的规则放在前面。为此我在实现里做了一个小设计节点上可以标注order字段链条启动时把所有节点按order排序再串起来。后续调整顺序只需要改节点的order值不用改任何代码结构非常省心。这一点是我在跑了很多次线上问题之后才领悟的希望你不要像我一样走到这一步才意识到顺序的重要性。3. 核心代码实现从接口定义到节点编写3.1 定义一套干净的校验链骨架代码先给出整套实现的核心骨架这一套代码我是打磨了三个版本才确定下来的基本可以直接拷贝到项目里用。首先是校验节点的统一接口。我需要每个节点既能执行校验又能知道下一棒是谁。这里用泛型来约束上下文类型保证强类型public interface ValidatorT { // 当前节点的校验逻辑返回true表示通过 boolean validate(T context); // 设置下一节点返回下一节点便于链式调用 ValidatorT setNext(ValidatorT next); // 获取下一节点 ValidatorT getNext(); }然后是校验链的组装器。这个类的职责非常单一接收一批校验节点按order排序串成链然后对外提供一个执行入口public class ValidatorChainT { private ValidatorT head; private ValidatorChain(ValidatorT head) { this.head head; } SafeVarargs public static T ValidatorChainT of(ValidatorT... validators) { ListValidatorT list Arrays.asList(validators); list.sort(Comparator.comparingInt(v - v.order())); ValidatorT head null; ValidatorT prev null; for (ValidatorT validator : list) { if (head null) { head validator; } else { prev.setNext(validator); } prev validator; } return new ValidatorChain(head); } public ListString execute(T context) { ListString errors new ArrayList(); ValidatorT current head; while (current ! null) { if (!current.validate(context)) { errors.add(current.errorMessage(context)); if (current.breakOnFail()) { break; } } current current.getNext(); } return errors; } }这里有两个设计细节值得说。第一我让每个节点实现一个order()方法组装的时候统一排序保证顺序定义在节点内部而不是组装外部。这样当你新写一个规则节点时只需要标注自己的顺序即可不用去改组装代码。第二breakOnFail()方法让每个节点自行决定校验失败时是否中断整条链。绝大多数节点返回true即失败即中断符合业务直觉少数“只记录不拦截”的节点返回false可以实现放行式校验。接着定义Context。以下单校验为例我定义一个强类型的上下文对象public class OrderContext { private Long userId; private Long productId; private Integer quantity; private BigDecimal amount; private String channel; private String phone; // getter/setter 省略 }还要定义一个抽象基类把一些样板代码收拢新节点只需要实现校验逻辑和错误信息public abstract class AbstractValidatorT implements ValidatorT { private ValidatorT next; Override public ValidatorT setNext(ValidatorT next) { this.next next; return next; } Override public ValidatorT getNext() { return next; } // 默认中断子类可覆盖 public boolean breakOnFail() { return true; } // 默认错误信息子类覆盖 public String errorMessage(T context) { return 校验失败; } // 子类必须实现的排序字段 public abstract int order(); }3.2 十个校验节点怎么拆代码怎么落地有了骨架接下来就是写具体的校验节点。我用订单创建的典型校验场景来演示一共拆了10个规则。下面逐一过一遍逻辑。第一个是参数基本校验节点检查userId、productId、quantity是否为空quantity是否大于0。这种是防御性编程的第一道关卡必须放在最前面。错误信息也要友好比如“商品ID不能为空”而不是空指针异常。public class ParamBaseValidator extends AbstractValidatorOrderContext { Override public boolean validate(OrderContext context) { return context.getUserId() ! null context.getProductId() ! null context.getQuantity() ! null context.getQuantity() 0; } Override public String errorMessage(OrderContext context) { return 参数不合法: 用户ID/商品ID/数量不能为空且数量必须大于0; } Override public int order() { return 1; } }第二个是手机号格式校验节点某些渠道下单要求传手机号格式必须匹配。这里用正则做快速校验属于纯字符串判断成本极低放在靠前位置合理。第三个是用户状态校验节点查用户表确认用户状态是正常不是封禁状态。这个要查数据库了所以不能放在太靠前的位置——先保证入参合法、手机号没问题再查库避免无效查询。第四个是黑名单校验节点把userId放进黑名单集合里判断。这个可以做成内存判断也可以做成Redis查询。这里我尤其要提一个细节黑名单校验最好做增量更新不要每次请求都全量加载黑名单否则流量一打进来就是性能瓶颈。第五个是商品状态校验节点查商品状态是否为上架售卖中。说明一下这个节点和后面的库存节点其实是有关联的——只有商品处于售卖中才去校验库存这才是合理的逻辑顺序。第六个是库存校验节点库存扣减前置校验判断下单数量是否超过剩余库存。这里有一个容易踩的坑校验完之后后续可能还会有支付、锁定库存等环节需要预留库存。所以这个节点的校验要配合幂等设计否则校验通过了但实际扣库存又失败会白白占用单据。第七个是订单金额校验节点判断前端传入的金额与后端计算金额是否一致。这个属于一致性校验能防止前端恶意篡改价格。第八个是频控校验节点同一用户、同一商品单位时间内下单频率是否超过阈值。这个通常用Redis计数器实现属于“重资源”校验放在中间位置是为了避免对高频无效请求白做前面节点的工作——等等这里我注意逻辑矛盾了既然频控是为了挡住无效请求为什么不放前面一点实际经验是频控往往依赖userId和productId的业务含义需要先做用户、商品的合法性校验才能保证频控的“唯一键”是可信的。所以频率控制放在合法性校验之后是正确的顺序。第九个是风控规则校验节点比如设备指纹、IP地址、历史订单行为是否符合风控规则。这个通常要调用外部风控服务网络开销大、耗时长应当放在靠后的位置。前面都把基础校验跑完了只剩最后一道大关这时候再做重判断逻辑是最合理的。第十个是业务扩展校验节点比如“新人首单限制”、“积分抵扣限制”等未来可能要频繁变动的规则。单独拆成一个节点是为了把不确定的变化点隔离出来。以后需求变了只需要改这个节点不会影响前面九个节点的代码。把这十个节点放在一起组装的时候调用方的代码就变得非常清爽ListString errors ValidatorChain.OrderContextof( new ParamBaseValidator(), new PhoneFormatValidator(), new UserStatusValidator(), new BlacklistValidator(), new ProductStatusValidator(), new StockValidator(), new AmountValidator(), new FrequencyControlValidator(), new RiskControlValidator(), new BusinessExtensionValidator() ).execute(context); if (!errors.isEmpty()) { return Result.fail(errors.get(0)); }一行代码串起十个校验规则。调用方不需要知道这些规则是怎么编排的不需要知道谁先谁后不需要关心以后怎么扩展。这就是业务代码和解耦之间最直观的区别。3.3 用“校验链式编排”替代“if-else”的重构步骤如果你正在维护一个老项目里面已经堆了大量if-else校验代码直接推倒重来风险太高。我的建议是分三步渐进式重构。第一步抽出Context。把现有校验方法涉及的所有参数封装成Context对象。这一步先不改变任何逻辑只是让参数“有地方放”。这一个纯机械操作测试用例跑一遍确保重构没有破坏现有行为。第二步逐个抽取节点。从最外围的、最独立的校验开始每次抽一个规则出来变成独立的Validator类。注意一个原则每次只挪一个规则挪完立刻跑测试。不要憋大招一次性把十个规则全抽出来那样出问题你连回滚都难。第三步切换执行入口。等所有规则都变成了节点把原来的校验方法替换成链条组装和执行。这时原来的方法体就只剩下那三行组装代码清爽得让你怀疑这是不是同一个方法。4. 实操中的几个关键决策与踩坑记录4.1 上下文传递用强类型对象别用Map前面提过一句“别用Map做Context”这里展开说一下。Map的灵活性在初期确实是诱惑不用定义类、随手往里塞值、取的时候强转一下就行。但业务校验规则一旦多了以后Map的两个致命问题就暴露了。第一个问题是无语法检查。你把一个BigDecimal金额塞进Map取值的人以为里面是String一强转就报错。特别是多人协作的项目里别人根本不知道你的Map里有哪些key、对应什么类型。第二个问题是上下文无业务含义。Map里塞了十个字段你无法一眼看出哪些是业务数据、哪些是校验中间结果、哪些是冗余缓存。字段之间的依赖关系完全靠开发人员自己脑补。强类型对象能让这些关系在类结构上显式表达出来比如订单金额、实付金额、优惠金额三个字段之间的计算关系一目了然。4.2 节点之间不要互相调方法责任链模式最容易走歪的一个方向是节点虽然拆开了但内部还在偷偷访问下一个节点的某个方法。比如“库存校验节点”里直接调用了“商品状态校验节点”的查询方法。这样表面上是责任链实质还是耦合的。正确做法是节点之间不通信只跟Context通信。如果某个节点需要前一节点的计算结果就把结果放到Context里后一节点从Context里读。比如商品状态查询出来的“商品状态码”放到Context里库存校验节点直接读上下文字段就行不需要再查一次。这样节点之间保持完全独立真正实现“只和上下文交互”。4.3 校验失败的错误提示怎么聚合与收敛还有一个值得展开的点错误信息的返回策略。我见过两种做法一种是一发现校验失败立刻返回只告诉用户第一个错误另一种是跑完全部节点把多个错误一起返回给前端。这两种做法各有适用场景。对于表单类校验多个错误一次性返回体验更好用户可以一次性改完所有问题再提交。对于流程类校验比如下单、支付前面的失败意味着后面没有必要继续执行所以返回第一个错误就够了。我在上面的实现里用的是errors列表采集多个错误再配合breakOnFail()控制中断行为就是希望给使用者留够灵活度。不过这里有个细节要注意如果选择“聚合返回所有错误”那错误信息本身也要讲究优先级。比如“手机号格式错误”和“用户被封禁”同时发生应该先提示哪个从用户视角看手机号格式是他自己能改的用户封禁他是改不了的——所以先提示格式错误更友好。这种“提示优先级”也需要通过顺序来控制而不是把所有错误信息无脑丢给前端。4.4 链路执行过程的可视化与调试我实际使用后发现一个比较头疼的问题链路一旦长了出问题的时候你根本不知道卡在哪个节点。尤其是生产环境日志里只显示了“校验失败”四个字你又不能像本地调试那样断点进去看。解决办法是在链路执行过程中加追踪信息。最简单的方式是让每个节点执行时记录业务日志链标识、节点类名、校验结果。链标识可以用UUID生成一个请求全链路共用方便日志平台上做关联查询。下面是我在节点基类里加日志的一个简单版本public boolean doValidate(OrderContext context, String traceId) { try { boolean pass validate(context); log.info(traceId{}, validator{}, pass{}, traceId, this.getClass().getSimpleName(), pass); return pass; } catch (Exception e) { log.error(traceId{}, validator{} error, traceId, this.getClass().getSimpleName(), e); return false; } }如果你的公司有全链路追踪系统比如SkyWalking、Zipkin还可以直接用现有的traceId不用自己生成。这个改进虽然只多了几行日志代码但线上排查的效率能提升一个量级。5. 一线常见的责任链“翻车”问题速查整理一下我在实际项目中见过、踩过的一些问题。做成表格方便你以后排查。问题现象根本原因解决方案新规则加了但没执行忘了在组装入口注册新节点组件扫描或配置中心统一管理节点列表避免手动注册校验顺序错乱节点的order值重复或定义不规范初始化时校验order唯一性冲突直接抛异常暴露问题Context被某个节点改了数据节点直接修改原始请求字段区分“原始请求”和“校验中间态”Context内部分段隔离一个节点异常导致整条链挂掉节点内异常未捕获基类用模板方法包住validate统一捕获异常并标记失败循环依赖导致栈溢出节点A的next指向节点BB又指回A组装时增加环路检测链表最长N个节点N节点总数多线程下Context互相串用静态变量或ThreadLocal存Context每个请求new自己的Context禁止静态复用这些坑我基本都踩过一遍。其中有几个最开始完全是无意识的错误比如把Context定义成静态变量结果线上出现了一单的校验数据串到另一单的情况。排查了大半天最后发现是静态变量没清空。这种问题一旦发生非常隐蔽强烈建议一开始就用正确的实现方式避免留隐患。6. 从校验规则延伸出去责任链在其他场景的复用6.1 数据清洗与格式转换流水线说实在的责任链并不只适用于校验。我后来在项目里把一个数据清洗的逻辑也用责任链重写了。数据清洗其实也是典型的流水线场景原始的脏数据进来先做类型转换再做字段补全再做去重最后做格式标准化。每一步都是独立的并且顺序敏感。用责任链模式重写之后新增一种清洗规则不需要改清洗主流程只需要新写一个节点注册进去。这种做法跟校验链在结构上是完全一样的只是节点的“validate”换成了“process”。6.2 表单提交前端校验和后端校验的规则统一我在做一个中后台项目时遇到过一个问题前端表单校验规则和后端接口校验规则经常不一致导致用户在前端怎么都提交不过去报错信息还是后端返回的。后来我们把校验规则抽象成一份配置化的规则描述前端做“即时校验”用一份规则后端做“最终校验”也用同一份规则。后端拿到配置后通过责任链模式按顺序执行这些配置化的规则。这一步简化了大量前后端沟通成本也避免了同一套规则在两个地方各写一份出现的漂移。如果你还停留在“责任链模式就是用来替代if-else”的认知层面那我建议你想一想凡是满足“步骤化、可拆分、顺序敏感、频繁变化”这几个特征的逻辑都可以考虑责任链。比如审批流、数据管道、策略编排甚至微服务之间的中间件处理链都是同一个套路。6.3 和知识库流水线的底层思路对比最近在逛一些技术社区时看到不少人讨论知识库流水线、规则引擎之类的概念。我特意去了解了一下发现那些系统的“流水线”设计底层思路跟责任链是很像的——都是把一个大的流程拆成细粒度的处理节点按顺序编排执行支持节点的插拔和动态调整。区别只在于校验链是代码级别的灵活知识库流水线是配置级别的灵活。如果将来你的校验规则经常要由业务人员调整顺序而不是开发人员改代码那就值得考虑把责任链升级成规则引擎让规则配置化、动态化。不过那是后话了起码大多数业务的入口校验一个责任链模式搞定已经够清爽了。7. 关于这套写法我最想对你说的话代码这东西很多时候没有绝对的对错只有合不合适。责任链模式也不例外。我自己经历了三个阶段从最开始啥都用if-else硬写到学了设计模式后强行往项目里套再到后面根据场景来判断是否使用。老实说刚学责任链那会儿我写过很多“为了模式而模式”的代码——明明三个规则硬拆成五个节点结果代码量和阅读成本反而变高了。那会儿我都没意识到问题直到一次代码评审同事委婉地说“这个责任链是不是拆得有点碎了”我才回头反思。真正开始得心应手是我给自己定下了一个标准当校验规则超过五个并且预计未来还会新增或者将来规则顺序会经常调整时我才会用责任链。低于这个阈值我还是会老老实实地用if-else按序写毕竟三个规则用责任链反而像是在给蚂蚁装轮子。还有一点责任链模式下最容易出问题的不是单个节点的逻辑而是节点之间的顺序和依赖。你写完每个节点的单测后一定要再写一个整条链路的测试把规则顺序的合理性验证一下。那种“单个节点都正常、串起来就出问题”的场景我在实际项目中见过太多次了。最后如果你刚开始重构自己的if-else校验代码我的建议是先把当前的所有校验规则列成一张清单按名称、顺序、依赖、错误信息列出来然后再动代码。清单对了代码只是时间问题。列清单这个习惯我至今都在用。
返回列表