上周帮一个客户换服务器。新机器上系统装完,数据库倒完,运维问了一句:规则引擎里那十几条联动规则,是走 SQL 导出来再导进去,还是有专门的迁移脚本?
答案是都不用。把 data/workflows 整个目录打个包拷过去,重启进程,规则就全在。
这件事听起来不像什么技术亮点,但它决定了后面很多事情的做法:规则链的版本管理、回滚、灰度、Audit,全都可以用文件和目录那一套现成工具来做。SagooIoT 的规则引擎(ruleserver 目录,31 个 Go 文件、3500 多行)走的就是这条路------规则链的权威副本是磁盘上的 JSON 文件,数据库里没有它的一张表。
这篇文章拆四件事:一条规则链在磁盘上到底由哪几个文件组成、上线和下架为什么只改一个布尔位、改完为什么不用重启、以及界面上那个"每个节点最近 40 条报文"的调试窗口是怎么攒出来的。
一、一条规则链在磁盘上是什么
先看目录。规则引擎的根目录由配置项 data_dir 决定,默认 ./data。进去之后是这样:
data/
└── workflows/
├── admin/ ← 一个用户名一个目录
│ ├── rules/
│ │ ├── index ← 索引:所有链的元信息
│ │ ├── chain_9f3a.json ← 规则链 DSL 本体
│ │ └── chain_1c07.json
│ ├── setting.json ← 最近打开的链、主链
│ └── runs/ ← 运行日志(可选,save_run_log)
└── operator/
└── rules/
├── index
└── chain_5b21.json
三个常量把这张图钉死了,写在 ruleserver/constants/constants.go 里:
const (
// DirWorkflows 工作流目录
DirWorkflows = "workflows"
DirWorkflowsRun = "runs"
DirWorkflowsRule = "rules"
// FileNameIndex 索引文件名
FileNameIndex = "index"
)
const (
RuleChainFileSuffix = ".json"
)
注意 index 这个文件没有扩展名 。这不是随手写的------有扩展名就会被 *.json 的 glob 扫进去,进而被当成一条规则链去解析。文件名本身就是一层过滤器。
那 chain_9f3a.json 里面是什么?就是画布上那条链的完整定义,一个 JSON,结构大致是:
{
"ruleChain": {
"id": "chain_9f3a",
"name": "高温告警联动",
"root": true,
"disabled": false,
"debugMode": false,
"additionalInfo": {
"username": "admin",
"createTime": "2026/10/18 09:12:33",
"updateTime": "2026/10/27 16:40:07"
},
"configuration": {
"temperatureThreshold": 65
}
},
"metadata": {
"nodes": [
{ "id": "node_in", "type": "endpoint/sagoo/devicein", "name": "设备上报" },
{ "id": "node_js", "type": "jsFilter", "name": "温度超阈值" },
{ "id": "node_out", "type": "deviceAction", "name": "下发降档指令" }
],
"connections": [
{ "fromId": "node_in", "toId": "node_js", "type": "Success" },
{ "fromId": "node_js", "toId": "node_out", "type": "True" }
]
}
}
节点、连线、参数,全在这一个文件里。没有"规则表 + 节点表 + 连线表"三张表要 join,也没有外键要维护。一条规则链 = 一个文件,这个约束一旦成立,后面所有的操作都变简单了:
- 复制一条链调试,就是
cp一个文件 - 回滚,就是
git checkout一个文件 - 对比两个环境的差异,就是
diff两个目录 - 备份,就是打包一个文件夹
二、一个人一套引擎,目录当隔离边界
上面目录图里,workflows 下面第一层是用户名。这不是为了好看,是因为 UserRuleEngineService 是按用户分的引擎池:
// UserRuleEngineService 用户规则引擎池
type UserRuleEngineService struct {
Pool map[string]*RuleEngineService
config config.Config
locker sync.RWMutex
}
每个用户名对应一个 RuleEngineService,各自持有一个独立的 rulego.RuleGo 实例池、一个独立的 RuleDao,而 RuleDao 里所有路径都以 workflows/<username>/rules 为根:
func (d *RuleDao) Get(username, chainId string) ([]byte, error) {
var paths = []string{d.config.DataDir, constants.DirWorkflows}
paths = append(paths, username, constants.DirWorkflowsRule, chainId+constants.RuleChainFileSuffix)
pathStr := path.Join(paths...)
return os.ReadFile(pathStr)
}
用户名从哪来?请求头。AuthProcess 是每个路由的第一个处理器:
var AuthProcess = func(router endpointApi.Router, exchange *endpointApi.Exchange) bool {
msg := exchange.In.GetMsg()
username := exchange.In.Headers().Get(constants.KeyUsername)
if username == "" {
username = config.C.DefaultUsername
}
msg.Metadata.PutValue(constants.KeyUsername, username)
return true
}
它把 username 塞进消息元数据,后面所有处理器都从元数据里取------GetRuleGoFunc 拿它选池子,controller.Rule.List 拿它选目录。取不到就用 default_username(默认 admin)兜底,所以单机演示环境不用配任何鉴权也能直接跑。
这种"用户名即目录"的做法有个很实在的好处:多租户的隔离边界是文件系统,不是 SQL 的 where 条件 。少写一个 where user_id = ? 不可能导致看到别人的规则,因为路径里根本没有别的用户名的文件可读。
三、保存一次,写三样东西
点保存的时候,前端把整个 DSL 的 JSON POST 到 /api/v1/rules/:id,落到 SaveAndLoad。这个函数是整个模块的主干,做的事比名字多一点:
func (s *RuleEngineService) SaveAndLoad(chainId string, def []byte) error {
//设置最新修改规则链
_ = s.userSettingDao.Save(constants.SettingKeyLatestChainId, chainId)
var ruleChain types.RuleChain
if err := json.Unmarshal(def, &ruleChain); err != nil {
return err
}
//修改更新时间
s.fillAdditionalInfo(&ruleChain)
b, err := json.Marshal(ruleChain)
if err != nil {
return err
}
//持久化规则链
if err = s.ruleDao.Save(s.username, chainId, b); err != nil {
return err
}
if ruleChain.RuleChain.Disabled {
//下架规则
return s.Undeploy(chainId)
} else {
//部署规则链
return s.Deploy(chainId)
}
}
四个动作,顺序有讲究。
第一步记"最近打开的链"。 userSettingDao.Save("latestChainId", chainId)。这不是审计,是为了让前端下次打开时直接落到上次编辑的那条链上------画布类工具都有这个需求,用户一天里在三条链之间来回切,每次都要重新找一遍位置很烦。
第二步补时间戳。 fillAdditionalInfo 干的事很朴素,但两处细节值得看:
func (s *RuleEngineService) fillAdditionalInfo(def *types.RuleChain) {
if def.RuleChain.AdditionalInfo == nil {
def.RuleChain.AdditionalInfo = make(map[string]interface{})
}
def.RuleChain.AdditionalInfo[constants.KeyUsername] = s.username
nowStr := time.Now().Format("2006/01/02 15:04:05")
if _, ok := def.RuleChain.AdditionalInfo["createTime"]; !ok {
def.RuleChain.AdditionalInfo["createTime"] = nowStr
}
def.RuleChain.AdditionalInfo["updateTime"] = nowStr
}
createTime 只在不存在时写,updateTime 每次都覆盖。这是"创建时间不可变、修改时间跟随"的最小实现,不需要触发器,也不需要额外字段。而 username 也被写进 DSL 里------文件被人从 A 目录拷到 B 目录时,至少还能看出它原本属于谁。
第三步落盘,并且格式化。 saveRuleChain 里有一句 json.Indent:
var buf bytes.Buffer
if err := json.Indent(&buf, def, "", " "); err != nil {
return err
}
return fs.SaveFile(filepath.Join(pathStr, chainId+constants.RuleChainFileSuffix), buf.Bytes())
前端传来的 JSON 通常是压缩成一行的,json.Indent 把它展开成两空格缩进。多花的这几毫秒换来的是:这个文件可以直接 git diff。改了一个阈值,diff 里就是一行变化,而不是"整个文件都变了"。可读的文件才有版本管理的价值,压缩过的 JSON 塞进 Git 等于没有历史。
第四步更新索引。 d.createIndex(ruleChain) 把 name / id / root / disabled / updateTime 五个字段摘出来塞进内存里的 map[string]RuleMeta,然后 d.saveIndex() 整体写回 index 文件。
索引存在的理由只有一个:列表页。规则链列表要支持按名字搜索、按"是否根链"筛、按"是否启用"筛、按更新时间倒序、还要分页。如果每次都去 glob 目录、逐个读文件、逐个 json.Unmarshal,十条链还行,一百条链的列表页就会明显卡。索引文件让"列出来"变成一次 os.Open + 一次 json.Decode。
四、索引是缓存,丢了能自己长回来
索引和 DSL 文件之间有个天然风险:两者不同步。手工删了个文件、从别处拷进来一条链、index 被误删------任何一种都会让索引和磁盘对不上。
这个模块的处理方式是:索引不是权威数据,是缓存;缓存没了就重建。
func NewRuleDao(config config.Config, username string) (*RuleDao, error) {
dao := &RuleDao{ /* ... */ }
indexPath := dao.getIndexPath()
if _, err := os.Stat(indexPath); errors.Is(err, os.ErrNotExist) {
return dao, dao.rebuildIndex()
} else if err != nil {
return nil, err
} else {
if err := dao.loadIndex(indexPath); err != nil {
return nil, err
}
}
return dao, nil
}
rebuildIndex 就是扫目录:
files, err := os.ReadDir(basePath)
// ...
for _, file := range files {
if file.IsDir() {
continue
}
if filepath.Ext(strings.ToLower(file.Name())) == constants.RuleChainFileSuffix {
filePath := filepath.Join(basePath, file.Name())
data, err := os.ReadFile(filePath)
if err != nil {
continue
}
var ruleChain types.RuleChain
if err := json.Unmarshal(data, &ruleChain); err != nil {
continue
}
d.createIndex(ruleChain)
}
}
return d.saveIndex(d.getIndexPath())
这段代码有三个很"运维友好"的选择:
.JSON大写也能认。filepath.Ext(strings.ToLower(...)),Windows 上手工改名不会掉链子。2. 读不动就跳过,不中断。 某个文件权限不对、某个 JSON 被编辑器存坏了,continue继续扫下一个。一条坏文件不该让整个用户目录打不开。3. 重建完顺手把索引写回去。 下次启动就不用再扫了。
所以运维场景变成了这样:想批量导入一批规则?把十个 JSON 文件丢进 rules/ 目录,删掉 index,重启------十条链全部出现在列表里,name、root、disabled 都是从各自文件里读出来的。不需要写任何导入脚本。
五、上线和下架,差的就是一个布尔位
这是全文最核心的一句话:规则链只有两个状态,用一个 disabled 布尔位表示。
不是"草稿/待审/已发布/已下线"四个状态字段,也不是 status 枚举加一张状态流转表。就一个 bool,写在 DSL 里,跟着文件走。
disabled: true→ 下架。DSL 还在,但不进内存池,不接消息。disabled: false→ 上线。进内存池,开始处理。
看 Deploy:
func (s *RuleEngineService) Deploy(chainId string) error {
def, err := s.Get(chainId)
var ruleChain types.RuleChain
err = json.Unmarshal(def, &ruleChain)
if err != nil {
return err
}
ruleChain.RuleChain.Disabled = false
def, err = json.Marshal(ruleChain)
if err != nil {
return err
} else {
ruleEngine, ok := s.Pool.Get(chainId)
if ok {
err = ruleEngine.ReloadSelf(def)
} else {
_, err = s.Pool.New(chainId, def, rulego.WithConfig(s.ruleConfig))
}
saveErr := s.saveRuleChain(ruleChain, err)
if saveErr != nil {
s.ruleConfig.Logger.Printf("saveRuleChain err: %s", saveErr)
}
return err
}
}
Undeploy 是它的镜像:
s.Pool.Del(chainId)
ruleChain.RuleChain.Disabled = true
b, _ := json.Marshal(ruleChain)
err = s.ruleDao.Save(s.username, chainId, b)
一个有意思的顺序问题:Undeploy 是先摘实例、再改文件 ,Deploy 是先改文件、再挂实例。两个方向都是"让内存先停/后起,让磁盘先写/后读",保证任何一刻崩溃,重启后的状态都是收敛的------要么没上线,要么已上线,不会出现"文件说启用但池子里没有"这种鬼状态(因为启动时会以文件为准重新加载)。
四个动作放在一起看,内存和磁盘的对应关系非常清楚:
| 操作 | 接口 | 内存池 | disabled |
文件 |
|---|---|---|---|---|
| 部署 | POST /api/v1/rules/:id/operate/start |
New 或 ReloadSelf |
false |
重写 |
| 下架 | POST /api/v1/rules/:id/operate/stop |
Del |
true |
重写 |
| 删除 | DELETE /api/v1/rules/:id |
Del |
--- | 删除 + 删索引 |
| 启用 | GET /api/v1/rule/:chainId/enable |
New |
不动 | 不写 |
最后一行值得单独说。Enable 和 Deploy 看起来是一回事,其实不是:
func (s *RuleEngineService) Enable(chainId string) error {
jsonStr, err := s.ruleDao.GetRuleChainSrc(s.username, chainId)
if err != nil {
return err
}
if _, err := s.Pool.New(chainId, jsonStr, rulego.WithConfig(s.ruleConfig)); err != nil {
return err
}
return nil
}
它只挂实例、不改文件。用途是进程重启后把某条链提前预热进内存,而不动它的持久化状态------比如批量启动时先加载高频链路,避免第一波消息打到冷引擎上。
六、改完不用重启:ReloadSelf
Deploy 里那个分支是热更新的关键:
ruleEngine, ok := s.Pool.Get(chainId)
if ok {
err = ruleEngine.ReloadSelf(def)
} else {
_, err = s.Pool.New(chainId, def, rulego.WithConfig(s.ruleConfig))
}
如果这条链已经在池子里,就不新建,而是调 ReloadSelf 用新 DSL 原地重载。区别在哪?
新建实例意味着:旧的丢掉,新的从零开始。正在处理的消息、已经建立的 endpoint 订阅、节点内部的状态,全部重来。对一个每秒几十条上报的设备链路来说,这个空窗期就是丢数据。
ReloadSelf 是"原地换定义":引擎实例不变,节点拓扑按新 DSL 重建。对上层调用方来说,s.Pool.Get(chainId) 拿到的还是同一个对象,Execute 照常能用。所以前端的体验是:点保存 → 提示成功 → 下一次设备上报就走新逻辑,中间没有重启,也没有断流。
SaveConfiguration 用的是同一个机制,只是改的只有 configuration 那一块:
self := ruleEngine.RootRuleChainCtx().Definition()
if self.RuleChain.Configuration == nil {
self.RuleChain.Configuration = make(types.Configuration)
}
self.RuleChain.Configuration[key] = configuration
// ...
if err := ruleEngine.ReloadSelf(ruleEngine.DSL()); err != nil {
return err
}
这个设计对"参数化规则链"特别顺手。比如同一条"高温告警"链,给三个车间各用一份,区别只是 temperatureThreshold 从 65 改成 70 和 75。链的定义(节点、连线、脚本)完全一样,只有 configuration 不同------改阈值不用碰节点拓扑,一次 ReloadSelf 结束。
七、跑不起来的链自动摘下来,还留一句话
规则链是用户画出来的,必然有画错的时候:引用了不存在的节点、某个组件的配置项类型不对、脚本语法错。这类错误发生在 Pool.New 或 ReloadSelf 的时候。
如果只是 return err 给前端弹个红条,会有个隐患:这条链在池子里可能挂了一半。所以 saveRuleChain 在收到错误时做了两件事:
func (s *RuleEngineService) saveRuleChain(ruleChain types.RuleChain, whenErr error) error {
if whenErr != nil {
ruleChain.RuleChain.Disabled = true
ruleChain.RuleChain.PutAdditionalInfo(constants.AddiKeyMessage, whenErr.Error())
}
if def, err := json.Marshal(ruleChain); err != nil {
return err
} else {
return s.ruleDao.Save(s.username, ruleChain.RuleChain.ID, def)
}
}
一、把 disabled 置为 true。 加载失败的链,状态就是"下架"。这不是惩罚,是隔离------一条坏链留在池子里,可能就是每次消息进来都 panic 一次。
二、把错误原文写进 DSL。 PutAdditionalInfo("message", err.Error())。这一句是整个设计里我最欣赏的细节:错误信息不是打到日志里让人去 grep,而是持久化到规则链自己的文件里 。下次打开这条链,additionalInfo.message 里就躺着"node node_js: component not found"这样的原话。
把这两个动作放在一起,运维能看到的现象是:一条链莫名其妙变成灰色(未启用),点开一看,属性面板里有一行红字说明为什么。不需要翻日志、不需要问开发------失败原因是随规则链一起存下来的。
更重要的是它不扩散。一条链加载失败,Deploy 返回错误给这一次请求,进程照常跑,其他链照常工作,端口照常监听。规则的错误被关在规则的边界内。
八、每个节点最近 40 条报文
规则引擎最难排查的问题不是"报错",是"消息进来了但没往下走"。过滤条件判成 false、连线类型选错、脚本返回了空------这些都不报错,只是安静地什么都不做。
所以需要能看到消息在节点之间的流动。这块的实现是 RuleChainDebugData,一个三层的固定长度队列:
type RuleChainDebugData struct {
//Data 规则链ID->节点列表调试数据
Data map[string]*NodeDebugData
// MaxSize 每个节点允许的最大数量
MaxSize int
mu sync.RWMutex
}
type NodeDebugData struct {
Data map[string]*FixedQueue
MaxSize int
mu sync.RWMutex
}
chainId → nodeId → FixedQueue。队列本身是个切片,满了就砍头:
func (q *FixedQueue) Push(item DebugData) {
q.mu.Lock()
defer q.mu.Unlock()
if len(q.Items) == q.MaxSize {
q.Items = q.Items[1:]
}
q.Items = append(q.Items, item)
}
q.Items = q.Items[1:] 这一行是 Go 里最省事的"丢掉最旧的"写法------不搬数据,只把切片头往后挪一格。代价是底层数组会缓慢往前漂,攒够一次扩容才真正腾出空间;对一个最多四十个元素的队列来说,这点开销可以忽略。
每条记录里存的是 OnDebug 回调给的完整上下文:
type DebugData struct {
Ts int64 `json:"ts"`
NodeId string `json:"nodeId"`
FlowType string `json:"flowType"` // IN / OUT
Msg types.RuleMsg `json:"msg"`
RelationType string `json:"relationType"` // Success / True / False ...
Err string `json:"err"`
}
FlowType 是进去还是出来,RelationType 是从哪个出口走的。这两个字段合起来,就能回答"这条消息为什么没走到下一个节点"------它在 node_js 的 OUT 里,relationType 是 False,说明过滤条件判假了,问题在条件不在连线。
容量从配置来,max_node_log_size,默认 40:
maxNodeLogSize := c.MaxNodeLogSize
if maxNodeLogSize == 0 {
maxNodeLogSize = 40
}
为什么是 40 而不是 4000?因为这是内存里的窗口,不是历史归档 。规则链跑起来每秒几十条消息,四百条也只是十秒的事;存多了既占内存又没人看。真要看历史,save_run_log 会把运行日志落到 runs/ 目录。源码注释写得很直白:"如果需要查询历史数据,请把调试日志数据存放数据库等可以持久化载体"。
查询接口按时间倒序 + 分页:
sort.Slice(list.Items, func(i, j int) bool {
return list.Items[i].Ts > list.Items[j].Ts
})
最新的在最上面,符合排查时"我刚发的那条消息呢"的直觉。
九、一次回调,三个出口
上面的数据是 OnDebug 回调喂进去的。这个回调在 InitRuleGo 里注册,只有一处,但它扇出到三个消费者:
ruleConfig.OnDebug = func(chainId, flowType string, nodeId string, msg types.RuleMsg, relationType string, err error) {
var errStr = ""
if err != nil {
errStr = err.Error()
}
if s.config.Debug {
logger.Printf("chainId=%s,flowType=%s,nodeId=%s,data=%s,err=%s",
chainId, flowType, nodeId, msg.Data, err)
}
//把日志记录到内存管理器,用于界面显示
s.ruleChainDebugData.Add(chainId, nodeId, DebugData{
Ts: time.Now().UnixMilli(),
NodeId: nodeId,
FlowType: flowType,
Msg: msg,
RelationType: relationType,
Err: errStr,
})
s.OnDebug(chainId, flowType, nodeId, msg, relationType, err)
}
三个出口,各有各的读者:
| 出口 | 消费者 | 场景 |
|---|---|---|
logger.Printf |
日志文件 | 事后排查,debug: true 才开 |
ruleChainDebugData.Add |
HTTP 接口 /api/v1/logs/debug |
界面上点开某个节点看最近报文 |
s.OnDebug |
WebSocket 观察者 | 链在跑的时候实时看流动 |
第一个出口受 debug 开关控制,因为它是唯一会落盘的。另外两个都在内存里,开销只有一次 map 查找和一次切片追加,所以常开。
这里的设计取向很清楚:同一个事件点,不同时效性的消费方式分开。要历史就开日志开关,要近期就查内存窗口,要实时就挂 WebSocket。三者共用一次回调,不需要在三个地方各埋一遍点。
十、调试流是一条 WebSocket
第三个出口 s.OnDebug 是给实时观测用的:
func (s *RuleEngineService) OnDebug(chainId, flowType string, nodeId string,
msg types.RuleMsg, relationType string, err error) {
s.locker.RLock()
defer s.locker.RUnlock()
for _, observer := range s.onDebugObserver {
if observer.chainId == chainId {
go observer.fn(chainId, flowType, nodeId, msg, relationType, err)
}
}
}
三层判断:读锁、按 chainId 过滤、go 出去异步执行。为什么必须异步?因为观察者的 fn 最终是要往 WebSocket 上写字节的,写操作可能因为对端网络慢而阻塞。如果同步调,一条卡住的浏览器标签页就能把整条规则链的处理协程拖死。go observer.fn(...) 把这段阻塞隔离出去,规则链该多快还是多快。
观察者的注册和摘除,挂在 WebSocket endpoint 的连接事件上。路由是 /api/v1/logs/ws/:chainId/:clientId------链 ID 和客户端 ID 都在路径里:
case endpointApi.EventConnect:
exchange := params[0].(*endpointApi.Exchange)
username := exchange.In.Headers().Get(constants.KeyUsername)
if username == "" {
username = config.C.DefaultUsername
}
if s, ok := service.UserRuleEngineServiceImpl.Get(username); ok {
chainId := exchange.In.GetParam(constants.KeyChainId)
clientId := exchange.In.GetParam(constants.KeyClientId)
s.AddOnDebugObserver(chainId, clientId, func(...) {
// 组装 map → json.Marshal → exchange.Out.SetBody
if exchange.Out.GetError() != nil {
s.RemoveOnDebugObserver(clientId)
}
})
}
三个细节值得注意。
clientId 是观察者的 key。 同一个用户可能开着两个浏览器标签页看两条不同的链,也可能是两个人同时在看同一条链。用 clientId 做 key,各自收各自的,互不干扰。
写失败就地摘除。 exchange.Out.GetError() != nil 说明这一帧没写出去------对端已经关了。这时候立刻 RemoveOnDebugObserver,而不是等下一次回调再失败一次。这是 SSE 那篇文章里"慢连接直接踢掉"的同一个思路:消费者跟不上,就从消费者列表里删掉,不要让它拖累生产者。
断线还有一道兜底。 EventDisconnect 也会摘:
case endpointApi.EventDisconnect:
// ...
s.RemoveOnDebugObserver(exchange.In.GetParam(constants.KeyClientId))
连接正常关闭走这条,异常关闭走上面那条写失败的分支。两条路覆盖完,onDebugObserver 这张表不会随运行时间变长------这一点很关键,一个常驻几周的规则引擎,如果观察者表只增不减,最后会变成内存里的垃圾场。RemoveOnDebugObserver 里那句 Printf("debug observer length=%d") 就是留给运维看的:这个数应该跟当前在看的页面数差不多,涨上去就有问题。
十一、入口那一步还有防抖
规则链的输入端是 endpoint/sagoo/devicein 这个自定义 endpoint,注册在 ruleserver/components/devicein。它把"设备上报"抽象成四种输入事件:
const (
DeviceOnLine = "online"
DeviceOffLine = "offline"
DeviceReportAttribute = "reportAttribute"
DeviceReportEvent = "reportEvent"
)
属性上报和事件上报各对应一个主题模板:
switch formStr {
case DeviceReportAttribute:
topicTxt = "/sys/%s/%s/thing/event/property/post"
case DeviceReportEvent:
topicTxt = "/sys/%s/%s/thing/event/+/post"
}
deviceKey := x.Config.DeviceKey
if x.Config.DeviceKey == "" {
deviceKey = "+"
}
if topicTxt != "" {
topic := fmt.Sprintf(topicTxt, x.Config.ProductKey, deviceKey)
x.client.Subscribe(topic, 1, x.handler(router))
}
DeviceKey 留空就退化成 +,一条链吃整个产品下所有设备的上报;填了具体值就只吃那一台。这个"通配靠留空"的约定,比让用户自己写 + 要少踩坑------画布上没人愿意记 MQTT 的通配符语义。
订阅用的 QoS 1,也就是至少一次。对规则触发来说这个选择是对的:漏一条告警比重复一条告警代价大得多,重复可以由链里的去重逻辑处理,漏了就没机会了。
再往上一层,还有个可选的防抖:
if x.Config.AntiShake {
if x.debounceController.ShouldProcess() {
x.dataHandler(router, c, data)
}
} else {
x.dataHandler(router, c, data)
}
debounce.Controller 的判定条件是两个,谁先满足都放行:
now := time.Now()
c.count++
// 判断是否达到数量阈值
if c.config.Threshold > 0 && c.count >= c.config.Threshold {
c.reset()
return true
}
// 判断是否达到时间间隔
if c.config.Interval > 0 && now.Sub(c.lastTime) >= c.config.Interval {
c.reset()
return true
}
return false
再加一个 ProcessFirstItem:开启后第一条数据一定放行,不等阈值也不等时间窗。
这套组合解决的是很具体的现场问题。传感器上报频率高(比如每秒一次),但规则要判断的是"温度持续偏高",不需要每条都算。配上 AntiShakeNum = 10、时间窗 30 秒,效果就是:每十条算一次,或者攒够半分钟算一次,首条立即算。
这里有个容易忽略的点:ShouldProcess 返回 false 的时候,消息是直接丢弃的,既不进队列也不缓存。这不是缺陷,是防抖的定义------它要的是"降频",不是"攒批"。如果要攒批,应该用别的组件。把这两件事分开,语义才干净。
handler 本身还包了一层 recover:
defer func() {
if e := recover(); e != nil {
x.Printf("mqtt endpoint handler err : %v", runtime.Stack())
}
}()
规则链是可配置的,里面可能有用户写的 JS、Lua、甚至 shell。任何一个节点 panic,都不能让 MQTT 的收包协程挂掉------挂了就是整条链路静默。这层 recover 是最后一道网。
十二、启动时把配置灌进引擎
进程启动时,InitRuleGo 做的事比"加载规则"多。它把配置文件里的东西翻译成引擎的运行时参数:
for k, v := range s.config.Global {
ruleConfig.Properties.PutValue(k, v)
}
ruleConfig.Properties.PutValue(luaEngine.LoadLuaLibs, s.config.LoadLuaLibs)
ruleConfig.Properties.PutValue(action.KeyExecNodeWhitelist, s.config.CmdWhiteList)
ruleConfig.Properties.PutValue(action.KeyWorkDir, s.config.DataDir)
if s.config.ScriptMaxExecutionTime > 0 {
ruleConfig.ScriptMaxExecutionTime = time.Millisecond * time.Duration(s.config.ScriptMaxExecutionTime)
}
四行配置,四个安全边界:
global------ 自定义全局变量,规则里用${global.xxx}取值。改一个地址不用去改十份 DSL。cmd_white_list------exec节点能跑哪些命令。默认给了一串cp,scp,git,make,docker,kubectl,helm,ansible...,白名单之外一律拒绝。规则链是配置,配置能执行任意 shell 就等于把服务器交出去。work_dir------ 脚本类节点的工作目录,固定到data_dir下面,相对路径不会跑到别处。script_max_execution_time------ 脚本执行上限,毫秒。防的是死循环:一段while(true)的 JS 不该把整个规则引擎挂死。
然后加载三样东西,顺序是 js → 插件 → 规则:
//加载js
jsPath := path.Join(workspacePath, "js")
err := s.loadJs(jsPath)
//加载组件插件
pluginsPath := path.Join(workspacePath, "plugins")
err = s.loadPlugins(pluginsPath)
//加载规则链
rulesPath := path.Join(workspacePath, constants.DirWorkflows, username, constants.DirWorkflowsRule)
_ = s.loadRules(rulesPath)
loadJs 扫 js/*.js,用 goja 预编译,再用文件名注册成 UDF:
if p, err := goja.Compile(file, string(b), true); err != nil {
s.logger.Printf("Compile js file=%s err=%s", file, err.Error())
} else {
s.ruleConfig.RegisterUdf(path.Base(file), types.Script{
Type: types.Js,
Content: p,
})
}
预编译的好处是:语法错误在启动时就暴露(打到启动日志里),而不是等第一条消息进来才炸。而且编译产物被复用,每条消息省掉一次解析。文件名即函数名,js/util.js 里的函数在 DSL 里就能直接用 util 调------这个约定比在 DSL 里写一段 import 路径省事得多。
loadPlugins 扫 plugins/*.so,走 Go 的 plugin 机制注册自定义组件。规则链里想用一个业务特有的节点,不用改 ruleserver 的代码重新编译,编译成 .so 丢进去就行。
loadRules 最后加载 DSL,并且只加载已启用的:
enableDataList, _ := dcache.GetRuleChainData()
var enableList []string
for k := range enableDataList {
enableList = append(enableList, k)
}
for _, filePath := range paths {
if len(enableList) > 0 {
chainKey := GetFileNameWithoutExt(filePath)
if !slices.Contains(enableList, chainKey) {
continue
}
}
// ...
}
启用清单从缓存里取(dcache.GetRuleChainData,扫的是 Redis 里 RuleChain 前缀的 key),文件名去掉扩展名就是 chainId。这个过滤的意义在于:磁盘上有多少链是一回事,进程里跑多少链是另一回事。一个用户可能存了五十条链做参考,实际在跑的只有八条;启动时只加载这八条,冷启动时间跟链的总数无关。
十三、文件当数据库的边界
话说到这里得把边界讲清楚,不然容易把"文件存储"当成万能方案。这套做法的适用面是明确的:
| 维度 | 文件方案的实际情况 |
|---|---|
| 规模 | 单用户百级规则链毫无压力,列表页靠索引一次读完 |
| 部署形态 | 单机 / 单实例。规则引擎进程本来就该跟设备接入跑在同一台机器上 |
| 版本管理 | 目录直接纳入 Git,改一条链就是一次 diff |
| 备份恢复 | 打包 workflows/ 一个目录,恢复就是解压 |
| 跨节点共享 | 不提供。多节点部署时各节点有各自的规则副本 |
| 并发写 | 按用户目录分片,同一用户的写操作串行经过 RuleDao 的读写锁 |
第 5 行是唯一的硬边界,也是最该说清的地方:规则链是配置,不是共享状态。多条规则引擎实例各自持有一份规则,谁也不需要看到别人正在编辑什么。真要做多节点统一管理,做法是从管理端下发同一份 DSL,而不是让几个进程去抢同一个文件。
反过来看,文件方案换来的东西在这个场景里更值钱:
第一,规则链可以进 Git。 这一点在客户现场的价值比想象中大。规则改了导致误告警,git log 一看就知道是哪天谁把阈值从 65 改成 55 的,git revert 一下就好。如果规则存在数据库的 text 字段里,你得自己搭一套审计表才能达到同样效果。
第二,迁移和克隆零成本。 就是开头那一幕:拷目录。测试环境复现生产问题,把生产的 workflows 拷到测试机,规则一模一样,不需要"导出再导入"这种容易漏步骤的流程。
第三,故障范围天然收敛。 一个用户的规则目录坏了,只影响这个用户;一条链的 JSON 坏了,rebuildIndex 会跳过它,其他链照常加载。文件系统这一层的隔离,比在一个共享表里靠 where 过滤要硬。
第四,规则引擎可以独立部署。 ruleserver 有自己的 data_dir、自己的 HTTP 端口(默认 :9090)、自己的日志文件。它可以跟主程序同机,也可以单独跑在一台机器上,只要目录在本地就行------不需要它也去连一遍 MySQL。
结语
回头看这个模块,所有设计都围绕一个很朴素的判断:规则链是配置,配置就该是文件。
从这个判断往下推,很多做法就变成了必然:
- 因为配置是文件,所以一条链是一个 JSON,
cp就是复制,git就是版本管理。 - 因为配置是文件,所以状态只需要一个
disabled布尔位------文件在不在是"有没有",disabled是"跑不跑",不需要第三第四个状态。 - 因为配置是文件,所以加载失败可以直接把错误写回文件,让失败原因跟着规则走。
- 因为配置是文件,所以热更新只需重新读文件、原地
ReloadSelf,不用重启进程。
而"看得见"这件事,是这套设计能落地的另一半。规则链再优雅,如果消息进来不往下走的时候没人知道为什么,用户就不会信任它。所以有了每个节点 40 条的环形窗口、有了 FlowType 和 RelationType 这两个字段、有了一条按 chainId 过滤的 WebSocket 调试流,以及观察者在写失败和断线时自动摘除的收尾。
写配置的系统很多,难的是让人敢改。文件 + 一个布尔位 + 一扇调试窗口,是 SagooIoT 给出的答案。
项目地址
- 主仓库:https://github.com/sagoo-cloud
- 文档站:https://iotdoc.sagoo.cn
- 社区版源码:https://github.com/orgs/sagoo-cloud/repositories
本文代码基于 SagooIoT 专业版本地发行版 ruleserver 目录(service/engine.go、service/debug_data.go、dao/rule.go、constants/constants.go、config/config.go、router/websocket.go、controller/rule.go、controller/run_log.go、components/devicein/devicein.go)与 pkg/utility/debounce,规则引擎内核使用 rulego。