协议

Protobuf 语法通关教程:写给后端开发者的实战指南(PHP / Go 双视角)

By karp 6 Views 54 MIN READ 0 Comments
适合人群:会一门后端语言(PHP / Go / Java 都行),听说过 gRPC 但被 .proto 文件劝退过的人。
读完你能得到:看懂任意 proto 文件、能自己写服务契约、知道怎么改 schema 不把生产搞挂。

目录

  1. Protobuf 是什么:三分钟版
  2. 环境准备与第一个 .proto
  3. 文件骨架五个关键字(syntax / package / import / option / extend)
  4. 类型系统:标量、字段编号与默认值
  5. 复合结构:repeated / map / oneof / 嵌套
  6. 枚举 enum:规则与坑
  7. import 进阶与 Well-Known Types
  8. service 与 rpc:四种调用模式
  9. google.api.http:一份契约同时暴露 gRPC 和 REST
  10. Schema 演进规则:怎么改不把生产搞挂
  11. proto2 vs proto3 速览
  12. 工程化:用 Buf 管理 proto
  13. 踩坑清单 10 条
  14. 参考资源

1. Protobuf 是什么:三分钟版

Protocol Buffers(简称 Protobuf)是 Google 开源的数据序列化协议 + 接口描述语言(IDL)。它由两部分组成:

  • 一门描述语言:你在 .proto 文件里声明"数据长什么样、服务有哪些方法"
  • 一套二进制编码 + 代码生成器:编译器(protocbuf)把 .proto 翻译成 Go / PHP / Java / Python 等语言的代码,并提供紧凑的二进制序列化格式

和 JSON 对比

维度JSONProtobuf
格式文本,肉眼可读二进制,不可读(需工具解码)
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 --version

2.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.SubscribeRequest

3.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_METHODextend MethodOptions,意思都是"这种元数据只允许贴在方法上"。


4. 类型系统:标量、字段编号与默认值

4.1 标量类型总表

proto 类型GoPHP说明
doublefloat64float8 字节浮点
floatfloat32float4 字节浮点
int32int32int变长编码;存负数效率低(恒 10 字节),负数多用 sint32
int64int64int变长编码;同上
uint32 / uint64uint32/uint64int无符号变长
sint32 / sint64int32/int64intZigZag 编码,负数场景首选
fixed32 / fixed64uint32/uint64int定长 4/8 字节;值恒大于 2^28 时比变长省空间
sfixed32 / sfixed64int32/int64int定长有符号
boolboolbool
stringstringstring必须是合法 UTF-8
bytes[]bytestring(二进制)任意字节流,最长 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""
boolfalse
bytes
枚举第一个值(必须是编号 0 的那个)
repeated / map空集合
messagenull(消息类型有 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;
}

规则:

  1. 第一个值必须是编号 0——proto3 字段默认零值,枚举零值必须有名
  2. 底层就是 int32,跨语言生成的是各语言的枚举/常量
  3. 想让两个名字共用同一编号,要显式开 option allow_alias = true
  4. 建议每个枚举都定义 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 里的样子
Timestampgoogle/protobuf/timestamp.protoUTC 时间点(秒 + 纳秒)Go timestamppb.Timestamp;PHP Google\Protobuf\Timestamp
Durationgoogle/protobuf/duration.proto时长
Anygoogle/protobuf/any.proto任意消息(存 type URL + 字节),事件总线/插件场景
Struct / Valuegoogle/protobuf/struct.proto任意 JSON 结构
StringValue 等 wrappersgoogle/protobuf/wrappers.proto可空标量(解决 presence 问题)
Emptygoogle/protobuf/empty.proto无字段占位(健康检查类接口)
FieldMaskgoogle/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.protoextend MethodOptions 注册了一个叫 http 的扩展字段;option (google.api.http) = {...} 就是填这个字段;Kratos 启动时通过 proto 反射读出来注册路由——和 Symfony 扫 #[Route] Attribute 是同一个套路。


10. Schema 演进规则:怎么改不把生产搞挂

回忆第 1 章的心智模型:二进制流里只有编号和值。演进规则全是它的推论。

10.1 安全 vs 危险操作速查

操作安全性说明
新增字段(用新编号)✅ 安全老代码读到不认识的编号直接忽略
删除字段,然后 reserved 编号和名字✅ 安全(推荐流程)防止后人复用
字段改名⚠️ wire 安全,代码危险名字不进二进制,但生成代码的方法名全变,调用方要跟着改
int32int64uint64bool 互转⚠️ 兼容(varint 家族)值超出新类型范围会被截断
stringbytes⚠️ 兼容前提是内容是合法 UTF-8
改字段编号❌ 事故老数据会被解析成别的字段
改成不兼容类型(如 int32string❌ 事故解码直接错
singularrepeated 切换❌ 对数值类型危险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 上线顺序口诀

  1. 先发「能读新字段」的服务端/消费方
  2. 再发「会写新字段」的客户端/生产方
  3. 永远假设新旧版本长期共存(滚动发布、客户端不升级都是常态)

11. proto2 vs proto3 速览

维度proto2proto3
必填字段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/go
buf 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 条

  1. 枚举第一个值忘了设 0 → 编译报错 The first enum value must be zero
  2. double 存金额 → 浮点误差,账务对不平。金额用 string 传十进制 + 业务侧 decimal 库。
  3. 以为 proto3 能分清"没传"和"传了 0" → 分不清。要区分用 optionalgoogle.protobuf.*Value 包装类型。
  4. 复用已删除字段的编号 → 老数据/老客户端把新字段解析成乱码。删字段必须 reserved
  5. map 当 PHP 数组用,依赖遍历顺序 → proto map 不保证顺序,要顺序用 repeated message {key, value}
  6. 核心字段编号用到 16 以上 → 每个字段多浪费 1 字节。高频字段守住 1~15。
  7. int64 到前端 JS 变精度 → proto3 JSON 映射里 int64 会序列化成字符串(防 JS 2^53 精度丢失),前端别当 number 处理。
  8. 负数用 int32 而不是 sint32 → 每个负数恒占 10 字节,带宽浪费。
  9. Timestamp 换成 int64 存时间 → 前端猜单位、时区出一天差。统一 Timestamp(UTC)。
  10. 以为改了 proto 只影响自己 → proto 是跨团队契约。改之前 buf breaking,发版遵守"先读后写"顺序(第 10.3 节)。

14. 参考资源

本文由 karp 原创

采用 CC BY-NC-SA 4.0 协议进行许可

转载请注明出处:https://ikarp.top/index.php/archives/867.html

标签: protobuf

相关推荐

  • 暂无相关推荐,看看别的吧。

0 评论