☰
CRMEB 项目中的 Guzzle AsyncPlugin 解析:基于 cURL 进度回调的“发后即忘”异步请求插件
2026/10/15 10:52:13 网站建设 项目流程
  • 后端
  • 电商
  • 前端

【免费下载链接】CRMEB

🔥CRMEB开源商城免费开源多语言商城系统,Tp6框架商城,系统可商用;包含小程序商城、H5商城、公众号商城、PC商城、App,支持分销、拼团、砍价、秒杀、优惠券、积分、会员等级、小程序直播、页面DIY,前后端分离,方便二开,更有详细使用文档、接口文档、数据字典、二开文档/视频教程,欢迎大家提出宝贵意见和建议

项目地址:https://gitcode.com/gh_mirrors/cr/CRMEB
点击查看免费下载

导读

Guzzle(本仓库锁定为 3.x 系列)内置了一个极简但颇具巧思的AsyncPlugin:它不依赖任何异步扩展,也不使用多进程/协程,而是纯基于 cURL 的进度回调(progress callback)机制,让请求在"数据全部发送完成"后立刻以 1ms 超时终止传输,从而实现"发后即忘(fire-and-forget)"的效果。本文以 async-plugin.rst 为骨架,结合 AsyncPlugin.php、CurlHandle.php、RequestMediator.php 等源码与 AsyncPluginTest.php 测试用例,讲清它的工作原理、接入方式、X-Guzzle-Async响应头语义,以及它在 CRMEB 这类"Tp6 + Guzzle"项目中的适用边界。读完你将能独立判断何时该用、何时不该用这个插件,并能在自己的 Guzzle 3 请求链上快速落地同样的异步发送能力。


一、AsyncPlugin 是什么:先认识官方文档的原始定义

官方文档对 AsyncPlugin 的定位非常清晰:它允许你发送一个"不等待响应"的请求。文档原文说明了它的实现要点:

This is handled through cURL by utilizing the progress event. When a request has sent all of its data to the remote server, Guzzle adds a 1ms timeout on the request and instructs cURL to not download the body of the response. The async plugin then catches the exception and adds a mock response to the request, along with an X-Guzzle-Async header to let you know that the response was not fully downloaded.

即整个"异步"由三步完成:

  1. 借助 cURL 进度事件:当请求已把全部数据发送到远端服务器时触发判定;
  2. 施以 1ms 超时并禁止下载响应体:让 cURL 停止继续读取响应内容;
  3. 捕获异常、塞入模拟响应:把因超时抛出的 cURL 异常"吞掉",换成一个带X-Guzzle-Async头的 200 模拟响应,同时通过该响应头告知调用方"响应体并没有完整下载"。

需要先说明一个前提:这里的 Guzzle 是Guzzle 3.x(仓库中 composer.json 的 description 明确写着"This library is deprecated in favor of guzzlehttp/guzzle"),其事件机制依赖symfony/event-dispatcher ~2.1,运行要求php >= 5.3.3与ext-curl。它实现的是单请求层面的"发送即返回"语义,与 Guzzle 后来版本基于 promise/future 的并发异步模型不是一回事。

文档给出的最小接入示例

use Guzzle\Http\Client; use Guzzle\Plugin\Async\AsyncPlugin; $client = new Client('http://www.example.com'); $client->addSubscriber(new AsyncPlugin()); $response = $client->get()->send();

关键点:插件通过addSubscriber()挂到Client上。按照 plugins-overview.rst 的说明,挂到客户端上的订阅者会自动被该客户端创建的所有请求继承,因此这里$client->get()生成的请求在send()时即走异步路径。

一个被官方文档刻意留白、但对理解该插件非常重要的细节是:示例里get()请求不带请求体。此时异步路径与后续分析中CURLOPT_NOBODY的设定存在微妙关系(详见下文源码解读),实际项目中若要安全使用,建议参照测试用例采用PUT/POST等带请求体的请求。


二、源码级原理:四个事件钩子与 cURL 进度回调的完整链路

插件本体只有 84 行(AsyncPlugin.php),核心是getSubscribedEvents()注册的四个事件:

public static function getSubscribedEvents() { return array( 'request.before_send' => 'onBeforeSend', 'request.exception' => 'onRequestTimeout', 'request.sent' => 'onRequestSent', 'curl.callback.progress' => 'onCurlProgress' ); }

1.request.before_send→ 开启进度回调

public function onBeforeSend(Event $event) { // Ensure that progress callbacks are dispatched $event['request']->getCurlOptions()->set('progress', true); }

progress只是 Guzzle 层的一个开关,真正落底到 cURL 的是 CurlHandle.php:

// Enable the progress function if the 'progress' param was set if ($requestCurlOptions->get('progress')) { $curlOptions[CURLOPT_PROGRESSFUNCTION] = function () use ($mediator, $handle) { $args = func_get_args(); $args[] = $handle; if (is_resource($args[0])) { array_shift($args); // PHP 5.5 起句柄会出现在参数首位,需要剔除 } call_user_func_array(array($mediator, 'progress'), $args); }; $curlOptions[CURLOPT_NOPROGRESS] = false; }

由此,cURL 传输过程中每一次进度回调都会被转发到 RequestMediator.php 的progress(),并触发curl.callback.progress事件,事件上下文携带download_size / downloaded / upload_size / uploaded / handle五个字段。

2.curl.callback.progress→ 上传完毕即"掐断"传输

public function onCurlProgress(Event $event) { if ($event['handle'] && ($event['downloaded'] || (isset($event['uploaded']) && $event['upload_size'] === $event['uploaded'])) ) { // Timeout after 1ms curl_setopt($event['handle'], CURLOPT_TIMEOUT_MS, 1); // Even if the response is quick, tell curl not to download the body. if ($event['uploaded']) { curl_setopt($event['handle'], CURLOPT_NOBODY, true); } } }

判定条件是二选一:

  • downloaded非零:已经收到了一些响应字节;
  • 或upload_size === uploaded:带请求体时,上传数据量已达到应上传总量。

一旦命中,立刻把该 cURL 句柄的超时时间设为1ms,并在uploaded非空时追加CURLOPT_NOBODY(告诉 cURL 不要再下载响应体)。源码注释还解释了CURLOPT_NOBODY的一个隐蔽陷阱:只有当请求确实上传过请求体时才允许设置,否则会"在请求尚未通过 cURL 发出之前就把请求方法转换成 HEAD",导致原始方法语义丢失。这正是官方文档示例用GET时该分支不生效的原因——也是 AsyncPluginTest.php 中testEnsuresIntegration用POST并断言$received[0]->getMethod() === 'POST'的用意所在。

3.request.exception→ 吞掉超时异常,注入模拟响应

public function onRequestTimeout(Event $event) { if ($event['exception'] instanceof CurlException) { $event['request']->setResponse(new Response(200, array( 'X-Guzzle-Async' => 'Did not wait for the response' ))); } }

1ms 超时必然导致 cURL 抛错,而 Request.php 在请求进入STATE_ERROR时会派发request.exception事件。插件在此拦截到CurlException后,给请求注入一个状态码 200、带X-Guzzle-Async头、无响应体的模拟响应,从而把"网络异常"改写成"正常完成"。

4.request.sent→ 兜底标注"未等待响应"

public function onRequestSent(Event $event) { // Let the caller know this was meant to be async $event['request']->getResponse()->setHeader('X-Guzzle-Async', 'Did not wait for the response'); }

如果传输在 1ms 内恰好完成(比如服务器响应极快),CurlException并未抛出,此时由request.sent分支兜底:给真实响应也补上X-Guzzle-Async头。两条路径最终殊途同归——只要响应上出现X-Guzzle-Async: Did not wait for the response,就表示这次请求没有完整下载响应体。


三、事件派发链路的验证:从请求状态机看插件的插入时机

request.before_send与request.exception两个钩子并非插件自定义的"私货",而是 Guzzle 请求状态机内置的生命周期事件。看 Request.php 的setState():

  • 进入STATE_TRANSFER:派发request.before_send(AsyncPlugin 在此打开progress);
  • 进入STATE_ERROR且有异常:派发request.exception(AsyncPlugin 在此注入模拟响应);
  • 正常完成时:经由响应处理流程走到STATE_COMPLETE,随后派发request.sent(AsyncPlugin 在此补X-Guzzle-Async头)。

也就是说,AsyncPlugin 完全遵循 Guzzle 3 的"插件 = 事件订阅者"设计范式,没有侵入任何传输核心代码,只是把四个时机的回调拼装成"异步"语义。这四点也可以在 AsyncPluginTest.php 中逐条对照:

测试方法验证点
testSubscribesToEvents确认四个事件订阅注册齐全
testEnablesProgressCallbacksonBeforeSend后getCurlOptions()->get('progress') === true
testAddsTimesOutAfterSending上传完成时onCurlProgress可正常执行(不抛异常)
testEnsuresRequestIsSet无handle时onCurlProgress安全返回
testMasksCurlExceptions遇CurlException后请求状态变为STATE_COMPLETE、状态码 200、带X-Guzzle-Async头
testEnsuresIntegration真实服务端:POST 发出、响应体为空、带X-Guzzle-Async头、服务端实际收到 POST 方法

其中testMasksCurlExceptions直接断言了插件的核心契约:异常被掩蔽后,请求状态必须是STATE_COMPLETE,而不是STATE_ERROR——这保证了调用方send()不会因为异步而抛错。


四、在 CRMEB 这类 Tp6 项目中的落地方式与适用边界

CRMEB 的公共 HTTP 封装位于 HttpService.php,它并未使用 Guzzle,而是自研了一套静态 cURL 封装(getRequest/postRequest/request),以CURLOPT_CUSTOMREQUEST + CURLOPT_POSTFIELDS + CURLOPT_TIMEOUT + CURLOPT_RETURNTRANSFER完成同步请求,并通过curl_getinfo的http_code判断成败。因此 AsyncPlugin 属于随依赖引入但并未被业务代码直接调用的能力,这一点从"仓库中仅有 AsyncPlugin 的源码、文档与测试,无业务调用点"可以得到印证。

如果你的二开项目中恰好使用 Guzzle 3 发起 HTTP 调用,可以参考以下两种接入方式:

方式一:挂在 Client 上(官方示例,全局生效)

use Guzzle\Http\Client; use Guzzle\Plugin\Async\AsyncPlugin; $client = new Client('http://www.example.com'); $client->addSubscriber(new AsyncPlugin()); $response = $client->get()->send(); // 立即返回,响应体为空

方式二:挂在单个 Request 上(仅本次请求异步)

use Guzzle\Http\Client; $client = new Client('http://www.example.com'); $request = $client->post('/', null, array('foo' => 'bar')); $request->getEventDispatcher()->addSubscriber(new AsyncPlugin()); $request->send(); // 请求已发出,不等待完整响应

接入后通过响应头判断结果:

if ($response->hasHeader('X-Guzzle-Async')) { // 请求已发出,但响应体未完整下载,勿解析 body }

适用与不适用场景

  • 适用:日志上报、埋点统计、异步通知推送、不需要返回结果的内部触发类调用——"数据发出去就算成功"。
  • 不适用:
    • 依赖响应内容(如 CRMEB 与支付/物流/短信网关间的签名校验、结果回执解析);
    • 需要可靠的重试机制(建议搭配 backoff-plugin.rst 中BackoffPlugin这样的重试策略使用);
    • 追求真正的并发吞吐——AsyncPlugin 只是"不等响应",单次请求仍会阻塞到 1ms 超时,与 promise/future 式并发不是一回事;
    • 对远端服务器而言,"1ms 即断开"意味着它可能记录到异常中断的连接,服务端可能因此产生不必要的错误日志或半截请求处理。

从源码结构看,AsyncPlugin 最适合"能接受部分副作用、但不能阻塞主流程"的请求,是 Guzzle 3 时代一种轻量、无需额外扩展的权宜方案。


五、一句话总结

AsyncPlugin 用四行事件订阅、两行curl_setopt,把一个"发后即忘"的语义装进了 Guzzle 3 的事件系统:上传完毕 → 1ms 超时 → 吞异常 → 注入X-Guzzle-Async模拟响应。理解它,等于同时理解了 Guzzle 3 的插件机制、cURL 进度回调管道和请求状态机——这套"以小博大"的实现思路,本身就是值得反复研读的经典教材。

关键源码速查

  • 插件实现:AsyncPlugin.php
  • 单元测试(含集成测试):AsyncPluginTest.php
  • cURL 进度回调落点:CurlHandle.php
  • 进度事件上下文组装:RequestMediator.php
  • 请求生命周期事件派发:Request.php
  • 插件体系总览:plugins-overview.rst
  • 依赖声明与版本约束:composer.json
  • 项目自研的同步 cURL 封装(对比参考):HttpService.php
  • 后端
  • 电商
  • 前端

【免费下载链接】CRMEB

🔥CRMEB开源商城免费开源多语言商城系统,Tp6框架商城,系统可商用;包含小程序商城、H5商城、公众号商城、PC商城、App,支持分销、拼团、砍价、秒杀、优惠券、积分、会员等级、小程序直播、页面DIY,前后端分离,方便二开,更有详细使用文档、接口文档、数据字典、二开文档/视频教程,欢迎大家提出宝贵意见和建议

项目地址:https://gitcode.com/gh_mirrors/cr/CRMEB
点击查看免费下载

相关推荐

创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询