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.模块名,简洁明了。
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; // 这是一个数组
}
required 规则。后来发现坑太多——字段一旦被标记为 required,后期改了数据结构,老客户端直接崩。所以我从来不用 required,建议你也直接忘掉它。
2.3 字段编号:1 到 15 是黄金位
不知道你有没有注意到,我在定义字段时,后面有个 = 1、= 2 这种编号。
这不是随便写的。
protobuf 不传字段名,只传编号。编号越小,编码后的体积越小。
具体规则:
- 1 - 15:占用 1 个字节。适合最常用的字段。
- 16 - 2047:占用 2 个字节。
- 最大:2^29 - 1,约 5 亿。但一般没人用那么大。
2.4 默认值:别被它骗了
proto3 有一个非常“坑”但也很“智能”的特性:默认值。
简单说:
int32默认为 0string默认为空字符串 ""bool默认为 falsebytes默认为空- 枚举默认为第一个枚举值(0)
但是,注意了!
你发一个 age = 0 过去,收方收到的是 0。你发一个 age 字段没设置,收方收到的也是 0。你无法区分“没传”和“传了默认值”这两种情况。
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 到代码
咱们把前面讲的一切串起来,画个图。
整个流程不难理解:你写 .proto → 运行 protoc 编译器 → 生成目标语言的代码 → 业务代码里直接调用生成的类。序列化和反序列化,protobuf 库全给你包了。
2.7 我的一点经验
最后聊几句题外话。
我见过团队一上来就搞几百行的 .proto 文件,后来改一个字段名,十几个人一起合并代码。那叫一个酸爽。
我个人的习惯是:每个 .proto 文件不超过 5 个 message,类似业务的放在一个包里,独立改动。
另外,千万别把 .proto 当成内部文档。哪怕你的团队只用 Go,我也建议用 protoc 生成 Java 和 Python 的存根。万一将来对接第三方,你就知道什么叫“有备无患”了。