适合人群:会一门后端语言(PHP / Go / Java 都行),听说过 gRPC 但被.proto文件劝退过的人。
读完你能得到:看懂任意 proto 文件、能自己写服务契约、知道怎么改 schema 不把生产搞挂。
目录
- Protobuf 是什么:三分钟版
- 环境准备与第一个 .proto
- 文件骨架五个关键字(syntax / package / import / option / extend)
- 类型系统:标量、字段编号与默认值
- 复合结构:repeated / map / oneof / 嵌套
- 枚举 enum:规则与坑
- import 进阶与 Well-Known Types
- service 与 rpc:四种调用模式
- google.api.http:一份契约同时暴露 gRPC 和 REST
- Schema 演进规则:怎么改不把生产搞挂
- proto2 vs proto3 速览
- 工程化:用 Buf 管理 proto
- 踩坑清单 10 条
- 参考资源
1. Protobuf 是什么:三分钟版
Protocol Buffers(简称 Protobuf)是 Google 开源的数据序列化协议 + 接口描述语言(IDL)。它由两部分组成:
- 一门描述语言:你在
.proto文件里声明"数据长什么样、服务有哪些方法" - 一套二进制编码 + 代码生成器:编译器(
protoc或buf)把.proto翻译成 Go / PHP / Java / Python 等语言的代码,并提供紧凑的二进制序列化格式
和 JSON 对比
| 维度 | JSON | Protobuf |
|---|---|---|
| 格式 | 文本,肉眼可读 | 二进制,不可读(需工具解码) |
| Schema | 无约束,靠文档和自觉 | 强 schema,编译期校验 |
| 体积 | 字段名重复传输,大 | 只传字段编号 + 值,约为 JSON 的 1/3 ~ 1/10 |
| 解析速度 | 慢(文本解析) | 快(二进制 Varint 解码) |
| 跨语言 | 天然支持 | 靠代码生成,支持几乎所有主流语言 |
| 演进能力 | 字段随意增删,易翻车 | 有明确的兼容规则(见第 10 章) |
| 典型场景 | 对外 REST API、配置文件 | 服务间 RPC(gRPC)、消息队列 payload、存储格式 |
一句话理解: JSON 是"每次通信都把字典抄一遍",Protobuf 是"双方先约好字典长什么样(.proto),通信时只传字典的页码和值"。
核心心智模型(这决定了你后面能不能看懂一切)
线上跑的二进制流里只有「字段编号 + 值」,没有字段名。
uid = 10086 序列化后大致是 [编号1][类型][值10086] 几个字节。所以:
- 字段改名不影响线上数据(名字不进二进制)
- 字段改编号 = 老数据全被解析成别的字段 = 事故
记住这一条,第 10 章的演进规则你只看标题就能推出来。
2. 环境准备与第一个 .proto
2.1 安装
# macOS
brew install protobuf buf
# 验证
protoc --version # libprotoc 25.x
buf --version2.2 第一个文件 user.proto
syntax = "proto3"; // ① 语法版本声明
package user.v1; // ② 命名空间, 防符号撞名
option go_package = "example.com/gen/user/v1;userv1"; // ③ 生成 Go 代码的包路径
// 一个消息 = PHP 里的一个 DTO 类 / Go 里的一个 struct
message User {
int64 uid = 1; // 字段: 类型 名字 = 编号;
string name = 2;
string email = 3;
bool is_vip = 4;
repeated string tags = 5; // 数组
}
// 一个服务 = 一组 RPC 方法的契约
service UserService {
rpc GetUser(GetUserRequest) returns (GetUserResponse);
}
message GetUserRequest {
int64 uid = 1;
}
message GetUserResponse {
User user = 1; // 消息可以嵌套引用
}2.3 编译生成代码
# 生成 Go 代码(需要 protoc-gen-go 插件)
protoc --go_out=. --go_opt=paths=source_relative user.proto
# 生成 PHP 代码(gRPC 的 PHP 插件)
protoc --php_out=. --grpc_out=. --plugin=protoc-gen-grpc=$(which grpc_php_plugin) user.proto生成出来的东西是什么? 就是普通的类/结构体:
- Go 侧:
type User struct { Uid int64 ... },带GetUid()等 getter - PHP 侧:
class User extends \Google\Protobuf\Internal\Message,带getUid()/setUid()
你业务代码里 new 这些类、调 getter/setter、传给 gRPC 客户端 stub,序列化/反序列化全部由生成代码内置完成——你永远不用手写二进制编解码。
3. 文件骨架五个关键字(PHP 对照版)
打开任意 proto 文件,开头高频出现的关键字就五个。用 PHP 类比一次讲清:
| proto 关键字 | 作用 | PHP 里的对应物 |
|---|---|---|
syntax = "proto3" | 声明本文件按哪版语法解析,必须是第一句 | declare(strict_types=1); |
package user.v1 | 命名空间(点号分隔),防全局符号撞名 | namespace User\V1;(反斜杠分隔) |
import "google/api/http.proto" | 按文件路径引入另一个 proto 的全部符号 | require + use 的合体 |
option xxx = yyy | 贴元数据(文件级 / 方法级),给编译器和框架读 | PHP 8 Attribute / docblock 注解 |
extend XxxOptions | 给"别人定义的类型"外挂自定义字段 | 没有完全对应物,最接近 Laravel macro() |
逐个展开:
3.1 syntax
syntax = "proto3";proto2 和 proto3 规则不同(默认值、必填字段、枚举行为都有差异),不写默认 proto2——新工程一律 proto3。
3.2 package
proto 编译时所有文件在同一个全局符号池,没有 PSR-4 那种目录约定,全靠 package 硬隔离。两个团队都定义 Message,没有 package 就编译冲突。
package earn.v1; // 引用时写全名: earn.v1.SubscribeRequest3.3 import
import "google/protobuf/timestamp.proto"; // 写的是文件路径, 不是包名
message Order {
google.protobuf.Timestamp created_at = 1; // 用全名引用
}和 PHP use 的区别:PHP 的 use 只给单个类起别名(加载靠 autoloader);proto 的 import 是把目标文件的符号纳入可引用范围,之后用全名引用,没有别名层。
3.4 option
两个层级:
// 文件级: 给代码生成器的输出配置(对 proto 逻辑零影响)
option go_package = "example.com/gen/earn/v1;earnv1";
option java_package = "com.example.earn.v1";
// 方法级: 就是我们第 9 章要讲的 HTTP 注解
service PositionService {
rpc Subscribe(SubscribeRequest) returns (SubscribeResponse) {
option (google.api.http) = {post: "/api/v1/position/subscribe" body: "*"};
}
}方法级 option ≈ PHP 8 的 #[Route(...)]:贴在方法上的元数据,框架启动时反射读取。
3.5 extend
最陌生的一个。proto 是强 schema 二进制协议,不允许像 PHP 那样 __set 动态加属性,所以"给别人的类型加字段"必须显式声明:
// google/api/annotations.proto 的全部核心内容就这几行:
import "google/protobuf/descriptor.proto"; // MethodOptions 定义在这里
import "google/api/http.proto"; // HttpRule 定义在这里
extend google.protobuf.MethodOptions {
HttpRule http = 72295728; // 宣布: MethodOptions 多一个可选字段 http, 类型 HttpRule, 编号 72295728
}人话:Google 在 descriptor.proto 里定义了 MethodOptions(方法的选项容器),我改不动它;但我可以声明"它现在多了一个扩展字段"。之后任何人写 option (google.api.http) = {...},就是在往这个扩展字段里填值。字段编号必须用 1000 以上的大段,避免和官方保留编号冲突。
PHP 里最接近的心智模型:
// 你自己定义一个 PHP 8 Attribute 类 —— 本质也是"注册一种新的、允许贴在方法上的元数据"
#[Attribute(Attribute::TARGET_METHOD)]
class HttpRoute {
public function __construct(
public string $method,
public string $path,
public string $body = '*',
) {}
}Attribute::TARGET_METHOD ≈ extend MethodOptions,意思都是"这种元数据只允许贴在方法上"。
4. 类型系统:标量、字段编号与默认值
4.1 标量类型总表
| proto 类型 | Go | PHP | 说明 |
|---|---|---|---|
double | float64 | float | 8 字节浮点 |
float | float32 | float | 4 字节浮点 |
int32 | int32 | int | 变长编码;存负数效率低(恒 10 字节),负数多用 sint32 |
int64 | int64 | int | 变长编码;同上 |
uint32 / uint64 | uint32/uint64 | int | 无符号变长 |
sint32 / sint64 | int32/int64 | int | ZigZag 编码,负数场景首选 |
fixed32 / fixed64 | uint32/uint64 | int | 定长 4/8 字节;值恒大于 2^28 时比变长省空间 |
sfixed32 / sfixed64 | int32/int64 | int | 定长有符号 |
bool | bool | bool | |
string | string | string | 必须是合法 UTF-8 |
bytes | []byte | string(二进制) | 任意字节流,最长 2^32 |
金额场景的忠告: proto 没有 decimal 类型。金融系统通行做法是用 string 传十进制字符串("123.45000000"),业务代码里用 decimal 库(Go 用 shopspring/decimal,PHP 用 brick/math)解析计算。千万别用 double 存金额——0.1 + 0.2 != 0.3 的浮点误差在账务系统里是对不平的账。
4.2 字段编号(tag)规则
message User {
int64 uid = 1; // 等号右边这个 1 就是编号
}- 取值范围:
1~536,870,911(2^29 - 1) 19000~19999是 protobuf 保留段,用了直接编译报错- 编号 1~15 只占 1 个字节,16~2047 占 2 字节 → 高频字段、核心字段尽量放 1~15
- 编号一旦上线终身绑定(见第 10 章)
4.3 默认值与"字段 presence"(proto3 最大的坑之一)
proto3 里,字段不写就是零值,且零值不会进二进制流:
| 类型 | 默认值 |
|---|---|
| 数值 | 0 |
string | "" |
bool | false |
bytes | 空 |
| 枚举 | 第一个值(必须是编号 0 的那个) |
repeated / map | 空集合 |
message | null(消息类型有 presence) |
坑: 对方传了 count = 0 和根本没传 count,在你的代码里都读出来 0——分不清。两种解法:
message Query {
// 方案 1: proto3 optional(3.15+), 生成代码多出 HasCount()/count 指针
optional int32 count = 1;
// 方案 2: 包装类型(Well-Known Types), 生成代码里是可空对象
google.protobuf.Int32Value limit = 2;
}PHP 侧感受:方案 1 生成 getCount() + hasCount();方案 2 生成 getLimit() 返回 ?Int32Value,为 null 就是没传。
5. 复合结构:repeated / map / oneof / 嵌套
5.1 repeated —— 数组
message Product {
repeated string tags = 1; // string[]
repeated PriceTier rate_tiers = 2; // 消息数组, PHP 里读起来是 RepeatedField(可 foreach)
}5.2 map —— 关联数组
message Config {
map<string, string> labels = 1;
}- key 只允许:整数家族 /
bool/string(不允许浮点、bytes、消息) - 遍历顺序不保证(底层就是 hashmap,PHP 同学注意:不是 PHP 数组那种插入序)
- map 字段不能再标
repeated(它本身就隐含多条)
5.3 oneof —— 多选一,同时只有一个生效
message Payment {
oneof method {
string card_id = 1; // 设了 card_id, crypto_address 自动被清空
string crypto_address = 2;
}
}语义:设其中一个,其它的自动置空。生成的代码里通常带 GetMethod() / WhichOneof() 判断当前是哪个。典型场景:请求参数互斥、结果类型互斥。
坑: oneof 里的字段无法再用 optional 修饰判断 presence——它本身就是 presence 语义。
5.4 嵌套定义
message Order {
message Item { // 嵌套消息, 全名 Order.Item
string sku = 1;
int32 qty = 2;
}
repeated Item items = 1;
enum Status { // 枚举也可以嵌套
STATUS_UNSPECIFIED = 0;
STATUS_PAID = 1;
}
Status status = 2;
}6. 枚举 enum:规则与坑
enum OrderDirection {
ORDER_DIRECTION_UNSPECIFIED = 0; // 铁律: 第一个值必须编号 0(零值语义 = "未设置")
ORDER_DIRECTION_BUY = 1;
ORDER_DIRECTION_SELL = 2;
}规则:
- 第一个值必须是编号 0——proto3 字段默认零值,枚举零值必须有名
- 底层就是
int32,跨语言生成的是各语言的枚举/常量 - 想让两个名字共用同一编号,要显式开
option allow_alias = true - 建议每个枚举都定义
XXX_UNSPECIFIED = 0,把"未设置"和"值为 0 的某个业务态"区分开
坑(演进相关):proto3 收到不认识的枚举值不会报错,会保留原数字。老版本服务读到新版本加的枚举值,读出来是个"没名字的整数"——升级顺序要留意。
7. import 进阶与 Well-Known Types
7.1 三种 import
import "google/protobuf/timestamp.proto"; // 常规: 引入符号
import public "legacy/base.proto"; // 转口: 别人 import 我, 就能连带用 base.proto 的符号
import weak "vendor/plugin.proto"; // 弱引用(极少用, 知道有这个词就行)7.2 Well-Known Types(官方自带,直接 import 就能用)
| 类型 | import 路径 | 用途 | PHP/Go 里的样子 |
|---|---|---|---|
Timestamp | google/protobuf/timestamp.proto | UTC 时间点(秒 + 纳秒) | Go timestamppb.Timestamp;PHP Google\Protobuf\Timestamp |
Duration | google/protobuf/duration.proto | 时长 | |
Any | google/protobuf/any.proto | 任意消息(存 type URL + 字节),事件总线/插件场景 | |
Struct / Value | google/protobuf/struct.proto | 任意 JSON 结构 | |
StringValue 等 wrappers | google/protobuf/wrappers.proto | 可空标量(解决 presence 问题) | |
Empty | google/protobuf/empty.proto | 无字段占位(健康检查类接口) | |
FieldMask | google/protobuf/field_mask.proto | 部分更新时声明"只改这几个字段" |
时间字段建议: 统一用 Timestamp,别用 int64 存 Unix 秒——Timestamp 转 JSON 是 RFC3339 字符串(2026-07-30T08:00:00Z),可读性和时区语义都正确;裸整数到前端还得猜单位是秒还是毫秒。
8. service 与 rpc:四种调用模式
service TradeService {
// ① 一元(Unary): 一问一答, 最常见 ≈ 普通 HTTP 请求
rpc PlaceOrder(PlaceOrderRequest) returns (PlaceOrderResponse);
// ② 服务端流: 一次请求, 服务端持续推送(行情订阅典型场景)
rpc SubscribeTicker(TickerRequest) returns (stream Ticker);
// ③ 客户端流: 客户端连续上传, 服务端最后一次性应答(批量导入)
rpc ImportOrders(stream OrderRow) returns (ImportSummary);
// ④ 双向流: 双方随时互发(撮合推送 + 下单通道合一)
rpc TradeStream(stream ClientMsg) returns (stream ServerMsg);
}| 模式 | 关键字 | 类比 |
|---|---|---|
| 一元 | 无 | 一次 curl |
| 服务端流 | returns (stream X) | WebSocket 订阅 / SSE |
| 客户端流 | stream X 入参 | 分片上传完给回执 |
| 双向流 | 两侧都 stream | 全双工 WebSocket |
PHP 同学的限制提示: PHP 的 gRPC 扩展做客户端时只支持一元 + 服务端流两种;客户端流和双向流用不了(Go / Java 四种全支持)。PHP 服务要消费流式场景,通常让 Go 网关中转一层。
9. google.api.http:一份契约同时暴露 gRPC 和 REST
gRPC 走 HTTP/2 + 二进制,浏览器和外部合作方直接用不了。Google 的方案:在 proto 里给方法贴注解,声明它对应的 HTTP 路由,由框架(Kratos / grpc-gateway / Envoy)在启动时读出注解、自动注册 HTTP 端点——一次定义,gRPC 和 REST 双暴露。
syntax = "proto3";
package earn.v1;
import "google/api/annotations.proto"; // ≈ PHP 里 use Route; 只是让注解语法合法
service PositionService {
// POST /api/v1/position/subscribe, 整个请求 JSON 映射为 SubscribeRequest
rpc Subscribe(SubscribeRequest) returns (SubscribeResponse) {
option (google.api.http) = {post: "/api/v1/position/subscribe" body: "*"};
}
// 路径参数: {uid} 从 URL 提取; 未进路径、未进 body 的字段自动成为 query 参数
rpc GetPosition(GetPositionRequest) returns (GetPositionResponse) {
option (google.api.http) = {get: "/api/v1/positions/{position_id}"};
}
// 一个 RPC 暴露多个 HTTP 端点
rpc Redeem(RedeemRequest) returns (RedeemResponse) {
option (google.api.http) = {
post: "/api/v1/position/redeem"
body: "*"
additional_bindings {post: "/api/v1/positions/{position_id}/redeem" body: "*"}
};
}
// 不贴注解 = 只走 gRPC, 不暴露 HTTP(对内接口)
rpc EnsureSpendable(EnsureSpendableRequest) returns (EnsureSpendableResponse) {}
}body 的三种取值:
| 写法 | 含义 |
|---|---|
body: "*" | 路径参数之外的所有字段都进 HTTP body(POST 动作类接口常用) |
body: "message" | 只有指定字段进 body,其余进 query |
| 省略 | 没有 body,字段全走路径 + query(GET/DELETE 常用) |
这套注解的本质(呼应第 3.5 节):annotations.proto 用 extend MethodOptions 注册了一个叫 http 的扩展字段;option (google.api.http) = {...} 就是填这个字段;Kratos 启动时通过 proto 反射读出来注册路由——和 Symfony 扫 #[Route] Attribute 是同一个套路。
10. Schema 演进规则:怎么改不把生产搞挂
回忆第 1 章的心智模型:二进制流里只有编号和值。演进规则全是它的推论。
10.1 安全 vs 危险操作速查
| 操作 | 安全性 | 说明 |
|---|---|---|
| 新增字段(用新编号) | ✅ 安全 | 老代码读到不认识的编号直接忽略 |
删除字段,然后 reserved 编号和名字 | ✅ 安全(推荐流程) | 防止后人复用 |
| 字段改名 | ⚠️ wire 安全,代码危险 | 名字不进二进制,但生成代码的方法名全变,调用方要跟着改 |
int32 ↔ int64 ↔ uint64 ↔ bool 互转 | ⚠️ 兼容(varint 家族) | 值超出新类型范围会被截断 |
string ↔ bytes | ⚠️ 兼容 | 前提是内容是合法 UTF-8 |
| 改字段编号 | ❌ 事故 | 老数据会被解析成别的字段 |
改成不兼容类型(如 int32 → string) | ❌ 事故 | 解码直接错 |
singular ↔ repeated 切换 | ❌ 对数值类型危险 | packed 编码格式不同 |
| 给 oneof 增删字段 | ⚠️ 谨慎 | 老代码不认识新分支会拿到空 |
| 枚举新增值 | ⚠️ 谨慎 | 老代码读出不认识的整数(见第 6 章) |
10.2 删字段的正确姿势:reserved
message User {
reserved 5, 8 to 10; // 这些编号永久封存, 谁用谁编译报错
reserved "old_name", "fax"; // 名字也封存(防止名字被复用导致语义混淆)
int64 uid = 1;
string name = 2;
// email 原来在 5, 已下线
}为什么名字也要 reserved?编号防的是二进制层面的事故;名字防的是人——半年后新同事看到 fax 空出来,以为是新功能位就填进去了。
10.3 上线顺序口诀
- 先发「能读新字段」的服务端/消费方
- 再发「会写新字段」的客户端/生产方
- 永远假设新旧版本长期共存(滚动发布、客户端不升级都是常态)
11. proto2 vs proto3 速览
| 维度 | proto2 | proto3 |
|---|---|---|
| 必填字段 | 有 required(后被证明是灾难) | 删除,只有普通字段 |
| 默认值 | 可自定义 default = 100 | 固定零值,不可自定义 |
| 字段 presence | 所有字段都有 hasXxx() | 标量没有(需 optional 或 wrappers) |
| 枚举 | 首值任意编号 | 首值必须是 0 |
扩展 extend | 支持任意消息 | 只允许扩展 descriptor.proto 里的 Options 家族 |
| JSON 映射 | 无官方规范 | 有官方 JSON Mapping |
| 现状 | 存量老系统 | 新项目唯一选择 |
12. 工程化:用 Buf 管理 proto
protoc 手工拼参数、vendor third_party 的时代过去了,现代做法是 Buf(≈ proto 世界的 Composer/npm):
# buf.yaml —— 依赖与代码检查
version: v2
modules:
- path: proto
deps:
- buf.build/googleapis/googleapis # google.api.http 等官方 proto 直接引用, 不用拷进仓库
lint:
use: [STANDARD]
breaking:
use: [FILE] # CI 里自动做破坏性变更检查(第 10 章规则的自动化)# buf.gen.yaml —— 代码生成
version: v2
plugins:
- local: protoc-gen-go
out: gen/go
- local: protoc-gen-go-grpc
out: gen/gobuf lint # 风格检查(文件包名、目录结构、枚举首值 0 等)
buf breaking --against '.git#branch=main' # 和 main 分支对比, 有破坏性变更直接 fail
buf generate # 一键生成全部语言目录布局建议:
proto/
earn/v1/position.proto # package earn.v1
earn/v1/product.proto
user/v1/user.proto # 目录结构与 package 对齐(buf lint 会强制)13. 踩坑清单 10 条
- 枚举第一个值忘了设 0 → 编译报错
The first enum value must be zero。 - 用
double存金额 → 浮点误差,账务对不平。金额用string传十进制 + 业务侧 decimal 库。 - 以为 proto3 能分清"没传"和"传了 0" → 分不清。要区分用
optional或google.protobuf.*Value包装类型。 - 复用已删除字段的编号 → 老数据/老客户端把新字段解析成乱码。删字段必须
reserved。 - map 当 PHP 数组用,依赖遍历顺序 → proto map 不保证顺序,要顺序用
repeated message {key, value}。 - 核心字段编号用到 16 以上 → 每个字段多浪费 1 字节。高频字段守住 1~15。
int64到前端 JS 变精度 → proto3 JSON 映射里 int64 会序列化成字符串(防 JS 2^53 精度丢失),前端别当 number 处理。- 负数用
int32而不是sint32→ 每个负数恒占 10 字节,带宽浪费。 Timestamp换成int64存时间 → 前端猜单位、时区出一天差。统一Timestamp(UTC)。- 以为改了 proto 只影响自己 → proto 是跨团队契约。改之前
buf breaking,发版遵守"先读后写"顺序(第 10.3 节)。
14. 参考资源
- 官方语言指南(proto3):https://protobuf.dev/programming-guides/proto3/
- 官方 JSON 映射规范:https://protobuf.dev/programming-guides/json/
- googleapis 仓库(annotations.proto 等官方 proto 源码):https://github.com/googleapis/googleapis
- Buf 文档:https://buf.build/docs
- gRPC 官方:https://grpc.io/docs/
- Kratos v2(Go 微服务框架,HTTP 注解落地参考):https://go-kratos.dev/