简介面向希望掌握 PHP 微服务通信的后端开发者这份 gRPC PHP 示例教程以可运行工程讲解服务定义、客户端与服务端实现及交互测试帮助读者理解 HTTP/2 传输、ProtoBuf 编码与远程过程调用模型适合具备 PHP 基础、准备进入分布式系统开发的学习者。压缩包共 29 个文件、约 22KB以 PHP 源码、XML 配置、Shell 脚本和 Dockerfile 为主辅以 Markdown 说明、JSON 依赖配置和 proto 服务定义兼顾代码阅读、环境搭建与示例运行。已有 134 人学习表明其在入门与进阶场景中具有一定参考价值。内容覆盖普通 RPC、流式调用、TLS 认证、错误处理、日志记录和集成测试等主题配合容器化配置与运行脚本读者可复现服务端和客户端交互流程并逐步扩展到并发处理、超时设置等性能调优实践。教程同时提供依赖安装、协议编译、示例运行与测试验证的完整线索便于读者按模块拆解学习。1. 卡在空 Channel 上的那个下午这份 gRPC PHP 示例包能帮你少走几步第一次把 PHP 服务接进 gRPC 调用链时我在一个空 Channel 上耗掉了一下午代码不报错请求就是没有回应日志里干干净净最后翻文档才发现是 UnaryCall 没有调用wait()对象返回了但根本没触发请求。这类问题对熟悉的人属于常识对第一次照着 gRPC PHP 教程敲的人就是黑匣子。这份 grpc-php-examples 示例包的价值在于它把 .proto 定义、protoc 代码生成、服务端启动循环、客户端 Stub 调用这条完整链路摊开摆在一起而不是只丢一段孤立的 Client 代码让人猜上下文。它适合两类开发者手上已有 PHP 业务、想用 gRPC 替掉内部那层 HTTP JSON 调用的后端以及要接手 RPC 通信模块、必须先搞清楚生成物到底长什么样的同学。下面按我拆包的顺序来从目录骨架讲到怎么把示例服务换成自己的。2. 依赖三件套与环境底座protoc、grpc 扩展、composer 包怎么咬合PHP 做 gRPC 和 Go、Java 那种「一个依赖搞定」的体验完全不同它是三样东西分段负责protoc 负责把 .proto 翻译成 PHP 类grpc C 扩展负责提供底层 HTTP/2 传输以及 Channel、Server 这些对象composer 里的grpc/grpc和google/protobuf提供 PHP 侧的封装和消息序列化基类。三者版本不对齐就是新手翻车的第一现场而且报错信息往往和真正的原因隔着两层。2.1 三件套各自的职责与装法grpc/grpc这个 composer 包本身很薄扩展存在时它基本只做转发扩展缺失时才会退回到纯 PHP 实现。纯 PHP 那条路性能差一大截流式调用的支持也残缺所以php -m | grep grpc这一行能不能看到 grpc直接决定了你后面跑的到底是哪条路。我一般建议先装扩展再composer install顺序反了容易出现 composer 装了包但运行时找不到类的情况。# 锁版本安装别直接 latest扩展和 composer 包版本差太远会出玄学问题 pecl install grpc-1.65.0 # 装完必须确认php-fpm 和 cli 是两个 ini别忘了分别看 php -m | grep -i grpc php --ri grpc # 这里能看到版本号和是否带 HTTP/2 支持扩展装完要在 ini 里显式启用很多「装了但没用」都是漏了这一步; php.ini extensiongrpc.so ; Windows 下是 extensionphp_grpc.dll注意区分 TS / NTS 版本接着是 protoc 和它的 PHP 插件。protoc 是 protobuf 官方编译器多数系统包管理器能直接装grpc_php_plugin就麻烦一些它跟着 grpc 源码走通常得自己编# 从 grpc 源码编译插件产物在源码根目录 git clone --depth 1 --branch v1.65.0 https://github.com/grpc/grpc.git cd grpc git submodule update --init make grpc_php_plugin # 丢进 PATH或者生成时用 --plugin 显式指定绝对路径 sudo cp grpc_php_plugin /usr/local/bin/ grpc_php_plugin --version这一步是大量教程跳过的环节。结果就是 protoc 一跑就报program not found or is not executable: protoc-gen-grpc看着像 protoc 的问题其实是插件没在 PATH 里或者没加--plugin参数告诉 protoc 去哪儿找。composer 侧就两份依赖版本按你本机扩展的版本靠{ name: demo/grpc-php-examples, require: { php: 7.4, grpc/grpc: ^1.65, google/protobuf: ^3.21 }, autoload: { psr-4: { Helloworld\\: src/Helloworld, GPBMetadata\\: src/GPBMetadata } } }autoload这段是整份配置里最容易被忽略、又最容易出事的部分。protoc 生成的代码按命名空间落盘PSR-4 映射少一条GPBMetadata运行时就报Class GPBMetadata\Helloworld not found而这个类恰恰是消息序列化的元数据入口缺了它所有 RPC 都跑不通。2.2 目录结构与每个文件的职责拆开包先看骨架比直接钻进代码效率高得多。示例包的结构一般是这样路径作用是否手写proto/helloworld.proto服务与消息定义整条链路的唯一真相源手写src/Helloworld/*.php消息类与 Client Stub由 protoc 生成生成src/GPBMetadata/*.php字段编号与类型的元数据序列化时用生成server.php服务端启动循环 业务 handler手写client.php客户端调用示例手写scripts/gen.sh把 protoc 命令固化下来的脚本手写composer.json依赖与 PSR-4 映射手写关键判断是src/下面那一坨是生成物改它等于白改下次重新生成会被覆盖。真正属于「你的代码」的只有 proto、server、client 和那条生成脚本。我见过有人直接在生成的消息类里加业务字段跑了两天没问题某次重新生成之后全部消失这就是没有搞清楚边界。scripts/gen.sh看着不起眼但它是团队协作的后悔药。生成参数-I路径、输出目录、插件位置一旦散落在各人终端历史里换台机器就复现不出来#!/usr/bin/env bash set -euo pipefail PROTO_DIR$(cd $(dirname $0)/../proto pwd) OUT_DIR$(cd $(dirname $0)/.. pwd)/src protoc -I ${PROTO_DIR} \ --php_out${OUT_DIR} \ --grpc_out${OUT_DIR} \ --pluginprotoc-gen-grpc$(command -v grpc_php_plugin) \ ${PROTO_DIR}/helloworld.proto-I是 proto 的 import 根目录--php_out产出消息类--grpc_out产出 Client Stub两个输出目录必须一致否则 Client 里use的消息类路径和实际文件对不上。2.3 十分钟环境验证先确认三样东西都在位我习惯把验证拆成三步每步只验证一件事出问题时能立刻定位到是哪一层而不是对着一个笼统的报错猜。第一步不碰任何生成代码只确认扩展和 Channel 能不能建出来?php // verify_ext.php只验证扩展层不依赖 protoc 的产物 if (!extension_loaded(grpc)) { exit(grpc extension not loaded\n); } $channel new \Grpc\Channel(127.0.0.1:50051, [ credentials \Grpc\ChannelCredentials::createInsecure(), ]); // true 表示允许短暂等待连接状态变化返回 0IDLE 1CONNECTING 2READY ... echo grpc ok, state, $channel-getConnectivityState(true), PHP_EOL;createInsecure()对应明文 HTTP/2本地调试都用它生产环境换成createSsl()传证书。getConnectivityState()返回的是枚举整数服务端没起时不会是 READY但脚本本身能跑通就说明扩展层没问题。第二步跑生成脚本确认产物落盘位置正确bash scripts/gen.sh find src -name *.php | sort # 预期看到 GreeterClient.php、HelloRequest.php、HelloReply.php、GPBMetadata/Helloworld.php第三步用 composer 的 autoload 把生成类拉起来这一步能一次性验证 PSR-4 映射对不对php -r require vendor/autoload.php; var_dump(class_exists(Helloworld\\HelloRequest)); var_dump(class_exists(Helloworld\\GreeterClient));两个都输出bool(true)才算环境齐了。只要有一个 false问题一定在composer.json的 PSR-4 或者composer dump-autoload没执行不用去怀疑 proto 写得对不对。这套三步验证法我基本每个新环境都走一遍比直接跑 client.php 然后看一堆堆栈快得多。3. 代码生成链路一个 .proto 文件怎么变成 PHP 类gRPC 的核心契约全在 proto 里PHP 侧几乎没有运行时反射所以「生成」这一步的质量直接决定后面写起来顺不顺。示例包里的 proto 很小但把该有的约定都示范到了值得逐行看。3.1 proto 里的三个关键约定package、php_namespace、字段编号先看这份定义syntax proto3; package helloworld; // 决定生成类落在哪个 PHP 命名空间 option php_namespace Helloworld; // 决定元数据类落在哪个命名空间缺了它序列化会报错 option php_metadata_namespace GPBMetadata; service Greeter { rpc SayHello (HelloRequest) returns (HelloReply) {} } message HelloRequest { string name 1; // 编号一旦上线就不能改 } message HelloReply { string message 1; }package是 proto 层的包名用于跨文件引用和避免同名冲突php_namespace是 PHP 专属的落地命名空间两者不是一回事别混着理解。这俩不一致是常见配置但 PSR-4 映射必须跟着php_namespace走。字段编号那行看着最不起眼其实是整套协议里最不能动的东西。protobuf 的二进制格式里传的是编号不是字段名编号一改旧客户端发来的数据就会被解析成别的字段而且不报错只是值莫名其妙地空了或者串了。我一般的做法是1 到 15 留给高频字段编码只占一个字节16 以上留给后来加的字段删字段时把编号用reserved锁死防止后人复用。message HelloRequest { reserved 2, 3; // 这两个编号永久作废 reserved old_name; // 字段名也一起锁 string name 1; string trace_id 16; // 后加的字段编号往后放 }3.2 生成命令与插件挂载方式生成命令在上一章已经写进脚本了这里讲清楚每个参数到底在干什么因为改错一个字符结果就完全不同protoc -I proto \ --php_outsrc \ --grpc_outsrc \ --pluginprotoc-gen-grpc/usr/local/bin/grpc_php_plugin \ proto/helloworld.proto--php_out走的是 protoc 内置的 PHP 生成器产出 Message 类和 GPBMetadata--grpc_out走的是外部插件产出 Client Stub。两者是独立的两套生成逻辑只跑一个就会出现「有消息类没 Client」或者反过来的情况。--pluginprotoc-gen-grpc路径里的protoc-gen-grpc是固定名字等号后面才是真实可执行文件写成别的名字 protoc 就找不到。还有一个容易忽略的点如果 proto 之间互相 import-I的根目录必须覆盖所有被 import 的路径否则报File not found。示例包只有一个文件看不出来一旦拆成common/base.proto加service/user.proto-I就必须指向这两个文件的共同父目录。3.3 生成物拆解Message 类、Client Stub、GPBMetadata生成完之后src/下大概是这样src/ ├── GPBMetadata/ │ └── Helloworld.php └── Helloworld/ ├── GreeterClient.php ├── HelloReply.php └── HelloRequest.phpHelloRequest.php是最典型的消息类用起来就是纯 getter/setter没有魔法?php // 生成物不要手改 namespace Helloworld; class HelloRequest extends \Google\Protobuf\Internal\Message { protected $name ; public function getName() { return $this-name; } public function setName($var) { GPBUtil::checkString($var, true); $this-name $var; } }GPBUtil::checkString是 protobuf 运行时的类型校验传个数组进去会直接抛异常这层保护比自己写is_string靠谱。$name 这个默认值来自 proto3 的语义字符串默认空串、数字默认 0、布尔默认 false没有 null 的概念。这一点在业务里要特别注意getName()返回空串和「字段没传」在 PHP 侧是分辨不出来的需要区分的话得在 proto 里显式加optional对应生成hasName()方法。GreeterClient.php是客户端用的 Stub核心就两件事构造时建 Channel调用时把方法名和请求打包送出去?php namespace Helloworld; class GreeterClient extends \Grpc\BaseStub { public function __construct($hostname, $opts, $channel null) { parent::__construct($hostname, $opts, $channel); } public function SayHello(\Helloworld\HelloRequest $argument, $metadata [], $options []) { // 第二个参数是方法全路径服务端靠它路由 return $this-_simpleRequest( /helloworld.Greeter/SayHello, $argument, [\Helloworld\HelloReply, decode], $metadata, $options ); } }这里有个很多人没注意到的细节返回的不是HelloReply而是一个\Grpc\UnaryCall对象。真正的响应要调wait()才拿得到/helloworld.Greeter/SayHello这个字符串是服务端路由的唯一依据由package加service名拼成改了包名就等于改了接口路径两边必须同步重新生成。GPBMetadata/Helloworld.php平时不用碰它是字段编号到类型的映射表序列化成二进制时靠它。它出问题的典型症状是「服务端收到了请求但字段全是默认值」本质是生成时 metadata 命名空间和运行时 autoload 对不上加载了另一个版本的表。4. 一次 Unary 调用的完整实现服务端挂载与客户端调用生成物就位后剩下的事就是服务端把方法实现挂上去、客户端把请求发出去。示例包最大的参考价值就在这两段代码上它们把「PHP 里 gRPC 到底怎么跑起来」这件事讲得很直白。4.1 服务端Grpc\Server 的启动循环与 handlerPHP 没有常驻进程模型gRPC 服务端是手写一个 while 循环撑着这是它和 Go、Java 最大的体感差异?php require __DIR__ . /vendor/autoload.php; use Grpc\Server; use Grpc\ServerCredentials; use Helloworld\HelloRequest; use Helloworld\HelloReply; // handler 里方法名要和 proto 里的 rpc 名严格对齐 class GreeterHandler { public function SayHello(HelloRequest $request): HelloReply { $reply new HelloReply(); $reply-setMessage(Hello . $request-getName()); return $reply; } } $server new Server(); $server-addHttp2Port(0.0.0.0:50051, ServerCredentials::createInsecure()); $server-start(); // 开始监听但还不处理请求 while (true) { // 阻塞等待一个调用返回 ServerCall 对象 $call $server-requestCall(); if (empty($call)) { continue; } // 交给 handler 处理扩展按方法名反射调用对应实现 $server-handle($call, new GreeterHandler()); }addHttp2Port的第二个参数是凭据本地用createInsecure()线上换成ServerCredentials::createSsl()并传证书路径。0.0.0.0和127.0.0.1的区别在容器环境里是致命的后面避坑章会专门讲。$server-handle()这个调用在单进程模型下是同步的也就是说这个 while 循环同一时刻只能处理一个请求第二个请求必须等第一个返回。示例包为了讲清楚链路用这种写法没问题真上生产要么在handle前自己加进程池要么换成基于 Swoole 协程的框架来承接。这一点在看示例代码时必须有意识不然照着搬上线压测第一轮就会发现 QPS 只有个位数。handler 里的方法名对齐也不是随便说说的。扩展按$call里的方法名去找同名方法proto 里叫SayHello实现类里就得有SayHello大小写敏感。名字写错的表现是请求能进来、但没有响应客户端一直等到超时日志里什么都没有属于典型的需要靠经验才能定位的坑。4.2 客户端Channel 复用、Stub 构造与 wait()客户端这边最常见的问题不是「调不通」而是「调了没反应」根因基本都落在 Channel 和 wait 上?php require __DIR__ . /vendor/autoload.php; use Helloworld\GreeterClient; use Helloworld\HelloRequest; // 一个进程内复用一个 Channel别每次请求都 new $client new GreeterClient(127.0.0.1:50051, [ credentials \Grpc\ChannelCredentials::createInsecure(), ]); $request new HelloRequest(); $request-setName(php); // 第二个参数 metadata第三个参数 options超时单位是微秒 $call $client-SayHello($request, [], [timeout 2000000]); list($reply, $status) $call-wait(); if ($status-code ! \Grpc\STATUS_OK) { fwrite(STDERR, sprintf(rpc failed: code%d detail%s\n, $status-code, $status-details)); exit(1); } echo $reply-getMessage(), PHP_EOL;timeout单位是微秒这一条坑过太多人。写2000看着像 2 秒实际是 2 毫秒本地可能偶尔通一上网络就全是 DEADLINE_EXCEEDED。我在项目里一般会封一层常量杜绝手写数字。wait()返回的是二元数组[$reply, $status]$status是stdClass里面code是整型状态码、details是字符串。code ! 0就代表失败必须判示例代码里如果不判直接echo $reply-getMessage()失败时会因为$reply是 null 而抛致命错误把真正的 RPC 错误信息盖掉。Channel 复用这点也值得单独强调。new GreeterClient(...)内部会建一条 HTTP/2 连接每次请求都 new 一个等于每次都重新握手压测时连接数会直接打爆。我的做法是在框架容器里把 Client 注册成单例hostname和credentials从配置读请求进来直接取// 简易容器写法别在业务代码里到处 new function greeter_client(): GreeterClient { static $client null; if ($client null) { $client new GreeterClient(getenv(GREETER_ADDR) ?: 127.0.0.1:50051, [ credentials \Grpc\ChannelCredentials::createInsecure(), ]); } return $client; }4.3 流式调用服务端流、客户端流和双向流的写法差异示例包里如果覆盖了流式方法会发现四种模式的调用方式差别挺大不是换个方法名就能复用的。Unary 是发一个收一个服务端流是发一个收一串// 服务端流一次请求多次响应responses() 返回迭代器 $call $client-ListFeatures($rect); foreach ($call-responses() as $feature) { // 边收边处理适合大批量数据下发 echo $feature-getName(), PHP_EOL; } // 迭代结束后同样要看状态 $status $call-getStatus();客户端流反过来先多次write()再writesDone()最后wait()收唯一响应// 客户端流多次发送一次响应 $call $client-RecordRoute(); foreach ($points as $point) { $call-write($point); // 逐条推送 } $call-writesDone(); // 显式告诉服务端我发完了 $reply $call-wait();双向流就自由一些读写可以交错$call $client-RouteChat(); foreach ($notes as $note) { $call-write($note); // 每写一条就尝试读读到 null 表示当前没有更多 while (($resp $call-read()) ! null) { echo $resp-getMessage(), PHP_EOL; } }实际项目里我的建议很直接PHP 侧优先用 Unary 加分页流式只在数据量确实大到一次拉不完时才用。原因不是语法难而是 PHP 扩展在服务端侧的流式支持没有 Go、Java 那么顺手加上常驻进程本身要靠自己撑一旦出现半开连接或者客户端提前断开排查成本比 Unary 高一个量级。示例代码跑通了是一回事能不能在线上稳住是另一回事。5. 避坑与排查示例包跑不起来的五个高频问题这份示例包的代码量不大但环境依赖多从零跑通的过程里踩坑几乎是必定的。下面五条是我在不同机器、不同容器里反复遇到过的按「现象 → 原因 → 解决」记下来照着对能省不少时间。5.1 现象Class Grpc\Channel not found跑client.php第一行就报这个堆栈里连 composer 的 autoload 都进不去。原因只有两种扩展没装或者装了但当前 SAPI 没加载。命令行php -m看得到、php-fpm跑起来却报错是最常见的形态因为 CLI 和 FPM 用的是两份 ini。另一个变体是 Docker 镜像里只装了 composer 包没装扩展composer install时因为ext-grpc平台检查被跳过运行时才炸。解决分三步走先php -m | grep grpc确认当前 SAPI再php --ini看加载的是哪份配置文件最后把extensiongrpc.so加进去重启 FPM。容器里就在 Dockerfile 里显式pecl install grpc docker-php-ext-enable grpc别指望基础镜像自带。5.2 现象生成完了new消息类时报找不到类src/Helloworld/HelloRequest.php文件明明在class_exists却返回 false。原因是 PSR-4 映射和实际生成目录对不上。php_namespace写的是Helloworld生成路径就是src/Helloworld/composer.json里就必须有Helloworld\\: src/Helloworld。漏掉GPBMetadata那条更隐蔽消息类能 new 出来但一序列化就报GPBMetadata\Helloworld未定义。解决补全两条映射后执行composer dump-autoload然后跑一遍class_exists验证。如果还不行用composer dump-autoload -o看优化后的 classmap 里有没有这个类classmap 里没有就说明路径确实错了。5.3 现象客户端不报错也不返回进程挂住请求发出去了服务端日志也没有客户端一直等到超时才退出或者干脆卡死。两个高频原因一是 Unary 调用忘了wait()拿到的只是个 Call 对象请求压根没触发二是超时单位写错timeout 2000以为是 2 秒实际是 2 毫秒请求刚发出就被取消。第三种情况是服务端 handler 方法名和 proto 里的 rpc 名不一致请求进来了但匹配不到实现就那么挂着。解决先补wait()并检查返回值再把超时统一改成微秒并抽成常量最后打开服务端日志确认方法名。定位阶段可以用grpcurl直连服务端list一下能列出方法就说明服务端正常问题在客户端。5.4 现象传 metadata 报Invalid metadata带上自定义 header 之后客户端直接抛异常提示 metadata 格式非法。原因是 HTTP/2 的 header 名必须小写而很多人习惯性地写成X-Trace-Id。另外 metadata 的值必须是字符串数组写成[trace abc]也会报错正确写法是[trace [abc]]。解决key 全小写、value 一律套数组。业务侧我一般封装一层把 trace_id、超时、重试策略统一塞进去业务代码只管传一个关联数组避免每个调用点都要记这两条规则。5.5 现象本机跑通容器里连不上同一条命令宿主机正常容器里就是连接超时或者Connection refused。原因是服务端addHttp2Port(127.0.0.1:50051)只监听了回环地址容器外访问不到另一种是客户端配了localhost:50051容器内解析到 IPv6 的::1而服务端只监听了 IPv4。第三种是 Docker 网络没打通两个容器不在同一网络里。解决服务端一律监听0.0.0.0客户端用服务名或者具体 IP别用 localhost。容器间通信走自定义 bridge 网络端口映射只用于对外暴露内部调用直接走容器端口。排查时在容器里curl -v一下 HTTP/2 端口或者用grpcurl探活比看业务日志快。6. 二次开发把示例服务换成自己的业务示例包跑通只是起点真正的活儿是把它改造成自己的服务。我的改造顺序一般是四步顺序别乱乱了容易反复返工。第一步先把 proto 换掉别在helloworld.proto上直接改。新建一个proto/your_service.proto改package、php_namespace把scripts/gen.sh里的文件名改掉或者改成遍历目录。这里有个小技巧gen.sh里用find ${PROTO_DIR} -name *.proto收集所有文件再加一个-I指向共同父目录后面拆文件时不用每次改脚本。第二步重新生成同步改composer.json的 PSR-4。命名空间换了之后旧映射要删掉否则 classmap 里残留旧类dump-autoload -o之后可能出现两个类名冲突报错信息还特别绕。第三步才是写业务。把示例里那个GreeterHandler拆成独立文件服务端循环里做一层简易注册表别把所有 handler 都塞在server.php里。业务逻辑里那些数据库、缓存依赖通过构造参数注入进去别在 handler 里直接new PDO否则测试没法跑。示例写法生产写法每请求new一个 Client容器里注册单例复用 Channel超时写死2000从配置读单位统一微秒不判$status-code失败转异常带上 code 和 detailshandler 内直接查库依赖注入业务与传输解耦单进程 while 循环进程管理或协程框架承接第四步补可观测性。gRPC 的错误信息藏在$status-details里不打日志等于没有。我一般在 handler 外层包一层统一记录方法名、耗时、状态码client 侧同样记录两边一对比就知道耗时花在哪一段。再把 trace_id 通过 metadata 透传链路就串起来了。最后一个我反复验证过的技巧改完 proto 之后先别急着写业务用grpcurl直连服务端把方法列一遍确认服务注册正确、方法名没错再写客户端调用。这一步能拦掉八成的「方法找不到」类问题。从那以后我每次接新的 gRPC 服务都强制先把环境三步验证走一遍、grpcurl探活确认一遍再动业务代码。这个习惯帮我省掉的排查时间比任何调试技巧都值。希望帮到你。本文还有配套的精品资源点击获取
阅读完成 · 觉得有帮助?