多彩编程 多彩编程MZPH · CODE BLOG
ARTICLE DETAIL

文章详情

深耕前端与后端开发技术的一线实战笔记与踩坑复盘。

从Node-RED到标准语法:用Go构建可维护的规则引擎

从Node-RED到标准语法:用Go构建可维护的规则引擎 如果用一句话概括我这一段时间做的事我把项目里的规则判定层从 Node-RED 的拖拽流里整个拆了出来重新建模成一套基于标准语法的规则描述然后用 Go 先做出了一个可运行的规则引擎运行时。这篇东西就是完整的设计思路、落地细节和实测数据适合正在用 Node-RED 但觉得规则越来越难维护的人也适合想给后端系统加入规则引擎、但不想一上来就绑定某个商业产品或者自创 DSL 的团队。先说结论Node-RED 在设备接入、可视化调试、快速演示这类场景里依然很好用我到现在还在拿它当“可视化调试面板”。但把线上规则从 flow 文件里抽出来变成一套能校验、能评审、能版本管理、能跑测试、能被任何语言解释执行的“标准语法”这才是规则层该有的样子。而 Go 版本是我认为最容易落地的第一步实现。1. Node-RED 让我决定重做规则层的三个真实场景1.1 flow 文件本质是“编辑器存档”不是规则描述很多团队用 Node-RED是看中它的拖拽体验一端接 MQTT一端接告警中间挂几个 function 节点规则就“跑”起来了。这个体验在原型阶段几乎无敌。但等到线上规则多起来麻烦就来了。Node-RED 里的所有规则都保存在一个 flow.json 里这个 JSON 的格式是为编辑器存档设计的节点 ID 随机、坐标信息到处都是规则逻辑混在线缆连接关系里。我试过把两个版本的 flow.json 丢进 diff 工具结果满屏都是 x、y 坐标变化和节点 ID 差异完全分不清哪条规则改过。线上出问题要回滚规则靠肉眼对比 flow 文件根本不可行。而且 flow 里的 function 节点经常是一段 JavaScript。规则逻辑一旦写在 JS 里就彻底锁死在 Node-RED 这个运行时上了。之后想换引擎、想离线测试、想写单元测试全都得先把代码搬出来。我的判断是Node-RED 适合作为“流编辑器”但规则需要的是另一套可持续演进的东西——一份和运行平台无关的规则描述。1.2 复杂条件一旦多起来拖拽图就变成蜘蛛网Node-RED 的强项是“事件流转”收到消息、做简单判断、发给下游。但规则引擎真正要处理的是条件组合温度大于阈值并且设备在线并且不在免打扰名单里或者最近 5 分钟平均负载超过 80% 并且至少有 3 台同型号设备同时告警。这种多因子组合、时间窗口、白名单排除的逻辑在 Node-RED 里只能靠 nested switch 节点和 function 节点堆。我印象最深的一次一个小型告警流逻辑本身只有六七个条件但画出来的图线缆交叉、节点堆叠我自己过一周再看都要理半天。拖拽这种方式天然不适合表达复杂判定逻辑。规则一旦复杂文本化、结构化、可测试化的优势就完全体现出来了。1.3 单线程运行和内存占用在做批量场景时很吃力Node-RED 基于 Node.js它的运行时是单线程事件循环。社区里确实有 cluster、PM2 多实例之类的方案但那些都是“多开几个进程”的横向方案单条流内部的判定逻辑依然是单线程。我做过一个批量重算场景一次性灌进来一场设备历史数据几万个事件排队过同一条规则流CPU 只能吃满一个核处理速度完全取决于那条流里串行的 function 节点有多快。这个场景本身不是 Node-RED 的设计目标但我从中意识到一件事规则判定这种计算密集、需要高吞吐的部分最好下沉到一个没有语言运行时包袱的引擎里。Go 在这种场景下的优势非常直接编译型、并发模型成熟、内存可控、部署产物是单个二进制。所以“标准语法 Go 运行时”的方案从我第一次实验起就没有再动摇过。2. 为什么是“标准语法”语法选型的三个硬性条件2.1 我眼里的标准语法必须同时满足三件事要做一套规则描述语法我给自己定了三个硬性条件。第一语言中立。规则文件不能绑定 Node.js也不能绑定 JVM。它可以被 Go 读也应该能被 Python、Java、甚至前端 JavaScript 读。这样规则文件本身才有长期资产价值。第二可校验。规则文件必须能用 schema 做静态检查写错一个字段名、漏一个括号在加载阶段就该报错而不是跑起来之后再暴露。第三可无损转译。规则描述要能被编译器翻译成不同运行时内部的执行计划。比如我现在用 Go 实现将来如果某个边缘节点只能用 C规则文件应该可以原样丢过去用。对照下来Node-RED 的 flow 不满足第一和第二条Drools DRL 虽然表达能力强、有校验工具链但和 JVM 的绑定太深在我们的技术栈里不合适。B 站开源的 gengine 我也认真看过它是 Go 生态里很有代表性的规则引擎性能也不错但它的语法是自己定义的 GRL规则文件只能被 gengine 自己解释很难在其他语言环境里复用。RuleGo 同理规则链是私有 JSON 格式工程上很务实但规则语法本身没有“标准化”这一层设计。2.2 条件部分我选了 JSON Logic动作部分自己扩展最终我决定用 JSON Logic 作为规则条件的标准语法。JSON Logic 是一套公开的、和语言无关的逻辑表达式规范规则本身就是 JSON天然可校验、可序列化、可被人读。它的规则长这样{ and: [ { : [ { var: temperature }, 30 ] }, { in: [ dust, { var: device.tags } ] }, { !: { var: device.muted } } ] }这种表达式的含义很直白temperature 小于 30、设备的 tags 里包含 dust、device.muted 不为真三个条件同时满足。它不包含任何可执行代码只有数据、变量引用和操作符。这一点非常重要——规则文件可以在任意环境里被安全解析。但 JSON Logic 只解决了“条件判定”规则引擎还需要“动作”。一个规则至少要有 when 和 then。when 用 JSON Logic 表达then 我定义成一个受限动作列表。所谓受限是指动作类型必须是白名单内的固定集合比如告警、写时序库、发 MQTT、调用某个预设的 Webhook。动作参数只能是基本数据类型和变量引用不允许写任意代码。这样既保留了规则引擎的“效果”又没破坏语法的安全性。2.3 把几种方案摆在一张表里看差异其实很明显维度Node-RED flowDrools DRLgengine GRLJSON Logic 动作扩展语言绑定Node.jsJVMGo/Lua语言中立规则可校验弱强一般强有 JSON Schema规则可移植性绑定编辑器绑定 JVM 生态绑定 gengine任意语言可解释复杂条件表达弱靠 function强强强可视化配套原生完整弱弱需要自建或复用适合场景流编排、原型重规则系统Go 后台规则多语言、多端一致规则这张表不是想说 JSON Logic 最完美而是它最符合“规则语法标准化”这个目标。如果你完全没有跨语言需求团队也不会换技术栈那 gengine 或 RuleGo 都是完全合理的选项。但如果你像我一样需要规则文件变成团队资产和跨端标准标准语法就是必须走的路。3. Go 先行实现从 JSON 规则到可执行 AST3.1 项目结构怎么拆我踩过的包管理坑Go 版本是整个标准语法的第一个正式实现。项目结构我压得很小核心就四个文件rule-engine/ ├── go.mod ├── rule.go // Rule / Action 模型定义 ├── compile.go // 规则解析、校验、AST 构建 ├── eval.go // 条件求值器 ├── exec.go // 动作执行器 └── registry.go // 规则注册、分区索引、热更新为什么这么拆因为我把整个规则生命周期分成了四个阶段定义、编译、判定、执行。rule.go 是规则模型compile.go 把 JSON 转成内部 ASTeval.go 只负责条件计算exec.go 负责执行动作。这样每个文件职责单一测试也好写。提示如果你打算参考这个结构注意 go.mod 里的 module 名和包名不需要复杂规则引擎项目最容易犯的错是把目录搞得很深抽象一大堆 interfaces最后阅读成本和编译时间都上去了。初期就按数据流方向拆文件够用。3.2 编译期做什么校验、白名单、AST规则加载后第一步不是直接 eval而是先编译。编译期我要做三件事一是用标准校验规则schema 检查 JSON 结构二是做深度和节点数限制防止有人写一条嵌套 100 层的规则把引擎打崩三是把 JSON 操作符转成 Go 的内部函数闭包。编译得到的 AST 大概是这样的结构type Node interface { Eval(ctx *Context) (Value, error) } type OpNode struct { Op string // and, or, , in, ... Args []Node } type VarNode struct { Path string // device.tags支持嵌套路径 } type ConstNode struct { Val Value }每个 JSON Logic 操作符都映射成 OpNode变量引用映射成 VarNode字面量映射成 ConstNode。编译完成后原来的 JSON 字符串就不再需要保留了运行时只面对内存里的 AST不再反复解析 JSON。3.3 求值器和动作执行器我坚持把这两块分开eval.go 的求值器职责非常窄给一个事件上下文返回一个 bool。它只管“这个条件是否成立”不关心成立后要做什么。exec.go 的动作执行器则反过来它拿到 Context 和命中的规则列表按动作类型分派。为什么坚持分开因为规则引擎最容易腐化的地方就是“判定和动作耦合在一起”。一旦耦合规则做单元测试就难了因为你没法只测条件不触发动作规则做回放审计也难因为判定结果和动作副作用混在一起不知道谁先发生。我现在每次批量导入历史事件做回归测试都是只跑 eval不跑 exec这样能快速定位是条件变了还是动作处理变了。动作执行器的接口我做得非常简单type ActionExecutor interface { Execute(ctx *Context, action Action) error }内部按 action.Type 分发到 alert、mqtt、http 等具体执行器。所有执行器都要求在 3 秒内完成超过的直接报错并由引擎层记录不允许一个慢动作阻塞后续规则。4. 压测记录10 万条规则下的性能表现与瓶颈4.1 压测环境与规则构造我把“标准语法 Go 实现”真正推向生产前做了一轮压测。环境是 4 核 8G 的容器Go 1.22规则总量 10 万条。规则不是随机生成的而是模拟真实告警场景每条规则包含 3 到 5 个条件组合字段涉及设备 ID、温度、状态、标签、时间。输入是一个 JSON 事件表示某台设备的一条状态上报目标是算出哪些规则命中。这里有一个重要的测试细节事件里只包含部分字段大量规则会因为字段缺失或条件不匹配而快速短路跳出。这个分布和真实场景很像但如果你压测时所有规则都命中性能会下降一个量级因为后续动作执行会挤占判定时间。4.2 基线结果和优化后的对比第一版实现很朴素把所有规则编译成 AST然后逐条顺序遍历每条规则对输入事件求值。结果单核只有 5200 事件/秒10 万条规则场景下 CPU 基本跑满。这个数字其实不算差但对于批量历史重算来说还是不够。随后我做了三项优化第一规则按 partition 分区索引比如同一台设备、同一个租户的规则提前归拢输入事件只扫描相关分区第二把 JSON 解析器从标准库换成更高效的字节级解析方案第三执行器改成对象复用和最小分配模式。优化后的数据如下指标基线优化后吞吐5200 事件/秒单核3.4 万事件/秒单核P99 单条规则判定耗时2.1ms0.38ms内存占用10 万规则1.2GB320MB事件漏判00内存从 1.2GB 降到 320MB主要靠两点编译阶段把不必要的接口{} 装箱去掉规则字段名用 intern 表统一复用字符串AST 节点不再持有原始 JSON 的引用和临时对象。4.3 瓶颈定位json 解析和反射才是大头很多人一提到规则引擎性能差第一反应是“规则太多循环太慢”。我用 pprof 分析后发现真正的瓶颈是标准库 json.Decoder 的反射行为和大量小对象分配。每条规则 eval 一次都要反复做类型断言、字符串拷贝、临时 map 创建。解决方式也很直接第一规则只在编译期解析一次运行期不再解析规则本身第二事件上下文只解析一次字段访问走预编译好的 path而不是每次都用 map 嵌套查找第三用 sync.Pool 复用执行上下文。这些优化做完单条规则判定的成本才真正降到一个可控范围。如果你只想做小规模场景比如几百条规则、每秒几十个事件直接跑第一版朴素实现完全没问题不必一上来就搞索引和对象池。优化是有成本的先看瓶颈在哪。5. 踩坑清单从“能跑”到“敢上线”的五个问题5.1 超深嵌套和超大规则一个小心思打崩引擎标准语法是 JSONJSON 就有一个特性可以无限嵌套。如果有人写了一条 2000 层的 and 规则编译时递归函数直接爆栈线上服务就崩了。这个问题不是理论风险我测试时就真的压出来过。解决方法是加载阶段做硬性限制规则嵌套深度最大 32单条规则的操作符节点数最大 512动作最多 8 个。限制参数可以在引擎初始化时配置但默认值必须保守。这个校验在编译期做不消耗运行时的任何性能。func checkDepth(node interface{}, depth int) error { if depth maxRuleDepth { return fmt.Errorf(rule depth exceeds %d, maxRuleDepth) } // 递归检查子节点 }5.2 规则循环触发CPU 突然 100% 的完整排查过程有一次线上告警服务突然 CPU 飙满我第一反应是用 pprof 抓 CPU profile。抓到后发现在registry.matchAndExec这个函数里反复执行同一条规则链。再往下查发现规则之间不是孤立的规则 A 命中后会给设备打一个“已处理”标签规则 B 监听这个标签标签一变又触发规则 C规则 C 又把设备状态改回“未处理”结果规则 A 再次命中。三条规则构成一个循环每个事件进来都死循环。这个问题和标准语法无关任何一个支持“动作触发状态变化、状态变化又触发规则”的引擎都会遇到。解决办法是在执行上下文中增加一个链深度计数器每次事件最多执行 8 条规则超过 8 条直接截断并记录告警。另外每条规则在一个事件的全生命周期里只允许命中一次用map[ruleID]bool做去重。这两招加进去之后CPU 100% 的情况再没出现过。5.3 热加载与并发安全atomic.Pointer 的无锁切换规则引擎上生产后最重要的一件事就是热更新规则。Go 里最容易踩的坑是一边在遍历规则一边有人往 map 里写新规则panic 随机出现。我用了atomic.Pointer[Registry]解决整个注册表的并发切换发布流程是先编译新规则集全部成功后构建新的 Registry 对象再一次性原子替换指针。这样读取方永远只会看到旧版本或新版本的完整快照不会看到中间状态。需要注意一个细节动作执行器的客户端连接不能跟着规则一起重建。比如 MQTT 客户端、HTTP client这些是长期连接应该单独作为依赖注入到执行器里而不是每次规则更新时创建。我第一次做热更新时顺手把 MQTT 客户端也关了重建结果切换瞬间消息丢失。5.4 字段缺失的三值逻辑不明确就是事故JSON Logic 在处理不存在的字段时不同实现的行为不完全一致。有的返回 null有的直接报错有的当成 false。这种差异在跨语言一致性上是最容易出事的。我定下的规则是访问不存在的字段其结果视为 undefined而 undefined 参与任何比较时条件结果直接短路为 false同时引擎产生一条 warn 级别的日志。这样定义之后规则文件不会因为缺字段而 panic也不会静默通过条件然后执行错误的动作。所有“字段缺失但没有命中的规则”都可以在日志流里被追踪到。这块语义我写进了语法标准文档作为各个语言实现都必须遵守的行为约定。5.5 过渡期怎么和现有 Node-RED 共存系统切换不会一夜完成我的方案是让 Node-RED 和 Go 引擎在过渡期内并存。具体做法是MQTT broker 作为统一事件总线设备消息同时被 Node-RED 和规则引擎消费。Node-RED 继续承担可视化监控和人工调试面板的角色规则引擎承担线上实际判定和执行。两边跑同一个事件流周期对账等规则引擎跑满一周稳定性验证后再把 Node-RED 里的规则流逐步停掉。这个共存期最大的价值是可以拿真实流量做回放比对。我把 MQTT 里的历史消息存下来批量灌给规则引擎再和 Node-RED 当时的处理记录对比找出差异规则。实际对比下来大概有 2% 的规则行为不一致绝大多数是 function 节点里隐式写的边界条件在标准语法里被漏掉了。6. 这套方案能走到哪适用边界和后续路线6.1 别什么场景都往规则引擎里塞做了这么久有一个很深的体会规则引擎不是银弹。我列一下自己实测下来适合和不适合的场景供参考。适合不适合设备告警、阈值条件组合判定复杂状态机如订单全生命周期流转风控黑白名单、限流策略需要严格线性执行的流程编排批量数据重算、规则回放测试图计算、复杂依赖调度边缘网关受限环境下的本地判定需要人机交互的审批流尤其要说一句如果你要表达的是“先做 A再做 B再做 C”这种流水线编排规则引擎不是合适的工具。真正常见的方法是规则引擎只做“条件判定命中的动作本身调度到别的系统去执行”判定和编排分开这就是第 5 节里我坚持拆 eval 和 exec 的另一个原因。6.2 后续想做的事可视化编辑器换血和 WASM 运行时标准语法给我带来最大的后劲是可视化可以重做而且不止一种。Node-RED 的 flow 之所以有价值是提供了一套可视化编辑体验。既然规则已经标准化了可视化编辑器就只是规则文件的一个 View。我计划做一套自己的轻量可视化前端拖拽生成 JSON Logic 规则导出的是标准规则文件而不是私有 flow。编辑器随时可以换规则不存在编辑器里。另一个方向是 Go 集成 WASM 虚拟机。我已经跑了一个原型把规则引擎编译成 wasm放在边缘设备和小型网关上执行。因为规则语法是语言无关的同一份规则文件在 x86 服务器、ARM 边缘盒子、甚至浏览器预览环境里跑出的结果完全一致。这对设备多、环境杂的 IoT 场景尤其有价值也是我选择标准语法路线后看到的最强收益点。后续如果时间允许我还打算补 Python 和 Java 两个版本实现。不是要重复造轮子而是把同一份标准语法翻译到这两个语言环境的执行器里去。这样规则文件的适用范围就不再受任何单一语言栈的局限了。最后再说说我的切身体会。把规则从 Node-RED 的 flow 里解放出来以后最大的变化不是性能提升了多少而是规则终于可以离开编辑器独立演进。团队里新同事来了不用点开画布看图理解规则直接读规则文件就能进入状态。规则测试可以写进 CI/CD 流水线每次改动跑一遍历史回归包再上线。这套流程走顺之后我才真正理解了“规则是资产引擎是执行者”这句话的分量。如果你也在被私有流程格式和拖拽逻辑折磨可以试试从一条真实规则开始把它改写成标准语法加上 Go 的编译校验和热加载你会很快看到差别。
返回列表