283 lines
10 KiB
Markdown
283 lines
10 KiB
Markdown
|
|
---
|
|||
|
|
tags: [grpc, protobuf, schema-versioning, proto3, client-server, stub-generation]
|
|||
|
|
create time: 2026-05-07 16:00
|
|||
|
|
---
|
|||
|
|
|
|||
|
|
# Proto 设计规范
|
|||
|
|
|
|||
|
|
## 概述
|
|||
|
|
|
|||
|
|
本文覆盖 **Proto3 进阶特性**和**版本管理策略**。如果说架构篇是"看懂 gRPC 长什么样",这篇就是教你"如何写出经得起演进的 Proto 文件"——这是工程中最容易踩坑、却最容易被忽视的部分。
|
|||
|
|
|
|||
|
|
> [!question] 思考
|
|||
|
|
>
|
|||
|
|
> 你的 Service A 调用 Service B 的 `GetUser`,Proto 里定义了 20 个字段。半年后你想加第 21 个字段,但旧版客户端没有编译更新。会发生什么?
|
|||
|
|
|
|||
|
|
## Proto 是什么
|
|||
|
|
|
|||
|
|
Protocol Buffers(简称 **Proto**)是 Google 开源的一套**语言无关、平台无关的结构化数据序列化方案**。它的核心作用有两层:
|
|||
|
|
|
|||
|
|
| 层面 | 说明 |
|
|||
|
|
|------|------|
|
|||
|
|
| **接口契约** | 用 `.proto` 文件声明 Service(有哪些 RPC 方法)、Request / Response(传什么数据),作为服务间通信的"宪法" |
|
|||
|
|
| **序列化格式** | 把内存中的对象编码成二进制字节流,跨进程 / 跨网络传输后再还原回对象 |
|
|||
|
|
|
|||
|
|
一句话总结:**Proto = IDL(接口定义语言)+ 二进制序列化**。
|
|||
|
|
|
|||
|
|
### 为什么两端都需要编译?
|
|||
|
|
|
|||
|
|
很多初学者会疑惑:服务端实现逻辑、客户端发起调用,两边的代码不是各写各的吗?——**不是的,.proto 文件是两端共享的唯一真相源**。
|
|||
|
|
|
|||
|
|
```mermaid
|
|||
|
|
graph LR
|
|||
|
|
Proto[".proto 文件<br/>唯一真相源"] --> protoc["protoc / Buf"]
|
|||
|
|
protoc --> Server["服务端生成代码<br/>(Go stub)"]
|
|||
|
|
protoc --> Client["客户端生成代码<br/>(Java / TS / …)"]
|
|||
|
|
Server --> Encode["编码 Request → 发送二进制流"]
|
|||
|
|
Client --> Decode["接收二进制流 → 反序列化 Response"]
|
|||
|
|
Encode --- Decode
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
| 角色 | 编译产物 | 用途 |
|
|||
|
|
|------|---------|------|
|
|||
|
|
| **服务端** | Server Stub | 接收二进制消息 → 反序列化为参数对象 → 执行业务逻辑 → 返回结果对象 → 编码为响应 |
|
|||
|
|
| **客户端** | Client Stub | 用户调用 Stub 方法 → 构造请求对象 → 编码为二进制字节流发送 → 收到响应后解码 |
|
|||
|
|
|
|||
|
|
> [!question] 思考
|
|||
|
|
>
|
|||
|
|
> 如果服务端用 Go 生成代码、客户端用 Java 生成代码,它们之间的二进制消息能正确互操作吗?
|
|||
|
|
>
|
|||
|
|
> > [!answer]- 答案
|
|||
|
|
> > **完全可以。** Proto 的二进制编码规则是语言无关的规范,只依赖字段编号(`=` 后面的数字)。只要两边用的是同一份 `.proto`,任何语言的编译器都会按照相同的规则编解码。
|
|||
|
|
|
|||
|
|
#### 实际编译示意
|
|||
|
|
|
|||
|
|
**.proto 定义(两端共用同一文件)**
|
|||
|
|
|
|||
|
|
```proto
|
|||
|
|
// proto/order/v1/order.proto
|
|||
|
|
syntax = "proto3";
|
|||
|
|
package order.v1;
|
|||
|
|
|
|||
|
|
service OrderService {
|
|||
|
|
rpc CreateOrder (CreateOrderRequest) returns (CreateOrderResponse);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
message CreateOrderRequest {
|
|||
|
|
string user_id = 1;
|
|||
|
|
repeated string item_ids = 2;
|
|||
|
|
}
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
**服务端(Go):** `buf generate` → 生成 `order_service.pb.go` + `order.pb.go`
|
|||
|
|
|
|||
|
|
```go
|
|||
|
|
// 服务端实现生成的 RPC 接口
|
|||
|
|
func (s *server) CreateOrder(ctx context.Context, req *orderpb.CreateOrderRequest) (*orderpb.CreateOrderResponse, error) {
|
|||
|
|
// 直接使用反序列化好的 Request 对象 ✅
|
|||
|
|
fmt.Println(req.UserId) // 强类型访问
|
|||
|
|
return &orderpb.CreateOrderResponse{OrderId: "ord-123"}, nil
|
|||
|
|
}
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
**客户端(TypeScript):** `buf generate` → 生成 `order_pb.ts`
|
|||
|
|
|
|||
|
|
```typescript
|
|||
|
|
// 客户端直接调用生成的 Stub 方法
|
|||
|
|
const response = await orderClient.createOrder({ userId: 'u-456', itemIds: ['i-7', 'i-8'] })
|
|||
|
|
console.log(response.orderId) // 直接得到结果 ✅
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
可以看到:两端都从**同一个 `.proto`** 生成了各自语言的代码,但编写的代码量极小——核心工作是写 `.proto` 和手写业务逻辑。
|
|||
|
|
|
|||
|
|
### 为什么不直接用 JSON / XML
|
|||
|
|
|
|||
|
|
| 维度 | Proto | JSON | XML |
|
|||
|
|
|------|-------|------|-----|
|
|||
|
|
| 体积 | 紧凑,无标签名冗余 | 冗余大(每个值都带 key 名) | 标签开销更大 |
|
|||
|
|
| 性能 | 编解码速度极快(变长整数 + 字段编号编码) | 需解析字符串 | 解析最慢 |
|
|||
|
|
| 类型安全 | 强类型,编译器检查 | 动态类型,运行时才暴露错误 | 动态类型 |
|
|||
|
|
| 向后兼容 | 天然支持字段增删 | 无约定,全靠文档 | 部分支持 |
|
|||
|
|
| 工具链 | `protoc` 生成各语言 Stub | — | — |
|
|||
|
|
|
|||
|
|
好消息是:Protocol Buffers 的二进制编码规则天然保证了向前兼容。坏消息是:**只有遵守规则的改动才是兼容的**,违反规则的静默破坏会让你排查整整一天。
|
|||
|
|
|
|||
|
|
## Oneof —— 互斥字段
|
|||
|
|
|
|||
|
|
当某个消息体可能有多种不同类型的值,但同一时刻只出现一种时,使用 `oneof`:
|
|||
|
|
|
|||
|
|
```go
|
|||
|
|
// proto/order/v1/order.proto
|
|||
|
|
message Payment {
|
|||
|
|
string order_id = 1;
|
|||
|
|
|
|||
|
|
oneof method {
|
|||
|
|
AlipayPayment alipay = 2;
|
|||
|
|
WechatPayment wechat = 3;
|
|||
|
|
CreditCard card = 4;
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
message AlipayPayment {
|
|||
|
|
string token = 1;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
message WechatPayment {
|
|||
|
|
string openid = 1;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
message CreditCard {
|
|||
|
|
string last_four = 1;
|
|||
|
|
string exp_date = 2;
|
|||
|
|
}
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
> [!note] Proto3 oneof 的限制
|
|||
|
|
>
|
|||
|
|
> - 不能加 `repeated` 修饰符
|
|||
|
|
> - 空 oneof 值为 `NONE(0)`,反序列化时各字段都为默认值
|
|||
|
|
> - **向后兼容策略**:新增 oneof 变体是安全的;删除变体会破坏旧客户端
|
|||
|
|
|
|||
|
|
## Well-Known Types —— 内置类型库
|
|||
|
|
|
|||
|
|
Proto3 内置了一些常用类型,直接引用即可,不用自己定义:
|
|||
|
|
|
|||
|
|
```proto
|
|||
|
|
import "google/protobuf/timestamp.proto";
|
|||
|
|
import "google/protobuf/empty.proto";
|
|||
|
|
import "google/protobuf/duration.proto";
|
|||
|
|
import "google/protobuf/wrappers.proto";
|
|||
|
|
|
|||
|
|
message Event {
|
|||
|
|
string name = 1;
|
|||
|
|
google.protobuf.Timestamp created_at = 2; // 替代手动时间字符串
|
|||
|
|
google.protobuf.Empty body = 3; // 空消息占位
|
|||
|
|
google.protobuf.StringValue desc = 4; // 可选字符串(区分 unset 和空串)
|
|||
|
|
}
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
| 类型 | 用途 |
|
|||
|
|
|------|------|
|
|||
|
|
| `Timestamp` | RFC 3339 纳秒级时间戳 |
|
|||
|
|
| `Duration` | 带单位的时长 |
|
|||
|
|
| `Empty` | 无参数 / 无返回值的 RPC 占位 |
|
|||
|
|
| `StringValue / Int32Value / BoolValue` | 包装基本类型,表示"可选"语义 |
|
|||
|
|
| `Struct / Value / ListValue` | 类 JSON 的动态结构 |
|
|||
|
|
| `Any` | 泛型消息容器(需搭配 type URL) |
|
|||
|
|
|
|||
|
|
> [!tip] StringValue 的妙用
|
|||
|
|
>
|
|||
|
|
> Proto3 默认把未设置的字段归入"默认值",导致无法区分"用户传了空字符串"和"用户没传这个字段"。用 `StringValue` 包装后,Proto3 可以精确表达 Optional 语义。
|
|||
|
|
|
|||
|
|
## Map 字段
|
|||
|
|
|
|||
|
|
```proto
|
|||
|
|
message Config {
|
|||
|
|
map<string, string> labels = 1;
|
|||
|
|
map<int32, float> price_cache = 2;
|
|||
|
|
}
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
- **有序遍历**:Proto3 的 map 按 key 排序遍历
|
|||
|
|
- **兼容性**:删除整个 map 字段可接受,删除其中某个 key-value 对**不安全**(会被当作未知字段忽略)
|
|||
|
|
|
|||
|
|
## 版本管理与向前兼容规则
|
|||
|
|
|
|||
|
|
这是工程中最容易踩坑的部分:
|
|||
|
|
|
|||
|
|
```mermaid
|
|||
|
|
graph LR
|
|||
|
|
Rule["向前兼容三大铁律"] --> R1["新增字段 → 旧客户端忽略"]
|
|||
|
|
Rule --> R2["删除字段 → 新客户端忽略"]
|
|||
|
|
Rule --> R3["字段编号永不重用"]
|
|||
|
|
|
|||
|
|
R1 -.-> F1["新字段标记 optional"]
|
|||
|
|
R2 -.-> D1["标记 deprecated 而非删除"]
|
|||
|
|
R3 -.-> N1["预留号段:1~19 用于 Google, 20~10000 自定"]
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
### 兼容性决策表
|
|||
|
|
|
|||
|
|
| 操作 | 是否安全 | 说明 |
|
|||
|
|
|------|---------|------|
|
|||
|
|
| 新增字段(用更大编号) | ✅ 安全 | 旧版客户端忽略未知编号字段 |
|
|||
|
|
| 删除字段 | ⚠️ 部分安全 | 建议标记 `deprecated`,保留编号 |
|
|||
|
|
| 修改字段类型 | ❌ 危险 | 可能导致二进制解析失败 |
|
|||
|
|
| 重编字段编号 | ❌ 致命 | 读写两边理解错位 |
|
|||
|
|
| 修改字段名 | ✅ 安全 | 名称不影响二进制编码 |
|
|||
|
|
| 修改 enum 值 | ⚠️ 部分安全 | 新增安全,删除需用 reserved |
|
|||
|
|
|
|||
|
|
### 常见错误示范
|
|||
|
|
|
|||
|
|
```proto
|
|||
|
|
// ❌ 错误做法:删掉 field 3 后把 field 4 改成 = 3
|
|||
|
|
message BadExample {
|
|||
|
|
string field1 = 1;
|
|||
|
|
string field2 = 2;
|
|||
|
|
// string removed_field = 3; ← 直接注释掉了
|
|||
|
|
string new_field = 3; // ← 重编了!危险!
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// ✅ 正确做法:保留编号并标记 reserved
|
|||
|
|
message GoodExample {
|
|||
|
|
string field1 = 1;
|
|||
|
|
string field2 = 2;
|
|||
|
|
reserved 3; // 锁定已删除字段的编号
|
|||
|
|
string new_field = 4; // 用新编号
|
|||
|
|
}
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
### reserved 的完整写法
|
|||
|
|
|
|||
|
|
```proto
|
|||
|
|
message OldMessage {
|
|||
|
|
reserved 2, 15; // 单个编号
|
|||
|
|
reserved 9, 10, 14; // 连续范围可用语法 reserved 9 to 14;
|
|||
|
|
reserved "name", "email"; // 已废弃的字段名
|
|||
|
|
}
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
> [!warning] reserved 不是可选项
|
|||
|
|
>
|
|||
|
|
> 当你要删除一个字段或其编号时,必须加上 `reserved` 声明。否则未来有人新增字段时重新使用了这个编号,就会造成静默的数据损坏——旧客户端读到新字段数据当成旧字段解析。
|
|||
|
|
|
|||
|
|
## Proto 组织规范
|
|||
|
|
|
|||
|
|
推荐的目录结构:
|
|||
|
|
|
|||
|
|
```
|
|||
|
|
proto/
|
|||
|
|
├── buf.gen.yaml # Buf 代码生成配置
|
|||
|
|
├── api/
|
|||
|
|
│ └── v1/
|
|||
|
|
│ ├── order/
|
|||
|
|
│ │ ├── order.proto # Service + Message 定义
|
|||
|
|
│ │ └── error.proto # 统一错误码定义
|
|||
|
|
│ ├── user/
|
|||
|
|
│ │ └── user.proto
|
|||
|
|
│ └── common/
|
|||
|
|
│ └── page.proto # 分页等通用定义
|
|||
|
|
└── gen/go/github.com/example/api/ # 自动生成代码
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
### 命名约定
|
|||
|
|
|
|||
|
|
| 元素 | 命名风格 | 示例 |
|
|||
|
|
|------|---------|------|
|
|||
|
|
| package | 小写,点分隔,含版本前缀 | `order.v1` |
|
|||
|
|
| service | PascalCase + Service 后缀 | `OrderService` |
|
|||
|
|
| rpc method | PascalCase | `CreateOrder`, `GetUserInfo` |
|
|||
|
|
| message | PascalCase | `CreateOrderRequest` |
|
|||
|
|
| enum | PascalCase + Status/Type 后缀 | `OrderStatus`, `RoleType` |
|
|||
|
|
| field | snake_case | `user_id`, `created_at` |
|
|||
|
|
|
|||
|
|
> [!tip] 推荐的工具链
|
|||
|
|
>
|
|||
|
|
> 建议使用 **[Buf](https://buf.build)** 替代 protoc 直调。Buf 提供统一的依赖管理、linting 和 CI 集成,且屏蔽了 protoc 在不同语言间的命令差异。
|
|||
|
|
|
|||
|
|
## 关联笔记
|
|||
|
|
|
|||
|
|
- [[01-协议与架构]] — Proto 文件最终服务于协议栈中的 Generated Stub 层
|
|||
|
|
- [[07-最佳实践]] — Buf 代码生成流程和生产配置
|
|||
|
|
- [[02-服务治理/05-服务间通信]] — 不同序列化方案的性能对比
|