本指南将详细介绍一个可正常工作的 Guzzle 代理配置的各个部分:proxy 请求选项的字符串和数组形式、经过身份验证的代理 URL、通过 CONNECT 隧道连接的 HTTPS 目标、超时和证书验证,以及当代理拒绝请求时 Guzzle 抛出的异常。 阅读完本指南后,您将获得一个可重用的 PHP 客户端,它能够通过家庭代理进行路由,在整个会话中保持一个出口 IP 地址,验证该 IP 地址,并在请求失败时使用新的会话进行重试。
所有示例均针对 Guzzle 7 和 PHP 8.1 或更高版本。请使用 composer require guzzlehttp/guzzle:^7 进行安装。
proxy 选项:字符串或数组
Guzzle 通过 proxy 请求选项配置代理,相关文档请参见 docs.guzzlephp.org。 您可以按每次请求传递该选项,或在客户端构造函数中将其设为默认值。最简单的形式是字符串,它适用于所有协议。
<?php
require 'vendor/autoload.php';
use GuzzleHttp\Client;
$client = new Client([
'proxy' => 'http://USERNAME:PASSWORD@premium-residential.geonode.com:9000',
'timeout' => 30,
]);
$response = $client->get('https://api.ipify.org?format=json');
echo $response->getBody(), PHP_EOL;
数组形式允许您针对不同的 URI 方案选择不同的代理,并排除特定主机。该形式支持三个键:http、https 和 no。
<?php
$proxyUrl = 'http://USERNAME:PASSWORD@premium-residential.geonode.com:9000';
$client = new GuzzleHttp\Client([
'proxy' => [
'http' => $proxyUrl, // used for http:// targets
'https' => $proxyUrl, // used for https:// targets (CONNECT tunnel)
'no' => ['localhost', '.internal.example'], // never proxied
],
]);
当所有请求均需通过同一代理时,请使用字符串形式。当您需要使用 no 列表,或者希望纯 HTTP 请求通过数据中心池,而 HTTPS 请求通过家庭网络时,请使用数组形式。
no 列表与 NO_PROXY 的注意事项
当您未设置 proxy 选项时,Guzzle 会从环境中读取 HTTP_PROXY、HTTPS_PROXY 和 NO_PROXY。一旦您设置了该选项,该自动处理机制(包括排除列表)将立即停止。 官方文档明确指出,此时需要您自行传递 no。如果您的容器为内部服务设置了 NO_PROXY,而您忘记了这一点,则这些调用会突然被路由到住宅代理。
$no = getenv('NO_PROXY') ? explode(',', getenv('NO_PROXY')) : [];
$client = new GuzzleHttp\Client([
'proxy' => [
'http' => $proxyUrl,
'https' => $proxyUrl,
'no' => $no,
],
]);
Guzzle 代理认证:在 URL 中包含凭据
Guzzle 没有单独的 proxy_auth 选项。 凭据需作为 userinfo(http://username:password@host:port)包含在代理 URL 中,而 cURL 处理程序会将其作为 Proxy-Authorization: Basic 发送。由此衍生出两条实用规则。
首先,对密码中所有非 URL 安全的字符进行百分比编码。 原始密码中若包含 @、: 或 /,会导致 URL 解析失败,并引发令人困惑的 407 错误或连接到错误的主机。构建 URL 时应使用 rawurlencode() 而非字符串拼接。
function proxyUrl(string $user, string $pass, int $port = 9000): string
{
return sprintf(
'http://%s:%s@premium-residential.geonode.com:%d',
rawurlencode($user),
rawurlencode($pass),
$port
);
}
其次,请勿将凭据写入代码中。应从环境变量或密钥管理器中读取凭据,且切勿记录完整的代理 URL;Guzzle 的 debug 输出包含 Proxy-Authorization 头部,因此在将日志发送至任何地方之前,请先对其进行屏蔽处理。
通过 HTTP CONNECT 代理访问 HTTPS 目标
一个常见的误区:代理 URL 方案描述的是如何与代理通信,而非目标服务器使用什么协议。 对于目标 https://,Guzzle(通过 cURL)会向代理建立一个纯 HTTP 连接,发送 CONNECT host:443,然后在该隧道内与目标进行 TLS 握手。 因此,数组中的 http 和 https 键通常都指向一个 http:// 代理 URL。如果写成 https://premium-residential.geonode.com:9000,则会指示 cURL 将代理跳转本身也封装在 TLS 中,而这并非目标端所预期的。
由于 TLS 在隧道内部端到端运行,代理永远无法看到或修改响应正文,因此您对目标网站的常规证书验证与不使用代理时完全相同。
verify 与证书问题
verify 选项控制对目标站点的证书验证。 请保留默认值 true,该选项使用系统 CA 证书包。如果 PHP 无法找到证书包(在精简版 Docker 镜像中很常见),请将 verify 指向一个 PEM 文件,而不是将其禁用。
$client = new GuzzleHttp\Client([
'proxy' => $proxyUrl,
'verify' => '/etc/ssl/certs/ca-certificates.crt', // or true for the system default
]);
将 verify => false 设置为抑制仅在添加代理后出现的错误是一种错误;配置正确的 CONNECT 隧道不会引发证书错误,因此真正的问题出在别处,通常是上述 https:// 方案的混淆。
超时设置:timeout 和 connect_timeout
Guzzle 的默认 timeout 值是 0,这意味着将无限期等待。 通过住宅代理时,这是一个不合理的默认设置,因为出口设备速度过慢可能会导致工作进程无限期停滞。请设置两个值:connect_timeout 用于限制与代理的 TCP 连接时长,且仅由 cURL 处理程序支持;timeout 则以秒为单位限制整个传输过程的时长。
$client = new GuzzleHttp\Client([
'proxy' => $proxyUrl,
'connect_timeout' => 10, // seconds to reach the proxy
'timeout' => 45, // total seconds for the request
]);
家庭网络出口的速度通常慢于数据中心出口,因此应将总超时时间设置得比直接请求时更宽松。如果连接超时频繁触发,通常表明是网络路径问题,而非目标服务器的问题。
处理来自 Guzzle 代理的异常
Guzzle 会抛出类型化的异常,这些异常均继承自 GuzzleHttp\Exception\RequestException 类,通过识别抛出的具体异常类型,即可确定故障发生的位置。
| 异常 | 抛出时机 | 典型代理原因 |
|---|
ConnectException | 完全没有 HTTP 响应 | 代理主机不可达、端口错误、连接超时、DNS 故障 |
ClientException | 4xx 响应 | 407 凭据错误;目标服务器返回 403/429 |
ServerException | 5xx 响应 | 目标服务器错误,或代理无法连接到目标服务器 |
RequestException | 其他情况 | 处理头部后超时、TLS 失败 |
407 状态码值得单独排查。这是代理(而非目标)拒绝了请求,因此在凭据未更改前,重试并无帮助。
<?php
use GuzzleHttp\Exception\ClientException;
use GuzzleHttp\Exception\ConnectException;
use GuzzleHttp\Exception\ServerException;
use GuzzleHttp\Exception\RequestException;
try {
$response = $client->get('https://example.com/');
} catch (ConnectException $e) {
// Could not reach the proxy or the connect_timeout expired.
error_log('connect: ' . $e->getMessage());
} catch (ClientException $e) {
$status = $e->getResponse()->getStatusCode();
if ($status === 407) {
// Proxy rejected credentials: check username grammar and password.
throw new RuntimeException('proxy auth failed', 407, $e);
}
// 403, 404, 429 etc. come from the target site.
} catch (ServerException $e) {
// 5xx from the target (or the proxy failing to reach it).
} catch (RequestException $e) {
// Timeouts after connect, TLS errors and other transfer failures.
}
使用 http_errors 关闭异常处理
如果您更倾向于根据状态码进行分支处理,而非捕获异常,请设置 http_errors => false。此时 Guzzle 会针对任何状态码(包括 407)返回响应,而您需要自行检查 getStatusCode()。 由于没有响应可返回,因此仍会抛出 ConnectException 异常。
$response = $client->get('https://example.com/', ['http_errors' => false]);
if ($response->getStatusCode() === 407) {
// handle auth failure without an exception
}
故障诊断:on_stats 和 debug
当 Guzzle 代理请求速度缓慢或间歇性失败时,有两种选项可帮助您了解网络传输过程中发生了什么。on_stats 在每次传输后都会接收一个 GuzzleHttp\TransferStats 对象,其中包含总耗时以及 cURL 处理程序的统计信息,例如域名解析、连接和 TLS 时序。
use GuzzleHttp\TransferStats;
$client->get('https://example.com/', [
'on_stats' => function (TransferStats $stats) {
printf("%s took %.3fs\n", $stats->getEffectiveUri(), $stats->getTransferTime());
if (!$stats->hasResponse()) {
var_dump($stats->getHandlerErrorData());
}
},
]);
debug => true 会启用 CURLOPT_VERBOSE 并打印 CONNECT 交换信息,其中包括代理的响应行。请仅使用一次以确认隧道已建立,随后请将其移除:该选项会将凭据写入 STDOUT。 使用 fopen() 流代替 true 可将输出重定向到文件。
端口 10000 上的粘性会话
端口 9000 会在每次请求时轮换出口 IP。对于多步骤流程(先登录,然后获取页面,接着分页),您需要保持相同的 IP,而端口 10000 正是为此提供的。 会话是通过用户名而非请求头来选择的:在 -session-XXXXXXXX 后附加一个 8 位字母数字 ID,并可选地将 -lifetime-SECONDS 的 ID 范围设为 180 至 86400。 诸如 -country-us 之类的定位后缀应包含在同一字符串中。
<?php
function stickyProxyUrl(string $baseUser, string $pass, string $country = 'us', int $lifetime = 600): string
{
$session = substr(bin2hex(random_bytes(4)), 0, 8); // 8 alphanumeric chars
$user = sprintf('%s-country-%s-session-%s-lifetime-%d', $baseUser, $country, $session, $lifetime);
return sprintf(
'http://%s:%s@premium-residential.geonode.com:10000',
rawurlencode($user),
rawurlencode($pass)
);
}
$client = new GuzzleHttp\Client([
'proxy' => stickyProxyUrl(getenv('GEONODE_USER'), getenv('GEONODE_PASS')),
'timeout' => 45,
]);
现在,来自 $client 的每个请求都会在十分钟内共享一个出口 IP。若需使用不同的 IP,请创建一个新客户端(或传递新的 proxy 选项)。
验证出口 IP
在信任一个会话之前,请确认目标端将看到什么。通过同一个客户端两次调用 IP 回显服务:启用粘性会话时,两次调用返回的地址相同;使用 9000 端口时,返回的地址则不同。
$first = json_decode((string) $client->get('https://api.ipify.org?format=json')->getBody(), true);
$second = json_decode((string) $client->get('https://api.ipify.org?format=json')->getBody(), true);
printf("exit IP: %s (stable: %s)\n", $first['ip'], $first['ip'] === $second['ip'] ? 'yes' : 'no');
一个用于轮换会话的小型重试辅助程序
家庭网络出口可能在会话中途消失,而且目标服务器有时会封禁某个特定 IP 地址。 一种务实的做法是使用新的会话 ID 重试失败的请求,这样既能更改出口 IP,又能避免重复使用已处于异常状态的连接。下面的辅助函数会在每次尝试时重建客户端,并且仅在连接失败、超时以及指示被封锁的状态码出现时才进行重试。
<?php
use GuzzleHttp\Client;
use GuzzleHttp\Exception\ConnectException;
use GuzzleHttp\Exception\RequestException;
use Psr\Http\Message\ResponseInterface;
function fetchWithNewSession(string $url, int $attempts = 3): ResponseInterface
{
$lastError = null;
for ($i = 1; $i <= $attempts; $i++) {
$client = new Client([
'proxy' => stickyProxyUrl(getenv('GEONODE_USER'), getenv('GEONODE_PASS')),
'connect_timeout' => 10,
'timeout' => 45,
'http_errors' => false,
]);
try {
$response = $client->get($url);
$status = $response->getStatusCode();
if ($status === 407) {
throw new RuntimeException('proxy credentials rejected'); // do not retry
}
if (!in_array($status, [403, 429, 502, 503, 504], true)) {
return $response;
}
$lastError = new RuntimeException("status $status");
} catch (ConnectException | RequestException $e) {
$lastError = $e;
}
usleep(500_000 * $i); // simple linear backoff
}
throw new RuntimeException("gave up after $attempts attempts", 0, $lastError);
}
如果省略会话后缀,该代码同样适用于 9000 端口,因为每次尝试都会获得新的 IP 地址。请将尝试次数控制在较低水平:如果某个目标连续三次返回 403 状态码,这通常表明请求指纹存在问题,而非代理本身的问题。
常见错误
在代理 URL 中使用 https:// 方案
代理跳转采用纯 HTTP;目标的 HTTPS 则在 CONNECT 隧道内运行。这两个数组键都应包含 http:// 格式的代理 URL。 常见症状是出现 ConnectException,其中提到了与代理主机的 SSL 握手。
设置 proxy 后遗漏 no 列表
一旦传递了 proxy,NO_PROXY 就会被忽略。通过 HTTP 访问的内部健康检查、元数据端点和数据库将通过住宅代理,从而导致失败,甚至更糟的是发生数据泄露。请务必显式传递 no。
将 timeout 设为零
Guzzle 默认会无限期等待。单个卡住的住宅连接可能会占用队列工作者,直到监督进程将其终止。请在每个被代理的客户端上同时设置 connect_timeout 和 timeout。
407 状态码的重试
407 状态码表示代理拒绝了凭据。使用相同的用户名和密码重试只会浪费时间。应快速返回失败,记录用户名(切勿记录密码),并检查后缀的语法正确性。
下一步
上述配置可直接应用于 Geonode 住宅代理:覆盖 195 多个国家/地区,在线 IP 数量达 250 万个,支持 HTTP、HTTPS 协议,同一主机上还提供 SOCKS5 代理,且完全通过用户名即可选择最长 24 小时的粘性会话。 针对国家、州、城市和ASN的定位均采用此处展示的相同语法,因此当您缩小出口位置范围时无需修改代码。套餐为pay-as-you-go,无合同限制,起始流量10 GB,每月7.92美元,3天试用期费用为5美元。 请参阅住宅代理了解完整详情,或在定价页面比较不同套餐。 对于超出代理配置范围的爬取模式,网页爬取用例 涵盖了后续的处理流程。
常见问题
如何为来自某个客户端的每个请求设置 Guzzle 代理?
请在客户端构造函数中传入代理选项,而非针对每个请求单独设置。new Client(['proxy' => 'http://user:pass@host:port']) 会将该代理应用于该客户端发送的每个请求,且任何请求级别的代理选项都会覆盖它。当所有来自工作进程的流量都应通过同一提供商时,这是常规做法。
为什么我的 Guzzle 代理对 http:// 格式的 URL 有效,但对 https:// 格式的目标却无效?
通常是因为数组形式的 https 键缺失,或者该键指向了 https:// 方案。 Guzzle 会通过支持 CONNECT 的 HTTP 代理隧道传输 HTTPS 目标,因此 https 键对应的代理 URL 仍应以 http:// 开头。请检查数组形式,并确认代理端口支持 CONNECT 请求——Geonode 端口即支持此功能。
在 Guzzle 中,407 代理身份验证所需(Proxy Authentication Required)意味着什么?
代理在转发任何内容之前拒绝了您的凭据。由于这是 4xx 响应,Guzzle 会将其作为 ClientException 并返回状态码 407。 请检查用户名格式、密码,并确保密码中的特殊字符已在 URL 中进行了百分比编码。若将 http_errors 设置为 false,则会返回 407 响应对象,而非抛出异常。
Guzzle 是否支持 HTTP_PROXY 和 NO_PROXY 环境变量?
是的,当你未传递代理选项时,Guzzle 会从环境中读取 HTTP_PROXY、HTTPS_PROXY 和 NO_PROXY。 一旦您自行传入代理选项,NO_PROXY的自动处理机制就会停止,因此您需要显式传入no键,通常为explode(',', getenv('NO_PROXY'))。
我可以在多个Guzzle请求中保持相同的退出IP地址吗?
可以,请使用粘性会话。将 Guzzle 代理指向 10000 端口,并在用户名中添加 -session-XXXXXXXX(8 个字母数字字符)以及可选的 -lifetime-SECONDS 后缀。 每个重用该用户名的请求,在您设定的有效期内(180 秒至 24 小时之间)将共享同一出口 IP。
通过 Guzzle 代理进行抓取时,有哪些限制需要提前规划?
家庭宽带按每千兆字节计费,因此大型 HTML 页面和图片会累积消耗流量。粘性会话最长持续 24 小时,如果上游设备断开连接,会话可能会提前结束,因此代码必须能够容忍出口 IP 的变化。此外,请遵守目标网站的条款、机器人规则和速率限制;代理并不会改变您的法律义务。