This repository has been archived on 2026-05-24. You can view files and clone it. You cannot open issues or pull requests or push a commit.
Files
all-in-kingsoft/hzh/MS/06-gRPC/02-Proto设计.md
T

10 KiB
Raw Blame History

tags, create time
tags create time
grpc
protobuf
schema-versioning
proto3
client-server
stub-generation
2026-05-07 16:00

Proto 设计规范

概述

本文覆盖 Proto3 进阶特性和版本管理策略。如果说架构篇是"看懂 gRPC 长什么样",这篇就是教你"如何写出经得起演进的 Proto 文件"——这是工程中最容易踩坑、却最容易被忽视的部分。

[!question] 思考

你的 Service A 调用 Service B 的 GetUser,Proto 里定义了 20 个字段。半年后你想加第 21 个字段,但旧版客户端没有编译更新。会发生什么?

Proto 是什么

Protocol Buffers(简称 Proto)是 Google 开源的一套语言无关、平台无关的结构化数据序列化方案。它的核心作用有两层:

层面 说明
接口契约 用 .proto 文件声明 Service(有哪些 RPC 方法)、Request / Response(传什么数据),作为服务间通信的"宪法"
序列化格式 把内存中的对象编码成二进制字节流,跨进程 / 跨网络传输后再还原回对象

一句话总结:Proto = IDL(接口定义语言)+ 二进制序列化。

为什么两端都需要编译?

很多初学者会疑惑:服务端实现逻辑、客户端发起调用,两边的代码不是各写各的吗?——不是的,.proto 文件是两端共享的唯一真相源。

graph LR
    Proto[".proto 文件<br/>唯一真相源"] --> protoc["protoc / Buf"]
    protoc --> Server["服务端生成代码<br/>(Go stub)"]
    protoc --> Client["客户端生成代码<br/>(Java / TS / …)"]
    Server --> Encode["编码 Request → 发送二进制流"]
    Client --> Decode["接收二进制流 → 反序列化 Response"]
    Encode --- Decode
角色 编译产物 用途
服务端 Server Stub 接收二进制消息 → 反序列化为参数对象 → 执行业务逻辑 → 返回结果对象 → 编码为响应
客户端 Client Stub 用户调用 Stub 方法 → 构造请求对象 → 编码为二进制字节流发送 → 收到响应后解码

[!question] 思考

如果服务端用 Go 生成代码、客户端用 Java 生成代码,它们之间的二进制消息能正确互操作吗?

[!answer]- 答案 完全可以。 Proto 的二进制编码规则是语言无关的规范,只依赖字段编号(= 后面的数字)。只要两边用的是同一份 .proto,任何语言的编译器都会按照相同的规则编解码。

实际编译示意

.proto 定义(两端共用同一文件)

// proto/order/v1/order.proto
syntax = "proto3";
package order.v1;

service OrderService {
    rpc CreateOrder (CreateOrderRequest) returns (CreateOrderResponse);
}

message CreateOrderRequest {
    string user_id    = 1;
    repeated string item_ids = 2;
}

服务端(Go): buf generate → 生成 order_service.pb.go + order.pb.go

// 服务端实现生成的 RPC 接口
func (s *server) CreateOrder(ctx context.Context, req *orderpb.CreateOrderRequest) (*orderpb.CreateOrderResponse, error) {
    // 直接使用反序列化好的 Request 对象 ✅
    fmt.Println(req.UserId)  // 强类型访问
    return &orderpb.CreateOrderResponse{OrderId: "ord-123"}, nil
}

客户端(TypeScript): buf generate → 生成 order_pb.ts

// 客户端直接调用生成的 Stub 方法
const response = await orderClient.createOrder({ userId: 'u-456', itemIds: ['i-7', 'i-8'] })
console.log(response.orderId)  // 直接得到结果 ✅

可以看到:两端都从同一个 .proto 生成了各自语言的代码,但编写的代码量极小——核心工作是写 .proto 和手写业务逻辑。

为什么不直接用 JSON / XML

维度 Proto JSON XML
体积 紧凑,无标签名冗余 冗余大(每个值都带 key 名) 标签开销更大
性能 编解码速度极快(变长整数 + 字段编号编码) 需解析字符串 解析最慢
类型安全 强类型,编译器检查 动态类型,运行时才暴露错误 动态类型
向后兼容 天然支持字段增删 无约定,全靠文档 部分支持
工具链 protoc 生成各语言 Stub — —

好消息是:Protocol Buffers 的二进制编码规则天然保证了向前兼容。坏消息是:只有遵守规则的改动才是兼容的,违反规则的静默破坏会让你排查整整一天。

Oneof —— 互斥字段

当某个消息体可能有多种不同类型的值,但同一时刻只出现一种时,使用 oneof:

// proto/order/v1/order.proto
message Payment {
    string order_id = 1;

    oneof method {
        AlipayPayment   alipay   = 2;
        WechatPayment   wechat   = 3;
        CreditCard      card     = 4;
    }
}

message AlipayPayment {
    string token = 1;
}

message WechatPayment {
    string openid = 1;
}

message CreditCard {
    string last_four = 1;
    string exp_date  = 2;
}

[!note] Proto3 oneof 的限制

  • 不能加 repeated 修饰符
  • 空 oneof 值为 NONE(0),反序列化时各字段都为默认值
  • 向后兼容策略:新增 oneof 变体是安全的;删除变体会破坏旧客户端

Well-Known Types —— 内置类型库

Proto3 内置了一些常用类型,直接引用即可,不用自己定义:

import "google/protobuf/timestamp.proto";
import "google/protobuf/empty.proto";
import "google/protobuf/duration.proto";
import "google/protobuf/wrappers.proto";

message Event {
    string name       = 1;
    google.protobuf.Timestamp created_at = 2;  // 替代手动时间字符串
    google.protobuf.Empty body              = 3;  // 空消息占位
    google.protobuf.StringValue desc       = 4;  // 可选字符串(区分 unset 和空串)
}
类型 用途
Timestamp RFC 3339 纳秒级时间戳
Duration 带单位的时长
Empty 无参数 / 无返回值的 RPC 占位
StringValue / Int32Value / BoolValue 包装基本类型,表示"可选"语义
Struct / Value / ListValue 类 JSON 的动态结构
Any 泛型消息容器(需搭配 type URL)

[!tip] StringValue 的妙用

Proto3 默认把未设置的字段归入"默认值",导致无法区分"用户传了空字符串"和"用户没传这个字段"。用 StringValue 包装后,Proto3 可以精确表达 Optional 语义。

Map 字段

message Config {
    map<string, string> labels      = 1;
    map<int32, float>   price_cache = 2;
}
  • 有序遍历:Proto3 的 map 按 key 排序遍历
  • 兼容性:删除整个 map 字段可接受,删除其中某个 key-value 对不安全(会被当作未知字段忽略)

版本管理与向前兼容规则

这是工程中最容易踩坑的部分:

graph LR
    Rule["向前兼容三大铁律"] --> R1["新增字段 → 旧客户端忽略"]
    Rule --> R2["删除字段 → 新客户端忽略"]
    Rule --> R3["字段编号永不重用"]

    R1 -.-> F1["新字段标记 optional"]
    R2 -.-> D1["标记 deprecated 而非删除"]
    R3 -.-> N1["预留号段:1~19 用于 Google, 20~10000 自定"]

兼容性决策表

操作 是否安全 说明
新增字段(用更大编号) ✅ 安全 旧版客户端忽略未知编号字段
删除字段 ⚠️ 部分安全 建议标记 deprecated,保留编号
修改字段类型 ❌ 危险 可能导致二进制解析失败
重编字段编号 ❌ 致命 读写两边理解错位
修改字段名 ✅ 安全 名称不影响二进制编码
修改 enum 值 ⚠️ 部分安全 新增安全,删除需用 reserved

常见错误示范

// ❌ 错误做法:删掉 field 3 后把 field 4 改成 = 3
message BadExample {
    string field1 = 1;
    string field2 = 2;
    // string removed_field = 3;  ← 直接注释掉了
    string new_field = 3;         // ← 重编了!危险!
}

// ✅ 正确做法:保留编号并标记 reserved
message GoodExample {
    string field1 = 1;
    string field2 = 2;
    reserved 3;                     // 锁定已删除字段的编号
    string new_field = 4;           // 用新编号
}

reserved 的完整写法

message OldMessage {
    reserved 2, 15;                    // 单个编号
    reserved 9, 10, 14;                // 连续范围可用语法 reserved 9 to 14;
    reserved "name", "email";          // 已废弃的字段名
}

[!warning] reserved 不是可选项

当你要删除一个字段或其编号时,必须加上 reserved 声明。否则未来有人新增字段时重新使用了这个编号,就会造成静默的数据损坏——旧客户端读到新字段数据当成旧字段解析。

Proto 组织规范

推荐的目录结构:

proto/
├── buf.gen.yaml            # Buf 代码生成配置
├── api/
│   └── v1/
│       ├── order/
│       │   ├── order.proto         # Service + Message 定义
│       │   └── error.proto         # 统一错误码定义
│       ├── user/
│       │   └── user.proto
│       └── common/
│           └── page.proto          # 分页等通用定义
└── gen/go/github.com/example/api/  # 自动生成代码

命名约定

元素 命名风格 示例
package 小写,点分隔,含版本前缀 order.v1
service PascalCase + Service 后缀 OrderService
rpc method PascalCase CreateOrder, GetUserInfo
message PascalCase CreateOrderRequest
enum PascalCase + Status/Type 后缀 OrderStatus, RoleType
field snake_case user_id, created_at

[!tip] 推荐的工具链

建议使用 Buf 替代 protoc 直调。Buf 提供统一的依赖管理、linting 和 CI 集成,且屏蔽了 protoc 在不同语言间的命令差异。

关联笔记