14 KiB
tags, create time
| tags | 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 命名规范
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中新建 packageuser.v2,同时保留旧版本的向后兼容。具体迁移方案参见 hhs/gRPC/1. Protobuf 基础篇/03-字段编号与前向兼容.md。
Go Module 路径对齐
同一 module 下的所有 .proto 共享相同的 go_package,确保生成的所有文件都在同一个 Go package 里:
// 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 规范
// api/user/v1/user.proto
import "common/v1/errors.proto";
import "common/v1/timestamps.proto";
三条铁律:
- 避免循环引用——如果 user.proto 需要引用 order.proto,而 order.proto 又引用 user.proto,提取共享 message 到独立文件(如
common/v1/shared.proto)。 - import 路径等于相对磁盘路径——
protoc -I api/时,user/v1/user.proto文件就用import "user/v1/user.proto"。Buf 同理,以buf.yaml中声明的 module 路径为根。 - 公共类型抽离到 common 包——错误码、通用时间戳、分页参数等跨 module 共用的类型放在
common/目录下。
字段编号分配策略
每个字段都有一个 tag number,这是 proto schema 最核心的约束之一。编号一旦分配并部署,就永远不能再复用(详见 hhs/gRPC/1. Protobuf 基础篇/03-字段编号与前向兼容.md)。
合理的分配方式应按字段重要性和使用频率分层预留:
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,162047 需 2 bytes。对于高频通信的消息体(比如每秒钟百万调用),core fields 保持在 1~15 能节省可观的带宽开销。
公共类型与 Well-Known Types
跨服务共用的数据类型应当统一收敛到 common/ 目录,避免每个模块各自定义导致的不一致:
// 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 演进保护机制:
# 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)
# 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 迁移指南
当必须做不兼容变更时(如修改字段类型、重构消息嵌套关系),遵循以下流程:
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 风格一致:
# buf.yaml
version: v2
lint:
use:
- STANDARD
ignore:
- user/v1/user.proto # 如果有特殊情况可以排除
# 执行 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 阶段就被拦截:
# .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"
# 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 — 序列化大小调优与压测方法