引言
微服务之间用 REST + JSON 通信时,你会遇到三个绕不开的痛点:JSON 体积大、契约靠文档而非编译器保证、没有原生流式。gRPC 用 Protobuf 二进制编码解决体积问题,用 .proto 文件作为单一契约源让客户端和服务端代码都从它生成,并内建四种流式模式与 HTTP/2 多路复用。
PHP 对 gRPC 的支持分两派:官方 grpc 扩展 + grpc/grpc 客户端库(客户端强、服务端弱),以及 RoadRunner / Spiral 内建的 gRPC 服务端(Go 主进程处理协议,PHP 只写业务)。本文从 Protobuf 语法讲到生成代码、服务端落地、流式调用与拦截器,最后给出「什么时候该用 gRPC、什么时候老实待着用 REST」的判断标准。
目录
- 1. gRPC 相比 REST/JSON 的取舍
- 2. Protobuf 语法与编码
- 3. 向后兼容:字段编号的纪律
- 4. 环境准备与代码生成
- 5. 客户端调用与四种流式模式
- 6. 服务端落地:RoadRunner 内建 gRPC
- 7. 拦截器、超时与元数据
- 8. 与 REST 共存与选型
- 延伸阅读
1. gRPC 相比 REST/JSON 的取舍
1.1 三个核心差异
| 维度 | REST + JSON | gRPC + Protobuf |
|---|---|---|
| 编码 | 文本,冗余字段名 | 二进制,仅字段编号 |
| 契约 | OpenAPI 文档(易漂移) | .proto 编译期生成代码 |
| 传输 | HTTP/1.1 或 HTTP/2 | 强制 HTTP/2,多路复用 |
| 流式 | 需 SSE/WebSocket 额外实现 | 原生四种流 |
| 浏览器支持 | 原生 | 需 grpc-web 代理 |
| 可读性 | 抓包可读 | 需工具解码 |
1.2 体积与性能
同一条「用户列表」响应,JSON 与 Protobuf 的差距主要在字段名与文本编码:
{"id":1001,"name":"alice","email":"a@x.com"}
Protobuf 里字段名不进 wire,只传字段编号 + 值,典型场景下体积可缩小 30~60%,且解析无需字符串扫描,CPU 更省。在高频内部调用(每秒数万次)下,省下的带宽与解析时间很可观。
1.3 什么时候不值得
- 对外公开 API(浏览器/第三方直连,需 grpc-web 或网关);
- 调用量低、字段少(省下的体积微不足道);
- 团队不熟悉 Protobuf 工具链(学习与调试成本超过收益)。
记忆:gRPC 的收益在「内部、高频、结构化、需要流式」;对外公开、低频、需浏览器直连的场景,REST 更划算。
2. Protobuf 语法与编码
2.1 .proto 基础
syntax = "proto3";
package user.v1;
message User {
int64 id = 1; // 字段编号,非值
string name = 2;
string email = 3;
repeated string roles = 4; // repeated = 列表
optional string nickname = 5; // 显式存在性(proto3 可选)
}
message GetUserRequest { int64 id = 1; }
service UserService {
rpc GetUser (GetUserRequest) returns (User);
}
syntax = "proto3" 是当前主流(字段默认无 required)。字段编号一旦分配就不能改——它才是 wire 上的真实标识。
2.2 wire type
每个字段在二进制里以「编号 + wire type」开头,wire type 决定值的读法:
| wire type | 含义 | 对应类型 |
|---|---|---|
| 0 | Varint | int32/int64/bool/enum |
| 1 | 64-bit | fixed64/double |
| 2 | Length-delimited | string/bytes/嵌套消息/repeated |
| 5 | 32-bit | fixed32/float |
理解 wire type 能解释「为什么改字段类型会破坏兼容」——比如 int32 改 string,wire type 从 0 变 2,旧客户端直接解析失败。
2.3 字段编号的分配建议
message Order {
int64 id = 1;
int64 user_id = 2;
// 1~15 编号占 1 字节,留给高频字段
string remark = 16; // 16+ 占 2 字节,留给低频字段
}
编号 1~15 编码只占 1 字节,把最常用的字段放这里能省空间。
记忆:Protobuf 靠「字段编号 + wire type」编码,编号 1~15 最省;编号与类型一旦上线就不能随意改。
3. 向后兼容:字段编号的纪律
3.1 安全与危险的操作
| 操作 | 是否兼容 | 说明 |
|---|---|---|
| 新增字段 | 安全 | 旧端忽略未知字段 |
| 删除字段(保留编号) | 安全 | 用 reserved 占位 |
| 改字段名 | 安全 | 名字不进 wire |
| 改字段编号 | 破坏 | 值会被错读 |
| 改字段类型 | 多数破坏 | wire type 可能变化 |
int32→int64 | 安全 | 同 wire type,注意负数 |
3.2 用 reserved 防误用
message User {
reserved 6, 8 to 10; // 编号 6、8~10 永久禁用
reserved "old_field_name"; // 名字也不许复用
int64 id = 1;
string name = 2;
}
删除字段时必须把编号和名字放进 reserved,防止后来者「复用」导致线上数据错乱。
3.3 演进策略
- 只增不减:新功能加新字段,旧字段标废弃(
deprecated = true)而非删除; - 破坏性变更:升级 package 版本(
user.v1→user.v2),新旧并存过渡; - 用
optional表达「未设置」与「默认值」的区别(proto3 早期无法区分)。
记忆:Protobuf 兼容靠「只加不改 +
reserved占位」;破坏性变更走v2包,新旧并存过渡。
4. 环境准备与代码生成
4.1 安装工具链
# 1. protoc 编译器
brew install protobuf # macOS
# 2. PHP grpc 扩展(客户端需要)
pecl install grpc
# 3. 生成 PHP 代码的插件
composer require --dev grpc/grpc google/protobuf
4.2 生成代码
protoc \
--php_out=./generated \
--grpc_out=./generated \
--plugin=protoc-gen-grpc=./vendor/bin/grpc_php_plugin \
./proto/user/v1/user.proto
生成两类产物:GPBMetadata(描述符)与消息类(User、GetUserRequest),以及 UserServiceClient(客户端 Stub)。
4.3 Composer 自动加载
{
"autoload": {
"psr-4": {
"User\\V1\\": "generated/User/V1/",
"GPBMetadata\\": "generated/GPBMetadata/"
}
}
}
4.4 消息类用法
<?php
use User\V1\User;
use User\V1\GetUserRequest;
$req = new GetUserRequest();
$req->setId(1001);
$user = new User();
$user->setId(1001)->setName('alice')->setEmail('a@x.com');
$user->setRoles(['admin', 'dev']);
// 序列化 / 反序列化
$bytes = $user->serializeToString();
$restored = new User();
$restored->mergeFromString($bytes);
记忆:protoc + grpc_php_plugin 从 .proto 生成消息类与 Client Stub,Composer 按 PSR-4 自动加载;
serializeToString即 wire 编码。
5. 客户端调用与四种流式模式
5.1 建立连接
<?php
use User\V1\UserServiceClient;
use Grpc\ChannelCredentials;
$client = new UserServiceClient('user-service:50051', [
'credentials' => ChannelCredentials::createInsecure(), // 生产用 TLS
]);
5.2 一元调用(Unary)
$req = new GetUserRequest();
$req->setId(1001);
[$reply, $status] = $client->GetUser($req)->wait();
if ($status->code !== \Grpc\STATUS_OK) {
throw new \RuntimeException('gRPC 调用失败: ' . $status->details);
}
echo $reply->getName();
注意:$client->GetUser() 返回的是 UnaryCall 对象,必须 .wait() 才真正发出并拿到结果——忘记 wait() 是最常见的坑。
5.3 四种流式模式
| 模式 | proto 写法 | 用途 |
|---|---|---|
| 一元 | rpc F(Req) returns (Resp) | 普通请求响应 |
| 服务端流 | returns (stream Resp) | 订阅、大结果集推送 |
| 客户端流 | rpc F(stream Req) returns (Resp) | 批量上传、聚合 |
| 双向流 | rpc F(stream Req) returns (stream Resp) | 实时对话、聊天 |
服务端流客户端写法:
$call = $client->ListUsers(new ListUsersRequest());
foreach ($call->responses() as $user) { // 逐条读取
echo $user->getName(), PHP_EOL;
}
responses() 返回一个迭代器,边收边处理,不必等全部返回——这正是流式相对「一次性 JSON 数组」的优势。
记忆:一元调用必须
.wait();流式用responses()/read()迭代;四种模式按「谁在流」区分。
6. 服务端落地:RoadRunner 内建 gRPC
6.1 为什么用 RoadRunner 而非 grpc 扩展
PHP 官方 grpc 扩展擅长客户端、不擅长服务端(传统 C 扩展服务端需自建事件循环)。RoadRunner 用 Go 主进程处理 HTTP/2 与 gRPC 协议,把请求转给 PHP worker,PHP 侧只需实现业务接口:
# .rr.yaml
version: "3"
grpc:
listen: "tcp://0.0.0.0:50051"
proto:
- "./proto/user/v1/user.proto"
pool:
num_workers: 8
6.2 实现服务接口
用 spiral/roadrunner-grpc 生成接口,然后实现:
<?php
use Spiral\RoadRunner\GRPC;
final class UserService implements UserServiceInterface
{
public function __construct(private UserRepository $repo) {}
public function GetUser(
GRPC\ContextInterface $ctx,
GetUserRequest $in
): User {
$row = $this->repo->find($in->getId());
if ($row === null) {
throw new GRPC\Exception\NotFoundException('user not found');
}
$out = new User();
return $out->setId($row->id)->setName($row->name);
}
}
抛出 NotFoundException 会被映射成 gRPC 状态码 NOT_FOUND,客户端能据此做处理。
6.3 状态码映射
| 异常 | gRPC 状态码 |
|---|---|
NotFoundException | NOT_FOUND (5) |
InvalidArgumentException | INVALID_ARGUMENT (3) |
UnauthenticatedException | UNAUTHENTICATED (16) |
PermissionDeniedException | PERMISSION_DENIED (7) |
| 其它 | INTERNAL (13) |
记忆:PHP 服务端优先用 RoadRunner 内建 gRPC(Go 处理协议、PHP 写业务);抛对应异常自动映射成 gRPC 状态码。
7. 拦截器、超时与元数据
7.1 超时
gRPC 默认无超时,必须显式设置,否则一个慢服务会拖垮调用方:
$client->GetUser($req, ['timeout' => 2_000_000]); // 微秒 = 2 秒
7.2 元数据(Metadata)
元数据相当于 HTTP 头,用于传认证令牌、追踪 ID:
$md = ['authorization' => ['Bearer ' . $token], 'x-trace-id' => [$traceId]];
[$reply, $status] = $client->GetUser($req, $md)->wait();
7.3 拦截器(Interceptor)
拦截器用于统一加日志、重试、认证:
class RetryInterceptor extends \Grpc\Interceptor
{
public function interceptUnaryUnary(
$method, $argument, $deserialize, $continuation, array $metadata = []
) {
for ($i = 0; $i < 3; $i++) {
$call = $continuation($method, $argument, $deserialize, $metadata);
[$reply, $status] = $call->wait();
if ($status->code === \Grpc\STATUS_OK) {
return new \Grpc\UnaryCall(...); // 简化示意
}
usleep(100_000);
}
return $call;
}
}
注意:只有幂等的方法才能安全重试,非幂等的写操作重试会造成重复提交。
记忆:超时必须显式设(微秒单位)、元数据传认证与追踪、拦截器做统一横切;重试只对幂等方法。
8. 与 REST 共存与选型
8.1 典型架构:外部 REST、内部 gRPC
浏览器/第三方 ──REST/JSON──▶ API 网关 ──gRPC──▶ 内部微服务
└──gRPC──▶ 内部微服务
对外仍用 REST(浏览器友好、易调试),内部服务间用 gRPC(省带宽、强契约、支持流式)。网关负责协议转换。
8.2 grpc-web
浏览器要直连 gRPC,需要 grpc-web 代理(Envoy 或专门的 grpc-web 网关)做 HTTP/1.1 到 HTTP/2 的桥接,因为浏览器无法直接使用 HTTP/2 trailer。
8.3 选型速查
| 场景 | 推荐 |
|---|---|
| 内部高频服务调用 | gRPC |
| 实时双向通信 | gRPC 双向流 |
| 对外公开 API | REST + OpenAPI |
| 前端灵活取数 | GraphQL |
| 异步解耦 | 消息队列 |
gRPC 与消息队列不是替代关系:gRPC 是同步 RPC(请求-响应),队列是异步消息(解耦、削峰),两者常在同一架构里并存。
记忆:外 REST、内 gRPC 是主流架构;浏览器直连需 grpc-web;gRPC(同步 RPC)与消息队列(异步)互补而非互斥。
延伸阅读
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。