代码 PHP 8.6 给 curl 加了 CURLOPT_SEEKFUNCTION:流式请求体在重定向和认证重发时能回卷了

2026-11-15 21:04:27

PHP 8.6 给 curl 加了 CURLOPT_SEEKFUNCTION:流式请求体在重定向和认证重发时能回卷了

用 CURLOPT_READFUNCTION 自己喂请求体的代码,遇到跟随 3xx、401/407 多轮认证、以及复用连接上的重试,过去只能拿到 CURLE_SEND_FAIL_REWIND:body 已经读到一半,libcurl 需要从头重发,却没有任何办法把读指针拨回去。PHP 8.6 增加了 CURLOPT_SEEKFUNCTION,允许注册一个回卷回调。

什么时候会重发请求体

  • 跟随重定向:开了 CURLOPT_FOLLOWLOCATION 后,307/308 要求原样重发 body;301/302 在 libcurl 决定沿用原方法时同样要重发。
  • 多轮认证:服务端返回 401(WWW-Authenticate)或代理返回 407(Proxy-Authenticate),libcurl 取到凭据后重新发起一次请求,body 从这里开始重新读取。
  • 复用连接上的重试:连接被判定已死、或请求在发送途中失败,libcurl 换一条连接重发,同样需要把 body 退回起点。

把大 XML/JSON 推给支付或物流网关、请求体前置签名(签名算在 body 上,边发边改做不到)都属于这一类:body 只能顺序生成一遍,重发必须能回到指定位置。

回调签名与返回值

function (CurlHandle $ch, int $offset, int $origin): int
  • $offset:目标位置。
  • $origin:偏移的参照,对应 fseek 的 whence(SEEK_SET / SEEK_CUR / SEEK_END),不能一律当成绝对偏移处理。
  • 返回值三选一:
    • CURL_SEEKFUNC_OK:已定位到目标位置;
    • CURL_SEEKFUNC_FAIL:这次回卷失败,传输中止;
    • CURL_SEEKFUNC_CANTSEEK:这个数据源根本不支持回卷,libcurl 之后不再尝试,传输以失败结束。

FAIL 与 CANTSEEK 都会让请求失败,差别在语义:前者是“这次没做到”,后者是“永远做不到”,后者能让 libcurl 少做无用的尝试。

一个可回卷的实现

读回调和 seek 回调必须共用同一个游标,否则回卷之后读到的还是旧位置。

$payload = $xml;          // 可能几十 MB
$length  = strlen($payload);
$cursor  = 0;

$read = function (CurlHandle $ch, $fd, int $len) use (&$payload, &$cursor, $length): string|false {
    if ($cursor >= $length) {
        return '';        // 读完了
    }
    $chunk   = substr($payload, $cursor, $len);
    $cursor += strlen($chunk);
    return $chunk;
};

$seek = function (CurlHandle $ch, int $offset, int $origin) use (&$cursor, $length): int {
    switch ($origin) {
        case SEEK_SET: $cursor = $offset;        break;
        case SEEK_CUR: $cursor += $offset;       break;
        case SEEK_END: $cursor = $length + $offset; break;
        default:
            return CURL_SEEKFUNC_CANTSEEK;
    }
    if ($cursor < 0 || $cursor > $length) {
        return CURL_SEEKFUNC_FAIL;
    }
    return CURL_SEEKFUNC_OK;
};

$ch = curl_init('https://pay.example.com/v1/settle');
curl_setopt_array($ch, [
    CURLOPT_UPLOAD        => true,          // PUT;POST 场景见下
    CURLOPT_INFILESIZE    => $length,
    CURLOPT_READFUNCTION  => $read,
    CURLOPT_SEEKFUNCTION  => $seek,
    CURLOPT_FOLLOWLOCATION => true,
    CURLOPT_MAXREDIRS     => 3,
    CURLOPT_HTTPHEADER    => [
        'Content-Type: application/xml',
        'Expect:',                          // 省掉 100-continue 的一次往返
    ],
    CURLOPT_RETURNTRANSFER => true,
]);

$response = curl_exec($ch);

要点:

  • $cursor 用引用捕获(或封进一个持有 body 的对象),read 与 seek 看到的是同一份状态。
  • 不要在读回调里“重新生成”body。每次重发都重新生成,位置和内容很难对上,签名尤其容易出问题。
  • 网关要求 POST 时,把 CURLOPT_UPLOAD 换成 CURLOPT_POST,读/seek 回调保持不变;长度已知就显式告知 libcurl,未知长度会走 chunked,重发时的行为更麻烦。
  • 目标位置越界就返回 CURL_SEEKFUNC_FAIL,不要返回 OK 假装成功——位置没动,重发出去的内容就是错的。

数据源不可 seek 时

管道、php://stdin、边算边发的一次性生成器、压缩流,都无法回卷。这类数据源在 seek 回调里只能返回 CURL_SEEKFUNC_CANTSEEK,让传输按原来的方式失败(CURLE_SEND_FAIL_REWIND),而不是返回 OK 却什么都没做。

想绕开这个失败,只有两条路:把 body 先落到 php://temp(小数据留在内存,超阈值自动落盘)再发;或者避免触发重发——关掉 FOLLOWLOCATION 自己处理跳转,认证改成预先带凭据的一次成功请求。

size_delivered:写回调实际吃了多少字节

curl_getinfo() 的返回数组新增 size_delivered 键,表示传给下载写回调的字节总数;也可以用 curl_getinfo($ch, CURLINFO_SIZE_DELIVERED) 单独取。

$buf = '';
$ch  = curl_init('https://api.example.com/report');
curl_setopt_array($ch, [
    CURLOPT_WRITEFUNCTION => function (CurlHandle $ch, string $data) use (&$buf): int {
        $buf .= $data;
        return strlen($data);
    },
]);
curl_exec($ch);

$info = curl_getinfo($ch);
printf(
    "download=%d delivered=%d\n",
    $info['size_download'],
    $info['size_delivered']
);

两个值的统计范围不同:size_download 是 libcurl 层面统计的下载量,size_delivered 是真正交到写回调手里的字节数。自己拼响应体时,两者对不上就说明回调没有接收全部数据(回调返回的字节数少于传入长度、提前中断等),这时候不该假定拿到的是完整响应体。

版本

  • 两项功能都要求 libcurl 8.20.0 或更高,运行时用 curl_version()['version'] 确认,别只看 PHP 版本。
  • PHP 8.6 GA 计划 2026-11-19,目前最新预发布为 8.6.0RC7(2026-11-12)。
  • 上面的代码片段没有在 8.6.0RC 上实际运行验证;$origin 的取值集合与 PHP 侧暴露的常量名,以 libcurl 文档和 php-src 为准。
复制全文 生成海报 PHP curl 接口对接

推荐文章

程序员茄子在线接单