Protobuf 与 Buf 实战:TypeScript WebSocket 服务端发送与客户端接收
WebSocket 负责传输帧,Protobuf 负责消息结构与二进制编解码。Buf 读取 .proto,通过 ts-proto 同时生成客户端和服务端的 TypeScript 类型;发送端调用 encode,接收端把 ArrayBuffer 交给 decode。
鉴权、重连和业务校验仍由 WebSocket 与应用层处理,Protobuf 不参与这些流程。
目录结构
示例把 .proto 放在服务端目录,由同一条生成命令同时输出客户端和服务端代码:
server/
├─ proto/
│ └─ realtime.proto
├─ buf.yaml
├─ buf.gen.yaml
└─ src/generated/realtime.ts
client/
└─ src/generated/realtime.tsrealtime.proto 是协议源文件。两个 generated/realtime.ts 都是生成产物,不应手工修改。
定义 WebSocket 消息
先定义一组与业务无关的订阅和通知消息:
syntax = "proto3";
package realtime;
message WsMessage {
oneof payload {
SubscribeRequest subscribe_request = 1;
Notification notification = 2;
PingRequest ping_request = 3;
PongResponse pong_response = 4;
ErrorMessage error = 5;
}
}
message SubscribeRequest {
string topic = 1;
}
message Notification {
string topic = 1;
string content = 2;
int64 created_at_ms = 3;
optional string trace_id = 4;
}
message PingRequest {
int64 timestamp = 1;
}
message PongResponse {
int64 timestamp = 1;
int64 server_time_ms = 2;
}
message ErrorMessage {
string message = 1;
}顶层 WsMessage 使用 oneof,表示一帧 WebSocket 消息只能选择一种 payload。客户端可能发送订阅请求或 ping,服务端则发送通知、pong 或错误。
字段名称用于阅读和生成代码,字段编号才是二进制协议里的身份:
string topic = 1;
string content = 2;协议发布后不要修改既有字段编号。把 content 从编号 2 改成 6,在线上协议中等同于删除旧字段,再创建一个同名新字段。
配置 Buf
buf.yaml 告诉 Buf 到哪里读取 .proto,并启用标准 lint 和 breaking change 检查:
version: v2
modules:
- path: proto
lint:
use:
- STANDARD
breaking:
use:
- FILEbuf.gen.yaml 负责代码生成。这里使用 Buf Schema Registry 上的 ts-proto 插件,一次生成两份 TypeScript 代码:
version: v2
plugins:
- remote: buf.build/community/stephenh-ts-proto:v2.12.0
out: ./src/generated
opt:
- env=node
- esModuleInterop=true
- outputServices=false
- useOptionals=messages
- outputJsonMethods=true
- remote: buf.build/community/stephenh-ts-proto:v2.12.0
out: ../client/src/generated
opt:
- env=browser
- esModuleInterop=true
- outputServices=false
- useOptionals=messages
- outputJsonMethods=true服务端使用 env=node,客户端使用 env=browser。这项配置主要影响 bytes 等环境相关类型。协议没有定义 service,所以明确设置 outputServices=false,只生成消息类型和编解码函数。
远程插件最好固定版本。否则同一份 .proto 在不同时间执行 buf generate,可能因为插件升级产生不同结果。
安装依赖并生成代码
生成端需要 Buf,运行生成代码的客户端和服务端都需要 @bufbuild/protobuf:
bun add --dev @bufbuild/buf
bun add @bufbuild/protobuf在 server 目录执行:
bunx buf lint
bunx buf generate也可以写入 package.json:
{
"scripts": {
"gen:proto": "buf generate"
}
}之后统一运行:
bun run gen:proto生成文件开头通常会出现:
// Code generated by protoc-gen-ts_proto. DO NOT EDIT.每次修改 .proto 都要重新生成两端代码,并运行客户端和服务端的 TypeScript 检查。只更新一端,会让协议定义和运行时代码悄悄分叉。
生成代码提供了什么
ts-proto 会为每个 message 生成 TypeScript 接口,以及 create、encode、decode、fromJSON 和 toJSON 等方法。
调用时最常用的是这三个:
const message = WsMessage.create({
notification: {
topic: 'system',
content: 'service started',
createdAtMs: Date.now(),
traceId: undefined,
},
})
const bytes = WsMessage.encode(message).finish()
const decoded = WsMessage.decode(bytes).proto 中使用 snake_case:
int64 created_at_ms = 3;生成的 TypeScript 属性默认是 camelCase:
createdAtMs: number业务代码使用生成类型,不需要自己维护第二套接口。
服务端编码并发送消息
服务端发送消息时,先构造 WsMessage,再编码成 Uint8Array:
import { WsMessage } from './generated/realtime'
function sendMessage(
socket: { send(data: Uint8Array): void },
message: WsMessage,
) {
const value = WsMessage.create(message)
const bytes = WsMessage.encode(value).finish()
socket.send(bytes)
}发送通知:
sendMessage(socket, {
notification: {
topic: 'system',
content: 'service started',
createdAtMs: Date.now(),
traceId: undefined,
},
})收到客户端消息时,把 ArrayBuffer 或 Uint8Array 统一转换为 Uint8Array:
function toUint8Array(data: ArrayBuffer | Uint8Array) {
return data instanceof Uint8Array
? data
: new Uint8Array(data)
}
function handleMessage(
data: ArrayBuffer | Uint8Array,
socket: { send(data: Uint8Array): void },
) {
const message = WsMessage.decode(toUint8Array(data))
if (message.subscribeRequest) {
subscribe(message.subscribeRequest.topic)
}
if (message.pingRequest) {
sendMessage(socket, {
pongResponse: {
timestamp: message.pingRequest.timestamp,
serverTimeMs: Date.now(),
},
})
}
}具体 WebSocket 库的事件参数不同,Protobuf 部分不变:边界处接收字节,decode 后进入业务逻辑;发送前 encode,WebSocket 只负责传输结果。
这里的 pong 同时返回客户端原始时间戳和服务端当前时间。如何用这两个值计算 RTT、时钟偏移,并把校准时间用于页面倒计时,见 Taro 微信小程序实时倒计时跳秒与节点累积排查。
客户端接收并解码消息
浏览器 WebSocket 默认可能把二进制消息交给 Blob。设置 binaryType = 'arraybuffer' 后,message 事件可以直接获得 ArrayBuffer:
import { WsMessage } from './generated/realtime'
const socket = new WebSocket('wss://example.com/ws')
socket.binaryType = 'arraybuffer'
socket.addEventListener('message', (event) => {
if (!(event.data instanceof ArrayBuffer)) return
const message = WsMessage.decode(
new Uint8Array(event.data),
)
if (message.notification) {
console.log(
message.notification.topic,
message.notification.content,
)
}
if (message.pongResponse) {
const roundTripTime =
Date.now() - message.pongResponse.timestamp
console.log('RTT:', roundTripTime)
}
if (message.error) {
console.error(message.error.message)
}
})客户端发送订阅消息也使用同一套生成代码:
socket.addEventListener('open', () => {
const message = WsMessage.create({
subscribeRequest: {
topic: 'system',
},
})
const bytes = WsMessage.encode(message).finish()
socket.send(bytes)
})浏览器 WebSocket 可以直接发送 Uint8Array。如果某个平台只接受 ArrayBuffer,需要保留当前视图的偏移量:
const data = bytes.buffer.slice(
bytes.byteOffset,
bytes.byteOffset + bytes.byteLength,
)
send(data)不要直接假设 bytes.buffer 与 bytes 的范围完全相同。Uint8Array 可能只是底层 ArrayBuffer 的一个视图。
oneof 在 TypeScript 中未必自动互斥
当前配置下,ts-proto 可能把 oneof 生成成多个可选属性:
export interface WsMessage {
subscribeRequest?: SubscribeRequest
notification?: Notification
pingRequest?: PingRequest
pongResponse?: PongResponse
error?: ErrorMessage
}.proto 的语义是只能选择一个,TypeScript 类型却允许同时填写多个。调用方仍要保证每次只构造一种 payload。
需要让 TypeScript 使用可判别联合类型时,可以评估 ts-proto 的:
- oneof=unions这会改变所有消息构造和判断代码,适合在接入初期决定。已经上线后再切换,应当把它视为生成 API 重构,而不是普通格式调整。
optional、默认值和 0
proto3 普通标量默认不跟踪字段存在性:
int64 created_at_ms = 3;数值为 0 时,编码器通常不写入该字段;解码器仍会返回类型默认值 0。接收方无法区分“发送方明确设置了 0”和“字段没有出现在二进制中”。
需要区分缺失与默认值时,使用 optional:
optional string trace_id = 4;生成代码便可以使用 undefined 表示缺失:
if (message.notification?.traceId !== undefined) {
console.log(message.notification.traceId)
}optional 描述字段存在性,不等同于业务校验。字符串是否允许为空、topic 是否存在、用户是否有订阅权限,仍然由服务端检查。
int64 在 TypeScript 中的范围
ts-proto 默认把 int64 转成 JavaScript number。Unix 毫秒时间戳目前低于 Number.MAX_SAFE_INTEGER,用于时间计算没有问题。
如果字段保存完整 64 位 ID、数据库大整数或其他可能超过安全整数范围的值,就不能继续使用默认配置。可以根据调用方式选择:
- forceLong=string或者:
- forceLong=bigint这个选项会改变生成类型,客户端和服务端必须保持一致。
删除字段时使用 reserved
字段不再使用时,不能只把定义删掉,再让后来的人复用原编号。
假设旧协议中有:
string legacy_message = 6;删除后应写成:
message WsMessage {
oneof payload {
SubscribeRequest subscribe_request = 1;
Notification notification = 2;
PingRequest ping_request = 3;
PongResponse pong_response = 4;
ErrorMessage error = 5;
}
reserved 6;
reserved "legacy_message";
}reserved 6 禁止重新使用字段编号,reserved "legacy_message" 禁止重新使用字段名。二进制解码主要依赖编号,因此编号尤其不能回收。
新增字段通常使用新编号,旧接收方遇到不认识的字段可以跳过。修改既有编号、复用已删除编号,或者把同一编号换成不兼容类型,都会让 wire format 产生歧义。
常见问题
客户端一直收不到二进制内容
先确认服务端发送的是 WsMessage.encode(...).finish() 的结果,不是普通对象。浏览器端设置:
socket.binaryType = 'arraybuffer'再检查 event.data 的实际类型。
decode 成功但字段全是默认值
常见原因是客户端和服务端生成文件不一致,或者发送方使用了另一种顶层 message 解码。先比较两端 .proto 和生成版本,不要直接修改生成文件排错。
明明发送了 0,抓包里却没有这个字段
这是 proto3 普通标量的默认值行为。解码结果仍然是 0。如果业务需要知道字段是否实际出现,把它声明为 optional。
修改 proto 后 TypeScript 没有报错
很可能忘记执行:
bunx buf generate生成代码不更新,TypeScript 检查只会继续验证旧类型。
收到消息后需要转发,未知字段却丢了
ts-proto 默认会跳过未知字段,但不会在重新编码时自动保留。中间代理如果需要无损转发不同版本的消息,可以评估:
- unknownFields=true普通客户端收到消息后直接消费,不需要为此增加额外负担。
使用原则
.proto 是协议源,生成文件只用于编译和运行。协议修改、双端生成、类型检查应当放在同一次变更里完成。
Protobuf 编解码只留在传输边界。业务层接收普通 TypeScript 对象,不关心 tag、wire type 和字节缓冲区;WebSocket 层也不承担业务校验。
字段编号发布后不再改动。新增字段使用新编号,删除字段写入 reserved。oneof、optional 和 int64 的生成选项会影响 TypeScript 调用方式,应该在接入时明确,而不是等上线后再凭感觉调整。
对于高频 WebSocket 消息,这套结构足够直接:一份 .proto,一条 Buf 生成命令,两端共用同一套消息定义。普通 HTTP 表单和需要直接查看内容的接口继续使用 JSON,没有必要把所有传输都改成 Protobuf。