SagooIoT 把协议解析和消息通知都放进了独立进程

先看一个目录。本地专业版的 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 怎么连,那是插件自己的事,宿主一个字都不用写。

这篇文章就围绕这条边界往下拆:插件和宿主怎么互相认出来、数据长什么样、什么时机被拉起来、装和更新走什么路。

一、九个文件,两种身份

按前缀分,这九个文件其实是两类:

类型 目录里的名字 数量 职责
protocol protocol-modbus、protocol-radar、protocol-tf100、protocol-tgn52 4 把设备私有报文翻译成统一 JSON,反方向也要能编回去
notice notice-mail、notice-sms、notice-webhook、notice-wework、notice-dingding 5 把一条通知按各自渠道的规矩发出去

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.json:

复制代码
result, 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 或 notice
handleType 通信方式 tcpServer、mqttClient 等十种
name 插件名称 决定文件名与调用时的 ID
title 展示标题 控制台里显示
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.Logger:

复制代码
func 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 对暗号,文件名定 ID,info.json 描述元信息,sys_plugins 表决定启不启用。

四是生命周期有人管。懒启动省内存,人工启用可预热,五秒心跳防孤儿,并发 Kill 保退出干净。

物联网平台的复杂度,很大一部分不在业务,而在"要接的东西太杂":不同厂家的报文、不同渠道的通知、不同现场的部署习惯。把这些杂事推到进程边界之外,核心就只需要处理一种东西------统一格式的 JSON。这条边界画在哪儿,直接决定了后面加一种协议是改十行配置还是改十个文件。

开源仓库:https://github.com/sagoo-cloud

在线文档:https://iotdoc.sagoo.cn

本文代码基于本地部署的 SagooIoT 专业版,涉及 pkg/plugins(manager.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。

相关推荐
雪兽软件1 小时前
物联网敲门:未来已来
物联网
北冥you鱼1 小时前
Go 语言 GORM 使用 Preload:从关联查询到嵌套预加载的完整实践
开发语言·jvm·golang
桐盛科技2 小时前
中央空调全链路管控:基于边缘计算的负荷预判与安全联锁实现
物联网·安全·能源·边缘计算
microrain2 小时前
把规则链存成文件之后,上线一条规则只需改一个字段
物联网·golang·规则引擎·sagooiot
李纲明3 小时前
WordPress 报 Allowed memory size exhausted:先抓堆栈找吃内存的插件,再改 memory_limit
性能优化·php·内存·插件·wordpress·devdiy
IT规划师4 小时前
MQTT 实战 - 放下炫技,8 年踩坑的真心话与方法论(收官)
物联网·.net
SEO_juper4 小时前
Go 站点地图生成器:从首页爬全站,一键生成 sitemap.xml
xml·开发语言·人工智能·golang·node.js·seo·外贸独立站
Quanqiucard13 小时前
电价 15 分钟一调,光伏物联网卡选错,断一次就没依据
物联网