从if-else到规则引擎:轻量级规则编排与可观测性实践
2026/9/9 5:07:47 网站建设 项目流程

做规则处理这几年,我最大的体会是:大部分时候我们不是在写复杂算法,而是在维护一堆“如果……就……”的条件判断。订单状态流转、日志清洗、消息路由、风控标记,说白了都是规则。规则一多,if-else就会长得失去控制,改一个分支要小心翼翼,线上出了问题又很难定位是哪条规则导致的。我自己折腾了一个叫“ruflo”的小项目,名字就是“rule flow”的缩写,用来解决这类规则串联、编排、可观测的问题。这篇文章就把我从零搭建 ruflo 的过程、踩过的坑、沉淀下来的设计思路一次说清楚,适合被规则逻辑折磨过、想找一个轻量替代方案的后端开发同学参考。

1. 项目源起与整体设计思路

1.1 为什么我不直接用一个现成的工作流引擎

先说实话,市面上决定工作流引擎其实不少,像流程编排、状态机、规则引擎都有成熟的方案。既然有现成的轮子,为什么还要写一个 ruflo?因为在具体业务里,我需要的不是“重量级流程引擎”,而是一个足够轻、能嵌进现有代码库、规则变动不依赖发版的工具。

我当时的场景比较典型:一个订单履约服务,系统里有一连串判断,比如订单是否超时、用户是否黑名单、库存是否充足、支付回调是否重复。这些判断交织在一起,代码写得久了就变成两层三层的嵌套判断,读起来非常吃力。

  • 排查问题时,你得顺着函数调用一层层追,才知道“这个订单为什么走到了这句分支”。
  • 新增一个判断条件时,你只能在上游或下游硬塞,很容易影响原有逻辑。
  • 想针对某类订单做灰度实验,只能临时加 if 并配上各种开关,最后开关越堆越多。

试过引入一个完整流程引擎,发现配置复杂、学习成本高,对我们这种“其实只是想把条件判断理清楚”的诉求来说,太重了。我需要的是:给一组规则起个名字,按顺序执行,每条规则负责一个判断,命中后就执行指定动作,并且整条链路的执行过程可以被记录和回放。这就是 ruflo 最初的原型。

1.2 核心抽象:规则、动作与链路

ruflo 最核心的三个概念很简单:规则、动作、链路。

  • 规则(Rule):一个可执行的判定单元,输入是一条上下文数据,输出是“命中”或“未命中”。
  • 动作(Action):规则命中后要做的事,可以是改状态、发通知、记录日志、调用外部接口。
  • 链路(Flow):一串有序的规则集合,数据按顺序流经每条规则,直到被某个动作终止,或者全部执行完。

你可以把链路想象成一根流水线,数据从入口进去,经过一道道检测闸口。每个闸口看一眼数据,要么放行、要么拦截、要么做点标记再放行。线上很多复杂判断,拆开来看都是这个结构。难的不是单个闸口怎么写,而是怎么把这一串闸口组织得清晰、可配置、可观测。

在设计时,我给自己定了几条硬性要求:

  1. 链路定义必须跟业务代码解耦。规则可以写在代码里,但链路的组装顺序要能在配置中调整。
  2. 单个规则必须足够简单。一个规则只做一件事,输入输出尽量可预期,避免“万能规则”导致逻辑又糊回去。
  3. 执行过程必须留痕。谁命中、谁没命中、为什么走到终止,每一步都要能回溯。
  4. 框架本身不能绑架业务。它只是一个库,嵌入现有项目即可,不搞独立服务、不引入额外中间件。

1.3 为什么默认采用“顺序执行 + 短路中断”

ruflo 默认的执行模型是顺序执行,并且提供短路能力:数据按配置好的规则列表依次执行,一旦执行动作后标记为“终止”,整条链路就结束,不再继续往后判断。这个设计是我有意为之的。

很多逻辑是有优先级的。举个例子,判断支付回调时,如果订单号不存在,那根本不用继续校验金额和签名;如果订单状态已经是“已支付”,那也不用再走重复支付处理。短路中断可以把这些“前置排除”放在链路前面,命中后直接拦截,既避免了重复计算,也让规则之间的依赖关系更明确。

有人会问,那如果某个场景需要所有规则都执行一遍、每条规则各自做不同的事呢?这也支持。你可以把动作定义为“仅标记不终止”,这样数据流会走完一整条链路,每条规则都在上下文中留下自己的处理结果,最后统一提交。

我见过很多团队把这种“判断 + 处理”写成了责任链模式,代码上确实解耦了,但职责划分经常混乱,要么一个 Handler 里塞了太多逻辑,要么链路顺序被隐式地写死在代码里。ruflo 想解决的问题就是把这些职责显式化:每条规则就是一个小模块,链路的编排用数据驱动,执行过程可观测。这个思路在很多场景下都有应用空间。

2. 核心细节解析与实操要点

2.1 规则模型怎么设计才不臃肿

规则模型是 ruflo 的基石。我第一版设计时把 Rule 定义成一个大接口,里面放了 match、doAction、onError、before、after 等方法,结果实现起来非常痛苦。每个规则都要写一堆空方法,阅读的人也分不清哪些是核心逻辑、哪些是生命周期钩子。

后来我重构了设计,把规则分成两部分:判定(condition)和处理(handler),并且允许配置“命中后是否继续”。核心接口收敛到三个关注点:

public interface Rule<T> { boolean condition(T context); ActionResult action(T context); }
  • condition 返回 true 或 false,表示这条规则是否命中。
  • action 表示命中后要做的处理,返回结果里携带两个关键信息:是否继续执行后续规则、是否把当前上下文标记为“最终状态”。

用配置来表达链路顺序,而不是用代码硬编码顺序。比如:

flow: - name: "订单号存在性检查" rule: "orderExistsRule" - name: "重复支付检查" rule: "duplicatePayRule" - name: "金额校验" rule: "amountCheckRule"

这样新加规则时,只需要实现一个类,然后在配置里插入一行,不需要改动其他规则代码。实际运行下来,这个模型对业务代码的侵入非常小,规则之间天然隔离。

2.2 引擎执行流程的完整过程

引擎的执行流程,我大致分了四个阶段:上下文初始化、规则链加载、逐条执行、结果归集。

上下文是贯穿全程的对象。它最开始只有原始业务数据,比如订单号、支付金额、回调参数。执行过程中,规则可以在上下文里写入中间变量。这好比流水线上的工件,每个工位都能往上贴标签、做记录。

规则链加载阶段,引擎读取配置,把规则名映射到已注册的规则实例,组装成一个有序列表。这里有个设计细节:规则实例不能每次请求都 new 一个。大部分规则只依赖上下文数据,自身是无状态的,所以规则实例要复用。采用注册表模式,启动时把所有规则实现类注册到一个 Map 里,链路配置保存的是规则名。

执行阶段是重点。每执行一条规则,引擎都包裹在一个执行记录里,记录规则名、耗时、命中结果、动作返回结果。如果规则执行抛出异常,默认行为是终止当前链路,并把这个异常包一层业务异常抛出。当然也支持“捕获异常继续”的配置,适合那种一条规则失败不影响整体结果的场景。

结果归集阶段,引擎把整个链路的执行记录汇总,返回给调用方。这个返回对象非常有用,我后面做排查时几乎全靠它。

2.3 规则 DSL:用 YAML 还是代码定义

我在第二版时做了一个小 DSL,用来声明规则之间的依赖和顺序。最初用 JSON,但因为不支持注释,规则一多就难维护。后来切到 YAML,发现对运维同学和刚接触项目的同学友好得多。

一个链路的最小表达如下:

flow: - name: "基础校验" rules: - "paramNotNullRule" - "userStatusRule" - name: "业务规则" rules: - "orderExistsRule" - "stockDeductRule"

其中 rules 是规则名列表。名字本身要取得有业务含义,比如“orderExistsRule”比“rule001”可读性好太多。DSL 里还可以给每条规则传参数,类似“超时时间 30 分钟”这类,避免同样的规则因为参数不同而复制多份。

不过要提醒一下,DSL 不是越复杂越好。我试过在 DSL 里支持嵌套条件、循环、变量赋值,最后发现使用门槛急剧上升,大家宁愿在代码里多写两个方法。所以 ruflo 的 DSL 保持了一个底线:只描述链路结构,不描述复杂业务逻辑。复杂判定还是要落在规则代码里,这就够了。

2.4 性能与并发要注意的几个关键点

规则引擎容易被人诟病性能差,我一开始也有些担心。实际压测下来,只要避开几个坑,纯内存的规则执行性能完全可以接受。

第一个坑是无状态规则的拆分。前面说了,规则实例要复用,不要让每个请求都创建新的规则对象。一个无状态规则在 JVM 里只保留一个实例,执行时通过上下文隔离数据,核心思想就是“规则共享、数据隔离”。这跟线程池的设计思路是一致的。

第二个坑是上下文的线程安全性。如果同一个链路会并发处理不同请求,那么上下文对象绝不能设计成单例。每来一条数据,都要 new 一个独立的上下文对象。规则本身无状态,上下文本身每次创建,这样就不存在共享可变状态的问题。

第三个坑是动作里的外部调用。规则的 action 里如果同步调用了外部服务,那整条链路耗时就会被这个调用拖住。我的建议是默认全部同步执行,保证逻辑简单;但对于真正耗时的动作,比如发通知、写审计日志,可以用异步线程池包装一下。要注意异步动作的异常不能吞掉,至少要有日志记录。

压测数据供参考:在 4C8G 的机器上,每条链路平均 5 条规则,纯内存运算(不含外部调用),单线程跑 100 万次耗时约 2 秒左右,换成 8 个线程并发跑,吞吐量能到每秒 20 万以上。这个性能对绝大多数业务系统都够用了。如果还不够,大概率瓶颈在外呼,不在规则判断本身。

3. 实操搭建:一个支付回调消息清洗示例

3.1 场景定义与技术选型

光讲概念比较虚,我实际操作了一个例子:支付回调消息清洗。这个场景的核心诉求是,第三方支付平台回调之后,我们需要对回调数据做一系列检查,决定是否进入后续业务处理流程。

这个场景非常典型,因为回调数据是不可控的。支付平台可能重复通知、参数顺序不一致、签名验证偶发失败,我们必须提前把这些情况处理掉,不能让脏数据流到下游。我用 ruflo 搭了一条链路,模拟了订单号检查、重复回调检查、金额校验、签名校验、业务处理五个环节。

代码示例用 Java 来写,因为真实业务中 Java 后端用得比较多。但 ruflo 的核心设计不限于 Java,换成 Go、Python 也是一样的思路。

3.2 先定义规则实现类

我先把每个检查点抽成一个规则实现。规则只负责一件事,逻辑控制在十行以内,超过十行就该考虑再拆分。比如订单号存在性检查:

public class OrderExistsRule implements Rule<PaymentContext> { private final OrderQueryService orderQueryService; public OrderExistsRule(OrderQueryService orderQueryService) { this.orderQueryService = orderQueryService; } @Override public boolean condition(PaymentContext ctx) { return ctx.getOrderId() == null || ctx.getOrderId().trim().isEmpty(); } @Override public ActionResult action(PaymentContext ctx) { ctx.setRejectReason("orderId is empty"); ctx.setFinalStatus(PaymentContext.FINAL_REJECT); return ActionResult.terminate(); } }

这里的 condition 负责判断“是否需要这条规则介入”。当订单号为空时,condition 返回 true,action 把原因写入上下文,并返回 terminate 表示链路结束。反过来,如果订单号存在,condition 返回 false,引擎会跳过 action,直接进入下一条规则。

这个模式非常符合人的思维习惯:先判断要不要处理,再决定怎么处理。写规则的人不必关心前后规则是什么,只需要管好当前这一小段逻辑。

接下来是重复回调检查。这个规则需要访问存储,判断这个订单号是不是已经处理过:

public class DuplicatePayRule implements Rule<PaymentContext> { private final PaymentRecordRepository repository; public DuplicatePayRule(PaymentRecordRepository repository) { this.repository = repository; } @Override public boolean condition(PaymentContext ctx) { if (ctx.getOrderId() == null) { return false; } return repository.existsProcessed(ctx.getOrderId()); } @Override public ActionResult action(PaymentContext ctx) { ctx.setRejectReason("duplicate payment callback"); ctx.setFinalStatus(PaymentContext.FINAL_DUPLICATE); return ActionResult.terminate(); } }

金额校验和签名校验也是类似结构,区别在于 condition 的判断条件不同。金额校验要对比回调金额和订单金额是否一致,签名校验要先拼接原始报文再验签。这段逻辑代码本身不复杂,但能明显看出来,所有规则的结构都是统一的,阅读成本非常低。

3.3 注册规则并组装链路

有了规则类之后,需要把它们注册到引擎里。我采用了一个简单的代码注册方式:

RuleRegistry registry = new RuleRegistry(); registry.register("orderExistsRule", new OrderExistsRule(orderQueryService)); registry.register("duplicatePayRule", new DuplicatePayRule(repository)); registry.register("amountCheckRule", new AmountCheckRule(amountService)); registry.register("signCheckRule", new SignCheckRule(signService)); registry.register("handlePayRule", new HandlePayRule(payService));

链路定义放到配置里,这样做的好处是调整顺序不用改代码。为了演示方便,这里直接在代码里定义链路:

FlowDefinition flow = FlowDefinition.builder() .name("payment_callback_cleanup") .next("orderExistsRule") .next("duplicatePayRule") .next("amountCheckRule") .next("signCheckRule") .next("handlePayRule") .build();

实际项目中建议把这部分做成 YAML 配置,启动时加载。配置里每个名字都会去注册表里找对应的规则实例,找不到就报错,提前暴露配置问题。

3.4 执行链路并分析执行记录

执行入口长这样:

PaymentContext ctx = new PaymentContext(); ctx.setOrderId("A10001"); ctx.setCallbackAmount(new BigDecimal("99.00")); ctx.setSign("xxx"); FlowResult result = engine.execute(flowName, ctx);

执行完之后,FlowResult 里带有整条链路的执行记录。我通常会在日志里打印执行摘要,排错时非常好用:

flow=payment_callback_cleanup status=FINAL_DUPLICATE timeCost=3ms rule-orderExistsRule matched=false timeCost=0ms rule-duplicatePayRule matched=true timeCost=2ms actionResult=terminate reason=duplicate payment callback

这一小段日志的价值,在做线上问题排查时才会真正体会得到。拿日志搜索重复回调的订单号,一眼就能看出数据卡在哪条规则、为什么被判重复、耗时多少。对比以前在 if-else 里打断点,效率提升是很明显的。

3.5 验证边界情况与回归测试

链路搭好后,不能只看正常流程,还要重点验证几条边界数据。

  • 订单号缺失:预期第一条规则拦截,终止,拒绝原因“orderId is empty”。
  • 同一笔订单回调两次:预期第一次正常处理,第二次被重复回调规则拦截。
  • 金额不一致:预期走到金额校验规则被拦截。
  • 签名错误:预期走到签名校验规则被拦截。

我用单元测试把这几条边界都固定下来。测试数据不需要多,但每条规则至少要有一条“命中”和一条“未命中”的用例。这样后续如果有人改动规则逻辑,跑一遍测试就能快速确认影响范围。

这里要特别提醒:规则的 action 里如果涉及外部调用,比如写库、发消息,在写单元测试时最好 mock 掉,只验证判断逻辑。否则测试一跑就写库,污染数据不说,测试速度和稳定性都会受影响。

4. 常见问题与排查技巧实录

4.1 规则命中但结果不对,优先检查短路顺序

我有一个高频踩坑点:规则本身判断没问题,但整体的最终结果不对。这种问题十有八九出在规则顺序上。比如我想“订单已关闭”的判断放在“重复支付”判断之后,结果实际配置里反过来了,导致一批重复回调的订单走到了关闭订单的规则里,被错误地标记成关闭。

排查思路很直接:先看执行记录。ruflo 的执行记录每一行都有 matched 和 actionResult,能把数据到底走了哪些规则完整复现出来。看到了那条链路日志,就会发现顺序问题一目了然。

规则顺序本质上是你对业务优先级的定义。像支付回调这类场景,建议把“不可恢复的异常”这类判断放在最前面,比如订单号不存在、订单已关闭、重复回调;把“可修复的校验”放在后面,比如签名失败、金额不一致。这样前面的规则拦截后,后面的规则根本不用执行,既省资源又避免中间状态被污染。

4.2 并发重复执行与幂等设计的坑

规则引擎本身不解决幂等问题。如果每次收到回调都新建一个上下文,那同一笔订单的两次回调就会并发进入两条链路,可能同时通过重复检查,然后各自执行后续动作。这个坑在实际对接支付平台时特别容易出现。

解决思路要放在链路外部。一是数据库唯一约束,这个最可靠;二是分布式锁,基于订单号加锁,保证同一订单的链路串行执行;三是在动作里做状态比对更新,比如“只有当订单状态 = 待支付时才更新为已支付”。实操中我一般是配合使用,锁做第一道拦截,状态比对做最终兜底。

另外要注意,测试并发场景时不要太温柔。我建议直接写一个多线程测试,模拟同一个订单号并发提交十次,断言结果里只能有一次被标记为“已支付”。这个测试大概率能暴露你幂等设计上的漏洞。

4.3 规则数量膨胀,怎么维持可维护性

规则引擎用顺了之后,规则数量一定会膨胀。我在三个业务模块里用了 ruflo,半年后规则数量到了三位数,这时候新的问题出现了:链路配置越来越长,新同事不知道往哪里插规则,有些规则之间还有隐式依赖。

我的应对办法是给链路加“分组”和“阶段”的概念。在 DSL 里把规则分成若干组,比如“参数校验组”“状态校验组”“业务处理组”。分组之间用注释分隔清楚。同时,每个规则要求有描述字段,说明这条规则解决什么问题、依赖哪些前置条件。这个描述会输出到日志和执行记录里。

这里也提醒一点:不要为了追求“纯配置化”把规则全配置到远程,除非你有完善的配置管理体系和灰度发布能力。我尝试过把规则配置放到配置中心,实时生效,结果有一次改配置改错了,导致线上误拦了一批订单,花了十分钟才定位。后来折中了一下:规则代码发布周期短,就随版本走;只有链路顺序这种不常改的配置放配置中心,并增加版本号和预览功能。

4.4 日志与可观测性的最佳实践

做规则引擎,可观测性比性能更重要。ruflo 每次执行都会产生 trace 数据,包括规则名、进入时间、离开时间、命中结果、动作返回结果、异常堆栈。我把这些数据同时输出到业务日志和结构化日志里,方便线上排错。

具体操作上,有几个小技巧:

  1. 每条业务数据进入链路前,生成一个 traceId,放入上下文和 MDC,这样整条链路的日志都能用 traceId 串联。
  2. 执行记录的耗时数据,按规则聚合后发送到监控系统。哪个规则平均耗时超过阈值,就单独优化它。
  3. 把“链路被终止”作为一种业务事件记录,标上终止原因。这样可以统计线上有多少请求被哪条规则拦截,对日凌晨异常流量非常有效。

有个实操细节容易忽略:在 action 里写日志时,切记不要只记录参数,要把上下文里的关键中间结果也带上。比如重复回调规则拦截时,除了订单号,还要记录当前订单状态、处理时间、回调次数。等接客服工单的时候,这些字段能帮你少说十句话。

4.5 从 if-else 迁移到 ruflo 的经验与注意事项

先别急着把所有逻辑都改造成规则链路。我见过最惨痛的教训是一次性重构一个上千行的复杂函数,把每个 if 都拆成一条规则,结果拆到一半业务逻辑乱掉,折腾了两周才恢复。

我的建议是自下而上迁移:先把最稳定的、边界清晰的判断抽成规则,比如参数校验、幂等判断、状态前置校验;等成熟了之后,再把业务策略流量慢慢迁入。每迁移一块,都要有对应的测试用例和线上对比验证。

另外一个容易忽略的点是团队认知。规则引擎用得好不好,关键不在于框架本身,而在于团队怎么理解“规则”和“链路”。要让大家意识到,规则不是随便写写的工具方法,它承载的是业务判断的可表达性。我每次新增规则时都会要求写清楚什么场景满足、什么场景不满足、命中后做什么,把它当成一个接口文档来维护。

ruflo 这个项目现在还在持续完善,比起最开始那版堆满条件的代码,它带来的最大改变不是性能提升,而是让规则逻辑变得可阅读、可追踪、可讨论。我个人觉得,这比用什么技术栈、跑多高的性能指标都重要。如果你现在也被一堆 if-else 缠得头疼,不妨先用一个最小链路把最混乱的那段逻辑理出来,跑两天看看日志,再决定要不要进一步推行。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询