386 lines
14 KiB
Markdown
386 lines
14 KiB
Markdown
|
|
---
|
|||
|
|
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]] — 序列化大小调优与压测方法
|