开发心得:国标视频云平台VSS WebSocket

3 阅读6分钟

开发心得:VSS WebSocket

本文是 2.1 VSS-WebSocket架构设计配套开发笔记:在理解「reader + 单协程 Receiver + 四路 channel」的前提下,把联调、扩展、排障里最容易出错的点理解清楚,便于开发过程中少走弯路。

项目地址 github.com/openskeye/g…


1. 明确三个要点

  1. 写连接尽量只走 ResponseMessageChan,由 Receiver 单协程消费后再 WriteMessage,避免与 Gorilla 读循环或其它 goroutine 并发写同一 Conn(设计上以此为纲;若绕过 channel 直连 WriteMessage,要自行维护并发模型)。
  2. 握手子协议不可缺少Sec-WebSocket-Protocol 必须是 [ConnType, Token] 两段,多一段少一段都会在 CheckOrigin 直接拒绝升级
  3. Receiver 一条流水线:所有 ReceiveMessageChan / ResponseMessageChan / BroadcastChan / CloseChan 的处理是串行的——在 dispatch 里做 RPC 阻塞、大 JSON、死锁 会直接卡住整站 WS 业务。

2. 握手与子协议(事故高发区)

2.1 必须为两段

实现见 internal/handler/ws/handler.go

  • len(subProtocols) != 2CheckOrigin 返回 false,升级失败。
  • ConnType 为空 → 同样失败。
  • TokenParseUserToken,失败则 连接根本不会建立

注意:失败原因多在服务端 log(upgrade err + checkOriginErr),浏览器侧往往只看到 HTTP 400/握手失败,要在文档里要求前端显式传两段子协议,不要用「只带 token、ConnType 写进 query」这种和实现不一致的拼法。

2.2 Token 签发与密钥

HTTP POST /api/ws-tokenlogic/http/base/ws_token.go)拿到的字符串,必须原样作为 子协议第二段
Config 里 AES/密钥与 HTTP 发 token 侧不一致时,表现就是「偶尔能连、换环境全挂」——优先核对同一套配置加载顺序与环境变量。

2.3 ClientId 与「后踢前连」

校验成功后:WSClientCache.Delete(client)Add(按 ClientId)。
同一 MakeClientId(ConnType, userId) 再次连接时,旧连接仍在 sync.Map 里可能被删掉键,但旧 reader 可能还在跑——业务上要接受 同用户同 ConnType 重连 = 顶替,旧连接若未收到关闭帧,可能仍短暂存活直到读错误进 CloseChan


3. 读循环 reader:帧类型与 panic

3.1 只认文本帧

websocket.TextMessage 会进入业务队列;二进制或其它 msgType 会走 关闭逻辑CloseChan),属于硬断开

前端务必须使用 send(JSON.stringify(...)),不要用 ArrayBuffer 发对讲以外的探测包。

3.2 defer recover 会重启 reader

proc.goreader 使用 defer pkg.NewRecover(..., func() { p.reader(client) }):panic 后会再拉起读循环。

注意:

  • 反复 panic 可能制造 goroutine/栈压力,需要找出根本原因并修复;
  • panic 期间若有半包状态,依赖下次 Read 行为,联调时不能只盯着第一次请求。

4. 中央 Receiver:背压与整进程单车道

4.1 四个 channel 默认缓冲 100

service_context.go 里构造 WSProc。任一通道堆满时,生产者会 阻塞

  • reader 阻塞在 ReceiveMessageChan <-该连接不再读新帧,但连接未关;
  • 业务往 ResponseMessageChan / BroadcastChan / CloseChan 狂塞也会阻塞各自生产方。

高峰期要 监控 channel 堵塞(日志/指标);必要时调大容量业务限流,对讲类高频上报尤其敏感。

4.2 dispatch 里不要有慢事务

路由在 dispatch.go 里带 ReqTimeoutcontext 调 handler,但 Receiver 仍是一条 select 车道——handler 里若忽略 ctx.Done() 仍长时间占用,等价于拖慢所有 WS 消息

快速返回异步化(丢到独立 worker,结果再进 ResponseMessageChan);禁止在 WS handler 里调慢 RPC 且不感知超时


5. 路由 routers:未注册 type 的诡异行为

dispatch.go 中逻辑:resp.WSResponse = new(types.WSResponse),若 routers[req.Type] 不存在,则 不会调用 handler,但 resp.WSResponse 仍非 nil

上游 Receiver 判断 resp != nil && resp.WSResponse != nil 成立,仍可能把空壳响应丢进 ResponseMessageChan

注意点:

  • 拼错 type 时,客户端可能收到「看似成功、_body 很空」的包,而不是明确的 404/unknown type
  • 联调应用固定契约:未知类型是否应返回 Errors,必要时应改代码对 !ok 分支显式返回错误(在这里我并没有实现,需要根据业务做出修改,这里尤为重要)。

register.gorouters 只有 heartbeatgbs-talk-audio-send(常量键) 等;新增业务必须同时注册 type 字符串(常量集中管理,避免手写拼写错误)。


6. 广播 broadcasters静默丢弃

newBroadcaster.dispatch

func (r broadcaster) dispatch(req *types.BroadcastMessageItem) error {
	item, ok := broadcasters[req.Type]
	if ok {
		return item.handler(r.svcCtx, req.Data)
	}

	return nil
}

broadcasters 未注册该 Type 时直接 return nil,无日志。

在架构文档中已描述,但生产环境需要注意:SIP 侧明明进了 BroadcastChan,前端永远收不到,也没有 error,这一步我是刻意丢弃了,这里也需要根据业务调整。

注意:

  • 新增广播:必须在 register.gobroadcasters 注册 Type → handler
  • 当前仓库broadcasters 表可能为空或未含你用的 Type(以 register.go 为准),联调第一步应 grep 投递的 Type 是否注册
  • 前后端一定要约束好类型,避免接收后无响应。

7. 活跃与鉴权:时间配置一错就莫名断线

interval.go 三路定时任务,参考架构文档,这里补充操作要点

机制注意点
MaxLifetime;客户端必须周期性发 heartbeat 或任意文本消息 刷新 ActiveTime,否则会被动断开。不要只依赖 TCP keepalive。
AuthorizationLifetime针对 Validate 仍 false 的路径;正常握手成功会 Validate=true,主要防半开/异常连接。
tokenVerify(约 20s)会重解析 Token;失败则 login 错误 + AlterCall 关连接。Token 短有效期服务端改密钥时,表现为「刚连上一会儿就踢」。
ClearTalkSipInterval对讲 无操作清理 TalkSipData,与 WS 「是否还连着」是两条线——WS 仍连着但对讲已被回收 时,要继续发音频必须先走完整占用/Invite 流程。

8. 出站 API:current vs somebody

  • current:回当前连接;写失败 → CloseChan
  • somebodyMakeClientId(当前连接的 ConnType, userid) 查缓存,给同 ConnType 的另一用户发(多协同场景)。

跨用户推送时 ConnType 必须一致;查不到记录 时静默失败与否取决于 response.go 实现,联调要打 log 或约定错误反馈。

AlterCalltokenVerify 失败时先发帧再关——扩展 ResponseMessageChan 时若需要写完再副作用,沿用此模式,避免 Write 和 Close 竞态


9. 与 SIP/对讲衔接时的状态心智

  • 音频上行:gbs-talk-audio-sendSipSendTalk占用与 TalkSipData 不同步时,表现为发了 WS 包但设备无声
  • 广播侧依赖 SipTalkActivateKey 过滤连接(b_base.go 等);Key 未在客户端状态里对齐时,广播像「全没收到」

WS 连接存在 ≠ 对讲会话存在;排障顺序:WS 报文 → TalkSipData → SIP Invite/ACK → RTP


10. 自检清单

  • 新 JSON type 已写入 routers,且与前端一字不差
  • 若走广播,broadcasters 已注册,并自测未注册时是否有日志。
  • Handler 是否可能超过 WS.ReqTimeout,是否需要 ctx 传递与异步化。
  • 是否只用 文本帧;对讲大包是否导致单帧过大(考虑分片或配置缓冲)。
  • 文档是否说明客户端 MaxLifetime 内必须心跳
  • 子协议两段、Token 来源是否写进请求。

源码聚焦:internal/server/ws.gointernal/handler/ws/{handler,proc,dispatch,register,interval,response}.go