首包即身份:SagooIoT 网络组件的注册包、粘包与透传设计

首包即身份: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 拿到什么就当成一条完整报文交下去。所以对接的时候有两条实打实的约束:

  1. 单帧数据不要超过 1024 字节,超出的部分会被截断(不报错,静默截断);
  2. 不要假设一次 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、不做映射、不手工建档,插卡上电就能被认出来。

这一层离业务最远,也最需要稳。业务逻辑改错了,改的是报表上的数字;这一层错了,改的是现场有没有数据。


项目地址

相关推荐
resh_people1 小时前
开源鸿蒙平台 KMP_CMP 三方库「kotlinx.html」适配全流程
开源·html·harmonyos
小小龙学IT1 小时前
TDengine 开源时序数据库深度解析:从超级表到工业数采落地
开源·时序数据库·tdengine
GoFly开发者2 小时前
纯 Go 桌面 AI 智能体实战:Wails3 + Eino ADK 构建 Agent 对话应用(流式输出 / 工具审批 / RAG / 打包全记录)
golang·ai agent·eino·wails3·agent桌面开发
数字新视界2 小时前
国产动环系统技术原理与应用实践的深入剖析
嵌入式硬件·物联网·系统架构·机房管理·动力与环境监控系统
分布式存储与RustFS2 小时前
GitLab 对接 RustFS 实战:OIDC 控制台 SSO + 制品存储落 S3 对象存储
运维·云原生·开源·对象存储·分布式存储·s3
SL-staff2 小时前
JVS私有化交付技术解析:如何通过全栈开源与引擎解耦实现真正可控的源码级交付
开源·私有化部署·springboot·信创适配·jvs·spi架构
程序媛之Lemon2 小时前
FluxMQ|面向海量终端的云原生MQTT物联网网关
物联网·mqtt
XLYcmy3 小时前
基于图神经网络的物联网固件漏洞静态挖掘技术方案 下
神经网络·物联网·二进制·poc·漏洞检测·静态检测·acfg
miofly3 小时前
GitHub 日榜趋势速报 | 2026-09-29
开源·github