
先看一个目录。本地专业版的plugins/built下ls -lh输出的是这样九行$ ls -lh plugins/built -rwxr-xr-x 22M notice-dingding -rwxr-xr-x 22M notice-mail -rwxr-xr-x 27M notice-sms -rwxr-xr-x 23M notice-webhook -rwxr-xr-x 23M notice-wework -rwxr-xr-x 22M protocol-modbus -rwxr-xr-x 22M protocol-radar -rwxr-xr-x 22M protocol-tf100 -rwxr-xr-x 22M protocol-tgn52二十二兆一个全是可执行文件file一看都是原生二进制。第一次翻到这个目录的人通常会问一句一个物联网平台为什么要把自己的功能拆成九个独立程序摆在这儿因为在这套设计里sagoo-core并不认识 Modbus也不认识邮件服务器。它只知道有一个叫modbus的插件你把一段字节丢给它它还你一段 JSON有一个叫mail的插件你把一段 JSON 丢给它它负责把信寄出去。至于字节怎么凑、SMTP 怎么连那是插件自己的事宿主一个字都不用写。这篇文章就围绕这条边界往下拆插件和宿主怎么互相认出来、数据长什么样、什么时机被拉起来、装和更新走什么路。一、九个文件两种身份按前缀分这九个文件其实是两类类型目录里的名字数量职责protocolprotocol-modbus、protocol-radar、protocol-tf100、protocol-tgn524把设备私有报文翻译成统一 JSON反方向也要能编回去noticenotice-mail、notice-sms、notice-webhook、notice-wework、notice-dingding5把一条通知按各自渠道的规矩发出去protocol处理的是进来和出去处理对象是设备报文notice处理的是只出不进处理对象是告警、场景通知、运维提醒。两类插件在宿主侧走的是同一套管理器只是接口不同、注入的plugin.Plugin实现不同。值得说的是为什么做成进程而不是包。做成包插件崩了宿主跟着崩插件换版本要重编译整个平台第三方想写插件还得把宿主源码拉下来对齐依赖。做成进程之后插件是一个能独立编译、独立替换的文件崩溃的边界就是操作系统的进程边界。二、握手先对暗号进程之间要先互相确认身份。module/module.go里定义了一组常量var HandshakeConfig plugin.HandshakeConfig{ ProtocolVersion: 1, MagicCookieKey: SAGOO_PLUGIN, MagicCookieValue: sagoo_plugin, }这三行不是装饰。MagicCookieKey会被写进子进程的环境变量插件启动后必须先读到这个值、比对通过才愿意进入 RPC 服务模式值不对就直接退出。作用很实在有人误把protocol-modbus当命令行工具双击运行或者把别的程序的二进制改名塞进插件目录都会在握手阶段被挡下来不会出现程序起来了但行为莫名其妙的情况。ProtocolVersion则负责版本对齐。宿主和插件是同一天编译的这个值一致将来接口真需要变改版本号就能让旧插件明确拒绝而不是在某个字段上悄悄错位。两边用的都是github.com/hashicorp/go-plugin它把起子进程、握手、建立 RPC 连接、进程退出时回收这几件事封装成了一层Client。三、两套接口形状对称协议插件的接口只有三个方法// Protocol 协议解析插件接口 type Protocol interface { Info() model.PluginInfo Encode(args model.DataReq) model.JsonRes Decode(data model.DataReq) model.JsonRes }Decode是设备上报方向Encode是平台下发方向一进一出名字对得上写插件的人不需要记两套命名习惯。Info()让插件能自报家门——类型、名称、版本、作者、图标、链接甚至可以声明自己带不带前端页面。通知插件更短type Notice interface { Info() model.PluginInfo Send(data []byte) model.JsonRes }Send只收一个[]byte因为不同渠道的参数差别太大邮件要收件人、短信要签名和模板号、企业微信要应用 ID 和部门、Webhook 要 URL 和自定义头。把这些差异塞进一个统一结构最后只会得到一个所有字段都可空的巨型 struct交给插件自己解反而更干净。宿主侧对应的是两个 RPC 桩ProtocolRPC和NoticeRPC。它们做的事一样把本地方法调用翻译成Client.Call(Plugin.Decode, args, resp)把返回值填回结构体。插件侧则是ProtocolRPCServer和NoticeRPCServer持有真正的Impl收到调用就转发。Plugin.Server/Plugin.Client这一对是 go-plugin 的约定前者在插件进程里跑后者在宿主进程里跑同一个plugin.Plugin实现两边各被调用一次于是同一份代码既能当服务端又能当客户端。四、数据契约窄到只有 JSON跨进程传的东西model/base.go定义得很小type DataReq struct { Data []byte DataIdent string } type JsonRes struct { Code int json:code // 错误码((0:成功, 1:失败, 1:错误码)) Message string json:message // 提示信息 Data interface{} json:data // 返回数据 }一个字节片、一个标识、一个int、一个字符串、一个interface{}就这么五样。这条边界定得窄好处是插件和宿主可以各自演进插件内部换成什么协议栈、用什么库、走不走 goroutine宿主完全不关心它只检查Code是不是 0、Data是不是空。Code的语义也留了梯度0 成功1 失败大于 1 可以自己定义细分错误码。宿主侧的处理很统一——Code ! 0就记一条日志、把消息丢掉不会拿着半截解析结果往下走。DataIdent则是给需要区分数据来源的场景预留的标识位。五、数据库说了算启用了什么才加载什么管理器不是无脑扫目录。Manager.Init()先去查库var inputData new(model.GetSysPluginsListInput) inputData.PageSize 1000 inputData.Status 1 _, _, pluginDataList, err service.SysPlugins().GetSysPluginsList(ctx, inputData) if err ! nil { g.Log().Debug(ctx, 插件查询出错, err.Error()) return err } if len(pluginDataList) 0 { return nil }Status 1是关键数据库里有记录但没启用的插件不会被加载。运营人员在上传了一批插件之后可以先放着不启用启用动作是一次状态变更而不是一次重新部署。查完库再去磁盘上发现文件plugins, err : plugin.Discover(m.Glob, m.Path) ... for i, p : range plugins { _, file : filepath.Split(p) globAster : strings.LastIndex(m.Glob, *) trim : m.Glob[0:globAster] id : strings.TrimPrefix(file, trim) list[i] pluginModel.Plugins{Id: id, Path: p} }m.Glob是protocol-*或notice-*m.Path来自配置项system.pluginsPath默认./plugins/built。插件 ID 不是写在某个注册表里的就是从文件名里去掉前缀——protocol-modbus去掉protocol-得到modbus。这个约定简单到不会出错也让部署变成纯粹的拷贝文件。六、进程什么时候被拉起来Manager.Launch()里做的是备料不是开火client : plugin.NewClient(plugin.ClientConfig{ HandshakeConfig: module.HandshakeConfig, Plugins: m.pluginMap(id), Cmd: exec.Command(info.Path), Logger: m.logger, }) m.Plugins[id].Client client这里只是构造了ClientConfig把可执行文件路径、握手配置、插件映射表、日志器准备好。真正的exec发生在第一次取接口时func (m *Manager) GetInterface(id string) (interface{}, error) { if _, ok : m.Plugins[id]; !ok { return nil, fmt.Errorf(在注册的插件中找不到插件ID:%s, id) } client : m.Plugins[id].Client // 返回协议客户端如rpc客户端或grpc客户端 rpcClient, err : client.Client() if err ! nil { return nil, err } raw, err : rpcClient.Dispense(id) ... }client.Client()是 go-plugin 的分水岭第一次调用会启动子进程、完成握手、建立连接之后复用同一个连接。所以平台启动时并不会同时拉起九个进程——哪个插件先被用到哪个才起来。对于装了 Modbus 但只有一条产品线在用的现场这一步省下的是实打实的内存。另一条路是人工启用。控制台上点启用走StartPlugin它会直接p.Client.Start()把进程立刻拉起来fname : fmt.Sprintf(/%s-%s, m.Type, id) path : g.Cfg().MustGet(context.Background(), system.pluginsPath).String() fname client : plugin.NewClient(plugin.ClientConfig{ HandshakeConfig: module.HandshakeConfig, Plugins: m.pluginMap(id), Cmd: exec.Command(path), }) p.Client client p.Client.Start()按需冷启动和人工预热两条路并存运维可以按现场情况选调试阶段先点启用让进程和日志都就位生产环境交给懒加载用到再说。七、一条上行报文走过的三段以 MQTT 上报为例network/core/router.go的processMessage是入口func processMessage(deviceInfo *model.DeviceOutput, payload []byte, logType string) (res string, err error) { jsonData : string(payload) 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(fmt.Sprintf(plugin error: %v, message:%v, deviceKey:%s, data:%s, err, pluginData, deviceInfo.Key, string(payload))) } pluginDataByte, _ : json.Marshal(pluginData.Data) jsonData string(pluginDataByte) } // javascript脚本处理 if deviceInfo.Product.ScriptInfo ! { ... } return strings.ReplaceAll(jsonData, \\, ), nil }三段分工很清楚阶段谁来做处理对象输出第一段协议插件原始字节流一棵 JSON 树第二段产品脚本JSON 里的字段语义统一属性名第三段topic 路由统一属性名业务落库、告警、计算第一段是格式不同第二段是叫法不同。一个表计上报{t:23.5,h:61}插件负责把私有帧拆成这两个字段脚本负责把t改名叫temperature。两件事分开管是因为它们的变更频率完全不同协议是设备厂家定的几年不动字段命名是业务定的随时会加。TCP/UDP 隧道那条链路也一样只是多了两个必检字段modelFuncName, ok : dataInfo[model_func_name].(string) if !ok { g.Log().Errorf(ctx, model_func_name not found, deviceKey:%s, data:%s, message ignored, deviceDetail.Key, string(data)) return } modelIdentifyName, ok : dataInfo[model_func_identify].(string)网关类设备一条连接上跑几十个子设备报文里必须带这是哪个功能、哪个子设备。插件把这两个字段从私有帧里解出来后面的路由才知道该往哪送。八、下行也要过一遍下发不是把 JSON 直接写通道。service-tunnel.go里写之前先编码if request.DeviceDetail.Product.MessageProtocol ! consts.DefaultProtocol request.DeviceDetail.Product.MessageProtocol ! { var reqData model.DataReq{} reqData.Data paramsBytes pluginData, err : plugins.GetProtocolPlugin().GetProtocolEncodeData(request.DeviceDetail.Product.MessageProtocol, reqData) if err ! nil { g.Log().Errorf(ctx, get plugin error: %v, deviceKey:%s, data:%s, message ignored, err, request.DeviceDetail.Key, string(paramsBytes)) return err } if pluginData.Code ! 0 { g.Log().Errorf(ctx, plugin parse error: code:%d message:%s, pluginData.Code, pluginData.Message) return fmt.Errorf(plugin parse error: code:%d message:%s, pluginData.Code, pluginData.Message) } dataPtr : pluginData.Data paramsBytes, jsonErr jsonx.Marshal(dataPtr) ... } return s.Instance.GetTunnel(base.GetTunnelIdByDeviceKey(ctx, request.DeviceDetail.Key)).Write(paramsBytes)这段代码的分工是业务侧只管我要把{relay: 1}这个语义发下去插件负责把它变成01 06 00 00 00 01 48 0A这样的帧通道负责把字节推出去。业务代码里不会出现任何寄存器地址、CRC 校验、字节序——这些全部收在插件里。对做交付的人来说这一点很省事现场换了一台不同品牌的 PLC改的是产品绑定的协议插件和插件里的点表业务逻辑、告警规则、看板一个都不用碰。九、通知插件渲染在宿主发送在插件告警触发的通知链路alarm_rule_action.go里是这样组织的// 模板数据准备模板变量替换 contentData[Level] level.Name contentData[ProductName] device.ProductName contentData[DeviceKey] deviceKey contentData[Rule] rule.Name expression ... // 模板解析 content, err : utils.ReplaceTemplate(tpl.Content, contentData) var msg extModel.NoticeInfoData{} msg.TemplateCode tpl.Code msg.MsgTitle tpl.Title msg.MsgBody content if plugins.GetNoticePlugin() ! nil { var toTag []extModel.NoticeSendObject for _, u : range v.Addressee { toTag append(toTag, extModel.NoticeSendObject{ Name: tpl.SendGateway, Value: u, }) } msg.Totag toTag sendRes, err : plugins.GetNoticePlugin().NoticeSend(tpl.SendGateway, msg)宿主把要说什么准备好标题、正文变量已经替换成这台设备的真实值、收件人列表。插件只管怎么说出去。NoticeSend里会把渠道配置一并带上var nd new(model.NoticeData) nd.Msg msg cfgData, err : getPluginsConfigData(PluginType.Notice, noticeName) if err ! nil { return } nd.Config cfgData ndJson : gjson.New(nd) byteData : ndJson.MustToJson() res p.(module.Notice).Send(byteData)插件侧用 SDK 把这坨数据拆回三段func DecodeNoticeData(data []byte) (res model.NoticeData, err error) { dataJson : gjson.New(string(data)) configJson : dataJson.Get(Config) sendParamJson : dataJson.Get(SendParam) msgJson : dataJson.Get(Msg) if err gjson.Unmarshal(msgJson.Bytes(), res.Msg); err ! nil { return } if err gyaml.DecodeTo(configJson.Bytes(), res.Config); err ! nil { return } res.SendParam sendParamJson.MapStrAny() return }三个字段Config是渠道的长期配置SMTP 服务器、应用密钥、Webhook 地址Msg是这次要发的内容SendParam留给渠道自己的临时参数。配置跟着消息走插件就不需要再回头找宿主问我的密码是多少——少一次跨进程往返也少一个状态同步的地方。十、配置放缓存键名有讲究渠道配置存在sys_plugins_config表里但每次发通知都查库显然不合适。所以写入时顺手更新缓存键名由常量拼出来// internal/consts/cache.go PluginsTypeName plugins:%s:%s // internal/logic/system/sys_plugins_config.go key : fmt.Sprintf(consts.PluginsTypeName, pluginsType, name) err cache.Instance().Set(ctx, key, value, 0)plugins:notice:mail这样的键一眼就能看出是哪类插件的哪一项配置排查时不需要猜。取的时候把 yaml 解成map[interface{}]interface{}func getPluginsConfigData(pluginType, pluginName string) (res map[interface{}]interface{}, err error) { key : fmt.Sprintf(consts.PluginsTypeName, pluginType, pluginName) pcgData, err : cache.Instance().Get(context.Background(), key) if err ! nil { return } if pcgData nil { return nil, errors.New(插件缓存配置不存在) } err gyaml.DecodeTo([]byte(pcgData.String()), res) return }用 yaml 而不是 JSON 存配置是照顾写配置的人SMTP 地址、多行证书、带引号的密钥yaml 的可读性明显更好处理。启动时还有一次全量预热UpdateAllPluginsConfigCache把表里所有配置刷进缓存command/performance.go的initPlugins会调它if err ! nil { return err } go disposePlugins() // 更新插件配置缓存 err service.SystemPluginsConfig().UpdateAllPluginsConfigCache(ctx)同一次初始化里还会按库里启用的插件逐个取一遍接口把进程提前拉起来for _, p : range pluginDataList { switch p.Types { case PluginType.Protocol: _, err plugins.GetProtocolPlugin().GetProtocolByName(p.Name) case PluginType.Notice: _, err plugins.GetNoticePlugin().GetNoticeByName(p.Name) } if err ! nil { g.Log().Debug(ctx, p.Name, 插件加载出错, err.Error()) } }冷启动的成本被挪到了启动阶段。这样第一条真实报文进来时不需要先等一个进程 handshake。十一、装一个插件一个 zip一个 info.json安装入口在AddSysPlugins收一个 zip 包。前几步都是校验ext : filepath.Ext(srcFileName) if ext ! .zip ext ! .zipx { err errors.New(只允许上传zip或zipx文件) return } pattern : ^[^\p{Han}]*$ match, err : regexp.MatchString(pattern, fileNameWithoutExt) if !match { err errors.New(文件名请用英文命名) return }文件名必须是纯英文因为这个名字最后会变成磁盘上的可执行文件名也会参与type-name的路径拼接带中文或空格在跨平台时会出各种意外。真正的元信息在包里的info.jsonresult, err : utils.ReadZipFileByFileName(file, info.json) if err ! nil { return } var sysPlugin *entity.SysPlugins if err gconv.Scan(result, sysPlugin); err ! nil { return }info.json的字段和sys_plugins表是一一对应的字段含义备注types插件类型protocol或noticehandleType通信方式tcpServer、mqttClient 等十种name插件名称决定文件名与调用时的 IDtitle展示标题控制台里显示version/author/link版本、作者、主页便于溯源icon图标控制台列表用frontend.ui/frontend.url是否带独立页面留给需要配置界面的插件frontend.configuration是否带配置页渠道参数填在这里handleType那十种取值值得单独看一眼因为它们描述了插件站在哪个位置和平台说话const ( TcpServer tcpServer TcpClient tcpClient UdpServer udpServer UdpClient udpClient HttpServer httpServer HttpClient httpClient MqttServer mqttServer MqttClient mqttClient WebSocketServer webSocketServer WebSocketClient webSocketClient )同样一个 Modbus 解析插件接串口转 TCP 的网关时是tcpServer主动去轮询现场设备时是tcpClient。解析逻辑一份接入姿态两种元信息里写清楚就能在控制台里筛出来。落盘是在一个事务里完成的插一条sys_plugins记录解压文件跳过__MACOSX排除info.json再补一次权限pluginsPath : g.Cfg().MustGet(context.Background(), system.pluginsPath).String() excludeFiles : []string{info.json} err utils.UploadZip(file, pluginsPath, excludeFiles) if err ! nil { return } err os.Chmod(pluginsPath/sysPlugin.Types-sysPlugin.Name, 0744)0744这个权限位有讲究所有者可读写执行其他人只读。插件是别人给的二进制跑起来就够不需要给它写权限——少一个权限就少一个被改的面。解压工具还顺手处理了 macOS 打包的垃圾目录// 去掉__MACOSX目录 if strings.Contains(f.Name, __MACOSX) { continue }做过交付的都知道从 Mac 上压缩出来的包里总会带着__MACOSX和.DS_Store放到 Linux 服务器上解出来一堆隐形文件。这一行省掉的是现场排查为什么插件目录里多了几个奇怪文件的时间。十二、宿主没了插件自己走进程隔离带来的一个必答题是宿主被kill -9之后插件进程怎么收场。答案是每个插件自己在服务端起一个心跳func init() { parentPid os.Getppid() } func (p *ProtocolPlugin) Server(broker *gplugin.MuxBroker) (interface{}, error) { p.shutdown make(chan struct{}) if p.Impl nil { return nil, errors.New(protocol implementation is nil) } ctx, cancel : context.WithCancel(context.Background()) go func() { ticker : time.NewTicker(5 * time.Second) defer ticker.Stop() for { select { case -ctx.Done(): return case -ticker.C: if !isParentAlive() { os.Exit(0) } } } }() ... } func isParentAlive() bool { if parentPid 1 || os.Getppid() ! parentPid { return false } proc, err : os.FindProcess(parentPid) if err ! nil { return false } err proc.Signal(syscall.Signal(0)) return err nil }判断分三层一层比一层严格1.parentPid 1——父进程没了被 init 收养直接退出2.os.Getppid() ! parentPid——当前父进程换人了说明原来那个走了3.Signal(0)——向父进程发零信号只探活不投递返回错误就是已经死了。三层合在一起能覆盖宿主正常退出、宿主被强杀、宿主崩溃三种情况。五秒的检查间隔是个折中再短就是无意义的轮询再长则会留下一段孤儿进程占内存。正常退出走的是另一条路。disposePlugins注册到进程退出信号上收到信号就并发地把所有 client 关掉func (m *Manager) Dispose() { var wg sync.WaitGroup for _, p : range m.Plugins { wg.Add(1) go func(client *plugin.Client) { client.Kill() wg.Done() }(p.Client) } wg.Wait() }九个插件串行 Kill每个都要等一手系统调用加起来是可感知的退出延迟并发之后退出时间取决于最慢的那一个。WaitGroup保证主进程在所有子进程真的收掉之后才走不会出现平台界面已经关了、后台还挂着几个 plugin 进程的观感问题。十三、插件日志并进同一个文件独立进程最怕的是日志各写各的出问题要开三个终端。这里的处理是给 go-plugin 注入一个自定义hclog.Loggerfunc NewPluginLogger(name string) *PluginLogger { logger : hclog.New(hclog.LoggerOptions{ Name: name, Level: hclog.Info, Output: g.Log().GetWriter(), // 使用 gf 的日志输出 JSONFormat: true, // 使用 JSON 格式便于解析 }) return PluginLogger{Logger: logger, ctx: context.Background()} }关键在Output: g.Log().GetWriter()。hclog 本来是要写到 stderr 的——go-plugin 默认就是靠捕获子进程 stderr 来收插件日志这里把输出目标换成了 GoFrame 的日志 writer插件的日志于是和业务日志落进同一批文件按天切分、保留份数这些策略自动继承。JSONFormat: true让每行都是结构化 JSON采集侧不需要再写正则。外层还包了一层PluginLogger把每个方法的参数前面补两个字段func (l *PluginLogger) Info(msg string, args ...interface{}) { newArgs : append([]interface{}{ plugin_type, l.Name(), time, gtime.TimestampStr(), }, args...) l.Logger.Info(msg, newArgs...) g.Log().Info(l.ctx, msg, newArgs) }plugin_type说明这条日志来自哪类插件time补上时间戳。一个进程里同时跑着 protocol 和 notice 两种管理器日志混在一起时这两个字段就是唯一的分辨依据。结语回头看这九个二十多兆的文件能让它们各就各位的其实就四件事一是边界窄。跨进程只传DataReq和JsonRes一个字节片进、一棵 JSON 树出插件内部怎么实现宿主不管。二是契约小。协议插件三个方法通知插件两个方法Info()自描述Encode/Decode对称Send一个参数。三是身份清楚。MagicCookie 对暗号文件名定 IDinfo.json描述元信息sys_plugins表决定启不启用。四是生命周期有人管。懒启动省内存人工启用可预热五秒心跳防孤儿并发 Kill 保退出干净。物联网平台的复杂度很大一部分不在业务而在要接的东西太杂不同厂家的报文、不同渠道的通知、不同现场的部署习惯。把这些杂事推到进程边界之外核心就只需要处理一种东西——统一格式的 JSON。这条边界画在哪儿直接决定了后面加一种协议是改十行配置还是改十个文件。开源仓库https://github.com/sagoo-cloud在线文档https://iotdoc.sagoo.cn本文代码基于本地部署的 SagooIoT 专业版涉及 pkg/pluginsmanager.go、sysPlugin.go、extend.go、module/protocol.go、module/notice.go、logger/logger.go、sdk/sdk.go、command/performance.go、network/core/router.go、network/core/tunnel/tunnel-base.go、network/core/logic/model/down/service/service-tunnel.go、internal/logic/system/sys_plugins.go、internal/logic/system/sys_plugins_config.go、internal/logic/alarm/alarm_rule_action.go。