Files
cs-note/hhs/gRPC/1. Protobuf 基础篇/01-Protobuf 语法与消息定义.md
T
2026-05-24 11:42:38 +08:00

310 lines
12 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, proto, message, enum, oneof, map, IDL]
create time: 2026-05-11 16:40
---
# Protobuf 语法与消息定义
## 概述
gRPC 使用 Protobuf(Protocol Buffers)作为接口定义语言(IDL)。所有 gRPC 服务契约都以 `.proto` 文件编写——这是你的 API「蓝图」,任何调用方、服务端都从这里生成代码。**学会写 `.proto` 文件,你就拿到了整个 gRPC 体系的入场券。**
> [!question] 为什么选 Protobuf 而不是 JSON Schema?
> JSON Schema 描述的是数据格式,但不提供序列化协议和跨语言代码生成能力。Protobuf 则是一套完整的 IDL:它定义了数据结构、wire format、序列化规则,并且为多语言自动生成强类型 Stub。对于内部微服务通信,这意味着**契约即代码**,编译期就能发现类型不匹配。
## .proto 文件骨架
一个 `.proto` 文件由若干顶层声明组成。先看完整骨架:
```protobuf
syntax = "proto3"; // ① 版本声明
package user.v1; // ② 包名(命名空间)
option go_package = "github.com/example/svc/user/v1;v1"; // ③ Go 输出路径
import "google/protobuf/timestamp.proto"; // ④ 引用外部 Proto
message GetUserRequest { // ⑤ 消息
string id = 1;
}
message GetUserResponse { // ⑤ 消息
User user = 1;
}
message User { // ⑤ 消息
string id = 1;
string name = 2;
google.protobuf.Timestamp created_at = 3;
}
service UserService { // ⑥ RPC 服务
rpc GetUser(GetUserRequest) returns (GetUserResponse);
rpc ListUsers(ListUsersRequest) returns (stream User);
}
```
> [!note] proto2 vs proto3
> - **proto3** 是当前默认版本,移除了 `required`/`optional` 字段修饰符、枚举必须从 0 开始等限制,语法更简洁。
> - **proto2** 仍被部分遗留系统使用,支持更完整的特性如 `required`/`optional`、manually-implemented map field 等。
> - **新项目一律使用 `syntax = "proto3"`**。除非你在维护十年前的遗留服务,否则没有理由用 proto2。
### 核心组成部分速查
| 声明 | 作用 | 是否必需 |
|------|------|----------|
| `syntax` | 指定 Protobuf 版本 | ✅ |
| `package` | 命名空间隔离,避免名称冲突 | ⚠️ 推荐 |
| `option go_package` | Go 生成的包路径和导出前缀 | ✅ Go 项目必需 |
| `import` | 引用其他 `.proto` 文件 | ❌ |
| `message` | 定义结构化数据类型 | ❌ |
| `enum` | 定义枚举类型 | ❌ |
| `service` / `rpc` | 定义远程调用接口 | ❌(纯数据 Proto 不需要) |
## Message 消息结构
Message 是 Protobuf 中最基本的结构化类型,对应 Go 的 `struct`:
```protobuf
message LoginRequest {
string username = 1; // 用户名
string password = 2; // 密码(生产环境走 TLS 加密通道)
bool remember_me = 3; // 记住登录状态
}
message LoginResponse {
string token = 1; // JWT Token
int64 expire_at = 2; // 过期时间戳(Unix seconds)
User profile = 3; // 嵌套消息
}
message User {
string id = 1; // UUID 格式
string name = 2; // 显示名称
string email = 3; // 邮箱地址
int32 age = 4; // 年龄
bool active = 5; // 是否活跃
repeated string roles = 6; // 角色列表(repeated 详见 [02-数据类型详解](./02-数据类型详解.md))
}
```
每个字段包含三部分:**类型 + 字段名 + tag number**。tag number 是字段在二进制 wire format 中的唯一标识——**一旦分配就不会再变**。后续讨论兼容性时会深入理解它的重要性。
> [!tip] tag number 分配原则
> 1. 从 1 开始连续编号,不要跳号
> 2. 预留编号区间给未来可能新增的字段(如保留 1-99 给常用字段,100+ 给扩展字段)
> 3. 已使用的编号永远不要重用或删除 —— 这会导致序列化数据解析错乱
> 4. 具体规则参见 [03-字段编号与前向兼容](./03-字段编号与前向兼容.md)
> [!question] 为什么不用 JSON 那样的"无编号"设计?
> tag number 的核心价值在于**向后兼容**:当你新增字段时,老版本客户端遇到未知的 tag number 会直接跳过该字节块继续解析。如果没有编号,你只能换字段名 —— 但改了名就是 breaking change。Protobuf 的二进制设计让它在小体积、高性能之余,还能优雅地处理版本演进。
### 字段的默认值行为
proto3 中所有字段都有明确的默认值:
| 类型 | 默认值 |
|------|--------|
| string | `""`(空串) |
| bytes | 空字节序列 |
| bool | `false` |
| numeric (int32, uint64, double…) | `0` 或 `0.0` |
| enum | 值为 `0` 的那个枚举值 |
| message | 返回"默认实例"(Go 中为零值 struct) |
| repeated | 空列表(Go 中为 nil slice) |
| map | nil map(Go 中为 nil) |
```go
// Go 中读取默认值 — 无法区分"未设置"和"显式设为零值"
var req LoginRequest
fmt.Println(req.Username) // "" — 到底是没传还是传了 ""?
```
**这就是 proto3 最著名的陷阱:客户端读不到"未设置"和"设为零值"的区别。** proto2 通过 `has_xxx` 字段解决这个问题,而 proto3 在 3.12+ 引入了 `optional` 关键字(生成时同样附带 `has_xxx`)。不过最通用的实践仍是用包装类型——详见 [02-数据类型详解](./02-数据类型详解.md)。
## Enum 枚举类型
枚举用于定义一组命名的整数值:
```protobuf
enum Role {
ROLE_UNSPECIFIED = 0; // 未指定(proto3 要求第一个值为 0)
ROLE_ADMIN = 1; // 管理员
ROLE_EDITOR = 2; // 编辑者
ROLE_VIEWER = 3; // 只读者
}
enum Status {
STATUS_OFFLINE = 0; // 离线
STATUS_ONLINE = 1; // 在线
STATUS_BUSY = 2; // 忙碌
STATUS_AWAY = 3; // 离开
}
message User {
string id = 1;
string name = 2;
Role role = 3; // 引用枚举类型
Status status = 4; // 引用枚举类型
}
```
> [!warning] 枚举铁律
> 1. **第一个枚举值必须是 0**(通常以 `_UNSPECIFIED` 或 `_UNKNOWN` 结尾),proto3 强制要求
> 2. 新增枚举值是向后兼容的,但旧版本客户端收到未知枚举值时会回退到 0(即第一个值)
> 3. **不要删除已有枚举值的编号**,否则可能引发不可预期的兼容问题
> 4. 枚举值可以打同一个数值做 alias,但需要在 enum 选项里声明 `allow_alias = true`
## Oneof 排他选择
当多个字段互斥、每次请求只能填其中一个时,使用 `oneof`:
```protobuf
message UpdateProfileRequest {
string id = 1;
oneof update_field {
string name = 2;
string email = 3;
Role role = 4;
}
}
```
这样保证了 `Name`、`Email`、`Role` 三个字段在序列化时只有一个会出现,节省带宽且语义清晰。在 Go 生成的代码中,oneof 会变成一个接口类型:
```go
type UpdateProfileRequest struct {
Id string
// 只能设置其中之一
UpdateField isUpdateProfileRequest_UpdateField
}
switch req.UpdateField.(type) {
case *UpdateProfileRequest_Name:
fmt.Println("更新了 name:", req.Name)
case *UpdateProfileRequest_Email:
fmt.Println("更新了 email:", req.Email)
}
```
> [!question] oneof vs 单独字段?什么时候该用 oneof?
> 如果你希望业务逻辑保证「每次请求只更新一个字段」,用 oneof 可以让编译器强制约束这个规则。但如果只是"几个可选字段可能同时出现"的场景,反而应该用单独的 field —— oneof 会增加代码复杂度(需要 switch/case 判断哪个被设置了)。**本质区别:oneof 表达的是"二选一或多选一"的互斥关系。**
## Map 键值映射
Protobuf 原生支持 key-value 映射,key 只能是整数或字符串类型:
```protobuf
message UserProfile {
string id = 1;
// 标签映射:string → string
map<string, string> tags = 2;
// 统计映射:string → int32
map<string, int32> login_count_by_day = 3;
}
```
在 Go 中生成的对应类型为 `map[string]string`,**注意默认为 nil**。如果需要确保非 nil,可以用 `repeated` + key-value message 替代。
## Reserved 保留字段
当你的 proto 文件 evolve 到新版本,可能需要移除某个字段。**但不能简单地删除——因为旧版本的客户端可能还在发送带有该字段编号的数据,新服务器解析时会把它塞进下一个字段里。** `reserved` 关键字就是为此而生:
```protobuf
message User {
reserved 7, 11; // 保留单个编号
reserved 9 to 13; // 保留编号区间
reserved "username", "telephone"; // 保留字段名
string id = 1;
string name = 2;
string nick = 8; // 7 不能用了,这里只能用 >= 14 的编号
}
```
> [!example] 典型场景:用户表迭代
> v1: `message User { string username = 1; string email = 2; string phone = 3; }`
>
> v2: 业务发现 `username` 改名了,决定删除并保留编号:
> ```protobuf
> message User {
> reserved 1; // 告诉 protoc:1 号编号作废
> string id = 1; // 重新用编号 1 放 id
> string email = 2;
> string nickname = 3; // 新的昵称字段
> }
> ```
>
> 这样如果 v1 客户端发来 `username` 的数据(tag=1),protoc 会自动丢弃而不会错误地填入 `id` 字段。
> [!tip] reserved 最佳实践
> 1. 删除字段时,同时记录被删字号的**原因注释**(可以在 git commit message 里说明,也可以加一行 `// reserved: replaced by xxx at YYYY-MM-DD`)
> 2. 不要把正在使用的编号标记为 reserved——编译不过就是最大的提示
> 3. 具体兼容策略参见 [03-字段编号与前向兼容](./03-字段编号与前向兼容.md)
## Package 与 Import
Protobuf 的 `package` 机制类似于 Go 的 import path,提供命名空间隔离:
```protobuf
// file: user/v1/user.proto
package user.v1;
import "google/protobuf/timestamp.proto"; // Well-Known Type
message User {
string id = 1;
string name = 2;
google.protobuf.Timestamp created_at = 3;
}
// file: order/v1/order.proto
package order.v1;
import "user/v1/user.proto"; // 引用 user 包的 message
message Order {
string id = 1;
user.v1.User buyer = 2; // 跨包引用
int64 amount_cents = 3;
}
```
> [!tip] import 路径约定
> `import "user/v1/user.proto"` 中的路径应当与文件的实际磁盘路径一致(相对于 `protoc -I` 参数指定的目录)。保持一致性是关键。
## 构建流程总览
下图展示从 `.proto` 源文件到最终 Go Stub 的完整编译链:
```mermaid
flowchart TD
A[".proto 源文件"] --> B["protoc 编译器"]
B --> C["protoc-gen-go 插件"]
B --> D["protoc-gen-go-grpc 插件"]
C --> E["pb.go — 消息结构体"]
D --> F["_grpc.go — client/server stub"]
E --> G["业务层调用 Client / Server"]
F --> G
style A fill:#EAB308,color:#fff
style B fill:#3B82F6,color:#fff
style C fill:#4FC08D,color:#fff
style D fill:#4FC08D,color:#fff
style E fill:#A0AEC0,color:#fff
style F fill:#A0AEC0,color:#fff
```
> [!info] 工具链细节
> `protoc` 负责解析 `.proto` 语法树,各类插件将其翻译成目标语言的代码。Go 生态需要两个插件协同工作:`protoc-gen-go` 生成消息结构体,`protoc-gen-go-grpc` 生成 gRPC 客户端和服务端 Stub。具体配置方法参见 [17-protoc 工具链与 Makefile](../6.%20工程实践篇/17-protoc%20工具链与%20Makefile.md)。
## 关联笔记
- [[hhs/gRPC/README]] — gRPC 知识库全景索引
- [[hhs/gRPC/1. Protobuf 基础篇/02-Protobuf 数据类型详解]] — Scalar、Wrapper、Well-Known、Repeated 详细对照
- [[hhs/gRPC/1. Protobuf 基础篇/03-Protobuf 字段编号与前向兼容]] — Field Number 分配规则、Reserved、版本演进策略
- [[hhs/gRPC/1. Protobuf 基础篇/04-Protobuf Oneof 与包装类型]] — Oneof 高级用法、Google.Protobuf.Value、Any 泛型封装