接入清单摊开的时候,差别往往不在协议栈上,而在报文长得什么样。同样是"上报一次温湿度",三台设备可能长成这样:
{"seq": 8812, "t": 235, "h": 611, "on": 1}
{"type": "report", "ts": 1729734000, "body": {"temp": "23.5", "humi": "61.1"}}
{"d": [{"k": "T", "v": 23.5}, {"k": "H", "v": 61.1}], "ver": 2}
字段名不同、数值量纲不同、甚至结构本身不同。但它们都是合法的 JSON,都能被同一个 MQTT Broker 收下来,也都能在十分钟内接进 SagooIoT ------ 前提是不去改 Go 代码。
这篇文章讲的就是这层"不改代码"的机制:SagooIoT 在产品上留了一个字段叫 script_info,里面放一段 JavaScript,平台只跟这段脚本约定三个函数名。剩下的翻译工作,全在脚本里完成。
一、把翻译层挪出编译期
传统做法是把设备差异写成驱动:一个厂家一个包,字段映射写死,改一次要重新编译、重新打包、重新发版。这套流程在设备型号稳定的场景里没问题,但在项目现场往往会退化成一个很别扭的循环:现场发现某台设备的量纲是 0.1 度、某台多了个状态位、某台把温度放在数组里,每一条都要回到研发排期。
SagooIoT 的选择是把这层翻译从代码里挪出来,挪到产品的配置里。它不是一个"高级选项",而是接在数据主链路上的一段固定工序:
- 产品实体上有一个
scriptInfo字段(数据库列script_info,MySQL 里是text+utf8mb4); - 有脚本就用,没有脚本就跳过,两条路互不影响;
- 脚本的输入是 JSON 文本,输出也是 JSON 文本,中间是纯 JS。
二、三个函数名,一份最小契约
pkg/jsinterpreter 这个包只有七十多行,但它定义了整套约定中最关键的部分------名字:
const (
FunctionNameByParse = "parse" // 数据接收处理解析函数名
FunctionNameBySend = "send" // 数据下发处理函数名
FunctionNameByReply = "reply" // 数据回复处理函数名
)
三个常量对应三个方向:
| 函数名 | 触发时机 | 输入 | 输出 |
|---|---|---|---|
parse |
设备上报数据(属性上报、网关批量上报) | 设备侧 JSON | 平台标准物模型报文 |
send |
平台向设备下发(服务调用等) | 平台标准报文 | 设备能听懂的报文 |
reply |
设备回复平台的方法调用 | 设备侧回复 JSON | 平台标准回复报文 |
也就是说,接入一台新设备,脚本作者要回答三个问题:收到的东西怎么变成平台认识的、平台发出去的东西怎么变成设备认识的、设备回的话怎么翻译回来。三个函数名一写,剩下的自由发挥。
三、第四个名字,是拼出来的
事件是物模型里比较特殊的一类:一个产品可以定义很多事件(告警、故障、上线通知......),每个事件都可能有自己的回复报文。如果也占用一个固定的函数名,就得在脚本里写一串 switch 分发。
SagooIoT 的处理方式是让函数名带后缀。事件上报走 thing.event.<eventKey>.post,如果要自定义这个事件的回复,就在脚本里写一个 event_reply_<eventKey>:
// 高温告警事件的回复
function event_reply_high_temp(data) {
return {
code: 200,
id: data.id,
message: "received",
method: "thing.event.high_temp.post_reply",
version: "1.0"
};
}
平台侧是这样用的:
checkEventReply, err := jsinterpreter.HasMethod(
data.DeviceDetail.Product.ScriptInfo,
"event_reply_"+eventKey,
)
if checkEventReply {
// 走脚本自定义的回复报文
}
注意这里先调用的是 HasMethod 而不是直接执行。HasMethod 会把脚本跑一遍,然后 vm.Get(methodName) 看这个位置是不是一个函数:
func HasMethod(jsCode, methodName string) (bool, error) {
vm := otto.New()
_, err := vm.Run(jsCode)
if err != nil {
return false, fmt.Errorf("error running JavaScript: %v", err)
}
value, err := vm.Get(methodName)
if err != nil {
return false, fmt.Errorf("error getting method: %v", err)
}
return value.IsFunction(), nil
}
有就调用,没有就回平台标准的 ReportEventReply。这个"先探测后调用"的顺序很关键:它让事件回复变成一个可选增强项 ------ 同一个脚本里,你只为需要特殊回复的事件写函数,其余事件自动走标准回执,不需要写一个"空实现"占位。
四、调哪个函数,由消息类型决定
脚本作者不声明"这段脚本是给什么消息用的",声明权在平台侧。MQTT 直连链路里,processMessage 拿到消息后先看 logType:
if deviceInfo.Product.ScriptInfo != "" {
functionName := ""
switch logType {
case consts.MsgTypePropertyReport, consts.MsgTypeGatewayBatch:
functionName = jsinterpreter.FunctionNameByParse
case consts.MsgTypeFunctionReply:
functionName = jsinterpreter.FunctionNameByReply
}
if functionName != "" {
jsonData, err = jsinterpreter.RunScriptWithData(functionName, deviceInfo.Product.ScriptInfo, jsonData)
if err != nil {
return string(payload), err
}
}
}
这段 switch 有两个设计意图。
一是同一个脚本服务多条链路 。属性上报用 parse、方法回复用 reply,都从同一份 script_info 里取函数,产品上不需要挂多份脚本,也不用担心两份脚本版本不一致。
二是语义由主题决定,不由脚本自述 。消息是属性上报还是方法回复,在 MQTT 主题解析阶段就已经确定了(logType 是订阅时注册进去的),脚本没有机会"自报家门"。这消除了一整类故障:脚本把一条属性上报误判成方法回复,平台不会跟着一起错。
五、进出都是字符串,中间是对象
把 JS 引擎嵌进 Go 程序最容易翻车的地方是类型:Go 的结构体、map[string]interface{}、float64 与 int 之间来回倒。SagooIoT 把边界收得很窄 ------ 进出都是 JSON 字符串。
RunScriptWithData 一共四步:
func RunScriptWithData(functionName, script, jsonData string) (string, error) {
if functionName == "" || script == "" || jsonData == "" {
return "", errors.New("RunScriptWithData: invalid parameters")
}
vm := otto.New() // 1. 新建 VM,注入脚本
_, err := vm.Run(script)
var data interface{}
err = json.Unmarshal([]byte(jsonData), &data) // 2. JSON -> Go 值
value, err := vm.Call(functionName, nil, data) // 3. 调用约定函数
result, err := value.Export() // 4. 返回值 -> Go 值
jsonResult, err := json.Marshal(result)
return string(jsonResult), nil
}
站在脚本作者的角度,看到的东西非常朴素:parse 收到一个对象,返回一个对象。不需要知道 otto 是什么,也不需要知道 Export 是什么。
function parse(data) {
return {
id: data.seq ? String(data.seq) : String(Date.now()),
version: "1.0",
method: "thing.event.property.post",
params: {
temperature: data.t / 10,
humidity: data.h / 10,
switch: data.on === 1 ? "on" : "off"
}
};
}
上面这段对应清单里的第一台设备:t: 235 变成 23.5,on: 1 变成物模型里的枚举值,seq 借位成报文 id。清单里第二台把数值塞在字符串里、第三台用 [{k,v}] 数组,都只是换个写法的事:
function parse(data) {
var params = {};
data.d.forEach(function (item) {
if (item.k === "T") params.temperature = Number(item.v);
if (item.k === "H") params.humidity = Number(item.v);
});
return {
id: String(data.ts || Date.now()),
version: "1.0",
method: "thing.event.property.post",
params: params
};
}
第四步的 json.Marshal 有个附带效果:脚本返回什么类型,平台都能接住。返回对象就是标准 JSON;返回字符串,会被序列化成带转义的 JSON 字符串 ------ 平台随后会把反斜杠清理掉(下一节会讲),所以脚本里用模板拼一段设备私有报文也是可行的。
六、两级翻译:插件管字节,脚本管语义
脚本的前提是"输入已经是 JSON"。可现实里大量设备吐的是十六进制帧、定长二进制、或者带包头包尾的自定义串。这类问题脚本解决不了,也不该由脚本解决。
SagooIoT 把这条链路拆成两级。第一级是协议插件,负责把字节流变成 JSON:
if deviceInfo.Product.MessageProtocol != consts.DefaultProtocol && deviceInfo.Product.MessageProtocol != "" {
pluginData, err := plugins.GetProtocolPlugin().
GetProtocolDecodeData(deviceInfo.Product.MessageProtocol, payload)
if err != nil {
return string(payload), errors.New(...)
}
pluginDataByte, _ := json.Marshal(pluginData.Data)
jsonData = string(pluginDataByte)
}
第二级才是脚本,负责把 JSON 变成物模型 JSON。
两级分工带来一个很实际的好处:协议栈的复杂度被关在插件里,业务语义的差异被留在脚本里。Modbus 的寄存器偏移、DL/T 645 的帧格式,这些一辈子只写一次的东西放在插件;"这台设备的温度要除以 10、那台要减 40"这种一次性的、随项目变化的东西放在脚本。两边都不用为对方的变动重新发版。
网关场景更明显。tunnel-base.go 里的 router 走的是同一条顺序:
// 通过消息协议插件解析数据
pluginData, err := plugins.GetProtocolPlugin().GetProtocolDecodeData(productDetail.MessageProtocol, data)
...
// 如果有js脚本,根据js脚本处理解析后的数据,处理后的数据数据格式为默认的消息协议格式
if productDetail.ScriptInfo != "" {
res, runScriptErr = jsinterpreter.RunScriptWithData(jsinterpreter.FunctionNameByParse, productDetail.ScriptInfo, res)
}
注释里那句话就是这套设计的目标:处理后的数据格式为默认的消息协议格式。不管前面经过了几层,到这一步都必须收敛成平台认识的样子。
七、网关场景下,脚本还兼职做路由
子设备接网关时,一条上行消息里可能混着好几个子设备的数据,平台得知道该交给哪个物模型处理。tunnel-base.go 从脚本输出里读两个字段:
modelFuncName, ok := dataInfo["model_func_name"].(string)
...
modelIdentifyName, ok := dataInfo["model_func_identify"].(string)
...
handleF := tunelBase.GetModelHandle(modelFuncName)
if modelFuncName == tunelBase.UpProperty {
modelIdentifyName = "property"
}
于是网关的 parse 除了翻译字段,还顺手把路由标出来:
function parse(data) {
return {
model_func_name: "up_property",
model_func_identify: data.subDevId,
id: String(Date.now()),
version: "1.0",
method: "thing.event.property.post",
params: {
temperature: Number(data.t) / 10
}
};
}
把"这是哪个子设备"的判断放进脚本,而不是做成平台侧的固定规则,是有道理的:子设备编号藏在报文的第几个字节、用的是 MAC 还是短地址、需不需要从网关缓存里反查,这些全看厂家怎么定。让脚本决定,平台只需要认识 model_func_name 这一个枚举。
八、没有脚本的产品,走的是原来的路
这一点值得单独说:整条链路上,脚本永远是一个 if。
if deviceInfo.Product.ScriptInfo != "" {
// ... 跑脚本
}
return strings.ReplaceAll(jsonData, "\\", ""), nil
没有脚本的产品,jsonData 就是原始 payload(或插件解码结果),后面的事情一模一样。这意味着:
- 已经接好的标准协议产品,不会因为引入脚本机制而多一次函数调用、多一次序列化;
- 脚本出问题时(语法错、函数不存在、JSON 不合法),
processMessage会带着原始payload返回错误,而不是把半成品往下游递; - 同一套
sagoo-core可以同时服务"贴一段脚本就能接"和"必须走插件"的两类设备。
九、下发方向的两次善后
上行链路相对干净,下行更麻烦一点,因为报文最终要出门。down/service/service.go 在脚本返回之后做了两件事:
requestDataStr := ""
if request.DeviceDetail.Product.ScriptInfo != "" {
requestDataStr, runScriptErr = jsinterpreter.RunScriptWithData(
jsinterpreter.FunctionNameBySend, request.DeviceDetail.Product.ScriptInfo, string(requestData))
}
// 对存在转义字符的进行全量替换
requestDataStr = strings.ReplaceAll(requestDataStr, "\\", "")
// 对存在变量的定义进行全量替换
requestDataStr = utils.PerformMultipleReplacements(requestDataStr, map[string]string{
"$deviceKey": request.DeviceDetail.Key,
"$productKey": request.DeviceDetail.Product.Key,
})
第一次善后是反斜杠。 脚本返回的字符串会被 JSON 序列化一层," 变成 \"。设备端不认识这种转义,所以出门前统一清掉。有了这一步,脚本里就可以直接写模板:
function send(data) {
return JSON.stringify({
cmd: "SET",
dev: "$deviceKey",
seq: data.id,
body: data.params
});
}
第二次善后是变量替换。 $deviceKey / $productKey 在下发前被换成真实的设备标识和产品标识。它的价值在于脚本可以被复制 :同一个产品的所有设备共用一份 script_info,脚本里不写死任何设备编号,换一台设备、换一个产品,脚本原样搬过去就能用。
十、改完就生效,还能先试跑
脚本是配置,配置就得管生效时机。UpdateScriptInfo 里除了写库,还有关键的一行:
_, err = dao.DevProduct.Ctx(ctx).
Data(devProduct).
Where(dao.DevProduct.Columns().Key, in.Key).
Update()
// 更新缓存
err = service.DevDevice().CacheDeviceDetailListByProduct(ctx, devProduct.Key)
CacheDeviceDetailListByProduct 按产品维度刷新设备详情缓存。因为脚本是挂在产品上的、运行时从设备详情的 Product.ScriptInfo 取,所以刷完缓存,这个产品下所有设备的下一帧报文就已经用上新脚本了 ------ 不用重启 sagoo-core,不用逐台设备重连。
改之前可以先试跑。产品接口上留了两个入口:
type UpdateScriptInfoReq struct {
g.Meta `path:"/script/update" method:"put" summary:"脚本更新" tags:"产品"`
*model.ScriptInfoInput
}
type RunScriptReq struct {
g.Meta `path:"/script/run" method:"post" summary:"脚本执行" tags:"产品"`
FunctionName string `json:"functionName"`
ScriptInfo string `json:"scriptInfo" v:"required#脚本信息不能为空"`
JsonData string `json:"jsonData"`
}
/script/run 的实现只有几行 ------ 拿函数名、脚本、一段样例 JSON,直接跑:
func (c *cProduct) RunScript(ctx context.Context, req *product.RunScriptReq) (res *product.RunScriptRes, err error) {
data, err := jsinterpreter.RunScriptWithData(req.FunctionName, req.ScriptInfo, req.JsonData)
if err != nil {
return nil, err
}
res = &product.RunScriptRes{Data: data}
return
}
这就是一个"离线调试台":把现场抓到的一帧报文贴进去,选 parse,立刻看到平台最终会拿到什么。调试不需要设备在线,也不需要发布。
十一、沙箱边界:一台 VM,一次调用
jsinterpreter 用的是 github.com/robertkrimen/otto,纯 Go 实现的 ES5 解释器。这个选择决定了几件事:
| 维度 | otto 带来的结果 |
|---|---|
| 依赖 | 纯 Go,无 CGO,交叉编译不受影响 |
| 部署 | 后端产物里直接带上引擎,不需要额外的运行时或 V8 动态库 |
| 隔离 | 每次调用 otto.New() 建独立 VM,脚本之间不共享全局变量 |
| 语法 | ES5 为主,够写字段映射、数组遍历、字符串拼接 |
"一次调用一台 VM"在 SaaS 场景里是必要的:脚本是用户自己写的,谁也不能保证上一台设备的脚本里没有留下全局状态。每次从零开始,等于把"脚本写错影响别人"的可能性关在门外。
被放弃的是 ES6+ 的语法糖。这是个有意的取舍 ------ 脚本的职责是翻译报文,不是写业务系统。箭头函数、let、模板字符串这些省下来的字符,换来的是一个不需要 CGO、不需要 Node 运行时、任何平台都能直接跑的编译器产物。
十二、什么写进脚本,什么写进插件
最后给一个判断口径。两者都能"处理数据",但边界其实很清楚:
| 特征 | 写进产品脚本 | 写进协议插件 |
|---|---|---|
| 输入形态 | 已经是 JSON | 二进制帧 / 十六进制 / 自定义串 |
| 变化频率 | 随项目、随设备型号变 | 随协议规范变,极少改 |
| 谁来写 | 实施、现场工程师 | 研发 |
| 生效方式 | /script/update + 刷缓存,立即生效 |
安装插件、重启进程 |
| 典型工作 | 字段改名、量纲换算、枚举映射、子设备路由、自定义回执 | 寄存器读取、帧头校验、CRC、编解码 |
一句话概括:跟字节打交道的归插件,跟字段打交道的归脚本。
结语
设备接入这件事,难的部分从来不是"支持多少种协议",而是"这一台和上一台差在哪"。差在字段名,就不该动代码;差在字节序,才该动插件。
SagooIoT 用一个 script_info 字段和三个函数名(parse、send、reply),把前者从编译期挪到了运行期。它不是什么新概念,但确实把现场那句"这个要排期"变成了"这个我自己改一下"。
项目地址
主仓库:https://github.com/sagoo-cloud
本文代码基于 SagooIoT 专业版本地发行版,涉及 pkg/jsinterpreter、network/core/router.go、network/core/tunnel/tunnel-base.go、network/core/logic/model/up/event/event.go、network/core/logic/model/down/service/service.go、internal/controller/product/product.go、internal/logic/product/dev_product.go。