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

14 KiB
Raw Blame History

tags, create time
tags create time
gRPC
Protobuf
proto
module
lint
buf
API design
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 中新建 package user.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";

三条铁律:

  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)。

合理的分配方式应按字段重要性和使用频率分层预留:

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 优化提示 编号 115 编码仅需 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 — 序列化大小调优与压测方法