Protobuf 与 Buf 实战:TypeScript WebSocket 服务端发送与客户端接收

太阳作者太阳
原创内容采用 CC-4.0 协议发布,转载请注明出处
ProtobufBufTypeScriptWebSocketts-proto

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.ts

realtime.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:
    - FILE

buf.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 接口,以及 createencodedecodefromJSONtoJSON 等方法。

调用时最常用的是这三个:

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,
  },
})

收到客户端消息时,把 ArrayBufferUint8Array 统一转换为 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.bufferbytes 的范围完全相同。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 层也不承担业务校验。

字段编号发布后不再改动。新增字段使用新编号,删除字段写入 reservedoneofoptionalint64 的生成选项会影响 TypeScript 调用方式,应该在接入时明确,而不是等上线后再凭感觉调整。

对于高频 WebSocket 消息,这套结构足够直接:一份 .proto,一条 Buf 生成命令,两端共用同一套消息定义。普通 HTTP 表单和需要直接查看内容的接口继续使用 JSON,没有必要把所有传输都改成 Protobuf。

参考资料