2. Protocol Buffers 入门:定义 .proto 的世界

好,咱们进入实战准备阶段。

gRPC 的底牌,其实是 Protocol Buffers(简称 protobuf)。

你可以把 protobuf 看成一套「数据契约」。

你用什么语言写后端?Go、Java、Python?没关系。只要你和对方都认同一份 .proto 文件,那么双方就能像对暗号一样,准确、快速地交换数据。

我说得直接一点:在 gRPC 的世界里,.proto 文件就是法律。

2.1 一个最简单的 .proto 文件长什么样?

咱们先不看那么多理论,直接上手。

新建一个文本文件,取名 user.proto,然后写下这段代码:

syntax = "proto3";

package user_service;

message UserRequest {
  int32 user_id = 1;
}

message UserResponse {
  string user_name = 2;
  string email = 3;
}

你看,结构非常清晰。

  • syntax:声明使用 proto3。现在基本都建议直接用 proto3,向下兼容也方便一些。
  • package:防止命名冲突。就跟 Java 的包名一个意思。
  • message:核心定义。相当于你 Java 里的 class,或者 C 里的 struct。
我个人的小习惯: package 命名尽量不要用“com.example”那种电商公司风格,建议直接用 package.模块名,简洁明了。

2.2 消息类型与字段规则

刚才的例子很简单。但实际上,message 里的字段可以玩出花来。

字段类型

类型 说明 示例
int32 / int64 变长编码,适合大多数整型 int32 age = 1;
uint32 / uint64 无符号整型 uint64 order_id = 2;
float / double 浮点数 double price = 3;
string UTF-8 字符串 string name = 4;
bytes 任意字节序列 bytes avatar = 5;
bool 布尔值 bool is_active = 6;
枚举 枚举类型 Gender gender = 7;
其他 message 嵌套 Address address = 8;

字段规则

在 proto3 里,规则只有两种:

  • singular(默认):字段出现 0 次或 1 次。
  • repeated:字段可以出现任意次数(包括0)。相当于一个数组。
message Order {
  int32 order_id = 1;
  repeated string item_names = 2; // 这是一个数组
}
注意: 早期 proto2 里有个 required 规则。后来发现坑太多——字段一旦被标记为 required,后期改了数据结构,老客户端直接崩。所以我从来不用 required,建议你也直接忘掉它。

2.3 字段编号:1 到 15 是黄金位

不知道你有没有注意到,我在定义字段时,后面有个 = 1、= 2 这种编号。

这不是随便写的。

protobuf 不传字段名,只传编号。编号越小,编码后的体积越小。

具体规则:

  • 1 - 15:占用 1 个字节。适合最常用的字段。
  • 16 - 2047:占用 2 个字节。
  • 最大:2^29 - 1,约 5 亿。但一般没人用那么大。
实战策略: 把最频繁访问的字段(比如 id、name)放在 1-15 号。我曾优化过一个老项目,把几个高频字段从 50 号挤到 1-15 号,整个服务的 QPS 直接提升了 5%。原因?网络带宽省下来的。

2.4 默认值:别被它骗了

proto3 有一个非常“坑”但也很“智能”的特性:默认值。

简单说:

  • int32 默认为 0
  • string 默认为空字符串 ""
  • bool 默认为 false
  • bytes 默认为空
  • 枚举默认为第一个枚举值(0)

但是,注意了!

你发一个 age = 0 过去,收方收到的是 0。你发一个 age 字段没设置,收方收到的也是 0。你无法区分“没传”和“传了默认值”这两种情况。

避坑指南: 假如你的业务里 0 是有含义的(比如分数 0 分),那你就得小心了。我曾经接手过一个监控系统,默认 0 表示“状态未知”,但实际上有机器真的上报了 0% 的 CPU 使用率。结果监控说一切正常,实际上是没采集到数据。后来我们引入了 google.protobuf.wrappers,把 int32 包起来,这样就能区分 nil 和 0 了。

2.5 导入与包管理

项目一大,你不能把所有的 message 都写在一个文件里。

你需要切分:

// -------------- common/address.proto --------------
syntax = "proto3";
package common;

message Address {
  string street = 1;
  string city = 2;
}

// -------------- user/user.proto --------------
syntax = "proto3";
package user;

import "common/address.proto";

message UserInfo {
  string name = 1;
  common.Address addr = 2;  // 引用其他包的类型
}

这里有一个细节:import 的路径不是路径,而是相对于你配置的 --proto_path 来说的。

包名 vs 命名空间

package 主要用来避免名称冲突。

比如我有两个模块都叫 LoginRequest,那就需要用包名隔开:

package admin;
message LoginRequest {}

package app;
message LoginRequest {}

在代码生成后,Go 会变成 admin.LoginRequest,Java 会变成 admin.LoginRequest。清爽多了。

2.6 流程总览:从 .proto 到代码

咱们把前面讲的一切串起来,画个图。

Protocol Buffers 定义流程 编写 .proto 文件 protoc 编译 *.pb.go *.java *_pb2.py 序列化(业务代码调用) 二进制数据流 核心优势 • 跨语言:一份 .proto 生成所有主流语言代码 • 体积小:二进制编码,比 JSON 小 3~10 倍 • 向前兼容:添加字段不影响老客户端

整个流程不难理解:你写 .proto → 运行 protoc 编译器 → 生成目标语言的代码 → 业务代码里直接调用生成的类。序列化和反序列化,protobuf 库全给你包了。

2.7 我的一点经验

最后聊几句题外话。

我见过团队一上来就搞几百行的 .proto 文件,后来改一个字段名,十几个人一起合并代码。那叫一个酸爽。

我个人的习惯是:每个 .proto 文件不超过 5 个 message,类似业务的放在一个包里,独立改动。

另外,千万别把 .proto 当成内部文档。哪怕你的团队只用 Go,我也建议用 protoc 生成 Java 和 Python 的存根。万一将来对接第三方,你就知道什么叫“有备无患”了。