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/hhs/gRPC/1. Protobuf 基础篇/01-Protobuf 语法与消息定义.md
T
2026-05-12 15:26:17 +08:00

12 KiB
Raw Blame History

tags, create time
tags create time
gRPC
Protobuf
proto
message
enum
oneof
map
IDL
2026-05-11 16:40

Protobuf 语法与消息定义

概述

gRPC 使用 Protobuf(Protocol Buffers)作为接口定义语言(IDL)。所有 gRPC 服务契约都以 .proto 文件编写——这是你的 API「蓝图」,任何调用方、服务端都从这里生成代码。学会写 .proto 文件,你就拿到了整个 gRPC 体系的入场券。

[!question] 为什么选 Protobuf 而不是 JSON Schema? JSON Schema 描述的是数据格式,但不提供序列化协议和跨语言代码生成能力。Protobuf 则是一套完整的 IDL:它定义了数据结构、wire format、序列化规则,并且为多语言自动生成强类型 Stub。对于内部微服务通信,这意味着契约即代码,编译期就能发现类型不匹配。

.proto 文件骨架

一个 .proto 文件由若干顶层声明组成。先看完整骨架:

syntax = "proto3";                              // ① 版本声明

package user.v1;                                // ② 包名(命名空间)
option go_package = "github.com/example/svc/user/v1;v1";  // ③ Go 输出路径

import "google/protobuf/timestamp.proto";       // ④ 引用外部 Proto

message GetUserRequest {                        // ⑤ 消息
    string id = 1;
}

message GetUserResponse {                       // ⑤ 消息
    User user = 1;
}

message User {                                  // ⑤ 消息
    string id       = 1;
    string name     = 2;
    google.protobuf.Timestamp created_at = 3;
}

service UserService {                           // ⑥ RPC 服务
    rpc GetUser(GetUserRequest) returns (GetUserResponse);
    rpc ListUsers(ListUsersRequest) returns (stream User);
}

[!note] proto2 vs proto3

  • proto3 是当前默认版本,移除了 required/optional 字段修饰符、枚举必须从 0 开始等限制,语法更简洁。
  • proto2 仍被部分遗留系统使用,支持更完整的特性如 required/optional、manually-implemented map field 等。
  • 新项目一律使用 syntax = "proto3"。除非你在维护十年前的遗留服务,否则没有理由用 proto2。

核心组成部分速查

声明 作用 是否必需
syntax 指定 Protobuf 版本 ✅
package 命名空间隔离,避免名称冲突 ⚠️ 推荐
option go_package Go 生成的包路径和导出前缀 ✅ Go 项目必需
import 引用其他 .proto 文件 ❌
message 定义结构化数据类型 ❌
enum 定义枚举类型 ❌
service / rpc 定义远程调用接口 ❌(纯数据 Proto 不需要)

Message 消息结构

Message 是 Protobuf 中最基本的结构化类型,对应 Go 的 struct:

message LoginRequest {
    string username = 1;          // 用户名
    string password = 2;          // 密码(生产环境走 TLS 加密通道)
    bool   remember_me = 3;       // 记住登录状态
}

message LoginResponse {
    string token      = 1;        // JWT Token
    int64  expire_at  = 2;        // 过期时间戳(Unix seconds)
    User   profile    = 3;        // 嵌套消息
}

message User {
    string id       = 1;          // UUID 格式
    string name     = 2;          // 显示名称
    string email    = 3;          // 邮箱地址
    int32  age      = 4;          // 年龄
    bool   active   = 5;          // 是否活跃
    repeated string roles = 6;    // 角色列表(repeated 详见 [02-数据类型详解](./02-数据类型详解.md))
}

每个字段包含三部分:类型 + 字段名 + tag number。tag number 是字段在二进制 wire format 中的唯一标识——一旦分配就不会再变。后续讨论兼容性时会深入理解它的重要性。

[!tip] tag number 分配原则

  1. 从 1 开始连续编号,不要跳号
  2. 预留编号区间给未来可能新增的字段(如保留 1-99 给常用字段,100+ 给扩展字段)
  3. 已使用的编号永远不要重用或删除 —— 这会导致序列化数据解析错乱
  4. 具体规则参见 03-字段编号与前向兼容

[!question] 为什么不用 JSON 那样的"无编号"设计? tag number 的核心价值在于向后兼容:当你新增字段时,老版本客户端遇到未知的 tag number 会直接跳过该字节块继续解析。如果没有编号,你只能换字段名 —— 但改了名就是 breaking change。Protobuf 的二进制设计让它在小体积、高性能之余,还能优雅地处理版本演进。

字段的默认值行为

proto3 中所有字段都有明确的默认值:

类型 默认值
string ""(空串)
bytes 空字节序列
bool false
numeric (int32, uint64, double…) 0 或 0.0
enum 值为 0 的那个枚举值
message 返回"默认实例"(Go 中为零值 struct)
repeated 空列表(Go 中为 nil slice)
map nil map(Go 中为 nil)
// Go 中读取默认值 — 无法区分"未设置"和"显式设为零值"
var req LoginRequest
fmt.Println(req.Username)  // "" — 到底是没传还是传了 ""?

这就是 proto3 最著名的陷阱:客户端读不到"未设置"和"设为零值"的区别。 proto2 通过 has_xxx 字段解决这个问题,而 proto3 在 3.12+ 引入了 optional 关键字(生成时同样附带 has_xxx)。不过最通用的实践仍是用包装类型——详见 02-数据类型详解。

Enum 枚举类型

枚举用于定义一组命名的整数值:

enum Role {
    ROLE_UNSPECIFIED = 0;     // 未指定(proto3 要求第一个值为 0)
    ROLE_ADMIN    = 1;        // 管理员
    ROLE_EDITOR   = 2;        // 编辑者
    ROLE_VIEWER   = 3;        // 只读者
}

enum Status {
    STATUS_OFFLINE  = 0;      // 离线
    STATUS_ONLINE   = 1;      // 在线
    STATUS_BUSY     = 2;      // 忙碌
    STATUS_AWAY     = 3;      // 离开
}

message User {
    string id     = 1;
    string name   = 2;
    Role    role  = 3;        // 引用枚举类型
    Status  status = 4;       // 引用枚举类型
}

[!warning] 枚举铁律

  1. 第一个枚举值必须是 0(通常以 _UNSPECIFIED 或 _UNKNOWN 结尾),proto3 强制要求
  2. 新增枚举值是向后兼容的,但旧版本客户端收到未知枚举值时会回退到 0(即第一个值)
  3. 不要删除已有枚举值的编号,否则可能引发不可预期的兼容问题
  4. 枚举值可以打同一个数值做 alias,但需要在 enum 选项里声明 allow_alias = true

Oneof 排他选择

当多个字段互斥、每次请求只能填其中一个时,使用 oneof:

message UpdateProfileRequest {
    string id = 1;

    oneof update_field {
        string name    = 2;
        string email   = 3;
        Role   role    = 4;
    }
}

这样保证了 Name、Email、Role 三个字段在序列化时只有一个会出现,节省带宽且语义清晰。在 Go 生成的代码中,oneof 会变成一个接口类型:

type UpdateProfileRequest struct {
    Id string
    // 只能设置其中之一
    UpdateField isUpdateProfileRequest_UpdateField
}

switch req.UpdateField.(type) {
case *UpdateProfileRequest_Name:
    fmt.Println("更新了 name:", req.Name)
case *UpdateProfileRequest_Email:
    fmt.Println("更新了 email:", req.Email)
}

[!question] oneof vs 单独字段?什么时候该用 oneof? 如果你希望业务逻辑保证「每次请求只更新一个字段」,用 oneof 可以让编译器强制约束这个规则。但如果只是"几个可选字段可能同时出现"的场景,反而应该用单独的 field —— oneof 会增加代码复杂度(需要 switch/case 判断哪个被设置了)。本质区别:oneof 表达的是"二选一或多选一"的互斥关系。

Map 键值映射

Protobuf 原生支持 key-value 映射,key 只能是整数或字符串类型:

message UserProfile {
    string id = 1;

    // 标签映射:string → string
    map<string, string> tags = 2;

    // 统计映射:string → int32
    map<string, int32> login_count_by_day = 3;
}

在 Go 中生成的对应类型为 map[string]string,注意默认为 nil。如果需要确保非 nil,可以用 repeated + key-value message 替代。

Reserved 保留字段

当你的 proto 文件 evolve 到新版本,可能需要移除某个字段。但不能简单地删除——因为旧版本的客户端可能还在发送带有该字段编号的数据,新服务器解析时会把它塞进下一个字段里。 reserved 关键字就是为此而生:

message User {
    reserved 7, 11;                              // 保留单个编号
    reserved 9 to 13;                            // 保留编号区间
    reserved "username", "telephone";            // 保留字段名

    string id     = 1;
    string name   = 2;
    string nick   = 8;   // 7 不能用了,这里只能用 >= 14 的编号
}

[!example] 典型场景:用户表迭代 v1: message User { string username = 1; string email = 2; string phone = 3; }

v2: 业务发现 username 改名了,决定删除并保留编号:

message User {
    reserved 1;           // 告诉 protoc:1 号编号作废
    string id     = 1;    // 重新用编号 1 放 id
    string email    = 2;
    string nickname = 3;  // 新的昵称字段
}

这样如果 v1 客户端发来 username 的数据(tag=1),protoc 会自动丢弃而不会错误地填入 id 字段。

[!tip] reserved 最佳实践

  1. 删除字段时,同时记录被删字号的原因注释(可以在 git commit message 里说明,也可以加一行 // reserved: replaced by xxx at YYYY-MM-DD)
  2. 不要把正在使用的编号标记为 reserved——编译不过就是最大的提示
  3. 具体兼容策略参见 03-字段编号与前向兼容

Package 与 Import

Protobuf 的 package 机制类似于 Go 的 import path,提供命名空间隔离:

// file: user/v1/user.proto
package user.v1;

import "google/protobuf/timestamp.proto";  // Well-Known Type

message User {
    string id       = 1;
    string name     = 2;
    google.protobuf.Timestamp created_at = 3;
}

// file: order/v1/order.proto
package order.v1;

import "user/v1/user.proto";  // 引用 user 包的 message

message Order {
    string id            = 1;
    user.v1.User buyer   = 2;  // 跨包引用
    int64 amount_cents   = 3;
}

[!tip] import 路径约定 import "user/v1/user.proto" 中的路径应当与文件的实际磁盘路径一致(相对于 protoc -I 参数指定的目录)。保持一致性是关键。

构建流程总览

下图展示从 .proto 源文件到最终 Go Stub 的完整编译链:

flowchart TD
    A[".proto 源文件"] --> B["protoc 编译器"]
    B --> C["protoc-gen-go 插件"]
    B --> D["protoc-gen-go-grpc 插件"]
    C --> E["pb.go — 消息结构体"]
    D --> F["_grpc.go — client/server stub"]
    E --> G["业务层调用 Client / Server"]
    F --> G

    style A fill:#EAB308,color:#fff
    style B fill:#3B82F6,color:#fff
    style C fill:#4FC08D,color:#fff
    style D fill:#4FC08D,color:#fff
    style E fill:#A0AEC0,color:#fff
    style F fill:#A0AEC0,color:#fff

[!info] 工具链细节 protoc 负责解析 .proto 语法树,各类插件将其翻译成目标语言的代码。Go 生态需要两个插件协同工作:protoc-gen-go 生成消息结构体,protoc-gen-go-grpc 生成 gRPC 客户端和服务端 Stub。具体配置方法参见 17-protoc 工具链与 Makefile。

关联笔记

  • hhs/gRPC/README — gRPC 知识库全景索引
  • hhs/gRPC/1. Protobuf 基础篇/02-Protobuf 数据类型详解 — Scalar、Wrapper、Well-Known、Repeated 详细对照
  • hhs/gRPC/1. Protobuf 基础篇/03-Protobuf 字段编号与前向兼容 — Field Number 分配规则、Reserved、版本演进策略
  • hhs/gRPC/1. Protobuf 基础篇/04-Protobuf Oneof 与包装类型 — Oneof 高级用法、Google.Protobuf.Value、Any 泛型封装