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 为准。