Skip to content

Commit fc3d23b

Browse files
author
hezhuozhuo
committed
feat: 优化WebSocket设备状态订阅,使用Redis Pub/Sub替代MQTT,简化架构并提升性能
1 parent f2fdd0a commit fc3d23b

9 files changed

Lines changed: 574 additions & 581 deletions

File tree

‎docs/demand-community/2025.10.16-WebSocket设备状态订阅优化方案.md‎

Lines changed: 443 additions & 0 deletions
Large diffs are not rendered by default.

‎internal/api/telemetry_data.go‎

Lines changed: 76 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import (
44
"context"
55
"encoding/json"
66
"errors"
7+
"fmt"
78
"strconv"
89
"sync"
910
"time"
@@ -457,52 +458,99 @@ func (*TelemetryDataApi) ServeDeviceStatusByWS(c *gin.Context) {
457458

458459
logrus.Infof("WebSocket连接已建立 - 设备ID: %s, 用户ID: %s, 租户ID: %s", deviceID, claims.ID, claims.TenantID)
459460

460-
// 订阅设备在线状态
461-
var mu sync.Mutex
462-
logrus.Info("User SubscribeOnlineOffline")
463-
var mqttClient ws_subscribe.WsMqttClient
464-
if err := mqttClient.SubscribeOnlineOffline(deviceID, conn, msgType, &mu); err != nil {
465-
logrus.Error("订阅设备状态失败:", err)
466-
conn.WriteMessage(msgType, []byte("Failed to subscribe to device status"))
461+
// 查询设备当前状态并立即推送 (保持原有格式: is_online 为整数 0/1)
462+
currentStatusMap, err := service.GroupApp.Device.GetDeviceOnlineStatus(deviceID)
463+
if err != nil {
464+
logrus.WithError(err).Error("查询设备当前状态失败")
465+
conn.WriteMessage(msgType, []byte("Failed to query device status"))
467466
return
468467
}
469-
defer mqttClient.Close()
470468

471-
// 处理心跳
469+
// 提取在线状态 (整数格式)
470+
isOnline := 0
471+
if status, ok := currentStatusMap["is_online"]; ok {
472+
isOnline = status
473+
}
474+
475+
// 发送当前状态 (简化格式,与原有接口保持一致)
476+
initialMsg := map[string]interface{}{
477+
"is_online": isOnline,
478+
}
479+
if data, err := json.Marshal(initialMsg); err == nil {
480+
conn.WriteMessage(msgType, data)
481+
}
482+
483+
// 订阅 Redis Pub/Sub 通道
484+
ctx, cancel := context.WithCancel(context.Background())
485+
defer cancel()
486+
487+
channel := fmt.Sprintf("device:%s:status", deviceID)
488+
pubsub := global.REDIS.Subscribe(ctx, channel)
489+
defer pubsub.Close()
490+
491+
logrus.Infof("WebSocket已订阅Redis通道: %s", channel)
492+
493+
// 使用 sync.Mutex 保护 WebSocket 写操作
494+
var mu sync.Mutex
495+
496+
// Goroutine 1: Redis消息转发到WebSocket
497+
go func() {
498+
ch := pubsub.Channel()
499+
for {
500+
select {
501+
case <-ctx.Done():
502+
logrus.Debug("Redis订阅上下文取消")
503+
return
504+
case redisMsg, ok := <-ch:
505+
if !ok {
506+
logrus.Warn("Redis通道已关闭")
507+
return
508+
}
509+
510+
// 转发到WebSocket
511+
mu.Lock()
512+
err := conn.WriteMessage(msgType, []byte(redisMsg.Payload))
513+
mu.Unlock()
514+
515+
if err != nil {
516+
logrus.WithError(err).Error("WebSocket写入失败")
517+
cancel() // 通知主goroutine退出
518+
return
519+
}
520+
521+
logrus.WithField("device_id", deviceID).Debug("状态更新已推送到WebSocket")
522+
}
523+
}
524+
}()
525+
526+
// Goroutine 2 (主): WebSocket心跳处理
472527
for {
473-
_, msg, err := conn.ReadMessage()
528+
_, wsMsg, err := conn.ReadMessage()
474529
if err != nil {
475-
// 记录错误日志
476-
logrus.Error("WebSocket读取错误:", err)
530+
logrus.WithError(err).Info("WebSocket连接关闭")
477531

478-
// 尝试发送错误消息给客户端
479-
closeMsg := []byte("connection closed due to error")
480-
// 使用 WriteControl 发送关闭消息,设置1秒超时
532+
// 发送关闭消息
533+
closeMsg := []byte("connection closed")
481534
deadline := time.Now().Add(time.Second)
482535
conn.WriteControl(websocket.CloseMessage,
483-
websocket.FormatCloseMessage(websocket.CloseInternalServerErr, string(closeMsg)),
536+
websocket.FormatCloseMessage(websocket.CloseNormalClosure, string(closeMsg)),
484537
deadline)
485538

486-
// 现在可以安全退出了
539+
cancel() // 通知Redis订阅goroutine退出
487540
return
488541
}
489542

490543
// 处理心跳消息
491-
if string(msg) == "ping" {
544+
if string(wsMsg) == "ping" {
492545
mu.Lock()
493-
if err := conn.WriteMessage(msgType, []byte("pong")); err != nil {
494-
logrus.Error("发送pong消息失败:", err)
495-
496-
// 尝试发送错误消息
497-
deadline := time.Now().Add(time.Second)
498-
conn.WriteControl(websocket.CloseMessage,
499-
websocket.FormatCloseMessage(websocket.CloseInternalServerErr, "failed to send pong"),
500-
deadline)
546+
err := conn.WriteMessage(msgType, []byte("pong"))
547+
mu.Unlock()
501548

502-
mu.Unlock()
549+
if err != nil {
550+
logrus.WithError(err).Error("发送pong失败")
551+
cancel()
503552
return
504553
}
505-
mu.Unlock()
506554
}
507555
}
508556
}

‎internal/app/manager.go‎

Lines changed: 0 additions & 55 deletions
This file was deleted.

‎internal/flow/status.go‎

Lines changed: 54 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -162,13 +162,16 @@ func (f *StatusFlow) processMessage(msg *DeviceMessage) {
162162
// 5. 清理设备缓存
163163
initialize.DelDeviceCache(device.ID)
164164

165-
// 6. SSE 通知客户端
165+
// 6. 发布到 Redis Pub/Sub (供 WebSocket 订阅)
166+
go f.publishToRedis(device, status, msg.Metadata)
167+
168+
// 7. SSE 通知客户端
166169
go f.notifyClients(device, status)
167170

168-
// 7. 触发自动化
171+
// 8. 触发自动化
169172
go f.triggerAutomation(device, status)
170173

171-
// 8. 预期数据发送(上线时)
174+
// 9. 预期数据发送(上线时)
172175
if status == 1 {
173176
go f.sendExpectedData(device)
174177
}
@@ -275,3 +278,51 @@ func (f *StatusFlow) sendExpectedData(device *model.Device) {
275278
f.logger.WithField("device_id", device.ID).Debug("Expected data sent")
276279
}
277280
}
281+
282+
// publishToRedis 发布设备状态到 Redis Pub/Sub (供 WebSocket 订阅)
283+
func (f *StatusFlow) publishToRedis(device *model.Device, status int16, metadata map[string]interface{}) {
284+
// 构造设备名称
285+
var deviceName string
286+
if device.Name != nil {
287+
deviceName = *device.Name
288+
} else {
289+
deviceName = device.DeviceNumber
290+
}
291+
292+
// 获取来源信息
293+
source, _ := metadata["source"].(string)
294+
if source == "" {
295+
source = "unknown"
296+
}
297+
298+
// 构造消息 (保持原有 WebSocket 接口格式: is_online 为整数 0/1)
299+
messageData := map[string]interface{}{
300+
"is_online": int(status), // 0 或 1
301+
}
302+
303+
jsonBytes, err := json.Marshal(messageData)
304+
if err != nil {
305+
f.logger.WithError(err).Error("Failed to marshal Redis message")
306+
return
307+
}
308+
309+
// 发布到设备专属通道: device:{device_id}:status
310+
channel := fmt.Sprintf("device:%s:status", device.ID)
311+
if err := global.REDIS.Publish(f.ctx, channel, string(jsonBytes)).Err(); err != nil {
312+
// 记录错误但不阻塞主流程
313+
f.logger.WithError(err).WithFields(logrus.Fields{
314+
"device_id": device.ID,
315+
"channel": channel,
316+
"status": status,
317+
}).Error("Failed to publish to Redis")
318+
return
319+
}
320+
321+
f.logger.WithFields(logrus.Fields{
322+
"device_id": device.ID,
323+
"device_name": deviceName,
324+
"channel": channel,
325+
"status": status,
326+
"source": source,
327+
}).Debug("Status published to Redis")
328+
}

‎main.go‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ import (
2121
// @name x-token
2222
func main() {
2323
// 解析命令行参数
24+
// go run . -config ./configs/config.yaml
2425
configPath := flag.String("config", "", "配置文件路径...")
2526
flag.Parse()
2627

0 commit comments

Comments
 (0)