Init
This commit is contained in:
@@ -0,0 +1,309 @@
|
||||
---
|
||||
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 泛型封装
|
||||
Reference in New Issue
Block a user