PHP接入gRPC实战:扩展安装、代码生成与客户端调用示例
简介:这份 gRPC PHP 示例教程压缩包面向希望用 PHP 构建高性能微服务的开发者,尤其适合已掌握 PHP 基础、准备实践 RPC 与分布式通信的中级学习者。压缩包共 29 个文件、约 22KB,以 7 个 PHP 源码、5 个 XML 配置、4 个 Shell 脚本、4 个 Dockerfile 和 3 个 Markdown 文档为主,另含 proto 服务定义、JSON 依赖配置与 Nginx 配置,分别承担示例实现、环境编排、协议生成和操作说明等用途。已有 134 人学习,说明其作为课程配套实践材料具有一定参考价值。读者可围绕问候、路线指南与回显等示例,理解 proto 接口定义、服务端与客户端调用、流式 RPC、TLS 认证、错误处理及集成测试等关键环节,并借助容器文件与脚本快速复现运行环境;教程还覆盖 ProtoBuf 消息建模、HTTP/2 通信机制与并发超时设置,目录按协议定义、源码示例、运行脚本和环境镜像分层组织,便于按主题检索并迁移到实际微服务项目。
1. 一份 grpc-php-examples 压缩包,解决的是 PHP 怎么接入 gRPC 的问题
上周一个做订单中台的同行问我:上游把接口换成了 gRPC,PHP 这边怎么调?他翻了半宿 gRPC PHP 教程,翻出来的示例八成是 Go 和 Java,PHP 部分只剩一句“先装扩展”。这种断层很典型——协议不复杂,麻烦的是工具链没人串起来。grpc-php-examples-master.zip 干的就是这件事:一份能直接跑的最小示例集,proto 定义、生成命令、客户端调用、服务端骨架都在里面,只回答三个问题——扩展怎么装、代码怎么生成、调用怎么写。适合手上真有 gRPC 接口要对接的 PHP 后端,也适合需要一份能演示的 php 课程资源 的讲师。
2. 三件套先装对:protoc、grpc_php_plugin 与 ext-grpc 的分工
示例包跑不起来,九成不是代码问题,是环境里少东西。PHP 接 gRPC 需要四到五个组件,每个组件负责一段流程;缺少任何一个,都会在半路报错。很多人装完扩展就以为结束了,结果一执行生成命令就报protoc: command not found,或者生成完了却没有GreeterClient。
2.1 每个组件负责什么
先看下面这张表,不要跳着安装。
| 组件 | 装在哪 | 负责的环节 | 缺了会看到什么 |
|---|---|---|---|
| protoc | 系统 PATH | 读.proto,生成消息类 | 连HelloRequest都没有 |
| grpc_php_plugin | 系统 PATH | protoc 的插件,生成*Client桩 | 有消息类,没有客户端类 |
| ext-grpc | php.ini | Channel、Call、BaseStub 运行时 | Class "Grpc\Channel" not found |
| ext-protobuf | php.ini | C 层序列化,性能关键 | 能跑但慢一个量级 |
| grpc/grpc(composer) | vendor | 补扩展未覆盖的 userland 类 | 偶发某个类找不到 |
理解这张表,能省掉很多绕路。protoc是编译器,grpc_php_plugin是编译器的插件,两者都是命令行工具,跟 PHP 运行时没关系;ext-grpc才是 PHP 里那个Channel、Call的来源。我见过不少人把插件当成扩展往 php.ini 里塞,卡了一下午——这两类组件完全不放在同一处。
还有个隐性约束:插件和扩展最好来自同一个源码版本。生成桩里引用的类名与扩展暴露的 API 是配套的,一边用很早的源码编、一边用很新的 pecl 包,生成的桩调不动扩展,报错信息还很难指向真正原因。
2.2 编译扩展与插件:顺序不能乱
下面的命令是完整流程,缺一步都会出问题。建议先编插件再编扩展,因为插件依赖的静态库和扩展是同一批。
# 1. 拉 gRPC 源码,--recurse-submodules 必须带 # abseil、boringssl、protobuf 这些依赖都在子模块里,漏了会在 cmake 阶段报找不到 target git clone --recurse-submodules https://github.com/grpc/grpc.git cd grpc export GRPC_SRC=$(pwd) # 2. 编 grpc_php_plugin(只编这一个 target,别 make 全量,能省十几分钟) mkdir -p cmake/build && cd cmake/build cmake ../.. -DgRPC_BUILD_TESTS=OFF -DgRPC_PHP_PLUGIN=ON make -j"$(nproc)" grpc_php_plugin sudo make install # 装到 /usr/local/bin,后面 protoc 能直接找到 # 3. 编 ext-grpc。注意 phpize 必须是目标 SAPI 对应的那个 cd "${GRPC_SRC}/src/php/ext/grpc" phpize ./configure --enable-grpc make -j"$(nproc)" sudo make install # 4. protobuf 扩展走 pecl 就行 sudo pecl install protobuf # 5. 写进 php.ini,顺序 protobuf 在前、grpc 在后 echo "extension=protobuf.so" | sudo tee -a "$(php --ini | awk -F': ' '/Loaded Configuration/{print $2}')" echo "extension=grpc.so" | sudo tee -a "$(php --ini | awk -F': ' '/Loaded Configuration/{print $2}')"-DgRPC_PHP_PLUGIN=ON这个开关是关键,不加它 cmake 不会生成插件 target,make grpc_php_plugin直接告诉你没有这条规则。--recurse-submodules同理,漏了会在链接阶段缺符号。phpize那一步最容易翻车:机器上同时装了多个 PHP 版本时,phpize和php可能不是同一套,编出来的.so加载时会报 API 版本不匹配。装之前先跑php -v和phpize --version,两个版本号对不上就先update-alternatives调过来。
提示:容器里做镜像时把这套流程放在 builder 阶段,只把编好的
.so和插件二进制拷进运行镜像,否则镜像会多出好几个 G 的编译产物。
2.3 composer 补运行时,再跑一次自检
扩展装完还差 userland 那层。示例包里的composer.json一般只写了 autoload,依赖要自己补。
# 这两个包提供 BaseStub、序列化辅助等扩展之外的类 composer require grpc/grpc google/protobuf # 关键一步:把生成的命名空间挂到 PSR-4 上 composer dump-autoloadcomposer.json的 autoload 段长这样,前缀和目录必须严格对应,注意 JSON 里反斜杠要转义两次:
{ "autoload": { "psr-4": { "Helloworld\\": "src/Helloworld" } } }依赖装完别急着写业务,先跑一个自检脚本,把“环境问题”和“代码问题”分开。这个习惯帮我省了无数次来回:
<?php require __DIR__ . '/vendor/autoload.php'; $checks = [ 'ext-grpc' => extension_loaded('grpc'), 'ext-protobuf' => extension_loaded('protobuf'), 'Grpc\Channel' => class_exists('Grpc\Channel'), 'GreeterClient' => class_exists('Helloworld\GreeterClient'), 'HelloRequest' => class_exists('Helloworld\HelloRequest'), ]; foreach ($checks as $name => $ok) { printf("%-16s %s\n", $name, $ok ? 'OK' : 'MISSING'); } // 有任何一项 MISSING 就以非 0 退出,方便塞进 CI exit(in_array(false, $checks, true) ? 1 : 0);extension_loaded查的是 C 扩展有没有进内存,class_exists查的是 composer 自动加载有没有生效——两件事分开查,报错时才知道该改 php.ini 还是改 autoload。写完用php -f check.php跑一遍,五项全 OK 再往下走。
3. 从 .proto 到 PHP 类:生成链路与目录约定
.proto是整个链路的唯一事实来源,服务端和客户端都从它生成。麻烦在于 PHP 的生成规则有几个专属 option,写错了不会报错,只会生成到奇怪的命名空间里,然后你满项目找不着类。
3.1 proto 里必须写死的两行 option
先看一个能直接用的 proto,重点在php_namespace和php_metadata_namespace:
syntax = "proto3"; package helloworld; // 消息类落在哪个命名空间,不写的话按 package 自动推导,容易出现大小写和层级偏差 option php_namespace = "Helloworld"; // 描述符类的命名空间,必须写全,注意 proto 里反斜杠要写两个 option php_metadata_namespace = "Helloworld\\GPBMetadata"; service Greeter { rpc SayHello (HelloRequest) returns (HelloReply); rpc SayHelloStream (HelloRequest) returns (stream HelloReply); } message HelloRequest { string name = 1; } message HelloReply { string message = 1; }package决定线上调用的方法全路径,格式是/package.Service/Method,服务端和客户端必须完全一致;php_namespace只影响 PHP 侧的类名。这两个东西不同步,就是后面12 UNIMPLEMENTED的根源。php_metadata_namespace更微妙——它是运行时注册描述符用的,缺了会在反序列化阶段才报错,排查成本很高。我的做法是每次改 proto 都先看一眼这两行还在不在。
3.2 生成命令与产物对照
生成命令不长,但参数位置错了会静默失败:
PROTO_DIR=./proto OUT_DIR=./src PLUGIN="$(command -v grpc_php_plugin)" protoc -I "${PROTO_DIR}" \ --php_out="${OUT_DIR}" \ --grpc_out="${OUT_DIR}" \ --plugin=protoc-gen-grpc="${PLUGIN}" \ "${PROTO_DIR}/greeter.proto"-I是 import 搜索根目录,多目录时按顺序查;--php_out生成消息类,--grpc_out生成客户端桩;--plugin=protoc-gen-grpc=这个固定写法是把插件注册成 protoc 认识的生成器,等号右边必须是可执行文件路径,写相对路径在 CI 里经常找不到。跑完对着下表核一遍产物:
| 产物路径 | 内容 | 谁在用 |
|---|---|---|
src/Helloworld/HelloRequest.php | 消息类,get/set + 序列化 | 客户端、服务端 |
src/Helloworld/HelloReply.php | 同上 | 客户端、服务端 |
src/Helloworld/GreeterClient.php | 客户端桩,继承\Grpc\BaseStub | 只给客户端 |
src/Helloworld/GPBMetadata/Greeter.php | 描述符,运行时反射用 | 双方都要 |
这里有个 PHP 特有的坑:grpc_php_plugin只生成客户端桩,不生成服务端基类。所以服务端要么自己按方法名分发,要么换 RoadRunner 那套生成器。刚接触的人常在这里僵住,以为生成器坏了,其实这是 PHP 生态的现状。另外GPBMetadata目录千万别手动改,重新生成会覆盖,而且它是自动加载链路里最容易被漏掉的一环。
3.3 生成之后先验证自动加载
生成成功不等于能用。下面这段代码验证的是“类能不能被找到”,跟“能不能调通”是两回事,但前者不通后者必然不通:
<?php require __DIR__ . '/vendor/autoload.php'; // 消息类:验证 php_namespace 生效 $req = new Helloworld\HelloRequest(); $req->setName('probe'); var_dump($req->getName()); // 客户端桩:验证 grpc_out 生效,注意构造参数还没连服务,不会真的建连接 $client = new Helloworld\GreeterClient('127.0.0.1:50051', [ 'credentials' => Grpc\ChannelCredentials::createInsecure(), ]); echo get_class($client), PHP_EOL;setName/getName是生成器按字段名驼峰化出来的,字段叫user_name就变成setUserName。如果这里报方法不存在,说明你看的是一份旧产物,重新生成一遍。第二段创建客户端不会真正建连,Channel 是懒加载的,所以就算服务端没起也能new成功——这点很误导人,别把“没报错”当成“连上了”。
4. 把示例跑成一次真实调用:客户端为主,服务端两条路
示例包的价值在于它把一次调用的最短路径摆出来了。真正搬到项目里,需要补三样东西:凭证方式、超时、错误处理。这三样缺一个都不算能用。
4.1 客户端:Channel、凭证与一次完整调用
<?php require __DIR__ . '/vendor/autoload.php'; use Helloworld\GreeterClient; use Helloworld\HelloRequest; // 地址只写 host:port,不要带 http:// 前缀,带了会解析失败 $client = new GreeterClient('127.0.0.1:50051', [ 'credentials' => Grpc\ChannelCredentials::createInsecure(), // 明文,内网调试用 'grpc.keepalive_time_ms' => 30000, // 长连接保活 ]); $request = new HelloRequest(); $request->setName('php-client'); // 第 2 个参数是 metadata,值用数组包一层最稳;第 3 个参数 timeout 单位是秒,支持小数 $call = $client->SayHello($request, ['x-trace-id' => ['abc123']], ['timeout' => 2.0]); [$reply, $status] = $call->wait(); if ($status->code !== Grpc\STATUS_OK) { fwrite(STDERR, sprintf("grpc error: code=%d detail=%s\n", $status->code, $status->details)); exit(1); } echo $reply->getMessage(), PHP_EOL; $client->close(); // 显式关,脚本常驻时别指望 GC几个参数值得单独说。credentials是必填项,不传会直接抛异常;生产环境用Grpc\ChannelCredentials::createSsl($rootCert, $privateKey, $certChain)三个参数按需传。timeout是客户端侧的截止时间,超了服务端会收到取消信号,这比在业务层加sleep靠谱得多。wait()返回的是二元数组,第一个是响应对象,第二个是状态对象,状态码非 0 时响应对象是 null——直接拿$reply->getMessage()会触发致命错误,所以必须先判状态。
| 参数 | 位置 | 说明 |
|---|---|---|
127.0.0.1:50051 | 构造参数 1 | 只写 host:port |
credentials | 构造 options | 明文 / TLS 二选一 |
grpc.keepalive_time_ms | 构造 options | 长连接保活,按网络情况调 |
| metadata | 方法参数 2 | 值是数组,透传 trace id 常用 |
timeout | 方法参数 3 | 秒,等价 deadline |
4.2 四种调用类型的写法差异
proto 里returns前面加不加stream,生成的调用方式完全不同。用同一套写法套四种类型,是新手最容易踩的坑。
| 类型 | proto 写法 | 客户端方法 | 取结果的方式 |
|---|---|---|---|
| 一元 | returns (Reply) | $client->SayHello() | $call->wait() |
| 服务端流 | returns (stream Reply) | $client->SayHelloStream() | 遍历$call->responses() |
| 客户端流 | (stream Req) returns (Reply) | $client->UploadStream() | write()多次后wait() |
| 双向流 | (stream Req) returns (stream Reply) | 双向方法 | write()和responses()交替 |
服务端流最常用,拉取列表、订阅变更都是它,写法反而比一元简单:
// 服务端流:responses() 是个生成器,边到边处理,不会一次性把全部结果拉进内存 $call = $client->SayHelloStream($request, [], ['timeout' => 5.0]); foreach ($call->responses() as $reply) { echo $reply->getMessage(), PHP_EOL; } // 循环结束后再取状态,判断是正常结束还是中途被中断 $status = $call->getStatus();客户端流是反过来的,先攒请求再收一个响应:
// 客户端流:先 write 多次,写完 half-close,最后 wait 拿唯一响应 $call = $client->UploadStream([], ['timeout' => 10.0]); foreach (['a', 'b', 'c'] as $name) { $req = new Helloworld\HelloRequest(); $req->setName($name); $call->write($req); } $call->writesDone(); [$reply, $status] = $call->wait();responses()是生成器,遍历过程中抛异常表示流被中断,用try/catch包住最稳。writesDone()这一步别省,某些版本里不调它,wait()会一直挂着直到超时。
4.3 服务端:两条路线与自测方法
PHP 服务端有两条路。一条是 ext-grpc 自带的Grpc\Server,示例包的 server 部分就是它,适合本地验证和内部工具;另一条是 RoadRunner 的 gRPC 插件,走常驻进程模型,能扛量,但需要额外配 worker。示例包走的是前者,改动最小的用法是:改addHttp2Port里的端口、把 handler 里的业务逻辑换成自己需要的、启动脚本加个nohup或交给进程管理器。
服务端起来之后,先别写客户端,用grpcurl直接打一下,能立刻把服务端问题和客户端问题分开:
# 直接打方法,不依赖反射,最稳 grpcurl -plaintext -d '{"name":"probe"}' 127.0.0.1:50051 helloworld.Greeter/SayHello # 列服务端暴露的服务,需要服务端开了反射;PHP 的 Server 默认没开,失败是正常的 grpcurl -plaintext 127.0.0.1:50051 list第一条命令能返回 JSON 响应,说明服务端和 proto 都没问题,接下来查客户端;返回Unimplemented就是方法全路径不对,对照 proto 的package和服务名核一遍。
5. 避坑:五个把示例跑挂的真实现场
下面这五条都是我在不同机器、不同项目上真实遇到过的。它们的共同点是:报错信息指向的地方,往往不是真正出问题的地方。
5.1 Class "Grpc\Channel" not found,但命令行明明装了
现象:php -m | grep grpc有输出,跑脚本还是报类找不到。
原因:命令行和 FPM 用的是两份 php.ini。用了sudo pecl install之后写进去的是 CLI 那份,FPM 那份没动;反过来也有,网页能跑、定时任务挂。
解决:分 SAPI 各查一遍,php --ini看 CLI 加载哪份,php-fpm -i | grep "Loaded Configuration"看 FPM 加载哪份,两边都补上extension=grpc.so。装完必须重启 FPM,reload有时不生效,直接restart最省事。
5.2 状态码 12 UNIMPLEMENTED,客户端代码一行没错
现象:连接建立成功,一调就返回Unimplemented,details里能看到方法名。
原因:客户端构造的请求路径是/package.Service/Method,只要 proto 里package改过而服务端注册的还是老名字,或者生成桩没重新生成,路径就对不上。还有一种情况是服务端把方法名写成了小写开头。
解决:把客户端实际发出去的路径和服务端注册的路径打出来比对,两边字符级一致才算对。改完 proto 一定重新跑一遍生成命令,别手动改生成的桩文件——下次生成会被覆盖,问题会复现。
5.3 状态码 14 UNAVAILABLE,配置看着都对
现象:Connection refused或一直重连,服务端日志一条请求都没有。
原因:多半是网络层。容器里用127.0.0.1连另一个容器的服务是最常见的;其次是端口映射只做了 TCP 但服务监听在别的地址;再次是 DNS 解析到了 IPv6 但服务只监听 IPv4。
解决:先在客户端所在机器上ss -lntp | grep 50051确认有人监听,再nc -zv host port确认通。容器场景把地址换成服务名而不是 localhost。监听地址写0.0.0.0而不是127.0.0.1,这一条能干掉相当一部分玄学问题。
5.4 生成完了,自动加载就是找不到类
现象:文件明明在src/Helloworld/下,class_exists返回 false。
原因:PSR-4 前缀和目录大小写不匹配。Linux 文件系统区分大小写,Helloworld\映射到src/Helloworld,但生成器按 proto 的 package 生成了helloworld/小写目录,或者你改了php_namespace却没重新生成。
解决:ls src看一眼实际目录名,把composer.json里的前缀和路径改成与实际完全一致,然后composer dump-autoload -o。改完用composer dump-autoload -o --strict-psr跑一遍,不合规的映射会直接报出来,比运行时才发现强。
5.5 小消息能通,稍大一点就超时或 RESOURCE_EXHAUSTED
现象:几百字节的请求正常,几 MB 的批量数据必挂,报 4 或 8。
原因:gRPC 默认单条消息接收上限是 4MB,超了直接拒。另外大消息序列化耗时长,原来设的 1 秒超时不够用,表现出来是 DEADLINE_EXCEEDED,容易被误判成网络问题。
解决:客户端 Channel options 里把grpc.max_receive_message_length和grpc.max_send_message_length调到业务实际需要的量级,服务端也要同步调;同时按消息大小分档设超时,别一个 1 秒打天下。真正的大数据量场景,正确做法是改成分页或流式,而不是一路把上限调大。
6. 把示例改造成能用的调用层:一个装饰器加一条基线
示例里的调用是裸的,搬到项目里我会做两件事:把所有调用收敛到一个装饰器后面,以及先跑出一条延迟基线。
之所以用装饰器而不是拦截器,是因为 PHP 扩展这边没有成熟的拦截器链,硬凑不如老老实实包一层。下面这个类把所有生成桩的方法代理出去,统一注入 deadline 和 trace metadata:
<?php final class GreeterGateway { public function __construct( private Helloworld\GreeterClient $client, private float $defaultTimeout = 2.0 ) {} public function sayHello(string $name, string $traceId): string { $req = new Helloworld\HelloRequest(); $req->setName($name); $call = $this->client->SayHello( $req, ['x-trace-id' => [$traceId]], ['timeout' => $this->defaultTimeout] ); [$reply, $status] = $call->wait(); if ($status->code !== Grpc\STATUS_OK) { // 把状态码带进异常消息,日志里能直接按码聚合 throw new RuntimeException("grpc {$status->code}: {$status->details}"); } return $reply->getMessage(); } }注意$this->client是长生命周期的,一个进程只 new 一次 Client、复用一个 Channel,这是性能上的硬要求——每次请求新建 Client 会让连接复用失效,延迟能翻好几倍。
第二件事是基线。上线前我会用固定并发跑 200 次调用,记录 P50 / P95 / P99 三档,写进配置注释里。这条基线最大的价值不是性能数字本身,而是出问题时有个对照:P99 突然从 30ms 涨到 800ms,基本能锁定是下游或网络,而不是自己的代码逻辑。
| 观测项 | 取值位置 | 用途 |
|---|---|---|
$status->code | 每次调用 | 按码聚合错误率 |
timeout命中数 | 捕获 DEADLINE_EXCEEDED | 判断是不是超时设小了 |
$call->getMetadata() | 响应头 | 拿服务端的耗时埋点 |
| P99 延迟 | 压测脚本 | 作为变更前的对照基线 |
getMetadata()这一项很多人不知道,服务端往响应头里塞的耗时、版本号都能在客户端读到,做链路分析比在客户端打点准得多。
血泪教训是:早期我图省事,直接在控制器里 new Client、不设 timeout、不看状态码,结果一次下游抖动把整个 PHP-FPM 进程池拖死,日志里只有一堆莫名其妙的 500。从那以后我每次接一个新的 gRPC 服务,都强制走一遍“自检脚本 → grpcurl 探活 → 装饰器收口 → 压测基线”这套流程,一步不跳。希望帮到你。
本文还有配套的精品资源,点击获取