轻量级规则流引擎ruflo:用YAML编排业务规则,告别if-else与频繁发版
做后台系统这几年我最怕听到的一句话不是线上出BUG了而是这个规则能不能再改一下。改一个风控阈值要发版加一个判断分支要回归业务方凌晨三点甩过来一句话整个研发链路跟着折腾半宿。后来我索性把这些散落各处的判断逻辑统一抽出来做成了一套轻量级的规则流引擎名字就叫ruflo。ruflo 解决的核心问题是把业务里那些频繁变动、又散落在代码各处的if-else分支从业务代码里彻底剥离出来用一套可读性极强的描述文件重新编排成规则流。它适合那些业务规则多、变化频率高、又不想引入 Drools 这类重型框架的团队。无论你是后端开发、架构师还是经常被业务方拉着改逻辑的技术负责人这套东西都能帮你少熬几个夜。这篇文章我会从设计思路讲起逐步拆解 ruflo 的核心概念、快速上手、完整实战案例最后把我在实际使用中踩过的坑和排查技巧一并整理出来希望能给正在做规则编排、工作流设计的朋友一些参考。1. 整体设计与核心思路拆解1.1 为什么不做成配置中心非要做成流最开始接到这个需求时我的第一反应是做一个配置中心把规则参数放进去代码里读配置就成了。但真正落地时发现单一参数根本表达不了复杂的业务判断。比如订单风控这个场景既有用户维度的黑名单判断又有订单金额的阈值判断还有地域维度的限制更麻烦的是这些判断有严格的先后顺序命中了黑名单后续的地域判断就没必要再执行了。这种场景用参数配置表达要么写出一堆嵌套的复杂表达式要么还是得回到代码里写分支。ruflo 的思路是引入流的概念把整个判定过程拆成多个节点节点和节点之间通过边串联数据通过上下文对象在节点之间流动。这样做有三个明显的好处单个节点的逻辑足够简单任何人一眼就能看懂。节点之间的顺序关系显式表达执行到哪一步一目了然。可以随时在两个节点之间插入新节点不用改动已有代码。1.2 核心概念节点、上下文、触发器ruflo 的核心概念只有三个节点Node、上下文Context、触发器Trigger。理解这三个概念基本就掌握了整套系统的骨架。节点是最小的执行单元对应一个具体的判断或动作。我把它分成了三类规则节点Rule Node、动作节点Action Node、条件节点Condition Node。规则节点只做判断输出 true 或 false动作节点执行具体操作比如发工单、记日志、调用外部 API条件节点做分支路由根据上下文数据决定下一步走哪个分支。这种分类方式借鉴了工作流引擎的思路但砍掉了很多用不到的花哨功能保留最核心的表达能力。上下文是一个贯穿整个流程的对象所有节点共享。它本质上就是一个线程安全的键值存储节点可以从里面读数据也可以往里面写数据。设计上我刻意没有做数据版本管理和作用域隔离因为大部分业务场景根本用不到做了反而增加学习和排错成本。触发器解决的是什么时候跑这个流程的问题。ruflo 支持三种方式API 触发、消息队列触发、定时触发。实际使用中 API 触发用得最多业务方把请求参数塞进 Context调用引擎执行就完了。1.3 技术选型上的几个取舍选型阶段我做了一些对比和取舍虽然这些选择未必适合所有场景但至少提供了一条已经验证过的路径。语言层面我选择了 Python。团队的技术栈以 Python 为主而且规则引擎这种 IO 密集型的任务Python 的性能瓶颈影响不大。如果你所在的团队以 Java 为主或者对性能有极致要求可以用同样的思想在 Java 上重写一遍核心设计是语言无关的。表达式引擎方面我没有自己发明一套规则语法而是直接复用了 Python 的ast模块做安全解析配合受限的eval。这样做的好处是业务方写规则时用的是接近 Python 的语法学习成本极低。风险是如果不对表达式做严格的访问控制就等于把代码执行能力暴露给了调用方。这块的细节我会在第 4 节单独讲。存储层面规则文件我直接放在了文件系统里配合 Git 做版本管理。没有引入数据库因为规则文件的读写频率很低用数据库反而要把规则序列化、反序列化徒增复杂度。热更新机制是监听文件变化后重新加载到内存具体实现后面展开。1.4 与现有方案对比ruflo 的定位在哪做之前我研究过市面上的规则引擎方案站在选型的角度给它们做了个分类方案优点缺点适用场景硬编码 if-else简单直接改规则要发版维护成本高规则极少变化的系统Drools功能强大社区成熟学习曲线陡峭较重金融、保险等复杂规则场景配置中心 动态参数改动灵活表达复杂逻辑困难规则以参数为主无复杂编排ruflo轻量、易读、灵活编排生态需自己建设中后台业务的规则编排与风控这个对比不是为了说明 ruflo 比 Drools 好而是希望大家意识到很多场景根本用不到 Drools 那么重的体量。规则引擎最重要的是可读性和可维护性而不是功能越多越好。2. 快速上手5分钟跑通第一个规则流2.1 安装与初始化ruflo 的安装非常简单目前已经发布到了 PyPI直接使用 pip 安装即可pip install ruflo安装完成后在项目根目录初始化一个规则目录ruflo init ./flows这条命令会创建一个flows目录并生成一个示例规则文件demo_flow.yaml。目录结构是这样的flows/ ├── demo_flow.yaml └── common/ └── nodes.py其中yaml文件描述流程结构nodes.py用来放置自定义节点代码。如果你只需要用内置节点搭建流程nodes.py甚至可以不用。2.2 编写一个最小可运行的流程打开demo_flow.yaml看一下它长什么样name: demo_flow version: 1 trigger: type: api nodes: - id: start type: start next: check_user - id: check_user type: rule expression: context[user_level] in (vip, svip) next: true: grant_discount false: reject - id: grant_discount type: action action: apply_discount params: rate: 0.8 next: end - id: reject type: action action: reject_order params: reason: user_level_not_satisfied next: end这个流程做了什么事呢它先接收一个包含user_level字段的请求判断用户是不是 VIP 或 SVIP如果是就执行折扣动作如果不是就拒绝。整个流程从start开始到end结束结构非常清晰。2.3 通过代码调用流程流程定义好了怎么跑起来用 ruflo 提供的 Client 就行from ruflo import Client client Client() result client.execute( flow_namedemo_flow, context{user_level: vip, amount: 100} ) print(result.status) # 执行状态success/failed print(result.context) # 执行后的上下文 print(result.trace) # 节点执行轨迹执行完成后result.trace会记录每个节点的执行顺序和耗时这在排查问题时非常有用。上述流程执行后trace 应该是这样的start - check_user - grant_discount - end2.4 动态更新规则而不发版跑通之后你可能会问规则改了怎么办这里就是 ruflo 的核心优势了——不需要改代码只需要修改 yaml 文件。ruflo 默认开启了文件监听模式修改yaml文件保存后引擎会自动检测到变化并重新加载。你也可以手动触发重载client.reload(demo_flow)如果你希望程序自己控制重载时机可以在初始化 Client 时关掉自动监听client Client(auto_reloadFalse)然后根据自己的调度策略在合适的时机调用reload方法。比如在 Spring 这类框架里你可以把重载逻辑挂到一个定时任务上或者放到管理端接口里由运维触发。3. 完整实战用 ruflo 改造一个订单风控服务3.1 业务背景与规则梳理空跑 demo 没有说服力这里我用一个真实的订单风控场景来演示完整改造过程。假设我们有一个电商系统下单前需要经过几道风控校验第一步检查用户是否在黑名单中如果在直接拒绝并记录风控日志。第二步判断订单金额是否超过单笔限额如果超过转人工审核。第三步判断用户当日累计下单金额是否超过限额如果超过转人工审核。第四步以上都通过放行。这个业务原来的实现是四个硬编码校验类串行执行每次调整阈值都要走开发流程。用 ruflo 改造后规则和代码彻底分离业务方调整阈值只需要改配置文件。3.2 规则文件设计我先定义流程描述文件order_risk_flow.yamlname: order_risk_flow version: 3 trigger: type: api nodes: - id: start type: start next: blacklist_check - id: blacklist_check type: rule expression: context[user_id] not in blacklist next: true: amount_check false: risk_reject_blacklist - id: amount_check type: rule expression: context[order_amount] context[single_limit] next: true: daily_check false: risk_review - id: daily_check type: rule expression: context[daily_total] context[order_amount] context[daily_limit] next: true: pass_order false: risk_review - id: risk_reject_blacklist type: action action: reject_order params: reason: blacklist_hit next: end - id: risk_review type: action action: create_review_ticket params: reason: amount_over_limit next: end - id: pass_order type: action action: approve_order next: end这里你可能会注意到有些规则节点依赖外部的黑名单数据、每日累计金额数据这些数据从哪来答案是在触发流程之前由调用方负责把数据从数据库或缓存中查好塞进 Context。这样的设计是为了保持引擎的纯粹性它不关心数据从哪来只关心数据到了之后该怎么判断。3.3 自定义动作节点的实现流程里有几个动作比如reject_order、create_review_ticket、approve_order这些是内置节点没有的能力需要我们在nodes.py里实现from ruflo import ActionNode class RejectOrderNode(ActionNode): def run(self, context): order_id context[order_id] reason self.params.get(reason, ) # 调用订单服务取消订单 cancel_order(order_id, reason) # 记录审计日志 audit_log(order_id, rejected, reason) context[risk_result] rejected return context class CreateReviewTicketNode(ActionNode): def run(self, context): order_id context[order_id] reason self.params.get(reason, ) # 创建人工审核工单 ticket_id create_ticket(order_id, reason) context[review_ticket_id] ticket_id return context class ApproveOrderNode(ActionNode): def run(self, context): order_id context[order_id] approve_order(order_id) context[risk_result] approved return context写完后需要在流程文件里声明自定义节点和类的映射关系。在 yaml 文件顶部加一段extends: actions: reject_order: nodes.RejectOrderNode create_review_ticket: nodes.CreateReviewTicketNode approve_order: nodes.ApproveOrderNode3.4 编排调用代码流程定义好节点实现好接下来就是接收入口。以 FastAPI 为例在接口里调用 ruflofrom fastapi import FastAPI, Request from ruflo import Client app FastAPI() client Client() app.post(/api/order/risk_check) async def risk_check(req: Request): data await req.json() context { user_id: data[user_id], order_id: data[order_id], order_amount: data[order_amount], single_limit: get_config(risk.single_limit), daily_limit: get_config(risk.daily_limit), daily_total: get_daily_total(data[user_id]), } result client.execute(flow_nameorder_risk_flow, contextcontext) return { status: result.status, risk_result: result.context.get(risk_result), trace: result.trace, }对比原来的硬编码实现这个改造的好处非常明显以后调整单笔限额、日累计限额、黑名单策略都只需要修改 yaml 文件不需要再动代码、走发布流程。3.5 灰度与回滚策略规则文件通过 Git 管理后天然就支持灰度与回滚。我的做法是每个环境对应一个独立的规则目录测试环境改完验证通过后再合并到生产环境的分支。所有规则文件跟随应用版本发布但引擎支持运行时重载所以规则的生效时间可以比代码更灵活。如果新规则出了问题直接在目标分支git revert上一次针对规则文件的提交然后触发一次重载即可。这个流程走顺之后业务方再提规则变更需求我通常十分钟内就能完成测试、发布、生效全流程。4. 运行机制与关键设计细节4.1 执行引擎的调度逻辑ruflo 的执行引擎本质上是一个有向无环图DAG调度器只不过这个 DAG 是在加载 yaml 时构建的执行时只需要按图遍历。流程从start节点开始根据每个节点的next指向决定下一步执行哪个节点直到end节点。引擎在实现时做了几个基础但重要的保障循环依赖检测加载流程文件时会先做一次拓扑排序如果发现环直接拒绝加载并报错避免执行时陷入死循环。节点超时控制每个节点的执行默认有 5 秒超时超过时间就标记为失败。这个值可以通过配置调整比如某些外部 API 调用比较慢可以放宽到 30 秒。上下文隔离每次execute调用都会复制一份 Context不同请求之间不会相互污染数据。但是要特别注意Context 里如果放的是可变对象比如 list、dict浅拷贝可能不够这里用的是深拷贝。4.2 表达式安全解析方案这是整个引擎最敏感的一环。规则节点里的expression字段是允许写任意 Python 表达式的如果不做限制就等于让配置文件变成了远程代码执行入口这是绝对不能接受的。我的处理方案是先把表达式交给 Python 的ast模块解析成抽象语法树然后遍历这个语法树只允许出现白名单里的节点类型。白名单包括比较运算,,,,,!逻辑运算and,or,not成员判断in,not in属性访问context[key]形式的下标操作字面量数字、字符串、布尔值、None任何不在白名单里的语法比如函数调用、导入语句、lambda 表达式都会在加载阶段被拦截并抛错。这样即使配置文件被误改或恶搞也造不成实质性危害。4.3 并发控制与性能优化实际运行中订单风控这类服务往往是高并发的。ruflo 的执行引擎默认是同步阻塞模型但在每个节点执行时引擎会先获取上下文的锁。这样做是为了防止多个请求同时修改 Context 里的同一个 key。性能方面我做了几个优化点规则文件缓存每个流程文件解析后的 DAG 结构会被缓存到内存中不会每次执行都重新解析。重载时只会替换对应流程的缓存项。表达式预编译表达式在加载阶段就会编译成字节码执行时直接运行字节码省去了每次解析的开销。节点执行池化对于纯计算型的规则节点引擎使用了一个有界的线程池来执行避免频繁创建线程。实测下来在我的一台 4 核 8G 的普通云主机上跑一个 10 个节点的流程QPS 大约在 3000 左右大部分耗时其实都在业务代码的 IO 操作上纯引擎开销占比非常低。4.4 热更新机制的实现原理最后说一下热更新这是规则引擎最有价值的能力。ruflo 的监听模块会为每个规则文件注册一个文件监视器当文件发生变更时会先完成以下动作读取最新的 yaml 内容做语法解析和拓扑排序。编译所有表达式如果表达式语法错误保留旧版本继续运行。将新版本流程实例原子替换内存中的旧实例。这套逻辑的关键在于变更失败不影响旧版本这也是我踩过不少坑之后总结出来的经验。最开始我的实现是直接替换结果有一次规则文件里写错了一个语法整个服务的规则全部加载失败了。后来改成上面这套事务式的加载方式问题再也没有出现过。5. 常见问题与排查技巧实录5.1 规则加载失败的定位方法现象调用execute时报错FlowNotFound或者提示规则文件解析失败。排查思路大部分情况是 yaml 格式写错了。ruflo 在解析失败时的报错信息里会包含具体的行号和列号先看那两行。其次是表达式编译失败错误信息会提示是哪个节点、哪条表达式出的问题。如果用的是我在第 3 节说的那种事务式加载方式新规则加载失败不会影响旧规则运行所以问题不会扩大。建议在测试环境加一个规则校验接口输入规则文件内容返回解析结果。每次上线前先在校验接口跑一遍可以挡掉大部分低级错误。5.2 上下文数据被意外覆盖现象A 节点写入了amountB 节点读到的amount不是预期值。原因大多是因为不同的节点用了同一个 key比如两个动作节点都往 Context 里写result后者覆盖了前者。排查技巧利用trace功能查看每个节点执行完后的 Context 快照对比哪个节点把数据写坏了。这个问题在设计上可以通过统一命名空间规避比如各个节点写数据时加上节点名前缀check_user.result、amount_check.result。5.3 流程执行超时但不知道卡在哪个节点现象整个流程跑了十几秒才返回远超正常耗时。排查思路ruflo 的result.trace中带每个节点的耗时直接用这个数据定位。如果发现是某一个动作节点的耗时异常那大概率是它调用的外部接口慢了。我的做法是在动作节点里统一接入 traceId这样调用链路的日志可以通过 traceId 串联起来。5.4 热更新没有生效现象修改了 yaml 文件但执行时发现改的规则没起作用。排查思路先看监听模块的文件路径对不对再确认文件保存后是否触发了重载日志。如果没触发多半是路径配置的问题。如果触发了但执行没变化可能是缓存没清掉可以在 yaml 文件里改一下version字段强制刷新缓存。5.5 常见问题速查现象可能原因处理建议FlowNotFound流程名称写错或加载失败检查流程名称确认加载日志ExpressionError表达式语法不支持对照白名单检查表达式ContextKeyError读取了不存在的 key检查节点执行顺序和写入逻辑节点超时外部依赖接口慢调整超时时间优化外部调用规则不生效缓存未刷新修改 version 字段手动 reload5.6 几个排查辅助手段除了上面这些具体问题我还整理了三个通用排查手段建议平时就用起来开启详细执行日志初始化 Client 时设置debugTrue引擎会打印每个节点的入参、出参和耗时这是排查问题最直接的抓手。用测试用例固化规则每个规则文件都应该配套一组测试用例覆盖正常、异常、边界三种情况。规则修改后跑一遍测试用例比手动验证靠谱得多。规则文件版本号递增每次修改 yaml 时随手把version递增一下既不费事又能让溯源变得容易。写在最后ruflo 这个项目目前已经在我负责的几个业务系统里稳定跑了一年多。回顾整个过程我最深的体会其实是规则引擎的价值不在于用了什么高深的技术而在于它逼着你把业务逻辑重新梳理成一张清晰的图。写 yaml 文件的过程就是一次业务的重新建模很多隐藏的逻辑矛盾都是在这个阶段被暴露出来并解决的。如果你也准备在项目里引入类似的规则流机制我的建议是别一上来就求大而全先从一条最核心的业务链路开始把规则文件写好把节点实现好跑通之后再逐步扩展。另外务必要重视规则文件的校验和测试一个写错的规则在生产环境造成的破坏比一个写错的代码逻辑更大因为它的变更门槛太低了。最后再分享一个小技巧可以在管理后台加一个规则执行预览功能输入测试参数直接在线查看流程跑出来的结果和执行轨迹。这个功能看着不起眼但在和业务方对规则逻辑时能省掉大量来回沟通的时间。希望这篇文章能给你一些启发欢迎在评论区交流你们的规则编排玩法。