← 返回博客
架构2026-08-31 13:26:354 分钟 · 1,093 0

工业数字孪生实战(三):Go + WebSocket 实时遥测后端

前两篇讲了架构/CI/CD 和前端渲染。最后这篇落到后端:浏览器怎么实时拿到工厂数据。

前两篇讲了架构/CI/CD 和前端渲染。最后这篇落到后端:浏览器怎么实时拿到工厂数据。

核心需求很明确——一个 WebSocket 端点,后端每秒采样一次遥测,广播给所有连接的浏览器;新连接进来先推一份全量快照。选型 Go + gorilla/websocket:长连接高并发下 goroutine 模型很轻;编译成单二进制,2G 小服务器也能跑;配合 gin 做路由。

一、目录结构

server/
├── cmd/main.go              # gin 路由 + 启动 Hub
├── Dockerfile.server        # golang:1.27 → distroless
├── go.mod
└── internal/
    ├── ws/ws.go             # Hub 广播 + 升级 + 协议
    ├── ws/ws_test.go        # 100% 覆盖测试
    ├── mock/                # 遥测引擎(采样 + 报警)
    └── model/               # Asset / Metric / Alarm 领域模型

二、Hub 广播模型:把连接抽象成接口

所有连接挂在 Hub 上。关键设计:websocket.Conn 抽象成 conn 接口,这样测试能注入假的连接,不用真起 socket:

// server/internal/ws/ws.go
type conn interface {
  Close() error
  WriteMessage(int, []byte) error
  ReadMessage() (int, []byte, error)
}

type Hub struct {
  mu      sync.Mutex
  clients map[*Client]struct{}
  engine  Telemetry
}

广播就是锁内遍历所有客户端,往各自 send channel 塞消息:

func (h *Hub) broadcastMsg(msg []byte) {
  h.mu.Lock()
  defer h.mu.Unlock()
  for c := range h.clients {
    select {
    case c.send <- msg:        // 客户端消费快:投递
    default:                    // 客户端消费慢:直接丢弃并清理
      c.drop()
      delete(h.clients, c)
    }
  }
}

这个 default 分支很重要:某台浏览器卡死、网络抖动,send channel 满(缓冲 256),新消息直接丢、连接清理。避免一个慢客户端拖垮整个广播循环——实时监控场景下这是必要的背压策略。

三、协议:snapshot / tick / alarm / ping

新连接进来,先推全量快照,再进每秒 tick 循环:

// 升级后:注册 + 推快照 + 起读写 pump
func (h *Hub) Serve(c *gin.Context) {
  conn, err := upgrader.Upgrade(c.Writer, c.Request, nil)
  if err != nil { return }
  cli := &Client{conn: conn, send: make(chan []byte, 256), hub: h}
  h.register(cli)
  assets, metrics := h.engine.Snapshot()
  snap, _ := json.Marshal(map[string]any{
    "type": "snapshot", "ts": time.Now().UnixMilli(),
    "assets": assets, "metrics": metrics,
  })
  cli.send <- snap
  go cli.writePump()
  go cli.readPump()
}

每秒 Tick 采样并广播指标 + 报警:

func (h *Hub) Tick() {
  h.engine.Sample()
  vals := make(map[string]float64)
  for _, m := range h.engine.Metrics() { vals[m.ID] = m.Value }
  tick, _ := json.Marshal(map[string]any{"type": "tick", "ts": now, "updates": vals})
  h.broadcastMsg(tick)
  for _, a := range h.engine.Alarms() { h.broadcastMsg(alarmMsg(a)) }
}

前端 WsClientweb/lib/ws.ts)按 type 分发:snapshot 灌入全局 store,tick 增量更新指标,alarm 推入告警列表。客户端还会每 20s 发 ping,后端回 pong,断线指数退避重连(最多 10s)。

四、坑:CheckOrigin 必须放行 Vercel 域

浏览器 WS 握手带 Origin,后端 CheckOrigin 校验防跨站。最初写的是精确白名单,只放行 https://twin.wxbuluo.com。结果前端上了 Vercel(默认域 wxbuluo-twin.vercel.app,preview 部署还有 *.vercel.app),握手直接被拒,连不上后端。

修法:精确白名单 + 受信任部署平台后缀放行:

func checkOrigin(r *http.Request) bool {
  origin := r.Header.Get("Origin")
  if origin == "" { return true }                 // 非浏览器客户端放行
  for _, o := range allowedOriginsFromEnv(...) {
    if o == origin { return true }                // 精确白名单(TWIN_WS_ALLOWED_ORIGINS)
  }
  // 受信任部署平台:Vercel 默认域/预览部署 + 自有 wxbuluo.com 子域
  if strings.HasSuffix(origin, ".vercel.app") || strings.HasSuffix(origin, ".wxbuluo.com") {
    return true
  }
  return false
}

TWIN_WS_ALLOWED_ORIGINS 仍保留给"必须精确限定"的生产部署,默认 http://localhost:3100。这样以后 Vercel 新增任何部署域都不会再卡 origin。

五、遥测引擎(mock)与测试门禁

真实工厂数据走 OPC-UA / MQTT 接入,演示阶段用 mock.Engine:随机游走采样、阈值触发报警。Telemetry 接口隔离,Hub 不依赖具体实现。

CI 有** 100% 覆盖率门禁**(go test ./internal/... -cover)。conn 接口可注入的优势在这体现:测试里塞一个内存 conn 假实现,直接验证"广播是否真的发到每个客户端""慢客户端是否被丢弃""CheckOrigin 各分支是否覆盖"。门禁不过,CI 直接红,镜像不推——保证推上服务器的代码一定带测试。

六、镜像:distroless + 静态编译

2G 服务器跑不动 Go 编译,镜像在 GitHub runner 构建(见第一篇)。Dockerfile 用多阶段,最终落到 distroless,体积小、攻击面小:

# server/Dockerfile.server
FROM golang:1.27 AS builder
WORKDIR /src
COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN CGO_ENABLED=0 GOOS=linux go build -o /out/twin-server ./cmd

FROM gcr.io/distroless/static-debian12
COPY --from=builder /out/twin-server /app/twin-server
EXPOSE 8080
CMD ["/app/twin-server"]

CGO_ENABLED=0 静态链接,distroless 里无 libc 也能跑。

七、gin 路由与启动

main.go 极简:注册健康检查、WS、起 Hub 采样循环:

r.GET("/healthz", func(c *gin.Context) { c.JSON(200, gin.H{"status": "ok"}) })
r.GET("/ws/", hub.Serve)
addr := os.Getenv("ADDR"); if addr == "" { addr = ":8080" }
go hub.Run(context.Background(), 1*time.Second)   // 每秒采样广播
_ = r.Run(addr)

/healthz 是 CI 部署后的健康检查端点——runner 拉起容器后 curl 它确认服务真活了才算部署成功(deploy.yml 里那段 for i in 1..10 轮询)。

八、完整链路回顾

浏览器 (vercel) --wss--> nginx slb (443, Let's Encrypt)
                        --proxy_pass--> twin-server 容器 (docker_default:8080)
                                         └ Hub.Run 每秒 Tick → broadcastMsg → 所有 WS 客户端

deploy.yml 只筛 server/**,push 即触发:runner 构建推 ghcr.io/ningbnii/wxbuluo-twin-server:latest → SSH 服务器 pull + docker compose up -d → 健康检查。服务器 2G 内存只拉镜像,绝不编译。

小结:后端关键点

关注点做法
广播Hub + per-client send channel,慢客户端非阻塞丢弃
协议snapshot(首推全量)/ tick(每秒增量)/ alarm / ping-pong
跨域CheckOrigin 精确白名单 + *.vercel.app / *.wxbuluo.com 后缀
可测conn 接口注入假连接,100% 覆盖门禁卡 CI
部署distroless 静态二进制 + GitHub Actions 自动推镜像

到这里,数字孪生项目的前后端 CI/CD 全部打通:前端 R3F 渲染带纹理的工业设备、状态实时发光;后端 Go 把遥测秒级广播;WS 经 nginx 反代端到端 101 Switching Protocols。系列三篇完。

相关推荐

本文为原创文章,采用CC BY-NC-SA 4.0协议授权,转载请保留署名与原文链接。原文链接:https://www.wxbuluo.com/article/162