做语音助手后端的这大半年,我被 WebSocket 协议折腾得不轻。功能跑通的时候一切都很美好,音频流上传、识别结果回传、TTS 音频下发,一条长连接全搞定。但等产品进入快速迭代期,问题就全冒出来了:字段随手加、格式不统一、老客户端兼容新服务端全靠运气。最惨的一次是我把audio_format从pcm改成opus,前端三端没一起发版,线上语音对讲直接哑火了一个小时。这篇文章就是把那次教训整理成的一套方法,核心就一句话:语音助手场景下,WebSocket 协议必须做契约化改造。我会从为什么改造、改什么、怎么改、怎么灰度上线,到常见问题排查,完整过一遍,适合正在做或者准备做语音助手、实时音频传输、长连接服务的后端和客户端同学参考。
1. 语音助手场景下,WebSocket 协议是怎么一步步失控的
1.1 为什么语音助手离不开 WebSocket 长连接
语音助手的交互链路跟普通 HTTP 接口完全不一样。拿我们产品举例,用户按住说话,客户端采集音频(16kHz 采样率、单声道、PCM 裸流),一边采集一边往服务端推;服务端这边的语音识别模块做流式识别,出了中间结果就要往回推,让 App 实时显示字幕;等到整句说完,识别结果要交给对话管理模块,拿到语义回复后再交给 TTS 合成音频,最后合成好的语音还要推回客户端播放。
这条链路有非常强的实时性和双向性要求。HTTP 轮询做不到:音频是持续流式产生的,等一整段上传完再请求,识别延迟高到用户根本没法用;而且服务端识别到一半的中间结果,也需要主动推给客户端,这也不是普通 HTTP 响应能解决的。WebSocket 恰好合适——一次 HTTP Upgrade 握手建立长连接,之后就是全双工通道,服务端和客户端随时都能发消息,延迟低、开销小。
语音助手的实时性要求比普通聊天场景更高。我这边实测过常规指标:从用户说完话到最后一句 TTS 开始播放,整体需要在 800ms 左右完成,否则用户就觉得"卡"。这要求音频分包不能太大(我们用的是 40ms 一包,每包 1280 字节 PCM 数据),并且每包之间的间隔要稳定,不能靠攒数据来减少请求次数。
有人会问,为什么不用 MQTT 或者直接基于 TCP 自研协议?MQTT 在 IoT 场景确实很强,但它在 Web 端和 App 端的原生支持不够,需要额外引库;而且 MQTT 的发布订阅模型对于"一对一的长连接语音流"来说,语义上有点绕。自研 TCP 协议更麻烦,要处理粘包拆包、心跳保活、重连逻辑、前端浏览器根本不支持裸 TCP,还得做一套 WebSocket 到 TCP 的桥接。所以到最后,WebSocket 几乎是唯一一个"浏览器原生支持 + App 端支持良好 + 全双工 + 低延迟"的选项。
1.2 协议失控的几个经典症状
WebSocket 长连接比短连接 HTTP 更容易失控,关键在于"连接有状态"。我一个一个说,看看你们是不是也遇到过。
第一,字段随手加。后端接口在迭代过程中,经常觉得"这里加个字段不就完了"。问题在于,新字段对老客户端是透明的,但如果新字段改变了语义,老客户端就会误解。我上面提到的audio_format就是典型:后端悄悄把默认值从pcm改成opus,想着新老客户端都能解析 opus(自以为是),结果老客户端的解码器根本不认 opus,直接崩溃。
第二,连接没有版本概念。客户端拿着一个{type: "start", params: {...}}就开始上传音频,服务端升级后改成{type: "audio_start", ...},老客户端发送的消息根本不匹配。最要命的是这种错误不是立刻报出来的,而是表现为"连接建立成功,但没有识别结果返回",排查的时候特别容易绕弯子。
第三,信令和媒体流混用。语音助手的长连接上有两类数据:控制信令(开始、停止、识别中间结果、错误码)和音频数据(PCM/Opus 二进制流)。我们早期一股脑全用 JSON 文本帧传音频,一包音频转成 Base64 塞进 JSON,解析性能差不说,Base64 膨胀 33% 的带宽成本,在弱网下非常明显。后来改成二进制帧传音频,但因为没有统一帧格式,客户端经常分不清哪一帧是音频、哪一帧是信令。
第四,心跳机制缺失。WebSocket 有 TCP 层面的 Keep-Alive,但默认两小时才探测一次,中间 Nginx 代理早就把空闲连接断掉了。客户端还傻等服务端推送,表现就是"语音对讲按下去了,好久没有响应",用户直接退出重进。
这些问题叠在一起,最终逼迫我得做一个决定:把 WebSocket 协议当成一份正式的"契约"来管理,而不是靠脑子记、靠嘴巴传。
2. 契约化改造到底改什么
2.1 契约化的定义:不是"规范文档",而是"可执行约束"
聊契约化改造之前,先厘清一个概念。很多人以为"出了协议文档"就算契约化了,这不够,文档存在于 Confluence 里,没人看就相当于不存在。真正的契约化,是把协议约束变成代码层面可校验、可感知、可回退的机制。
我打个比方。早期我们几个人的项目,好比合租屋里靠口头约定:谁带垃圾、谁交水电费,全靠自觉。后来人多了,口头约定就全乱套了,每个人都按自己的理解行事,自然就吵架。契约化改造相当于把合租屋变成长租公寓:签合同,写清租期、租金、违约责任,换租客了按合同办。协议契约化就是这份"合同",它明确写了:版本号是多少、消息有哪些字段、字段的取值范围、双方遇到不认识的字段怎么办。
落到代码层面,契约化至少要解决三个问题。版本可协商:客户端和服务端在连接建立后,第一时间互相告知各自支持的协议版本,如果不匹配,能明确告诉对方"我支持多少到多少,你升级到哪个版本再来"。消息可校验:每一条消息的字段结构、类型、枚举值,都在入口处做校验,非法消息直接拒绝、记日志,而不是等到业务逻辑深处才出诡异 bug。变更可回退:协议升级不是"上线的瞬间就切过去",而是通过版本号让新老协议在服务端同时存活,出错随时回退。
2.2 协议契约的三层设计:传输层、信令层、业务层
改造的时候,我没有一股脑把所有人都塞进一套大而全的框架里,而是把 WebSocket 通信拆成了三层,每一层管好自己的事。这个分层思路是整场改造里我觉得最值得沉淀的东西。
传输层是标准 WebSocket 语义:帧类型(文本帧/二进制帧)、ping/pong 控制帧、关闭帧状态码(1000 正常关闭、1006 异常断开等)。这一层基本不用我们操心,交给浏览器和 WebSocket 库,但要明确一条约定:音频数据必须用二进制帧,信令必须用文本帧 JSON,不许交叉混用。
信令层是通用能力层,所有业务共用一套机制,包含三类消息。连接鉴权:客户端连接建立后的第一条消息必须是hello,携带 token 和客户端支持的协议版本;服务端校验通过后返回hello_ack,否则返回error。心跳保活:客户端每 30 秒发一次ping(业务层心跳,不是 WebSocket 协议层的 ping),服务端回pong,超过 90 秒未收到心跳则主动断开。版本协商:hello里带着客户端的协议版本号,服务端根据自己支持的版本范围,决定继续、降级还是拒绝。
业务层就是语音助手自己的业务状态机:audio_start开始采集、audio_chunk上传音频二进制帧、audio_end结束采集、asr_partial识别中间结果、asr_final最终识别结果、tts_audio合成音频下发、error错误报告。业务层的消息类型可以持续扩展,只要遵循"只加不改不删"的演进原则,就不会破坏契约。
分层的核心价值在于:信令层的版本协商和心跳机制一旦稳定,就不需要再动;业务层再怎么加新功能,都不会影响连接层的稳定性。业务层升级只影响消息类型和字段,不威胁连接本身。
2.3 用 JSON Schema 和规范枚举把契约固化下来
光口头说"大家按这个格式传"没用,我在项目里引入了两层固化手段。第一层是 JSON Schema 做消息校验,服务端在消息入口统一做校验;第二层是规范化的枚举定义,每个消息类型的字段列表、字段类型、取值范围都列清楚,代码评审的时候对照这个列表查。
消息信令帧我用了一段统一的 JSON 外壳:
{ "v": 1, "type": "hello", "seq": 1680000001, "client_id": "android_12_a1b2c3", "data": { "token": "xxx", "supported_versions": [1, 2] } }v是客户端协议版本,type是消息类型,seq是单调递增的序列号(用来做会话内消息排序和丢包检测),data是具体业务负载。这个外壳在 JSON Schema 里是一个强约束:
{ "type": "object", "required": ["v", "type", "seq"], "properties": { "v": { "type": "integer", "minimum": 1 }, "type": { "type": "string", "enum": ["hello", "hello_ack", "ping", "pong", "audio_start", "audio_chunk", "audio_end", "asr_partial", "asr_final", "tts_audio", "error"] }, "seq": { "type": "integer", "minimum": 0 }, "data": { "type": "object" } } }服务端在升级入口统一跑一遍这个 Schema,不通过的连接直接拒绝并回错误码。这样做的直接好处是:协议变更不再靠"大家开会商量然后写文档",而是改 Schema、改枚举、改代码,三处同步,评审的时候有据可查。
你可能要问,为什么不用 Protobuf?我也考虑过。但当时客户端有 iOS(原生)、Android(原生)、Web(H5)三端,JSON 的调试成本最低,浏览器开发者工具直接能看,App 也能打日志看原始报文,不用额外引入 proto 编译和序列化库。音频二进制帧不需要 Schema,直接走独立的二进制帧类型,避免 Base64 编码。如果你客户端只有自己一家且都是原生,用 Protobuf 完全没问题,但别在 Web 场景下跟 JSON 混用,那会让调试麻烦不少。
3. 实操落地:把 WebSocket 协议管理起来
3.1 服务端版本协商与消息网关的代码实现
服务端我们用的是 Go,WebSocket 库选的gorilla/websocket,HTTP 框架用的 Gin。改造的第一步,是在升级到 WebSocket 之后、正式进入业务逻辑之前,强制插入一个版本协商环节。我直接贴关键代码。
// 连接升级后的第一个入口,所有新连接必须先过这一关 func handleWebSocket(c *gin.Context) { conn, err := upgrader.Upgrade(c.Writer, c.Request, nil) if err != nil { return } defer conn.Close() // 1. 先做版本协商,协商不通过直接关闭 if !negotiateVersion(conn) { return } // 2. 协商通过后,进入消息循环 reqCtx, cancel := context.WithCancel(context.Background()) defer cancel() go startHeartbeat(conn, reqCtx) messageLoop(conn, reqCtx) } func negotiateVersion(conn *websocket.Conn) bool { // 设置一个较短的首帧超时,防止恶意连接占着不发送 hello conn.SetReadDeadline(time.Now().Add(10 * time.Second)) _, raw, err := conn.ReadMessage() if err != nil { return false } var req HelloRequest if err := json.Unmarshal(raw, &req); err != nil { writeError(conn, ERR_BAD_JSON, "invalid hello message") return false } if req.Type != "hello" { writeError(conn, ERR_FIRST_MSG_NOT_HELLO, "first message must be hello") return false } // 清掉超时时间,进入正常读写 conn.SetReadDeadline(time.Time{}) v := req.Version if v < minVersion { writeError(conn, ERR_VERSION_TOO_OLD, fmt.Sprintf("client version %d too old, min version is %d", v, minVersion)) return false } if v > currentVersion { writeError(conn, ERR_VERSION_TOO_NEW, fmt.Sprintf("client version %d not supported yet, current is %d", v, currentVersion)) return false } ack := HelloAck{ Version: currentVersion, MinVersion: minVersion, Supported: supportedVersions, } _ = conn.WriteJSON(ack) return true }这段代码有几个细节值得展开。ReadDeadline必须设置,否则客户端连接上来不发任何消息,协程会一直被卡住,连接数一多直接打满 Goroutine。首帧超时我设的是 10 秒,正常客户端 1 秒内就会发hello,10 秒对于弱网也足够宽裕了。
版本协商通过之后,服务端立刻返回hello_ack,把服务端当前版本、最低支持版本、支持的版本列表全告诉客户端。这样客户端就能判断自己是否需要升级,或者当前版本在服务端灰度范围内。协商完成之后,服务端要保存这个连接对应的版本号,后续消息处理按版本走不同的逻辑分支。
3.2 音频二进制帧与信令帧如何共存
语音助手长连接上最核心的技术细节,就是同一连接上文本帧(信令)和二进制帧(音频)共存。WebSocket 本身支持文本帧和二进制帧两种类型,gorilla/websocket读消息时,messageType参数区分是文本还是二进制。我们需要在messageLoop里做类型分发。
func messageLoop(conn *websocket.Conn, ctx context.Context) { for { msgType, raw, err := conn.ReadMessage() if err != nil { if !websocket.IsUnexpectedCloseError(err, websocket.CloseNormalClosure, websocket.CloseGoingAway) { log.Printf("unexpected close: %v", err) } return } switch msgType { case websocket.TextMessage: handleSignaling(conn, raw) case websocket.BinaryMessage: handleAudioChunk(conn, raw) } } }文本帧全部走信令处理,二进制帧全部视为音频数据。这里有一个很容易踩的坑:不要用ReadMessage返回的raw字节切片去缓存或异步处理,这个切片在下次ReadMessage调用之后会被复用。要异步处理音频数据,必须先把rawcopy 一份到自己的缓冲池。
二进制帧我定义的格式是:前 4 字节是音频序号(uint32,大端序),后面跟一帧 PCM 数据。为什么不直接把音频序号放进 JSON 信令帧?因为二进制帧的解析性能比 JSON 高很多,而且音频流是高频数据(每秒 25 包),每包都塞进 JSON 既浪费带宽又把 CPU 烧在 JSON 编解码上,得不偿失。
业务层用三个信令控制音频流边界:audio_start表示开始采集,里面带采样率、位深、声道数、编码格式;audio_chunk是持续上传的二进制帧本体;audio_end表示采集结束。服务端只有在收到audio_end后才认为这段语音完整,否则如果连接断开,就按异常语音处理,丢弃半段。
服务端下发的tts_audio因为要携带合成音频,也是二进制帧,但通过文本信令tts_start预告格式和长度,随后跟的是二进制音频数据。这套"信令预告 + 二进制载荷"的模式,在语音场景下比"全塞 JSON"要清晰得多。
3.3 心跳、超时与连接健康管理
WebSocket 长连接有个永恒的话题:连接到底还活着吗?TCP 层的 Keep-Alive 不够及时,Nginx 代理默认空闲 60 秒就可能断开连接,而客户端毫不知情。所以我在信令层自己做了心跳,规则非常简单:
- 客户端每 30 秒发送
ping信令 - 服务端收到
ping后立即回pong - 服务端如果 90 秒没收到客户端的
ping,主动关闭连接并记录日志 - 客户端如果 90 秒没收到
pong,主动断开并重连
这里的时间参数不是拍脑袋定的。30 秒心跳间隔小于 Nginx 常见的proxy_read_timeout60 秒,确保连接不会因为空闲被代理切断;90 秒超时给网络抖动留了 3 次心跳周期的余量,不至于一次丢包就断线。如果你的链路还有更长的代理层,记得把心跳间隔设成"链路中最小超时时间的三分之一"以下。
服务端实现心跳,我用了独立的 Goroutine 配合定时器:
func startHeartbeat(conn *websocket.Conn, ctx context.Context) { ticker := time.NewTicker(30 * time.Second) defer ticker.Stop() for { select { case <-ticker.C: // 主动探测,超过 90 秒无 pong 就关闭 if err := conn.WriteControl(websocket.PingMessage, nil, time.Now().Add(5*time.Second)); err != nil { conn.Close() return } case <-ctx.Done(): return } } }实际上gorilla/websocket自带的WriteControl就可以发协议层 ping,我这里直接复用了 WebSocket 协议本身的 ping/pong,没有用业务层 JSON 心跳。两者区别在于:协议层 ping/pong 更轻,但业务层 ping 可以携带更多状态信息。语音助手场景协议层心跳足够,如果你需要统计往返时延,用业务层 ping 带时间戳更好。
连接健康管理还包含写超时。服务端给客户端推 TTS 音频时,如果客户端不消费(比如用户退到后台网络被系统挂起),写操作会一直阻塞。我给所有写操作统一加了 10 秒超时:
conn.SetWriteDeadline(time.Now().Add(10 * time.Second)) err := conn.WriteMessage(websocket.BinaryMessage, audioData) if err != nil { log.Printf("write tts audio failed: %v", err) conn.Close() }写超时是很多人忽略的细节。长连接场景下"读超时"大家都会设,但"写超时"经常漏掉。漏掉的后果就是:某个客户端断网了但服务端不知道,写操作挂在 TCP 缓冲区上不返回,协程越攒越多,最终服务端内存被吃满。加写超时就是给所有写操作上一道保险。
4. 版本兼容与灰度发布:改造的核心收益
4.1 双版本并存的兼容策略
契约化改造之后,版本升级就不需要"明天全量切换"了。我们的做法是服务端同时保留当前版本和上一版本两套解析与处理逻辑,具体通过版本号路由决策。
举例说明。客户端 v1 发的是{type: "start", audio_format: "pcm"},客户端 v2 发的是{type: "audio_start", audio: {format: "opus", sample_rate: 16000}}。服务端在hello协商的时候拿到了客户端的版本号,后续消息处理就按这个版本分发到对应的处理器。v1 的处理器解析旧格式,v2 的处理器解析新格式,两套逻辑在同一个进程里共存。
协议演进要遵守三条铁律,我写在团队文档最显眼的位置,评审时必须逐条对照。只加不改:新增字段必须有默认值,老客户端没传这个字段也能正常工作;老字段的语义永远不变,即使你觉得"这个字段名起得不合理"也绝对不能改,要改就新增一个字段。枚举只增不删:type和各类枚举值只能新增,不能修改已有枚举的含义,更不能删除。废弃字段要保留解析:被废弃的字段不要直接从代码里删除,要保留解析逻辑并打日志,观察一段时间确认没有流量后再下线。
这三条铁律在代码里怎么落实?服务端的消息结构体不要直接复用老字段,而是为每个版本定义独立的结构体,并分别写解析器。伪代码如下:
func handleMessage(conn *Conn, raw []byte) { switch conn.Version { case 1: handleV1Message(conn, raw) case 2: handleV2Message(conn, raw) } }每个版本的解析器在自己的目录里,互不影响。当 v2 稳定运行一个月后,v1 的流量降到接近零,再走一次下线流程把 v1 代码删掉。这个流程比"升级就是改字段"稳妥得多,回滚也方便——出问题就把路由切回旧版本处理器,不需要重新发布二进制。
4.2 灰度发布与回滚节奏
契约化的一个隐藏收益,是让协议升级可以像功能上线一样灰度。我们通过一个简单的开关控制服务端接受哪些客户端版本:新版本逻辑更新后,先只在测试环境开放给内部客户端;确认无误后,在生产环境灰度 5% 的流量(通过客户端版本号或者用户 ID 哈希),观察监控指标稳定后再逐步放开到 30%、50%、100%。
灰度期间重点盯三个指标。连接成功率:这个是最直接的一根红线,低于 99% 立刻报警并回滚。错误码分布:如果ERR_VERSION_TOO_OLD、ERR_BAD_JSON这类错误码突然增多,说明客户端有异常的兼容性问题。核心业务指标:语音会话的平均首帧耗时、TTS 下发成功率、音频中断率,这些指标比技术指标更能反映真实用户体验。
我们踩过一次灰度的坑。新版本上线后所有技术指标都正常,连接成功率 99.9%,错误码没有异常,但业务方反馈语音识别准确率下降。排查下来,发现新版本处理音频分包的 buffer 大小从 2048 字节调成了 4096 字节,某些小的音频包在边界处理时被丢弃了,导致识别准确率下降。这类问题技术指标很难发现,所以要留业务指标一起看。灰度发布不是"和技术指标对一下就行",得让业务指标拍板。
回滚机制设计得更简单:每次发布新版本逻辑时,服务端都保留上一版本的处理器和路由规则。一旦灰度期发现问题,运维只需把版本路由开关切回上一版本,不需要重新编译、不需要停机,30 秒内完成回滚。这个"路由开关"本质上就是契约化版本号的直接应用。
5. 常见问题与排查实录
5.1 连接反复断开,关闭码 1006
这是 WebSocket 场景下最经典的问题,没有之一。1006 表示连接在没有收到关闭帧的情况下被异常断开,几乎所有的中间层都可能成为元凶。
我遇到的情况是这样的:客户端每隔几分钟就会出现一次连接断开,关闭码是 1006,重连成功之后症状消失,过一段时间又断。排查下来发现,是 Nginx 代理层的proxy_read_timeout默认配置是 60 秒,客户端和服务端之间只要 60 秒没有任何数据交互,Nginx 就主动断开连接。而我们的心跳是 30 秒一次,理论上不应该触发超时,但实际排查发现 H5 端的 WebSocket 库在页面隐藏或手机息屏的时候,定时器被浏览器冻结了,心跳发不出去,自然就断线了。
解决方案有两层。第一层是 Nginx 配置,把proxy_read_timeout和proxy_send_timeout都调到 75 秒,略大于心跳间隔的 2 倍,确保"只要有心跳就不会被断"。第二层是客户端在收到 1006 后不要立刻重连,等 1 秒、2 秒、4 秒的退避节奏,避免所有客户端同时重连把服务端打挂。1006 本身就是网络层问题,重连是最快的恢复手段。
5.2 H5 能连、打包成 App 连不上
这个问题在热词列表里频繁出现,说明很多人踩过。现象比较诡异:同一个 WebSocket 地址,用浏览器打开 H5 页面能正常连接,但打包成 App 后就报连接失败。
我遇过三种具体原因。第一种是 Android 网络安全配置。Android 9(API 28)开始默认禁止明文流量,如果你连的是ws://(非 TLS),必须显式开启明文流量许可。在res/xml/network_security_config.xml里配一下就好:
<network-security-config> <base-config cleartextTrafficPermitted="true" /> </network-security-config>第二种是证书校验问题。如果 App 连的是wss://但使用自签名证书或私有证书,App 端的 WebSocket 库会在 TLS 握手阶段拒绝连接,而浏览器可能因为用户手动信任了证书而正常连接。解决方法是把证书正确配置到 App 的信任链里,不要用"跳过证书校验"这种方案——除非你只做内测包。
第三种是服务器端的跨域和 Origin 校验。浏览器 WebSocket 会带Origin请求头,服务端可以对Origin做白名单校验;但 App 端可能不带Origin或者带的值和 H5 不同,如果服务端校验写得太死,App 就被误杀了。我的做法是:生产环境校验Origin白名单,但白名单里包含 App 端固定的Origin值(比如com.yourapp.app),同时在日志里记录所有被拒绝的连接请求,方便排查。
5.3 stream disconnected before completion / io.ErrUnexpectedEOF
这个报错我在热词里看到了,英文原文是stream disconnected before completion: failed to send websocket request: io,典型的流式请求中断问题。在语音助手里出现的场景是:客户端上传音频过程中,连接被切断,服务端读到一个残留的二进制帧或者 EOF 错误。
从原理上看,io.ErrUnexpectedEOF表示读取时数据意外中断。服务端在处理时,有一个关键点:不要把这种 EOF 当成致命错误,而要作为一种正常状态来处理——代表用户可能取消了本次语音、网络断开了、或者客户端进程被杀。正确的做法是:
_, raw, err := conn.ReadMessage() if err != nil { if websocket.IsCloseError(err, websocket.CloseNormalClosure, websocket.CloseGoingAway, websocket.CloseNoStatusReceived) { // 正常关闭,回滚当前会话 session.Cancel() return } // 异常断开,同样要清理会话状态 session.Cancel() log.Printf("connection error: %v", err) return }这里最容易被忽略的坑是:连接断开时,当前正在处理的音频会话如果不做Cancel清理,服务端会一直等待audio_end信号,直到超时。我们线上遇到过这种情况:用户语音对讲时切到后台被系统杀了进程,服务端对应会话的 goroutine 迟迟不退出,要等 60 秒超时才清理。后来所有会话都绑定了连接生命周期,连接一断,会话立即取消,相关资源立刻释放。
客户端侧也要处理这个报错。如果客户端在发送音频流期间收到这个错误,说明服务端已经断开,客户端应该停止发送并进入重连流程。重连后,如果用户还没松开说话键,可以让用户重新说一遍,或者提示"网络连接中断,请重试"。
5.4 WebSocket 鉴权为什么不能用 Header
很多新手第一个问题就是"我在 WebSocket 握手时加个 Authorization Header 不就行了"。但浏览器 WebSocket API 根本不允许自定义 Header,ws.onopen之前你能控制的只有 URL。所以业界常见做法是 token 放在 URL query 里,比如ws://api.example.com/ws?token=xxx。
但这个方案有隐患:URL 会出现在 Nginx access log、浏览器历史、代理日志里,token 等于明文泄露。我采用的方案是"URL token 只做第一道临时校验,真正的鉴权在第一条hello消息里完成"。
具体流程是:客户端先用 URL 里的临时 token 建立 TCP 连接(这个 token 有效期 60 秒,专门用于握手),然后立刻发送hello消息,里面带正式的业务 token。服务端收到hello后,先校验正式 token,校验通过后这个连接才真正进入业务逻辑;校验失败就回error消息并关闭连接。
这样设计的另一个好处,是版本协商和鉴权可以在同一条hello消息里完成,客户端只需一次网络往返就完成"鉴权 + 版本确认 + 连接就绪"三个动作,体验最好。
5.5 开发环境 vite 代理连不上 WebSocket
这个问题只出现在 Web 端开发场景,但很让人抓狂。前端本地起 vite dev server,后端接口通过代理转发,HTTP 请求都正常,唯独 WebSocket 连不上。原因基本可以断定是 vite 代理配置漏了ws: true。
以我项目里的配置为例:
// vite.config.ts export default defineConfig({ server: { proxy: { '/ws': { target: 'ws://localhost:8080', changeOrigin: true, ws: true } } } })不要漏掉ws: true,也不要漏掉changeOrigin: true——后者会把请求头里的Host改写成目标地址,否则部分后端框架会拒绝来自非预期 Host 的升级请求。
如果配了ws: true还是连不上,下一步看浏览器 Network 面板的 WS 请求状态码。如果是 400,把 Nginx 或后端的升级日志打出来看具体报错;如果是 404,检查代理路径/ws跟后端注册的 WebSocket 路径是否一致;如果是 502,说明后端没起或者代理目标写错了。这类问题按 400/404/502 分类排查,基本都很明确。
6. 几点经验补充
整个契约化改造做完到现在,我的体会是:协议契约化的价值不在于设计了多精妙的协议格式,而在于它建立了一套"变更需要评审、升级需要灰度、异常需要感知"的机制。协议本来就是服务端和客户端共同遵守的约定,既然要共同遵守,就必须有明确的版本、明确的字段、明确的校验和明确的退出流程。
最后补充一个小技巧,也是我认为整个改造中性价比最高的一件事:在服务端给每一条协议消息加一个字段级别的访问日志。不是打印消息体,而是打印"哪个版本、哪个消息类型、哪个字段被访问了"。这个日志不需要全量保留,只需要以极低的采样率记录(比如千分之一)。当你想下线某个废弃字段,或者怀疑某个字段在新版本里已经没有业务使用时,翻一下这个日志,远比问产品经理、翻各种文档来得靠谱。契约化的最后一公里,靠的就是这种"可观测"。