把规则链存成文件之后,上线一条规则只需改一个字段

上周帮一个客户换服务器。新机器上系统装完,数据库倒完,运维问了一句:规则引擎里那十几条联动规则,是走 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())

这段代码有三个很"运维友好"的选择:

  1. .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 给出的答案。

项目地址

本文代码基于 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。

相关推荐
IT规划师4 小时前
MQTT 实战 - 放下炫技,8 年踩坑的真心话与方法论(收官)
物联网·.net
SEO_juper4 小时前
Go 站点地图生成器:从首页爬全站,一键生成 sitemap.xml
xml·开发语言·人工智能·golang·node.js·seo·外贸独立站
Quanqiucard13 小时前
电价 15 分钟一调,光伏物联网卡选错,断一次就没依据
物联网
MuMuMu122313 小时前
越华环保集团碳惠小屋:存量场景终端选型,全生命周期成本分析方案
物联网
数字新视界19 小时前
信创认证动环监控在提升机房环境安全与效率中的关键作用
物联网·数据可视化·数据中心基础设施管理·dcim管理系统·数据中心dcim
GIS数据转换器19 小时前
基于CIM的城市智慧供热监管服务平台
java·大数据·开发语言·人工智能·后端·物联网·struts
螺蛳粉 螺蛳粉21 小时前
CDH第七篇:CDH 集成 Kafka 实战:CSD + Parcel 组合拳,先装 ZooKeeper 是铁律
物联网·zookeeper·kafka·消息队列·parcel·cdh集成·csd