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

文章详情

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

RuleGo规则引擎实战:从DAG原理到自定义节点开发

RuleGo规则引擎实战:从DAG原理到自定义节点开发 做后端开发的这些年我调过形形色色的规则引擎从重型商业产品到团队自研的简单脚本RuleGo框架算是近几年让我真正觉得轻巧灵活的一个。它是一款基于Go语言实现的轻量级、高性能、云原生的规则引擎核心思路是把业务规则拆成一棵可编排的规则链用DAG有向无环图的方式组织节点消息沿着链路流转完成过滤、路由、脚本计算、外部服务调用等动作。这篇稿子不想复述官方文档我想直接聊聊RuleGo的核心原理以及插件机制怎么落地自己动手写一个自定义节点需要做哪些工作。适合正在做平台化改造、希望把业务规则从硬编码里抽离出来的读者也适合刚接触规则引擎想快速上手的朋友。1. 为什么需要规则引擎从硬编码到可编排1.1 传统if-else的痛点真实业务里最怕的不是数据量大而是规则一直在变。拿一个订单履约场景举例新用户要发欢迎券会员等级高的要免运费库存不足的要走预购支付超时要自动取消……这些判断散落在订单层、用户层、支付层到处都是。最开始用if-else写没什么问题但当规则叠加到四五个条件以后每一次改动都要重新梳理涉及链路还要担心改坏其他规则。发版部署的节奏完全跟不上产品需求的变化速度代码仓库频繁因为改规则产生无意义的提交记录。这个痛点是所有规则引擎要解决的根本问题把“逻辑”和“执行”解耦。业务人员或运维人员负责维护规则本身引擎负责执行规则。至于规则怎么描述、怎么流转就是RuleGo这一类框架的设计核心。传统实现和规则链方案对比下来区别在哪对比维度传统if-else规则链方案规则变更改代码、发版、回归改配置、热加载、生效规则可读性依赖代码review声明式描述直观可见复用能力函数级复用组合困难节点级复用连接即组合故障定位靠日志和堆栈按节点追踪上下文扩展方式加分支、加方法加节点、加边、加规则链我见过不少团队在if-else已经膨胀到难以维护的时候才想起来要换规则引擎。其实判断标准很简单如果一周内因为规则调整发了三次版本那就说明应该引入规则引擎了不用等到系统彻底失控。1.2 规则引擎的三个核心能力我理解的规则引擎至少要有三个核心能力一是规则可配置化让规则不是隐坐在代码里的魔法常数而是一份显式声明二是规则自解释每个节点做什么、输出什么描述里能直接看出来三是动态可变规则修改、新节点部署不需要重启整个服务。RuleGo在这三点上的做法比较典型。它用JSON描述规则链把节点和节点之间的连线都显式写在配置里引擎启动时读取配置并构建执行图。配置里有类型、参数、下游关系这就是一份可读性很强的规则文档。运行期间只要你把新配置热加载进去规则就能动态变化不必改一行业务代码。以它支持的节点类型来看开箱能力覆盖了大多数常见场景节点类型主要作用消息过滤节点按条件判断是否继续流转路由节点根据结果分发到不同分支日志节点记录消息内容、上下文状态HTTP节点发起外部接口调用脚本节点执行自定义逻辑表达式延迟节点控制消息在链路上的延时流转变更节点修改消息内容、元数据、类型这些都是声明式的配置字符串写在JSON里节点实例由框架在运行时创建。这个设计带来的好处是团队里不熟悉代码的人也能看懂规则链的大致走向而写代码的人只需要关注每个节点背后的实现。1.3 组件化与云原生定位RuleGo不是一个大而全的重量级产品它更强调轻量、可嵌入。因为它本身就是用Go写的编译产物只有一个二进制文件部署非常灵活。你可以在一个微服务里直接嵌入它作为服务内部的一个规则引擎也可以独立部署一个规则服务接收外部消息并返回结果甚至可以在边缘设备上运行把规则链当作数据过滤和本地决策的工具这也是它主打云原生端侧计算能力的原因。组件化是RuleGo另一个显著标签。节点是规则链的最小执行单元引擎内置了一批常用节点比如日志节点、HTTP节点、消息过滤节点、脚本节点等。需要扩展能力时不需要去改引擎本体而是写一个自定义节点注册进去就能在规则链里被引用。这种设计让RuleGo既有通用的开箱能力又有按场景定制的能力适配性很强。放到实际项目里这种“内核插件”的组合拳特别适合团队并行开发。不同模块的规则可以拆成独立的插件包谁负责订单就写订单的节点谁负责通知就写通知的节点最后统一注册进引擎互不干扰。这也是我在选型时特别看重的一点——框架的扩展成本直接决定了它能走多远。2. 核心原理拆解规则链与消息流转2.1 规则链的数据结构DAG怎么建模RuleGo的规则链本质上是一个DAG图。DAG的两个关键词是“有向”和“无环”有向表示消息只能按箭头方向流动无环则保证了只要拓扑正确执行一定会终止不会出现消息无限循环。在配置层面规则链由两部分组成nodes数组和edges数组。每个节点至少包含id、type、name、configuration几个字段edges则定义sourceNodeId和targetNodeId。解析器拿到配置后会先检查所有节点id是否唯一、边的端点是否存在、是否有环如果配置有问题会在启动阶段就报错而不是等运行到一半才发现。一个最简规则链的配置长这样{ ruleChain: { id: simple-chain, name: 简单示例 }, metadata: { nodes: [ { id: n1, type: filterNode, name: 只放行非空消息, configuration: { script: return msg.data ! ; } }, { id: n2, type: logNode, name: 打印消息 } ], edges: [ { sourceNodeId: n1, targetNodeId: n2 } ] } }这段配置描述了这样一条链路消息进入n1过滤节点如果满足脚本条件就流转到n2日志节点打印如果不满足消息在这里结束。注意edges表达的是“流转关系”而节点配置里的type决定“执行行为”两者一组合整条链路的语义就完整了。2.2 消息对象与上下文设计节点之间传递的不是裸数据而是一个包含路由信息和业务数据的上下文对象。我把这个设计类比成快递包裹包裹外面贴着快递单路由信息里面装着货物业务数据。每个节点看到的是同一个包裹但自己有权限读取和修改包裹内容。消息对象里最主要的字段包括消息ID、消息类型、消息数据、元数据。元数据里可以携带用户ID、设备ID、时间戳等业务上下文节点在处理过程中可以读取这些信息来做判断。处理完的中间结果也可以写回上下文下游节点读取时就不需要重复计算。这个机制带来两个好处。第一节点之间不直接耦合上游节点不需要知道下游节点长什么样子只管把结果交给上下文下游节点也不依赖上游的具体实现只按约定读取字段。第二链路执行的调试信息可以完整记录在上下文里出问题的时候能精准定位是哪个节点、哪一步骤出了问题。我在实际调优时经常直接在上下文里挂一个trace字段记录每个节点的处理耗时排查效率提升非常明显。2.3 节点的执行与回调机制RuleGo中的节点不是同步函数调用而是异步回调式的执行。当消息到达一个节点后节点执行自己的处理逻辑处理完成它通过回调函数把结果回传给调度器调度器再根据结果决定走哪条边、落到哪个下游节点。这种设计对异步场景特别友好。比如某个节点要调用外部HTTP接口它不必阻塞等待响应而是发完请求后挂起等回调触发后再继续推进链路。整条规则链的执行吞吐量不受单节点IO延迟影响这也是RuleGo能做高并发的底气之一。节点执行的结果一般有三种走向成功、失败、指定分支。成功时调用成功回调消息继续沿默认边流转失败时调用失败回调消息可以进入错误处理分支指定分支则由节点自己决定把消息发给哪个下游节点适用于路由场景。这三个能力组合起来几乎能覆盖所有业务流控诉求。3. 插件机制实战自己动手写一个自定义节点3.1 节点类型注册机制RuleGo的插件机制核心是一个节点注册表。框架在加载配置时遇到每个节点的type字段就去注册表里查找对应的节点构造函数。如果找不到会直接报错告诉你节点类型不存在。所以自定义节点最关键的一步就是把自己的类型名注册进去。注册的入口很简单一般是在main函数初始化阶段调用。调用注册函数传入类型字符串和节点工厂函数即可。工厂函数返回一个Node实例Node的OnMsg方法是真正干活的地方。这里以Go语言为例注册代码大概长这样func init() { registry.Register(userProfileNode, UserProfileNodeFactory) }规则链配置里只要把某个节点的type写成userProfileNode引擎在构建执行图的时候就会调用UserProfileNodeFactory创建一个实例。同一个类型可以被多条规则链复用每个节点实例会按各自的configuration完成初始化。3.2 实现节点接口需要做什么一个自定义节点至少要实现两部分初始化逻辑和执行逻辑。初始化逻辑负责解析配置把JSON格式的参数转成节点自己的结构体执行逻辑则读取上下文数据做业务处理最后把结果通过回调返回。节点工厂函数返回的其实是一个已经实现了完整接口的对象。接口里通常包含一个初始化方法和一个消息处理方法。初始化方法只在节点创建时执行一次适合做配置解析、客户端初始化这类准备工作消息处理方法则可能被并发执行多次必须考虑并发安全。以我经常写的一个用户画像查询节点举例。节点的配置大概是{ serviceUrl: http://profile-service:8081, timeout: 3000 }执行的时候节点先从消息里取出用户ID拼成请求体然后调用画像服务。调用成功就调用成功回调把画像数据写回消息数据区调用失败就调用失败回调让错误信息顺着链路传递由错误处理节点统一处理。3.3 动态加载插件的两种方式第一种是静态注册最常用适合大多数团队。在主程序里import自己的自定义节点包然后在启动时注册一切都很直观。这种方式编译期就能发现类型错误依赖关系也明确我推荐第一次接触RuleGo的团队先走这条路。第二种是动态加载适合需要热插拔的场景。把自定义节点编译成独立的可执行模块在运行时让引擎按路径加载。这种方式隔离性更好某个节点模块出问题不至于拖垮主程序也能缓解规则版本频繁更新的压力。但动态加载也带来一些运维成本不同模块之间如果依赖了同一个底层库的不同版本可能出现符号冲突或兼容性问题。所以如果你的团队不是特别需要热插拔我建议先用静态注册等确实有频繁更新需求的模块再考虑动态化。3.4 一个完整示例自定义用户画像节点下面我写一个完整的自定义节点示例。这个节点的作用是接收用户ID调用内部画像服务把返回的画像数据装配到消息体里供下游节点使用。代码基于RuleGo的核心接口实现不同版本接口名称可能有差异这里关注的是实现思路。package main import ( bytes encoding/json errors io net/http time example.com/rulego/api/types ) const NodeType userProfileNode // UserProfileConfig 节点配置 type UserProfileConfig struct { ServiceURL string json:serviceUrl Timeout int json:timeout } // UserProfileNode 自定义节点实例 type UserProfileNode struct { config UserProfileConfig client *http.Client } // UserProfileNodeFactory 节点工厂函数 func UserProfileNodeFactory() types.Node { return UserProfileNode{} } // Init 初始化节点仅执行一次 func (n *UserProfileNode) Init(ruleConfig types.Configuration) error { configBytes, err : json.Marshal(ruleConfig) if err ! nil { return err } cfg : UserProfileConfig{} if err : json.Unmarshal(configBytes, cfg); err ! nil { return err } if cfg.Timeout 0 { cfg.Timeout 3000 } n.config cfg n.client http.Client{ Timeout: time.Duration(cfg.Timeout) * time.Millisecond, } return nil } // OnMsg 处理消息 func (n *UserProfileNode) OnMsg(ctx types.RuleContext, msg types.RuleMsg) { userID : msg.Data if userID { ctx.TellFailure(msg, errors.New(user id is empty)) return } url : n.config.ServiceURL /profile?userId userID req, err : http.NewRequest(http.MethodGet, url, nil) if err ! nil { ctx.TellFailure(msg, err) return } req.Header.Set(Content-Type, application/json) resp, err : n.client.Do(req) if err ! nil { ctx.TellFailure(msg, err) return } defer resp.Body.Close() body, err : io.ReadAll(resp.Body) if err ! nil { ctx.TellFailure(msg, err) return } if resp.StatusCode ! http.StatusOK { ctx.TellFailure(msg, errors.New(profile service returns resp.Status)) return } newMsg : msg newMsg.Data string(body) ctx.TellSuccess(newMsg) }对应注册代码func init() { registry.Register(NodeType, UserProfileNodeFactory) }配置里引用这个节点{ id: n-profile, type: userProfileNode, name: 查询用户画像, configuration: { serviceUrl: http://profile-service:8081, timeout: 3000 } }写完注册后引擎在解析配置时遇到userProfileNode就会用工厂函数创建实例然后执行Init完成配置解析之后每来一条消息就触发一次OnMsg调用。整条规则链的逻辑是声明式的节点内部是命令式的这两者的边界非常清晰。3.5 插件开发中的常见坑自定义节点最典型的问题是配置解析和并发安全。配置解析失败通常是因为JSON结构体字段名和配置对不上建议把配置文件预先用校验工具过一遍。并发安全方面节点实例可能被多个消息同时使用任何成员变量的读写都必须加锁或避免共享可变状态。我偏好的做法是所有可变数据都放在消息上下文里节点实例保持只读状态这样天然线程安全。另一个容易踩的坑是回调时机。有些业务方在节点里开了goroutine做异步处理但忘了回调是在另一个协程里触发的结果导致后续节点的执行顺序不确定出现数据错乱。如果你的节点确实需要异步执行一定要明确回调的线程模型确保TellSuccess或TellFailure只被调用一次并且在合适的时机调用。4. 常见问题与排查技巧实录4.1 启动时报“节点类型不存在”这个错误最常见原因一般有两个一个是自定义节点的注册代码没被执行另一个是注册的类型字符串和配置里的type不一致。排查时先在启动日志里搜一下节点类型名再看看init函数有没有被正确调用。如果是动态加载的插件还要检查模块路径和加载时机。我把这种问题的排查步骤整理成了一个速查顺序检查项操作方法注册代码是否执行在注册函数里打印一行日志类型名是否完全一致比对配置type和注册字符串注意大小写插件加载路径是否正确检查动态加载配置中的路径是否存在是否有多个版本冲突确认主程序只引入了一个版本的节点包正常来说这几项检查完百分之九十的启动失败问题都能定位到。4.2 规则链配置校验与死循环虽然DAG在数学上保证无环但配置里如果出现了环执行就真的会无限循环下去。引擎通常在解析阶段会做环检测但如果你用了跳转节点之类的特殊节点有可能绕过检测。我的建议是自己写一个配置检查脚本在发布前扫描edges做一次拓扑排序如果最终排序的节点数量少于配置中的节点总数就说明有环。另一个配置问题是孤立节点。有些节点没有任何边指向它也没有任何出边它不会参与执行但很消耗排查者的时间。我习惯在配置评审阶段就把所有节点的入度和出度列出来一眼就能看出谁是被遗忘的节点谁是被错误连接的分支。4.3 并发下的数据串扰这个问题很隐蔽。业务方第一次用RuleGo的时候为了图方便把一些状态字段直接放到了自定义节点的成员变量里结果压测一上不同消息的数据互相串扰出现了莫名其妙的脏数据。后来排查发现节点实例在框架内是被多个协程共享的成员变量一旦被写入就会全局可见。解决办法很简单所有消息级别的数据都通过消息对象传递节点只保存只读配置。如果确实需要缓存用并发安全的Map结构或者把缓存放到独立的存储层不要图省事直接写在节点实例上。4.4 性能分析与调优建议规则链本身是轻量级的但性能瓶颈通常出现在外部依赖上。比如一个HTTP节点如果同步等待响应整条链的吞吐就会受限于该接口的响应时间。调优时可以先看链路里哪个节点耗时最长然后针对性地做并发改造。另一个容易忽略的问题是日志级别。生产环境如果开着调试级日志规则链的每个节点都打印一遍消息内容磁盘和CPU开销非常大。我建议只有出问题时临时开调试日志平时用信息级别。如果确实需要链路追踪开一条专门的trace通道不要把所有日志无差别打进同一个文件。4.5 调试排错三板斧我日常排查RuleGo问题时基本靠三招第一开启链路执行日志在上下文里打印每个节点的输入输出摘要第二用单条测试消息跑一遍链路逐步观察节点之间的数据流转第三用链路的失败分支接收错误把异常包装成结构化数据返回给上游让问题暴露在接口层而不是日志深处。这三招配合起来大部分问题都能定位到具体节点。如果还不够我建议在规则链配置里临时加一个日志节点放在你怀疑的节点后边这样就能看到消息离开上一个节点后到底变成了什么样子。这种调试方式非常直观而且不需要改任何业务代码。5. 规则链的工程化应用心得接触RuleGo之前我在项目里也用过一些重型的规则引擎方案。引入它们确实能解决规则配置化的问题但部署、维护的代价也不小。RuleGo给我的感觉更像是一个可以嵌入到业务系统里的规则执行内核它不逼你改变整个系统的架构而是允许你在合适的地方把它嵌进去。自定义节点这套插件机制尤其适合那种业务快速变化、团队又不想频繁发版的场景。如果正在读这篇稿子的你也在考虑接入规则引擎我建议先不要急着把所有业务规则全部迁移过来。可以挑一个真实场景比如订单超时自动处理、消息多级路由这类边界清晰的链路先用RuleGo跑通再逐步扩大覆盖面。最后分享一个我自己的习惯规则链配置文件也要像代码一样做版本管理和评审它同样需要工程化对待。配置里的每一步流转、每一个参数都直接影响线上行为。规则引擎让规则变更变快了但也意味着犯错的速度变快了所以配置审校、灰度发布、监控告警这些流程一样都不能少。
返回列表