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