gRPC 与 Protobuf 服务

PHP gRPC 与 Protobuf 服务实战:相比 REST/JSON 的取舍、.proto 语法与字段编号/wire type/向后兼容规则、protoc 与 grpc 扩展环境准备、生成 PHP 消息类与 Stub、grpc/grpc 客户端调用与四种流式模式、RoadRunner 内建 gRPC 服务端、拦截器/超时/重试/元数据、与 REST 共存的网关设计与性能对比。

引言

微服务之间用 REST + JSON 通信时,你会遇到三个绕不开的痛点:JSON 体积大、契约靠文档而非编译器保证、没有原生流式。gRPC 用 Protobuf 二进制编码解决体积问题,用 .proto 文件作为单一契约源让客户端和服务端代码都从它生成,并内建四种流式模式与 HTTP/2 多路复用。

PHP 对 gRPC 的支持分两派:官方 grpc 扩展 + grpc/grpc 客户端库(客户端强、服务端弱),以及 RoadRunner / Spiral 内建的 gRPC 服务端(Go 主进程处理协议,PHP 只写业务)。本文从 Protobuf 语法讲到生成代码、服务端落地、流式调用与拦截器,最后给出「什么时候该用 gRPC、什么时候老实待着用 REST」的判断标准。

前置阅读:API 设计:RESTful 与 OpenAPI 、GraphQL API 开发 。


目录


1. gRPC 相比 REST/JSON 的取舍

1.1 三个核心差异

维度REST + JSONgRPC + 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含义对应类型
0Varintint32/int64/bool/enum
164-bitfixed64/double
2Length-delimitedstring/bytes/嵌套消息/repeated
532-bitfixed32/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 状态码
NotFoundExceptionNOT_FOUND (5)
InvalidArgumentExceptionINVALID_ARGUMENT (3)
UnauthenticatedExceptionUNAUTHENTICATED (16)
PermissionDeniedExceptionPERMISSION_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 双向流
对外公开 APIREST + OpenAPI
前端灵活取数GraphQL
异步解耦消息队列

gRPC 与消息队列不是替代关系:gRPC 是同步 RPC(请求-响应),队列是异步消息(解耦、削峰),两者常在同一架构里并存。

记忆:外 REST、内 gRPC 是主流架构;浏览器直连需 grpc-web;gRPC(同步 RPC)与消息队列(异步)互补而非互斥。


延伸阅读

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「php」更多文章

  1. 图像与文档处理
  2. 流封装与文件系统
  3. 内存管理与垃圾回收