Files
cs-note/hhs/gRPC/6. 工程实践篇/18-模块拆分与 proto 规范.md
T
2026-05-24 11:42:38 +08:00

386 lines
14 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, module, lint, buf, API design]
create time: 2026-05-11 16:40
---
# 模块拆分与 proto 规范
## 概述
多人协作时,proto 规范是最容易产生分歧的地方。没有统一的规范,proto 文件会迅速变成一团乱麻。本文档提供一套被业界(Google、Uber、Stripe)验证过的最佳实践,涵盖目录结构、命名约定、Package/Import 规范、字段编号策略以及 CI 流水线集成。
> [!question] 为什么要花精力建立规范?
> Proto 文件是服务的"契约"——一旦发布就不能随意修改。如果每个人按照自己的习惯来组织文件,三个月后你会面临:import 路径混乱、循环引用频发、breaking change 无人察觉。**规范的本质是用一致性换取可维护性。**
## 推荐的目录结构
```
api/
├── user/
│ └── v1/
│ ├── user.proto # core message types
│ ├── service.proto # service definitions
│ ├── errors.proto # common error codes
│ └── README.md # API documentation
├── order/
│ └── v1/
│ ├── order.proto
│ └── service.proto
├── product/
│ └── v1/
│ └── product.proto
├── buf.yaml # workspace-level config
└── buf.gen.yaml # generation config
```
这种结构的核心理念:**一个资源(resource)一个目录,一条版本号(version)一层子目录**。这与 Go module 的导入路径完全一致,生成代码后 import path 无需任何映射。
> [!tip] 为什么推荐 Buf 而非原生 protoc?
> - **内置 lint 和 breaking change 检测**——省去自建规则的成本
> - **声明式配置取代 shell 脚本**——`buf generate` 一条命令搞定
> - **自动处理 plugin 版本管理**——不再有 "mismatched version" 报错
> - 详见 [[hhs/gRPC/6. 工程实践篇/17-protoc 工具链与 Makefile.md]]
## Proto 文件命名约定
| 文件类型 | 命名 | 说明 |
|----------|------|------|
| 消息定义 | `{entity}.proto` | `user.proto`, `order.proto` |
| 服务定义 | `service.proto` | 包含该 module 所有 RPC |
| 错误码 | `errors.proto` | 全局错误码 |
| 枚举 | 混入 entity.proto 或单独 `enum.proto` | 建议随 entity |
> [!tip] 为什么拆分 service.proto?
> 当业务增长时,`user.proto` 可能包含数十个 message。将 RPC 定义拆到独立的 `service.proto` 能让每次 review 聚焦单一职责——reviewer 不需要在大量 message 中查找接口变更。
## Package 命名规范
```protobuf
syntax = "proto3";
package mycompany.servicename.v1;
option go_package = "github.com/mycompany/platform/api/servicename/v1;v1";
```
**绝对不要用 package name 作为 API versioning 的方式**——换 package name 等于破坏兼容性。版本信息应该通过目录层级 `v1/`、`v2/` 来体现,保持 package 名不变。
> [!warning] 兼容性陷阱
> 从 `package user.v1` 改为 `package user.v2` 会使得所有旧引用失效。正确做法是在新目录 `user/v2/user.proto` 中新建 package `user.v2`,同时保留旧版本的向后兼容。具体迁移方案参见 [[hhs/gRPC/1. Protobuf 基础篇/03-字段编号与前向兼容.md]]。
### Go Module 路径对齐
同一 module 下的所有 `.proto` 共享相同的 `go_package`,确保生成的所有文件都在同一个 Go package 里:
```protobuf
// api/user/v1/user.proto
package user.v1;
option go_package = "github.com/mycompany/platform/api/user/v1;v1";
// api/user/v1/service.proto
package user.v1;
option go_package = "github.com/mycompany/platform/api/user/v1;v1";
```
如果 `go_package` 中的包名不同(比如分别用 `userpb` 和 `userservicepb`),编译时会报 "two different package names" 错误。所以**强烈建议统一使用同一个包名后缀**。
## Import 规范
```protobuf
// api/user/v1/user.proto
import "common/v1/errors.proto";
import "common/v1/timestamps.proto";
```
三条铁律:
1. **避免循环引用**——如果 user.proto 需要引用 order.proto,而 order.proto 又引用 user.proto,提取共享 message 到独立文件(如 `common/v1/shared.proto`)。
2. **import 路径等于相对磁盘路径**——`protoc -I api/` 时,`user/v1/user.proto` 文件就用 `import "user/v1/user.proto"`。Buf 同理,以 `buf.yaml` 中声明的 module 路径为根。
3. **公共类型抽离到 common 包**——错误码、通用时间戳、分页参数等跨 module 共用的类型放在 `common/` 目录下。
### 字段编号分配策略
每个字段都有一个 tag number,这是 proto schema 最核心的约束之一。**编号一旦分配并部署,就永远不能再复用**(详见 [[hhs/gRPC/1. Protobuf 基础篇/03-字段编号与前向兼容.md]])。
合理的分配方式应按字段重要性和使用频率分层预留:
```protobuf
message User {
// === Core fields (1-9): 核心字段,几乎每次都会序列化 ===
string id = 1;
string name = 2;
string email = 3;
// === Secondary fields (10-19): 常用但非必需 ===
string phone = 10;
string avatar_url = 11;
User_Role role = 12;
bool active = 13;
// === Tertiary fields (20-99): 偶尔使用 ===
string bio = 20;
string website = 21;
Location location = 22;
// === Audit & metadata (100-199): 系统级元数据 ===
google.protobuf.Timestamp created_at = 100;
google.protobuf.Timestamp updated_at = 101;
string created_by = 102;
string updated_by = 103;
}
```
> [!tip] 预留块的好处
> 如果所有字段从 1 开始连续排列,每加一个都需要改后面所有的编号——而且已经部署的旧客户端会把新编号的字段当成不同的语义。**预留空位只需分配一个新编号,不影响已有字段。**
> [!note] Wire Encoding 优化提示
> 编号 1~15 编码仅需 1 byte,16~2047 需 2 bytes。对于高频通信的消息体(比如每秒钟百万调用),core fields 保持在 1~15 能节省可观的带宽开销。
## 公共类型与 Well-Known Types
跨服务共用的数据类型应当统一收敛到 `common/` 目录,避免每个模块各自定义导致的不一致:
```protobuf
// api/common/v1/timestamps.proto
syntax = "proto3";
package common.v1;
message Timestamps {
google.protobuf.Timestamp created_at = 1;
google.protobuf.Timestamp updated_at = 2;
google.protobuf.Timestamp deleted_at = 3; // nil 表示未删除
}
// api/common/v1/pagination.proto
syntax = "proto3";
package common.v1;
import "google/protobuf/wrappers.proto";
message PaginationRequest {
int32 page_size = 1; // 默认值由服务端决定(通常 20)
string page_token = 2; // 游标翻页
google.protobuf.BoolValue include_deleted = 3; // 包装类型区分 unset
}
message PaginationResponse {
string next_page_token = 1;
bool has_more = 2;
}
// api/common/v1/errors.proto
syntax = "proto3";
package common.v1;
import "google/rpc/status.proto";
enum ErrorCode {
ERROR_CODE_UNSPECIFIED = 0;
ERROR_CODE_NOT_FOUND = 1;
ERROR_CODE_INVALID_ARG = 2;
ERROR_CODE_PERMISSION = 3;
ERROR_CODE_RATE_LIMIT = 4;
}
```
> [!tip] Well-Known Types优先
> Protobuf 内置了 `google/protobuf/{timestamp,duration,empty,wrapper,any,map}.proto`,尽量直接使用它们而不是自定义等价类型。这样做的好处是各语言 SDK 都有原生支持,序列化行为一致,且后续切换语言时零适配成本。
### 常见模式速查
| 场景 | 推荐类型 | 说明 |
|------|---------|------|
| 创建/更新时间 | `google.protobuf.Timestamp` | ISO 8601 格式,纳秒精度 |
| 软删除标记 | `google.protobuf.Timestamp deleted_at` | nil = 未删除,比额外 bool 字段更省空间 |
| 可选 bool/string | `google.protobuf.BoolValue/StringValue` | 区分 "未设置" 和 "设置为 false/空串" |
| 无返回值 RPC | `google.protobuf.Empty` | 不要自己定义空的 message |
| 不确定类型 | `google.protobuf.Value` | JSON-like 万能类型,牺牲类型安全换取灵活性 |
## Breaking Change 检测
Buf breaking 是最靠谱的 proto schema 演进保护机制:
```bash
# CI Pipeline step
buf lint && buf breaking --against 'https://github.com/repo.git#branch=main'
```
检测内容覆盖:
- 删除 / 修改 message field 类型
- 移除 service 或 method
- Enum value removal(除了追加新的值)
- 字段编号复用(deleted tag number reused)
```yaml
# buf.yaml — 配置 breaking check 规则
version: v2
breaking:
use:
- FILE
- WIRE
ignore:
- user/v1/user.proto # 允许某些文件跳过检查
```
> [!tip] WIRE vs FILE
> - `FILE`: 检测单个 .proto 文件内的 breaking change。
> - `WIRE`: 检测 wire format 层面的兼容性问题(更严格),比如字段类型从 int32 改为 string。
> - 生产环境推荐使用 `WIRE`。
### Major Version 迁移指南
当必须做不兼容变更时(如修改字段类型、重构消息嵌套关系),遵循以下流程:
```mermaid
flowchart TD
A["发现不兼容需求"] --> B["在 v2/ 下新建 proto\n不与 v1 共用 package"]
B --> C["API Gateway / Adapter\n实现 v1 <-> v2 双向转换"]
C --> D["灰度: 新旧客户端并行运行"]
D --> E{"全部客户端升级?"}
E -->|否| D
E -->|是| F["停用 v1, 清理旧代码"]
style B fill:#DBEAFE,color:#1E40AF
style C fill:#FEF3C7,color:#92400E
style F fill:#D1FAE5,color:#065F46
```
| 维度 | 并行共存(推荐) | 原地覆盖(❌ 高风险) |
|------|-----------------|---------------------|
| 线上影响 | 透明过渡 | 所有端同时断裂 |
| 回滚成本 | 切回 v1 即可 | 几乎无法回滚 |
| 开发成本 | 需写 Adapter 层 | 看似简单实则危险 |
| 适用场景 | 所有已发布服务 | 仅限内部未发布 proto |
具体兼容性矩阵和字段迁移细节参见 [[hhs/gRPC/1. Protobuf 基础篇/03-字段编号与前向兼容.md#版本演进策略]]。
## Lint 工具集成
使用 Buf 进行 lint 检查,保证整个团队的 proto 风格一致:
```yaml
# buf.yaml
version: v2
lint:
use:
- STANDARD
ignore:
- user/v1/user.proto # 如果有特殊情况可以排除
```
```bash
# 执行 lint
buf lint
# 带详细输出
buf lint --error-format=json
```
Buf lint 默认启用 20+ 条规则,包括:
- 字段编号范围(1-9999 为 reserved,10000-536870911 为用户自定义)
- 消息字段数限制(单文件不超过 1000)
- 枚举必须从 0 开始
- 禁止重复字段名
- 推荐添加注释
> [!question] 为什么要统一 lint?
> 不同开发者对字段编号的分配方式各异——有人用 1、2、3 连续编号,有人随意跳号。一旦引入 lint 规则,所有人的提交都会受到同一套标准的约束,减少 code review 中的琐事争论。
## CI Pipeline 集成示例
将 proto lint 和 breaking change 检测嵌入 CI 流程,确保问题在 PR 阶段就被拦截:
```yaml
# .github/workflows/proto-check.yml (GitHub Actions)
name: Proto Check
on:
pull_request:
paths:
- "api/**"
jobs:
proto-lint:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Setup Buf
uses: bufbuild/buf-setup-action@v1
- name: Run lint
uses: bufbuild/buf-lint-action@v1
with:
input: "api/"
- name: Run breaking change check
uses: bufbuild/buf-breaking-action@v1
with:
input: "api/"
against: "https://github.com/org/repo.git#branch=main,ref=head"
```
```yaml
# GitLab CI 等效配置 (.gitlab-ci.yml)
proto-check:
image: bufbuild/buf:latest
stage: test
script:
- buf lint api/
- buf breaking --against "https://gitlab.com/org/repo.git#branch=main,ref=head"
```
> [!tip] 关键设计原则
> - **只在 api/ 路径变更时触发**——其他代码变化不应该阻塞 proto 检查 job
> - **breaking 检测指向 main 分支**——对比的是当前 PR 相对于主干的变化
> - **lint 和 breaking 分两个 job**——失败时能快速定位是风格问题还是真正的兼容性问题
## Message Organization Strategy
有两种主流策略对比:
| 策略 | 优点 | 缺点 |
|------|------|------|
| 合并到一个 .proto | 简单,少文件 | 大文件难维护,冲突频繁 |
| 拆分多个 .proto | 关注点分离,便于 diff | 容易循环引用 |
| **推荐方案** | **按资源拆分 + 公共类型独立文件** | **团队规模 < 50 人适用** |
### 推荐的文件职责边界
```
order/v1/
├── order.proto # Order, OrderItem 等核心消息定义
├── service.proto # CreateOrder, GetOrder, ListOrders 等 RPC
└── errors.proto # ORDER_NOT_FOUND, ORDER_INVALID_STATE 等订单域错误码
```
每个文件的职责清晰,新增一个 RPC 只需改 `service.proto`,不影响其他文件,降低冲突概率。
### 何时不应拆分
并非越细越好。以下场景适合合并:
- 小型项目(< 5 个 message),一个文件即可
- 两个 message 强耦合、从不单独复用(强行拆分会增加维护成本)
- 原型阶段,快速迭代优先于规范化
> [!note] 决策树
> ```
> 消息数量 > 10 ?
> ├── 是 → 拆分为 message.proto + service.proto
> └── 否 → 是否会被其他 module 引用?
> ├── 是 → 放到 common/ 而非各自 module
> └── 否 → 合并在一个文件即可
> ```
## 关联笔记
- [[hhs/gRPC/README.md]] — gRPC 知识库总览
- [[hhs/gRPC/1. Protobuf 基础篇/01-Protobuf 语法与消息定义.md]] — Protobuf 语法与消息定义基础
- [[hhs/gRPC/1. Protobuf 基础篇/02-数据类型详解.md]] — Scalar Types、Wrapper Types、Well-Known Types 详解
- [[hhs/gRPC/1. Protobuf 基础篇/03-字段编号与前向兼容.md]] — Field Number 分配规则与版本演进策略
- [[hhs/gRPC/2. gRPC 核心篇/06-Service 定义与代码生成.md]] — Service 定义与代码生成机制
- [[hhs/gRPC/6. 工程实践篇/17-protoc 工具链与 Makefile.md]] — protoc / Buf 工具链选型与配置
- [[hhs/gRPC/6. 工程实践篇/19-跨语言兼容测试.md]] — 多语言互测注意事项
- [[hhs/gRPC/6. 工程实践篇/20-性能优化与压测.md]] — 序列化大小调优与压测方法