开发心得:VSS WebSocket
本文是 2.1 VSS-WebSocket架构设计 的配套开发笔记:在理解「reader + 单协程 Receiver + 四路 channel」的前提下,把联调、扩展、排障里最容易出错的点理解清楚,便于开发过程中少走弯路。
1. 明确三个要点
- 写连接尽量只走
ResponseMessageChan,由Receiver单协程消费后再WriteMessage,避免与 Gorilla 读循环或其它 goroutine 并发写同一Conn(设计上以此为纲;若绕过 channel 直连WriteMessage,要自行维护并发模型)。 - 握手子协议不可缺少:
Sec-WebSocket-Protocol必须是[ConnType, Token]两段,多一段少一段都会在CheckOrigin直接拒绝升级。 Receiver一条流水线:所有ReceiveMessageChan/ResponseMessageChan/BroadcastChan/CloseChan的处理是串行的——在dispatch里做 RPC 阻塞、大 JSON、死锁 会直接卡住整站 WS 业务。
2. 握手与子协议(事故高发区)
2.1 必须为两段
实现见 internal/handler/ws/handler.go:
len(subProtocols) != 2→CheckOrigin返回false,升级失败。ConnType为空 → 同样失败。- Token 经
ParseUserToken,失败则 连接根本不会建立。
注意:失败原因多在服务端 log(upgrade err + checkOriginErr),浏览器侧往往只看到 HTTP 400/握手失败,要在文档里要求前端显式传两段子协议,不要用「只带 token、ConnType 写进 query」这种和实现不一致的拼法。
2.2 Token 签发与密钥
HTTP POST /api/ws-token(logic/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.go 里 reader 使用 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 里带 ReqTimeout 的 context 调 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.go 里 routers 只有 heartbeat 与 gbs-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.go的broadcasters注册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。somebody:MakeClientId(当前连接的 ConnType, userid)查缓存,给同 ConnType 的另一用户发(多协同场景)。
跨用户推送时 ConnType 必须一致;查不到记录 时静默失败与否取决于 response.go 实现,联调要打 log 或约定错误反馈。
AlterCall:tokenVerify 失败时先发帧再关——扩展 ResponseMessageChan 时若需要写完再副作用,沿用此模式,避免 Write 和 Close 竞态。
9. 与 SIP/对讲衔接时的状态心智
- 音频上行:
gbs-talk-audio-send→SipSendTalk;占用与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.go、internal/handler/ws/{handler,proc,dispatch,register,interval,response}.go。