This repository has been archived on 2026-05-24. You can view files and clone it. You cannot open issues or pull requests or push a commit.
Files
all-in-kingsoft/hzh/MS/06-gRPC/02-Proto设计.md
T

283 lines
10 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
---
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-服务治理/服务间通信]] — 不同序列化方案的性能对比