首包即身份:SagooIoT 网络组件的注册包、粘包与透传设计
TCP 三次握手成功的那一刻,站在服务端这边看,这条连接的身份只有两样东西:一个来源 IP,一个来源端口。而在真实的物联网现场,这两样都不能当身份用------4G 卡每次拨号拿到的地址不一定相同,端口号更是每次重连都换。设备断电重启一次,昨天那个 10.3.7.21:51423 就换了主人。
所以「这条连接到底是谁」,平台没办法从网络层问出来,只能从数据里问。SagooIoT 的答案很直接:谁先开口,谁就是谁。 连接建立后设备发过来的第一个数据包,就是它的身份证。
这篇讲这套机制------服务器管理里的注册包、心跳过滤、粘包规则,通道管理里的重连、透传,以及数据进来之后怎么走到物模型。这一层在平台上位置最低,平时做业务几乎碰不到,但现场「接不进来」「掉线不恢复」「数据错位」这几类问题,十有八九根子在这里。
一、先把边界说清楚
SagooIoT 的「网络组件」不是一个功能,是两个页面、两套表、两种角色:
| 页面 | 数据表 | 平台扮演的角色 | 典型场景 |
|---|---|---|---|
| 服务器管理 | network_server |
服务端,监听端口等设备拨进来 | 几百台 DTU 通过 4G 卡上报 |
| 通道管理 | network_tunnel |
客户端,主动去连对端 | 平台去连现场的 Modbus TCP 网关 |
角色分清楚了,后面所有设计都能对上号:服务器管理解决的是「我不认识你,你怎么证明你是谁」;通道管理解决的是「我要连你,连不上怎么办」。
服务端的类型走 network_server_type 字典,通道的类型走 network_tunnel_type 字典。字典里列得挺全:服务器侧有 TCP 服务器、UDP 服务器、MQTT 服务、HTTP 服务、WebSocket 服务;通道侧有串口、TCP 客户端、TCP 服务端、UDP 客户端、UDP 服务端。但字典是「规划」,不是「实现」,实际代码里目前是这样的:
go
// network/core/server/server.go
func NewServer(server *model.Server) (base.ServerInstance, error) {
var svr base.ServerInstance
var err error
switch server.Type {
case "tcp":
svr = tcp.NewServerTCP(server)
break
case "udp":
break
case "http":
break
case "websocket":
break
default:
return nil, fmt.Errorf("Unsupport type %s ", server.Type)
}
return svr, err
}
TCP 是唯一有实现的,UDP / HTTP / WebSocket 三个分支是空的。通道那边同样,NewTunnel 只落地了 tcp-client 和 udp-client,serial 分支留着一行 //TODO 等待补全。这不是文档没写,是确实还没到。后面第十一节会把这类边界集中列一遍。
二、注册包:第一包就是身份证
服务器管理的表单里有一项叫「注册包」,展开只有一个字段:正则表达式。看着简单,但它决定了整条连接的归属。
对应的模型定义是:
go
// network/model/server.go
type RegisterPacket struct {
Regex string `json:"regex,omitempty"`
Length int `json:"length,omitempty"`
regex *regexp.Regexp
}
校验逻辑在 network/model/tunnel.go:
go
func (p *RegisterPacket) Check(buf []byte) (deviceKey string, checkOk bool) {
if p.Regex != "" {
if p.regex == nil {
p.regex = regexp.MustCompile(p.Regex)
}
data := string(buf)
data = strings.ReplaceAll(data, "\n", "")
data = strings.ReplaceAll(data, "\r", "")
re := regexp.MustCompile(p.Regex)
match := re.FindStringSubmatch(data)
if match == nil {
return "", false
}
return match[1], p.regex.Match(buf)
}
if p.Length > 0 {
if len(buf) != p.Length {
return "", false
}
}
return string(buf), true
}
三种取法,按优先级排:
| 配置 | 取到的 deviceKey | 适用场景 |
|---|---|---|
| 配了正则 | 正则里第一个捕获组的内容 | 注册包是 IMEI:867000000000001 这种带前缀的文本 |
| 只配长度 | 整包内容(长度不等直接拒绝) | 注册包就是纯 IMEI,固定 15 位 |
| 都不配 | 整包内容 | 最简单粗暴,但任何首包都会被当成设备标识 |
第一个坑就在这里:返回的是 match[1],不是整个匹配串。 意思是正则里必须写捕获组。如果配 ^IMEI:\d+$,FindStringSubmatch 会返回只含全匹配的切片,长度是 1,match[1] 直接下标越界。正确写法是 ^IMEI:(\d+)$。这个坑很隐蔽------表单上只有一个「正则表达式」输入框,没有任何提示告诉你必须带括号。
第二个细节是 match[1] 前那两行 ReplaceAll:\r 和 \n 会被先去掉。所以配正则的时候不用考虑 DTU 厂商在末尾多加的那个换行符,这是平台替你做掉的。
拿到 deviceKey 之后,服务器是怎么用它的:
go
// network/core/server/tcp/server-tcp.go(节选)
c, acceptErr := server.listener.AcceptTCP()
if acceptErr != nil { ... }
buf := make([]byte, 1024)
n, readErr := c.Read(buf) // 只读一次,这就是注册包
if readErr != nil {
_ = c.Close()
continue
}
data := buf[:n]
deviceKey, checkIsOk := server.server.Register.Check(data)
if !checkIsOk {
g.Log().Errorf(ctx, "register check not right, check_data:%s ...", string(data))
_ = c.Close() // 不合格,直接关
continue
}
tnl, tnlErr := newServerTcpTunnel(ctx, server.server.Id, deviceKey, c)
三处值得注意:
一是「只读一次」。 注册包必须在一次 Read 里完整拿到,缓冲区 1024 字节。这意味着注册包不能超过 1024 字节,也不能依赖 TCP 分段------如果 DTU 把注册包拆成两个包发,第二次 Read 拿到的内容会被当成注册包的后半段,校验必然失败。所以注册包要短,最好就是一条 IMEI 或 ICCID。
二是校验失败不留情面。 checkIsOk 为 false 就直接 c.Close(),连接不进任何队列,不占任何内存。这在有大量扫描流量或者配置错误的时候很重要------一个配置错的服务器端口,不会被垃圾连接慢慢堆死。
三是身份是「声明式」的,本身不做鉴权。 注册包给什么,平台就信什么。所以服务器管理页里才有另外那组字段:开启 TLS、认证方式(Basic / AccessToken / 证书)、证书选择。可信度不够的场景,得靠这一层加。走专网或者内网接入,注册包就够了;公网暴露的端口,建议把 TLS 和证书一起开上。
三、「自注册」在这里的准确含义
文档里说服务器功能支持「自注册、自创建」。这里得把话说准,不然接入方会踩空。
代码里实际发生的是:注册包里取出的 deviceKey,被拿去查平台里已有的设备。
go
// network/core/server/common/server-common.go
func ServerTunnelAction(ctx context.Context, serverId int, deviceKey string) {
_ = mqtt.Publish(consts.GetWrapperTopic(consts.DataBusServerTunnel, consts.ActionTunnel, strconv.Itoa(serverId)), nil)
deviceDetail, _ := service.DevDevice().Get(ctx, deviceKey)
if deviceDetail == nil {
g.Log().Debugf(ctx, "deviceKey:%s not found,ignore", deviceKey)
return // 找不到就忽略,不会创建设备
}
...
}
DevDevice().Get 查不到会直接返回「设备不存在」错误,上面这段拿到 nil 就 return。所以准确说法是:连接自动认领到已有设备 ,而不是设备凭空自建 。model.Server 里那个 Devices []DefaultDevice(默认设备,按站号自动建)字段,在 mapper 里的现状是:
go
// network/core/mapper/mapper.go
//TODO 这里暂时不写device,需要等待后续的device插入数据,考虑是不是启动的时候带入
//Devices: res.Devices,
注释掉了,所以这条路目前走不通。
那现场几百台设备怎么录入?走批量导入。dev_device 表的 key 字段是可填的设备标识(不是系统生成的自增 ID),设备新增接口里 DeviceKey 是必填项,平台另外提供 ImportDevices 批量导入接口。落地操作就是:把设备清单(每行一个 IMEI 或序列号)导进去,之后这些设备连上来的时候,注册包里的值一匹配,连接就自动挂到对应设备上了。
一次录入,之后自动认领------这是当前版本的准确形态。
四、心跳包:被「去抖」过滤掉的数据
DTU 一般会周期性发一条心跳,证明自己还活着。这条数据对平台来说没有业务价值,如果不处理,它会被当成业务报文往物模型解析,然后因为解析不出 model_func_name 报一堆错日志。
通道管理里有「心跳包」配置,字段是这些:
go
type HeartBeatPacket struct {
Enable bool `json:"enable"`
Timeout int64 `json:"timeout"`
Regex string `json:"regex,omitempty"`
Hex string `json:"hex,omitempty"`
Text string `json:"text,omitempty"`
Length int `json:"length,omitempty"`
hex []byte
regex *regexp.Regexp
last int64
}
四种匹配方式,都是「完全相等」而不是「包含」:Hex 是十六进制字节串比对,Text 是文本完全相等,Regex 走正则匹配,Length 只比长度。按设备厂商给的心跳格式挑一种就行。
真正有意思的是它的 Check 实现,里面藏了一段去抖:
go
func (p *HeartBeatPacket) Check(buf []byte) bool {
now := time.Now().Unix()
if p.last == 0 {
p.last = now
}
if p.last+p.Timeout > now {
p.last = now
return false // 距上次判断还没到 timeout,直接放行
}
p.last = now
if p.Regex != "" { ... }
if p.Length > 0 { ... }
if p.Hex != "" { ... }
if p.Text != "" { ... }
return true
}
注意开头那个分支:只要距离上一次判断还没超过 Timeout 秒,就一律返回 false(不是心跳),并把 last 刷新成当前时间。
last 在两次判断之间被反复刷新,所以这个「超时窗口」在实践中永远不会到期------除非这台设备真的安静了 Timeout 秒没发数据。而一旦安静了 Timeout 秒之后来的第一条数据,才会真正走一遍匹配逻辑。
这个设计要解决什么?心跳过滤的目的是「识别偶尔出现的那一条噪声」,不需要对每条数据都做一次正则或者 hex 比对。用去抖把判断频率从「每包一次」压到「静默之后一次」,正则编译和字节比对的成本基本可以忽略。一个端口挂着几百条连接、每条连接每秒几十个包的时候,这个差别是能看出来的。
顺带一个当前的边界:心跳过滤只接在通道(
tunnel-client)的接收循环里。服务器管理侧的receive循环目前没有做心跳过滤------model.Server里定义了Heartbeat字段、mapper 里也做了映射,但 TCP 服务器还没消费它。所以通过服务器管理接入的设备,它的心跳包需要靠协议插件或 JS 脚本自己丢掉,否则会进解析链路。这是接入前需要知道的一点。
五、连接状态存 Redis,不落库
服务器收到一条合法连接之后,会产生一个「通道」记录。这个记录没写进数据库,而是写进了 Redis:
go
// network/core/server/base/server-tunnel.go
const (
SagooServerTunnelPrefix = "sagoo-server-tunnel"
)
func AddOrEditServerTunnel(ctx context.Context, t ServerTunnel) (tunnelId string, err error) {
tunnelJson, _ := json.Marshal(t)
tunnelId = fmt.Sprintf("%s-%s", SagooServerTunnelPrefix, t.DeviceKey)
t.TunnelId = tunnelId
_, err = g.Redis().Do(ctx, "SET", fmt.Sprintf("%s-%s", SagooServerTunnelPrefix, t.DeviceKey), string(tunnelJson))
return tunnelId, err
}
Key 是 sagoo-server-tunnel-<deviceKey>,值是一段 JSON,里面放着 serverId、localAddr、remoteAddr、status。通道 ID 本身就是这个名字拼出来的,所以「反查某台设备当前走的是哪条通道」不需要额外索引,拼一下 key 就行。
为什么不落库?因为连接是进程内的瞬时状态。落库会带来两个麻烦:一是每条连接建立/断开都要写盘,几百台设备频繁上下线的时候数据库压力不小;二是进程重启之后,库里会残留一堆状态为「在线」但实际上早就断掉的记录,得额外写一套清理逻辑去对账。放在 Redis 里,进程一重启这些 key 自然就没了,语义上是干净的。
配置和状态分离得也很清楚:network_tunnel 表里存的是怎么连 (地址、重连策略、心跳规则、串口参数),Redis 里存的是连上了没有。改配置动数据库,看在线状态读 Redis,互不干扰。
边界说明:取通道列表用的是
KEYS sagoo-server-tunnel-*。Redis 的KEYS是 O(N) 全量扫描,在单实例上会阻塞其他命令。连接数上千、又要频繁刷列表的场景,这里会是个隐患,改用SCAN或者维护一个 Set 索引会更稳。
六、粘包规则:表单已就位,网络层还没接
TCP 是字节流,没有「消息边界」这个概念。设备发三次、平台收两次是常事,这就是物联网里经典的粘包/拆包问题。SagooIoT 在这块的模型设计相当完整:
go
// internal/model/network_server.go
type Stick struct {
Delimit string `json:"delimit,omitempty" dc:"分隔符"`
Custom string `json:"custom,omitempty" dc:"自定义脚本"`
FixedLen int `json:"fixedLen,omitempty" dc:"固定长度"`
Len struct {
Len int `json:"len" dc:"长度"`
Offset int `json:"offset" dc:"偏移量"`
Endian string `json:"endian" dc:"大小端(big|little)"`
} `json:"len,omitempty" dc:"长度字段"`
}
界面上对应「粘拆包规则」下拉,四个选项和这四组字段一一对应:分隔符 (比如 \r\n)、自定义脚本 、固定长度 、长度字段(在偏移量处读一个长度值,再按大小端解释)。这套设计基本覆盖了工业协议里常见的分帧方式。
但要说清楚当前的落地程度:network_server 表里有 stick 字段,API 里收 Stick 结构体,mapper 里也把它映射进了 model.Server------从表单到模型一路是通的,唯独 TCP 服务器的读取循环没有消费它 。现在的实现是固定 1024 字节的 Read:
go
buf := make([]byte, 1024)
n, err := l.Link.Read(buf)
...
go l.TunnelBase.ReadData(ctx, l.deviceKey, buf[:n])
一次 Read 拿到什么就当成一条完整报文交下去。所以对接的时候有两条实打实的约束:
- 单帧数据不要超过 1024 字节,超出的部分会被截断(不报错,静默截断);
- 不要假设一次
Read就是一帧 。设备连续上报时两帧可能被合并到一次Read里,这时候会当成一条报文去解析。
规避方式是把「分帧」这件工作前移到协议插件或产品上的 JS 脚本里:router 链路里会先过协议插件的 GetProtocolDecodeData,再跑一遍产品配置的 JS 脚本,这两处都能做分帧和拼帧处理。
七、透传:一条连接只能干一件事
通道管理的价值不只是「收数据」,还有一个容易被忽略的能力:把平台当成一根透明的网线。
实现是 Pipe。当有人给某条通道挂上一个 pipe(比如从调试终端接过来的一条连接)之后,这条通道的数据流就改道了:
go
// network/core/tunnel/tunnel-base.go
func (l *TunnelBase) Pipe(pipe io.ReadWriteCloser) {
if l.pipe != nil {
_ = l.pipe.Close()
}
l.pipe = pipe
if pipe == nil {
return // 传空,等于取消透传
}
buf := make([]byte, 1024)
for {
n, err := pipe.Read(buf)
if err != nil {
break
}
n, err = l.Link.Write(buf[:n]) // 从 pipe 收到的,原样写给设备
if err != nil {
_ = pipe.Close()
break
}
}
l.pipe = nil
}
接收方向在 TunnelClient.receive 里判断:
go
// 透传转发
if client.pipe != nil {
_, err = client.pipe.Write(data)
if err != nil {
client.pipe = nil
} else {
continue // 走了透传,就不进业务解析了
}
}
go client.TunnelBase.ReadData(ctx, tunnelInfo.DeviceKey, data)
那个 continue 是关键:透传期间,数据不落库、不进物模型、不产生设备日志。 上行下行都是纯字节转发。
还有一处更彻底的取舍,在 Write 里:
go
func (l *TunnelBase) Write(data []byte) error {
if !l.running {
return errors.New("tunnel closed")
}
if l.pipe != nil {
return nil //透传模式下,直接抛弃
}
_, err := l.Link.Write(data)
return err
}
只要 pipe 存在,平台自己下发的数据直接被丢弃,并且返回 nil,调用方不会察觉到失败。
这看起来粗暴,其实是刻意的:一条串口或一条 TCP 连接在同一时刻只能有一个「说话的人」。如果透传和平台下发同时往同一个 Link 上写,设备侧收到的会是两个来源交织在一起的字节流,谁也解析不出来,表现出来就是「命令发下去了但设备动作莫名其妙」。宁可让平台侧静默失败,也不能让设备收到乱码。所以透传的语义就是独占------要么平台管,要么人管,不存在「边透传边控制」。
实际用起来就是:现场工程师要临时抓一段设备的原始报文,或者用厂商私有工具连进去改参数,不用跑到现场拔线,在通道上开个透传窗口,完事关掉,通道自动回到正常解析模式。
八、断线重连:固定间隔,有限次还是无限次
通道作为客户端,Dial 失败是常态------对端还没上电、网络还没通、地址填错。所以 Open 里失败就直接进重试:
go
// network/core/tunnel/tunnel-client.go
func (client *TunnelClient) Retry(ctx context.Context) {
retry := &client.tunnelInfo.Retry
if retry.Enable && (retry.Maximum == 0 || client.retry < retry.Maximum) {
client.retry++
client.retryTimer = time.AfterFunc(time.Second*time.Duration(retry.Timeout), func() {
client.retryTimer = nil
err := client.Open(ctx)
...
})
}
}
界面上对应三个字段:启用 、间隔 、最大次数。
| 配置 | 行为 |
|---|---|
| 启用 = 否 | 连不上就报错,不重试 |
| 最大次数 = 0 | 无限重连,直到连上 |
| 最大次数 = N | 累计 N 次失败后放弃 |
两个实现细节。一是成功一次就清零 :Open 里连上之后紧接着 client.retry = 0,所以计数统计的是「连续失败次数」,不是「历史失败总数」------一条跑了三天、中间抖过几十次的通道,重试名额始终是满的。二是间隔是固定的,没有指数退避。对端长期不在线的时候,会按固定节奏一直敲。对于几千条通道同时重试的场景,如果担心太吵,把间隔设大一点比什么都强。
九、数据进来之后:一条消息走完的全程
到这一步,连接认出来了、心跳过滤掉了、字节流也拿到了。剩下的事情在 TunnelBase.router 里,一共四步:
go
func (l *TunnelBase) router(ctx context.Context, productDetail *model.DetailProductOutput,
deviceDetail *model.DeviceOutput, data []byte) {
res := string(data)
// 第一步:产品配了消息协议插件,交给插件解码
if productDetail.MessageProtocol != consts.DefaultProtocol && productDetail.MessageProtocol != "" {
pluginData, err := plugins.GetProtocolPlugin().GetProtocolDecodeData(productDetail.MessageProtocol, data)
if err != nil { return }
if pluginData.Code != 0 || pluginData.Data == nil { return }
pluginDataByte, _ := json.Marshal(pluginData.Data)
res = string(pluginDataByte)
}
// 第二步:产品配了 JS 脚本,再跑一遍
if productDetail.ScriptInfo != "" {
res, runScriptErr = jsinterpreter.RunScript(res, productDetail.ScriptInfo)
if runScriptErr != nil { return }
}
// 第三步:解析出来的 JSON 必须带这两个字段
var dataInfo = map[string]interface{}{}
if err := json.Unmarshal([]byte(res), &dataInfo); err != nil { return }
modelFuncName, ok := dataInfo["model_func_name"].(string)
if !ok { return }
modelIdentifyName, ok := dataInfo["model_func_identify"].(string)
if !ok { return }
// 第四步:按功能名分派到对应的 handler
handleF := tunelBase.GetModelHandle(modelFuncName)
if modelFuncName == tunelBase.UpProperty {
modelIdentifyName = "property" // 属性上报统一归到 property
}
handleF.Handle(ctx, topicModel.TopicHandlerData{
Topic: handleF.GetTopicWithInfo(productDetail.Key, deviceDetail.Key, modelIdentifyName),
ProductKey: productDetail.Key,
DeviceKey: deviceDetail.Key,
PayLoad: []byte(res),
})
baseLogic.InertTdLog(ctx, handleF.LogType, deviceDetail.Key, string(data))
}
这里有个设计上挺聪明的地方:每一步失败都是静默降级,不是抛错退出 。没配协议插件就走原始字符串;没配 JS 脚本就跳过分帧;model_func_name 缺了就当这条报文无效丢掉。对一个要接几十种协议的平台来说,这条链路的「宽容度」比「严格性」更重要------严格校验的结果往往是现场一个字段填错,整条链路全哑。
最后那行 InertTdLog 把原始报文 落一份日志(handleF.LogType != MsgTypeGatewayBatch 时)。排查问题的时候,协议插件解析出来的中间结果可以骗人,但原始字节不会。这也是为什么出问题第一时间应该看设备日志,而不是看解析后的数值。
十、TLS 与认证:什么时候需要
服务器管理表单里有一组安全字段,和注册包是互补关系:
| 字段 | 取值 | 作用 |
|---|---|---|
| 开启 TLS | 是 / 否 | 是否用 TLS 承载连接 |
| 选择证书 | 设备证书列表 | TLS 开启且类型不是 MQTT 服务时出现 |
| 认证方式 | Basic / AccessToken | TLS 开启且类型是 MQTT 服务时出现 |
| 用户名 / 密码 | --- | Basic 方式下填写 |
| Access Token | --- | AccessToken 方式下填写 |
从界面逻辑能读出一条规则:证书和 Basic/AccessToken 是两条平行路径,分别对应「非 MQTT 服务器」和「MQTT 服务器」两种接入形态。证书来自平台的证书管理模块,也就是那套设备证书、一机一密的体系。
什么时候需要开?一个判断标准就够:这个端口是不是暴露在公网上。内网接入、专网接入的场景,注册包认人已经完全够用,TLS 带来的是加解密开销和证书分发的麻烦;一旦端口暴露到公网,不做 TLS 也不做认证,等于把一个「谁都能塞数据进来」的接口挂出去了,报文伪造、设备冒充都是顺手的事。
十一、当前版本的边界清单
把前面散落的边界集中列一遍,接入前对照检查:
| 项 | 现状 | 对接方要做什么 |
|---|---|---|
| 服务器类型 | 仅 TCP 有实现 | UDP / HTTP / WebSocket 暂不可用 |
| 通道类型 | 仅 tcp-client、udp-client |
串口通道仍在 TODO |
| 粘包规则 | 表单/API/模型就位,TCP 读取未消费 | 单帧 ≤ 1024 字节;分帧放协议插件或 JS 脚本里做 |
| 心跳过滤 | 仅通道侧生效 | 服务器接入的设备需自行丢弃心跳 |
| 注册包正则 | 必须带捕获组 | 写成 ^IMEI:(\d+)$ 而不是 ^IMEI:\d+$ |
| 注册包长度 | 一次 Read,1024 字节上限 |
注册包保持短小、单包发送 |
| 设备自创建 | 未实现(默认设备映射被注释) | 预先把设备批量导入,key 与注册包取值一致 |
| 通道列表 | KEYS 全量扫描 |
连接数大时注意 Redis 压力 |
| 监听地址 | net.ListenTCP("tcp4", ...) |
只监听 IPv4 |
| 服务数量上限 | ServerListLimit = 10000 |
超过会在启动时报错并拒绝加载 |
| 重连间隔 | 固定间隔,无指数退避 | 对端长期离线时把间隔调大 |
这些不是「文档没写」,是代码的当前状态。写出来是因为接入阶段最容易踩的就是这类「表单上有、代码里没接」的落差------照着界面配了一遍,跑起来发现不生效,再花半天排查,不如一开始就知道。
十二、回到那一包数据
回过头看,这套机制的核心其实只有一句话:在最不可信的一层,用一个尽量简单的约定把身份固定下来。
网络层给的 IP 和端口都不稳定,那就让设备自己说;设备怎么说没有统一标准,那就给一个正则,让每个厂商按自己的格式填;正则可能写错,那就在连接建立的第一时间校验,错了立刻断开,不留后患;连上之后状态还会变,那就放 Redis,进程重启自然归零,不用维护对账逻辑。
每一步都不复杂,但合起来解决的是一个具体的现场问题:几千台设备,不写 IP、不做映射、不手工建档,插卡上电就能被认出来。
这一层离业务最远,也最需要稳。业务逻辑改错了,改的是报表上的数字;这一层错了,改的是现场有没有数据。
项目地址