跳到内容

HTTP 客户端

编辑此页

安装

HttpClient 组件是一个底层的 HTTP 客户端,支持 PHP 流包装器和 cURL。它提供了消费 API 的实用工具,并支持同步和异步操作。您可以使用以下命令安装它

1
$ composer require symfony/http-client

基本用法

使用 HttpClient 类来发起请求。在 Symfony 框架中,此类作为 http_client 服务提供。当为 HttpClientInterface 进行类型提示时,此服务将自动 自动装配

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
use Symfony\Contracts\HttpClient\HttpClientInterface;

class SymfonyDocs
{
    public function __construct(
        private HttpClientInterface $client,
    ) {
    }

    public function fetchGitHubInformation(): array
    {
        $response = $this->client->request(
            'GET',
            'https://api.github.com/repos/symfony/symfony-docs'
        );

        $statusCode = $response->getStatusCode();
        // $statusCode = 200
        $contentType = $response->getHeaders()['content-type'][0];
        // $contentType = 'application/json'
        $content = $response->getContent();
        // $content = '{"id":521583, "name":"symfony-docs", ...}'
        $content = $response->toArray();
        // $content = ['id' => 521583, 'name' => 'symfony-docs', ...]

        return $content;
    }
}

提示

HTTP 客户端可以与 PHP 中许多常见的 HTTP 客户端抽象互操作。您还可以使用任何这些抽象来从自动装配中获益。请参阅 互操作性 以获取更多信息。

配置

HTTP 客户端包含许多选项,您可能需要这些选项来完全控制请求的执行方式,包括 DNS 预解析、SSL 参数、公钥固定等。它们可以在配置中全局定义(以应用于所有请求)和每个请求中定义(这将覆盖任何全局配置)。

您可以使用 default_options 选项配置全局选项

1
2
3
4
5
# config/packages/framework.yaml
framework:
    http_client:
        default_options:
            max_redirects: 7

您还可以使用 withOptions() 方法来检索具有新默认选项的客户端新实例

1
2
3
4
5
$this->client = $client->withOptions([
    'base_uri' => 'https://...',
    'headers' => ['header-name' => 'header-value'],
    'extra' => ['my-key' => 'my-value'],
]);

或者,HttpOptions 类带来了大多数可用选项,并带有类型提示的 getter 和 setter

1
2
3
4
5
6
7
8
9
$this->client = $client->withOptions(
    (new HttpOptions())
        ->setBaseUri('https://...')
        // replaces *all* headers at once, and deletes the headers you do not provide
        ->setHeaders(['header-name' => 'header-value'])
        // set or replace a single header using setHeader()
        ->setHeader('another-header-name', 'another-header-value')
        ->toArray()
);

7.1

setHeader() 方法在 Symfony 7.1 中引入。

本指南中描述了一些选项

查看完整的 http_client 配置参考,以了解所有选项。

HTTP 客户端还有一个名为 max_host_connections 的配置选项,此选项不能被请求覆盖

1
2
3
4
5
# config/packages/framework.yaml
framework:
    http_client:
        max_host_connections: 10
        # ...

作用域客户端

常见的情况是,某些 HTTP 客户端选项依赖于请求的 URL(例如,当向 GitHub API 发出请求时必须设置某些标头,但对于其他主机则不需要)。如果属于这种情况,组件提供了作用域客户端(使用 ScopingHttpClient)以基于请求的 URL 自动配置 HTTP 客户端

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
# config/packages/framework.yaml
framework:
    http_client:
        scoped_clients:
            # only requests matching scope will use these options
            github.client:
                scope: 'https://api\.github\.com'
                headers:
                    Accept: 'application/vnd.github.v3+json'
                    Authorization: 'token %env(GITHUB_API_TOKEN)%'
                # ...

            # using base_uri, relative URLs (e.g. request("GET", "/repos/symfony/symfony-docs"))
            # will default to these options
            github.client:
                base_uri: 'https://api.github.com'
                headers:
                    Accept: 'application/vnd.github.v3+json'
                    Authorization: 'token %env(GITHUB_API_TOKEN)%'
                # ...

您可以定义多个作用域,以便仅当请求的 URL 与 scope 选项设置的正则表达式之一匹配时,才添加每组选项。

如果您在 Symfony 框架中使用作用域客户端,则必须使用 Symfony 定义的任何方法来 选择特定服务。每个客户端都有一个以其配置命名的唯一服务。

每个作用域客户端还定义了一个相应的命名自动装配别名。例如,如果您使用 Symfony\Contracts\HttpClient\HttpClientInterface $githubClient 作为参数的类型和名称,自动装配会将 github.client 服务注入到您的自动装配类中。

注意

阅读 base_uri 选项文档,以了解将相对 URI 合并到作用域客户端的 base URI 中的规则。

发起请求

HTTP 客户端提供了一个单一的 request() 方法来执行各种 HTTP 请求

1
2
3
4
5
6
7
8
9
10
11
$response = $client->request('GET', 'https://...');
$response = $client->request('POST', 'https://...');
$response = $client->request('PUT', 'https://...');
// ...

// you can add request options (or override global ones) using the 3rd argument
$response = $client->request('GET', 'https://...', [
    'headers' => [
        'Accept' => 'application/json',
    ],
]);

响应始终是异步的,因此对该方法的调用会立即返回,而不是等待接收响应

1
2
3
4
5
6
7
8
9
// code execution continues immediately; it doesn't wait to receive the response
$response = $client->request('GET', 'http://releases.ubuntu.com/18.04.2/ubuntu-18.04.2-desktop-amd64.iso');

// getting the response headers waits until they arrive
$contentType = $response->getHeaders()['content-type'][0];

// trying to get the response content will block the execution until
// the full response content is received
$content = $response->getContent();

此组件还支持 流式响应,用于完全异步的应用程序。

认证

HTTP 客户端支持不同的身份验证机制。它们可以在配置中全局定义(以应用于所有请求)和每个请求中定义(这将覆盖任何全局身份验证)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
# config/packages/framework.yaml
framework:
    http_client:
        scoped_clients:
            example_api:
                base_uri: 'https://example.com/'

                # HTTP Basic authentication
                auth_basic: 'the-username:the-password'

                # HTTP Bearer authentication (also called token authentication)
                auth_bearer: the-bearer-token

                # Microsoft NTLM authentication
                auth_ntlm: 'the-username:the-password'
1
2
3
4
5
6
$response = $client->request('GET', 'https://...', [
    // use a different HTTP Basic authentication only for this request
    'auth_basic' => ['the-username', 'the-password'],

    // ...
]);

注意

基本身份验证也可以通过在 URL 中包含凭据来设置,例如:http://the-username:[email protected]

注意

NTLM 身份验证机制需要使用 cURL 传输。通过使用 HttpClient::createForBaseUri(),我们确保身份验证凭据不会发送到 https://example.com/ 以外的任何其他主机。

查询字符串参数

您可以手动将它们附加到请求的 URL,或者通过 query 选项将它们定义为关联数组,这将与 URL 合并

1
2
3
4
5
6
7
8
// it makes an HTTP GET request to https://httpbin.org/get?token=...&name=...
$response = $client->request('GET', 'https://httpbin.org/get', [
    // these values are automatically encoded before including them in the URL
    'query' => [
        'token' => '...',
        'name' => '...',
    ],
]);

标头

使用 headers 选项来定义添加到所有请求的默认标头

1
2
3
4
5
6
# config/packages/framework.yaml
framework:
    http_client:
        default_options:
            headers:
                'User-Agent': 'My Fancy App'

您还可以为特定请求设置新标头或覆盖默认标头

1
2
3
4
5
6
7
// this header is only included in this request and overrides the value
// of the same header if defined globally by the HTTP client
$response = $client->request('POST', 'https://...', [
    'headers' => [
        'Content-Type' => 'text/plain',
    ],
]);

上传数据

此组件提供了几种使用 body 选项上传数据的方法。您可以使用常规字符串、闭包、可迭代对象和资源,它们将在发出请求时自动处理

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
$response = $client->request('POST', 'https://...', [
    // defining data using a regular string
    'body' => 'raw data',

    // defining data using an array of parameters
    'body' => ['parameter1' => 'value1', '...'],

    // using a closure to generate the uploaded data
    'body' => function (int $size): string {
        // ...
    },

    // using a resource to get the data from it
    'body' => fopen('/path/to/file', 'r'),
]);

当使用 POST 方法上传数据时,如果您没有显式定义 Content-Type HTTP 标头,Symfony 会假定您正在上传表单数据,并为您添加所需的 'Content-Type: application/x-www-form-urlencoded' 标头。

body 选项设置为闭包时,它将被多次调用,直到返回空字符串,这表示正文的结束。每次,闭包应返回小于作为参数请求的量的字符串。

生成器或任何 Traversable 也可以代替闭包使用。

提示

当上传 JSON 负载时,请使用 json 选项而不是 body。给定的内容将自动进行 JSON 编码,并且请求也会自动添加 Content-Type: application/json

1
2
3
4
5
$response = $client->request('POST', 'https://...', [
    'json' => ['param1' => 'value1', '...'],
]);

$decodedPayload = $response->toArray();

要提交带有文件上传的表单,请将文件句柄传递给 body 选项

1
2
$fileHandle = fopen('/path/to/the/file', 'r');
$client->request('POST', 'https://...', ['body' => ['the_file' => $fileHandle]]);

默认情况下,此代码将使用打开文件的​​数据填充文件名和内容类型,但是您可以使用 PHP 流配置来配置两者

1
2
stream_context_set_option($fileHandle, 'http', 'filename', 'the-name.txt');
stream_context_set_option($fileHandle, 'http', 'content_type', 'my/content-type');

提示

当使用多维数组时,FormDataPart 类会自动将 [key] 附加到字段名称

1
2
3
4
5
6
7
8
9
$formData = new FormDataPart([
    'array_field' => [
        'some value',
        'other value',
    ],
]);

$formData->getParts(); // Returns two instances of TextPart
                       // with the names "array_field[0]" and "array_field[1]"

可以通过使用以下数组结构来绕过此行为

1
2
3
4
5
6
7
$formData = new FormDataPart([
    ['array_field' => 'some value'],
    ['array_field' => 'other value'],
]);

$formData->getParts(); // Returns two instances of TextPart both
                       // with the name "array_field"

默认情况下,HttpClient 在上传时流式传输正文内容。这可能不适用于所有服务器,从而导致 HTTP 状态代码 411(“Length Required”),因为没有 Content-Length 标头。解决方案是将正文转换为字符串,使用以下方法(当流很大时,这将增加内存消耗)

1
2
3
4
5
$client->request('POST', 'https://...', [
    // ...
    'body' => $formData->bodyToString(),
    'headers' => $formData->getPreparedHeaders()->toArray(),
]);

如果您需要为上传添加自定义 HTTP 标头,可以这样做

1
2
$headers = $formData->getPreparedHeaders()->toArray();
$headers[] = 'X-Foo: bar';

Cookies

此组件提供的 HTTP 客户端是无状态的,但是处理 cookies 需要有状态的存储(因为响应可以更新 cookies,并且必须将其用于后续请求)。这就是为什么此组件不自动处理 cookies 的原因。

您可以 使用 BrowserKit 组件发送 cookies,该组件与 HttpClient 组件无缝集成,或者手动设置 Cookie HTTP 请求标头,如下所示

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
use Symfony\Component\HttpClient\HttpClient;
use Symfony\Component\HttpFoundation\Cookie;

$client = HttpClient::create([
    'headers' => [
        // set one cookie as a name=value pair
        'Cookie' => 'flavor=chocolate',

        // you can set multiple cookies at once separating them with a ;
        'Cookie' => 'flavor=chocolate; size=medium',

        // if needed, encode the cookie value to ensure that it contains valid characters
        'Cookie' => sprintf("%s=%s", 'foo', rawurlencode('...')),
    ],
]);

重定向

默认情况下,HTTP 客户端在发出请求时最多跟踪 20 个重定向。使用 max_redirects 设置来配置此行为(如果重定向次数高于配置值,您将收到 RedirectionException

1
2
3
4
$response = $client->request('GET', 'https://...', [
    // 0 means to not follow any redirect
    'max_redirects' => 0,
]);

重试失败的请求

有时,由于网络问题或临时服务器错误,请求会失败。Symfony 的 HttpClient 允许使用 retry_failed 选项自动重试失败的请求。

默认情况下,失败的请求最多重试 3 次,重试之间采用指数延迟(首次重试 = 1 秒;第三次重试:4 秒),并且仅针对以下 HTTP 状态代码:423425429502503(当使用任何 HTTP 方法时)以及 500504507510(当使用 HTTP 幂等方法 时)。使用 max_retries 设置来配置请求重试的次数。

查看完整的可配置 retry_failed 选项列表,以了解如何调整每个选项以适合您的应用程序需求。

当在 Symfony 应用程序外部使用 HttpClient 时,请使用 RetryableHttpClient 类来包装您的原始 HTTP 客户端

1
2
3
use Symfony\Component\HttpClient\RetryableHttpClient;

$client = new RetryableHttpClient(HttpClient::create());

RetryableHttpClient 使用 RetryStrategyInterface 来决定是否应重试请求,并定义每次重试之间的等待时间。

跨多个基本 URI 重试

可以将 RetryableHttpClient 配置为使用多个基本 URI。此功能为发出 HTTP 请求提供了更高的灵活性和可靠性。在发出请求时,将基本 URI 数组作为选项 base_uri 传递

1
2
3
4
5
6
7
8
$response = $client->request('GET', 'some-page', [
    'base_uri' => [
        // first request will use this base URI
        'https://example.com/a/',
        // if first request fails, the following base URI will be used
        'https://example.com/b/',
    ],
]);

当重试次数高于基本 URI 的数量时,最后一个基本 URI 将用于剩余的重试。

如果您想为每次重试尝试随机化基本 URI 的顺序,请将要随机化的基本 URI 嵌套在额外的数组中

1
2
3
4
5
6
7
8
9
10
11
$response = $client->request('GET', 'some-page', [
    'base_uri' => [
        [
            // a single random URI from this array will be used for the first request
            'https://example.com/a/',
            'https://example.com/b/',
        ],
        // non-nested base URIs are used in order
        'https://example.com/c/',
    ],
]);

此功能允许使用更随机的方法来处理重试,从而减少重复访问同一失败的基本 URI 的可能性。

通过对基本 URI 使用嵌套数组,您可以使用此功能在服务器集群中的多个节点之间分配负载。

您还可以使用 withOptions() 方法配置基本 URI 数组

1
2
3
4
$client = $client->withOptions(['base_uri' => [
    'https://example.com/a/',
    'https://example.com/b/',
]]);

HTTP 代理

默认情况下,此组件遵循操作系统定义的标准环境变量,以通过本地代理定向 HTTP 流量。这意味着通常无需配置即可使客户端与代理一起工作,前提是这些环境变量已正确配置。

您仍然可以使用 proxyno_proxy 选项设置或覆盖这些设置

  • proxy 应设置为要通过的代理的 http://... URL
  • no_proxy 禁用以逗号分隔的主机列表的代理,这些主机不需要代理即可访问。

进度回调

通过为 on_progress 选项提供可调用对象,可以跟踪上传/下载的完成进度。保证在 DNS 解析、标头到达和完成时调用此回调;此外,当上传或下载新数据时以及至少每秒调用一次

1
2
3
4
5
6
7
$response = $client->request('GET', 'https://...', [
    'on_progress' => function (int $dlNow, int $dlSize, array $info): void {
        // $dlNow is the number of bytes downloaded so far
        // $dlSize is the total size to be downloaded or -1 if it is unknown
        // $info is what $response->getInfo() would return at this very time
    },
]);

从回调中抛出的任何异常都将包装在 TransportExceptionInterface 的实例中,并将中止请求。

HTTPS 证书

HttpClient 使用系统的证书存储来验证 SSL 证书(而浏览器使用自己的存储)。在开发期间使用自签名证书时,建议创建您自己的证书颁发机构 (CA) 并将其添加到系统的存储中。

或者,您也可以禁用 verify_hostverify_peer(请参阅 http_client 配置参考),但这不建议在生产环境中使用。

SSRF(服务器端请求伪造)处理

SSRF 允许攻击者诱导后端应用程序向任意域发出 HTTP 请求。这些攻击也可能针对被攻击服务器的内部主机和 IP。

如果您将 HttpClient 与用户提供的 URI 一起使用,则最好使用 NoPrivateNetworkHttpClient 对其进行装饰。这将确保本地网络无法访问 HTTP 客户端

1
2
3
4
5
6
7
8
9
10
11
12
13
14
use Symfony\Component\HttpClient\HttpClient;
use Symfony\Component\HttpClient\NoPrivateNetworkHttpClient;

$client = new NoPrivateNetworkHttpClient(HttpClient::create());
// nothing changes when requesting public networks
$client->request('GET', 'https://example.com/');

// however, all requests to private networks are now blocked by default
$client->request('GET', 'https://127.0.0.1/');

// the second optional argument defines the networks to block
// in this example, requests from 104.26.14.0 to 104.26.15.255 will result in an exception
// but all the other requests, including other internal networks, will be allowed
$client = new NoPrivateNetworkHttpClient(HttpClient::create(), ['104.26.14.0/23']);

性能分析

当您使用 TraceableHttpClient 时,响应内容将保留在内存中,并可能耗尽内存。

您可以通过在请求中将 extra.trace_content 选项设置为 false 来禁用此行为

1
2
3
$response = $client->request('GET', 'https://...', [
    'extra' => ['trace_content' => false],
]);

此设置不会影响其他客户端。

使用 URI 模板

UriTemplateHttpClient 提供了一个客户端,简化了 URI 模板的使用,如 RFC 6570 中所述。

1
2
3
4
5
6
7
8
9
$client = new UriTemplateHttpClient();

// this will make a request to the URL http://example.org/users?page=1
$client->request('GET', 'http://example.org/{resource}{?page}', [
    'vars' => [
        'resource' => 'users',
        'page' => 1,
    ],
]);

在您的应用程序中使用 URI 模板之前,您必须安装一个第三方包,该包可以将这些 URI 模板扩展为 URL。

1
2
3
4
5
$ composer require league/uri

# Symfony also supports the following URI template packages:
# composer require guzzlehttp/uri-template
# composer require rize/uri-template

当在框架上下文中使用此客户端时,所有现有的 HTTP 客户端都会被 UriTemplateHttpClient 修饰。这意味着 URI 模板功能默认对您在应用程序中可能使用的所有 HTTP 客户端启用。

您可以配置将在应用程序的所有 URI 模板中全局替换的变量。

1
2
3
4
5
6
# config/packages/framework.yaml
framework:
    http_client:
        default_options:
            vars:
                - secret: 'secret-token'

如果您想定义自己的逻辑来处理 URI 模板的变量,您可以通过重新定义 http_client.uri_template_expander 别名来实现。您的服务必须是可调用的。

性能

该组件的设计旨在实现最高的 HTTP 性能。从设计上来说,它兼容 HTTP/2,并能进行并发的异步流式和多路复用请求/响应。即使进行常规的同步调用,这种设计也允许在请求之间保持与远程主机的连接打开,通过节省重复的 DNS 解析、SSL 协商等来提高性能。为了充分利用所有这些设计优势,需要 cURL 扩展。

启用 cURL 支持

此组件可以使用原生 PHP 流以及 amphp/http-client 和 cURL 库发出 HTTP 请求。尽管它们是可互换的,并提供相同的功能,包括并发请求,但只有在使用 cURL 或 amphp/http-client 时才支持 HTTP/2。

注意

要使用 AmpHttpClient,必须安装 amphp/http-client 包。

如果启用了 cURL PHP 扩展create() 方法会选择 cURL 传输。如果找不到 cURL 或 cURL 版本太旧,它会回退到 AmpHttpClient。最后,如果 AmpHttpClient 不可用,它会回退到 PHP 流。如果您希望显式选择传输方式,请使用以下类来创建客户端。

1
2
3
4
5
6
7
8
9
10
11
12
use Symfony\Component\HttpClient\AmpHttpClient;
use Symfony\Component\HttpClient\CurlHttpClient;
use Symfony\Component\HttpClient\NativeHttpClient;

// uses native PHP streams
$client = new NativeHttpClient();

// uses the cURL PHP extension
$client = new CurlHttpClient();

// uses the client from the `amphp/http-client` package
$client = new AmpHttpClient();

在完整的 Symfony 应用程序中使用此组件时,此行为是不可配置的,如果安装并启用了 cURL PHP 扩展,则会自动使用 cURL,否则将如上所述回退。

配置 CurlHttpClient 选项

PHP 允许通过 curl_setopt 函数配置大量的 cURL 选项。为了使组件在不使用 cURL 时更具可移植性,CurlHttpClient 仅使用其中一些选项(并且这些选项在其他客户端中会被忽略)。

在您的配置中添加 extra.curl 选项以传递这些额外的选项。

1
2
3
4
5
6
7
8
9
10
11
12
use Symfony\Component\HttpClient\CurlHttpClient;

$client = new CurlHttpClient();

$client->request('POST', 'https://...', [
    // ...
    'extra' => [
        'curl' => [
            CURLOPT_IPRESOLVE => CURL_IPRESOLVE_V6,
        ],
    ],
]);

注意

某些 cURL 选项无法被覆盖(例如,由于线程安全),当您尝试覆盖它们时,会抛出异常。

HTTP 压缩

如果满足以下条件,则会自动添加 HTTP 标头 Accept-Encoding: gzip

  • 使用 cURL 客户端:cURL 在编译时启用了 ZLib 支持(请参阅 php --ri curl)。
  • 使用原生 HTTP 客户端:安装了 Zlib PHP 扩展

如果服务器确实以 gzip 压缩的响应进行响应,则会透明地解码。要禁用 HTTP 压缩,请发送 Accept-Encoding: identity HTTP 标头。

如果您的 PHP 运行时和远程服务器都支持分块传输编码,则会自动启用。

警告

如果您将 Accept-Encoding 设置为例如 gzip,您将需要自己处理解压缩。

HTTP/2 支持

当请求 https URL 时,如果安装了以下工具之一,则默认启用 HTTP/2:

  • libcurl 包版本 7.36 或更高版本,与 PHP >= 7.2.17 / 7.3.4 一起使用;
  • amphp/http-client Packagist 包版本 4.2 或更高版本。

要为 http URL 强制使用 HTTP/2,您需要通过 http_version 选项显式启用它。

1
2
3
4
5
# config/packages/framework.yaml
framework:
    http_client:
        default_options:
            http_version: '2.0'

当使用兼容的客户端时,对 HTTP/2 PUSH 的支持是开箱即用的:推送的响应被放入临时缓存,并在后续请求触发相应 URL 时使用。

处理响应

所有 HTTP 客户端返回的响应都是 ResponseInterface 类型的对象,它提供了以下方法:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
$response = $client->request('GET', 'https://...');

// gets the HTTP status code of the response
$statusCode = $response->getStatusCode();

// gets the HTTP headers as string[][] with the header names lower-cased
$headers = $response->getHeaders();

// gets the response body as a string
$content = $response->getContent();

// casts the response JSON content to a PHP array
$content = $response->toArray();

// casts the response content to a PHP stream resource
$content = $response->toStream();

// cancels the request/response
$response->cancel();

// returns info coming from the transport layer, such as "response_headers",
// "redirect_count", "start_time", "redirect_url", etc.
$httpInfo = $response->getInfo();

// you can get individual info too
$startTime = $response->getInfo('start_time');
// e.g. this returns the final response URL (resolving redirections if needed)
$url = $response->getInfo('url');

// returns detailed logs about the requests and responses of the HTTP transaction
$httpLogs = $response->getInfo('debug');

// the special "pause_handler" info item is a callable that allows to delay the request
// for a given number of seconds; this allows you to delay retries, throttle streams, etc.
$response->getInfo('pause_handler')(2);

注意

$response->toStream()StreamableInterface 的一部分。

注意

$response->getInfo() 是非阻塞的:它返回关于响应的实时信息。其中一些信息在您调用它时可能还未知(例如 http_code)。

流式响应

调用 stream() 方法以顺序获取响应的,而不是等待整个响应。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
$url = 'https://releases.ubuntu.com/18.04.1/ubuntu-18.04.1-desktop-amd64.iso';
$response = $client->request('GET', $url);

// Responses are lazy: this code is executed as soon as headers are received
if (200 !== $response->getStatusCode()) {
    throw new \Exception('...');
}

// get the response content in chunks and save them in a file
// response chunks implement Symfony\Contracts\HttpClient\ChunkInterface
$fileHandler = fopen('/ubuntu.iso', 'w');
foreach ($client->stream($response) as $chunk) {
    fwrite($fileHandler, $chunk->getContent());
}

注意

默认情况下,text/*、JSON 和 XML 响应体缓存在本地 php://temp 流中。您可以使用 buffer 选项来控制此行为:将其设置为 true/false 以启用/禁用缓冲,或设置为一个闭包,该闭包应根据其接收的响应头作为参数返回相同的值。

取消响应

要中止请求(例如,因为它在规定时间内未完成,或者您只想获取响应的第一个字节等),您可以使用 cancel() 方法。

1
$response->cancel();

或者从进度回调中抛出异常。

1
2
3
4
5
6
7
$response = $client->request('GET', 'https://...', [
    'on_progress' => function (int $dlNow, int $dlSize, array $info): void {
        // ...

        throw new \MyException();
    },
]);

异常将被包装在 TransportExceptionInterface 的实例中,并将中止请求。

如果响应是使用 $response->cancel() 取消的,则 $response->getInfo('canceled') 将返回 true

处理异常

有三种类型的异常,所有这些异常都实现了 ExceptionInterface

当响应的 HTTP 状态码在 300-599 范围内(即 3xx、4xx 或 5xx)时,getHeaders()getContent()toArray() 方法会抛出一个适当的异常,所有这些异常都实现了 HttpExceptionInterface

要选择退出此异常并自行处理 300-599 状态码,请将 false 作为可选参数传递给这些方法的每次调用,例如 $response->getHeaders(false);

如果您根本不调用这 3 个方法中的任何一个,则在销毁 $response 对象时仍会抛出异常。

调用 $response->getStatusCode() 足以禁用此行为(但请不要忘记自己检查状态码)。

虽然响应是延迟加载的,但它们的析构函数将始终等待标头返回。这意味着以下请求完成;如果例如返回 404,则会抛出异常。

1
2
3
4
// because the returned value is not assigned to a variable, the destructor
// of the returned response will be called immediately and will throw if the
// status code is in the 300-599 range
$client->request('POST', 'https://...');

反过来,这意味着未分配的响应将回退到同步请求。如果您想使这些请求并发,您可以将它们对应的响应存储在一个数组中:

1
2
3
4
5
6
7
8
$responses[] = $client->request('POST', 'https://.../path1');
$responses[] = $client->request('POST', 'https://.../path2');
// ...

// This line will trigger the destructor of all responses stored in the array;
// they will complete concurrently and an exception will be thrown in case a
// status code in the 300-599 range is returned
unset($responses);

在析构时提供的此行为是组件的故障安全设计的一部分。不会有未被注意到的错误:如果您不编写代码来处理错误,异常将在需要时通知您。另一方面,如果您编写了错误处理代码(通过调用 $response->getStatusCode()),您将选择退出这些回退机制,因为析构函数将没有任何剩余的事情要做。

并发请求

由于响应是延迟加载的,因此请求始终是并发管理的。在足够快的网络上,当使用 cURL 时,以下代码在不到半秒的时间内发出 379 个请求:

1
2
3
4
5
6
7
8
9
10
$responses = [];
for ($i = 0; $i < 379; ++$i) {
    $uri = "https://http2.akamai.com/demo/tile-$i.png";
    $responses[] = $client->request('GET', $uri);
}

foreach ($responses as $response) {
    $content = $response->getContent();
    // ...
}

正如您在第一个 “for” 循环中可以读到的那样,请求已发出但尚未被使用。这就是期望并发时的技巧:应首先发送请求,然后在稍后读取。这将允许客户端监视所有挂起的请求,而您的代码等待特定请求,如上面 “foreach” 循环的每次迭代中所做的那样。

注意

您可以执行的最大并发请求数取决于您机器的资源(例如,您的操作系统可能会限制同时读取存储证书文件的文件的数量)。批量发出您的请求以避免这些问题。

多路复用响应

如果您再次查看上面的代码片段,响应是按照请求的顺序读取的。但是,也许第二个响应比第一个响应更早返回?完全异步操作需要能够以任何顺序处理返回的响应。

为了做到这一点,stream() 接受要监视的响应列表。正如 之前 提到的,此方法会在网络中到达时产生响应块。通过将代码片段中的 “foreach” 替换为以下代码,代码将变为完全异步:

1
2
3
4
5
6
7
8
9
10
11
12
foreach ($client->stream($responses) as $response => $chunk) {
    if ($chunk->isFirst()) {
        // headers of $response just arrived
        // $response->getHeaders() is now a non-blocking call
    } elseif ($chunk->isLast()) {
        // the full content of $response just completed
        // $response->getContent() is now a non-blocking call
    } else {
        // $chunk->getContent() will return a piece
        // of the response body that just arrived
    }
}

提示

使用 user_data 选项与 $response->getInfo('user_data') 结合使用,以跟踪 foreach 循环中响应的身份。

处理网络超时

此组件允许处理请求和响应超时。

当例如 DNS 解析花费的时间太长,当无法在给定的时间预算内打开 TCP 连接,或者当响应内容暂停时间过长时,可能会发生超时。这可以使用 timeout 请求选项进行配置。

1
2
3
// A TransportExceptionInterface will be issued if nothing
// happens for 2.5 seconds when accessing from the $response
$response = $client->request('GET', 'https://...', ['timeout' => 2.5]);

如果未设置该选项,则使用 default_socket_timeout PHP ini 设置。

可以使用 stream() 方法的第二个参数覆盖该选项。这允许一次监视多个响应,并将超时应用于一组中的所有响应。如果所有响应在给定的持续时间内都变为非活动状态,则该方法将产生一个特殊块,其 isTimeout() 将返回 true

1
2
3
4
5
foreach ($client->stream($responses, 1.5) as $response => $chunk) {
    if ($chunk->isTimeout()) {
        // $response stale for more than 1.5 seconds
    }
}

超时不一定是错误:您可以决定再次流式传输响应,并在新的超时中获取可能返回的剩余内容等。

提示

0 作为超时传递允许以非阻塞方式监视响应。

注意

超时控制在 HTTP 事务空闲时愿意等待多长时间。只要大型响应在传输过程中保持活动状态,并且暂停时间不超过指定的时间,它们就可以持续尽可能长的时间才能完成。

使用 max_duration 选项来限制完整请求/响应可以持续的时间。

处理网络错误

网络错误(管道破裂、DNS 解析失败等)作为 TransportExceptionInterface 的实例抛出。

首先,您不处理它们:在大多数用例中,让错误冒泡到您的通用异常处理堆栈可能真的很好。

如果您想处理它们,以下是您需要知道的:

要捕获错误,您需要包装对 $client->request() 的调用,以及对返回的响应的任何方法的调用。这是因为响应是延迟加载的,因此网络错误可能在调用例如 getStatusCode() 时发生。

1
2
3
4
5
6
7
8
9
10
11
use Symfony\Contracts\HttpClient\Exception\TransportExceptionInterface;

// ...
try {
    // both lines can potentially throw
    $response = $client->request(/* ... */);
    $headers = $response->getHeaders();
    // ...
} catch (TransportExceptionInterface $e) {
    // ...
}

注意

因为 $response->getInfo() 是非阻塞的,所以从设计上来说它不应该抛出异常。

当多路复用响应时,您可以通过在 foreach 循环中捕获 TransportExceptionInterface 来处理单个流的错误。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
foreach ($client->stream($responses) as $response => $chunk) {
    try {
        if ($chunk->isTimeout()) {
            // ... decide what to do when a timeout occurs
            // if you want to stop a response that timed out, don't miss
            // calling $response->cancel() or the destructor of the response
            // will try to complete it one more time
        } elseif ($chunk->isFirst()) {
            // if you want to check the status code, you must do it when the
            // first chunk arrived, using $response->getStatusCode();
            // not doing so might trigger an HttpExceptionInterface
        } elseif ($chunk->isLast()) {
            // ... do something with $response
        }
    } catch (TransportExceptionInterface $e) {
        // ...
    }
}

缓存请求和响应

此组件提供了一个 CachingHttpClient 装饰器,允许缓存响应并从本地存储为后续请求提供服务。该实现利用了底层的 HttpCache 类,因此需要在您的应用程序中安装 HttpKernel 组件

1
2
3
4
5
6
7
8
9
10
use Symfony\Component\HttpClient\CachingHttpClient;
use Symfony\Component\HttpClient\HttpClient;
use Symfony\Component\HttpKernel\HttpCache\Store;

$store = new Store('/path/to/cache/storage/');
$client = HttpClient::create();
$client = new CachingHttpClient($client, $store);

// this won't hit the network if the resource is already in the cache
$response = $client->request('GET', 'https://example.com/cacheable-resource');

CachingHttpClient 接受第三个参数来设置 HttpCache 的选项。

限制请求数量

此组件提供了一个 ThrottlingHttpClient 装饰器,允许限制在一定时期内的请求数量,并可能根据速率限制策略延迟调用。

该实现利用了底层的 LimiterInterface 类,因此需要在您的应用程序中安装 Rate Limiter 组件

1
2
3
4
5
6
7
8
9
10
11
12
13
14
# config/packages/framework.yaml
framework:
    http_client:
        scoped_clients:
            example.client:
                base_uri: 'https://example.com'
                rate_limiter: 'http_example_limiter'

    rate_limiter:
        # Don't send more than 10 requests in 5 seconds
        http_example_limiter:
            policy: 'token_bucket'
            limit: 10
            rate: { interval: '5 seconds', amount: 10 }

7.1

ThrottlingHttpClient 在 Symfony 7.1 中引入。

消费服务器发送事件

服务器发送事件 (Server-sent events) 是一种用于将数据推送到网页的互联网标准。其 JavaScript API 构建于 EventSource 对象之上,该对象侦听从某些 URL 发送的事件。这些事件是数据流(以 text/event-stream MIME 类型提供),格式如下:

1
2
3
4
5
6
data: This is the first message.

data: This is the second message, it
data: has two lines.

data: This is the third message.

Symfony 的 HTTP 客户端提供了一个 EventSource 实现来消费这些服务器发送事件。使用 EventSourceHttpClient 包装您的 HTTP 客户端,打开与响应 text/event-stream 内容类型的服务器的连接,并按如下方式消费流:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
use Symfony\Component\HttpClient\Chunk\ServerSentEvent;
use Symfony\Component\HttpClient\EventSourceHttpClient;

// the second optional argument is the reconnection time in seconds (default = 10)
$client = new EventSourceHttpClient($client, 10);
$source = $client->connect('https://127.0.0.1:8080/events');
while ($source) {
    foreach ($client->stream($source, 2) as $r => $chunk) {
        if ($chunk->isTimeout()) {
            // ...
            continue;
        }

        if ($chunk->isLast()) {
            // ...

            return;
        }

        // this is a special ServerSentEvent chunk holding the pushed message
        if ($chunk instanceof ServerSentEvent) {
            // do something with the server event ...
        }
    }
}

提示

如果您知道 ServerSentEvent 的内容是 JSON 格式,则可以使用 getArrayData() 方法直接获取解码后的 JSON 作为数组。

互操作性

该组件可与四种不同的 HTTP 客户端抽象互操作:Symfony ContractsPSR-18HTTPlug v1/v2 和原生 PHP 流。如果您的应用程序使用的库需要其中任何一个,则该组件与它们全部兼容。当使用 framework bundle 时,它们还可以从 自动装配别名 中受益。

如果您正在编写或维护一个发出 HTTP 请求的库,您可以通过针对 Symfony Contracts(推荐)、PSR-18 或 HTTPlug v2 进行编码,将其与任何特定的 HTTP 客户端实现解耦。

Symfony 契约

symfony/http-client-contracts 包中找到的接口定义了组件实现的主要抽象。它的入口点是 HttpClientInterface。当需要客户端时,这就是您需要针对其进行编码的接口。

1
2
3
4
5
6
7
8
9
10
11
use Symfony\Contracts\HttpClient\HttpClientInterface;

class MyApiLayer
{
    public function __construct(
        private HttpClientInterface $client,
    ) {
    }

    // [...]
}

上面提到的所有请求选项(例如超时管理)也在接口的措辞中定义,以便保证任何兼容的实现(如本组件)都提供它们。这是与其他抽象的主要区别,其他抽象不提供任何与传输本身相关的内容。

Symfony Contracts 涵盖的另一个主要功能是异步/多路复用,如前几节所述。

PSR-18 和 PSR-17

此组件通过 Psr18Client 类实现了 PSR-18(HTTP 客户端)规范,该类是一个适配器,用于将 Symfony HttpClientInterface 转换为 PSR-18 ClientInterface。此类还实现了 PSR-17 的相关方法,以简化请求对象的创建。

要使用它,您需要 psr/http-client 包和一个 PSR-17 实现。

1
2
3
4
5
6
7
8
9
10
# installs the PSR-18 ClientInterface
$ composer require psr/http-client

# installs an efficient implementation of response and stream factories
# with autowiring aliases provided by Symfony Flex
$ composer require nyholm/psr7

# alternatively, install the php-http/discovery package to auto-discover
# any already installed implementations from common vendors:
# composer require php-http/discovery

现在您可以按如下方式使用 PSR-18 客户端发出 HTTP 请求:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
use Psr\Http\Client\ClientInterface;

class Symfony
{
    public function __construct(
        private ClientInterface $client,
    ) {
    }

    public function getAvailableVersions(): array
    {
        $request = $this->client->createRequest('GET', 'https://symfony.ac.cn/versions.json');
        $response = $this->client->sendRequest($request);

        return json_decode($response->getBody()->getContents(), true);
    }
}

您还可以通过 Psr18Client::withOptions() 方法将一组默认选项传递给您的客户端。

1
2
3
4
5
6
7
8
9
10
11
12
13
use Symfony\Component\HttpClient\Psr18Client;

$client = (new Psr18Client())
    ->withOptions([
        'base_uri' => 'https://symfony.ac.cn',
        'headers' => [
            'Accept' => 'application/json',
        ],
    ]);

$request = $client->createRequest('GET', '/versions.json');

// ...

HTTPlug

HTTPlug v1 规范在 PSR-18 之前发布,并已被其取代。因此,您不应在新编写的代码中使用它。由于 HttplugClient 类,该组件仍然可以与需要它的库互操作。与 Psr18Client 实现 PSR-17 的相关部分类似,HttplugClient 也实现了相关的 php-http/message-factory 包中定义的工厂方法。

1
2
3
4
5
6
7
8
9
# Let's suppose php-http/httplug is already required by the lib you want to use

# installs an efficient implementation of response and stream factories
# with autowiring aliases provided by Symfony Flex
$ composer require nyholm/psr7

# alternatively, install the php-http/discovery package to auto-discover
# any already installed implementations from common vendors:
# composer require php-http/discovery

假设您要实例化一个具有以下构造函数的类,该构造函数需要 HTTPlug 依赖项:

1
2
3
4
5
6
7
8
9
10
11
use Http\Client\HttpClient;
use Http\Message\StreamFactory;

class SomeSdk
{
    public function __construct(
        HttpClient $httpClient,
        StreamFactory $streamFactory
    )
    // [...]
}

由于 HttplugClient 实现了这些接口,您可以按以下方式使用它:

1
2
3
4
use Symfony\Component\HttpClient\HttplugClient;

$httpClient = new HttplugClient();
$apiClient = new SomeSdk($httpClient, $httpClient);

如果您想使用 promises,HttplugClient 也实现了 HttpAsyncClient 接口。要使用它,您需要安装 guzzlehttp/promises 包。

1
$ composer require guzzlehttp/promises

然后您就可以开始了:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
use Psr\Http\Message\ResponseInterface;
use Symfony\Component\HttpClient\HttplugClient;

$httpClient = new HttplugClient();
$request = $httpClient->createRequest('GET', 'https://my.api.com/');
$promise = $httpClient->sendAsyncRequest($request)
    ->then(
        function (ResponseInterface $response): ResponseInterface {
            echo 'Got status '.$response->getStatusCode();

            return $response;
        },
        function (\Throwable $exception): never {
            echo 'Error: '.$exception->getMessage();

            throw $exception;
        }
    );

// after you're done with sending several requests,
// you must wait for them to complete concurrently

// wait for a specific promise to resolve while monitoring them all
$response = $promise->wait();

// wait maximum 1 second for pending promises to resolve
$httpClient->wait(1.0);

// wait for all remaining promises to resolve
$httpClient->wait();

您还可以通过 HttplugClient::withOptions() 方法将一组默认选项传递给您的客户端。

1
2
3
4
5
6
7
8
9
10
use Psr\Http\Message\ResponseInterface;
use Symfony\Component\HttpClient\HttplugClient;

$httpClient = (new HttplugClient())
    ->withOptions([
        'base_uri' => 'https://my.api.com',
    ]);
$request = $httpClient->createRequest('GET', '/');

// ...

原生 PHP 流

实现 ResponseInterface 的响应可以使用 createResource() 强制转换为原生 PHP 流。这允许在需要原生 PHP 流的地方使用它们。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
use Symfony\Component\HttpClient\HttpClient;
use Symfony\Component\HttpClient\Response\StreamWrapper;

$client = HttpClient::create();
$response = $client->request('GET', 'https://symfony.ac.cn/versions.json');

$streamResource = StreamWrapper::createResource($response, $client);

// alternatively and contrary to the previous one, this returns
// a resource that is seekable and potentially stream_select()-able
$streamResource = $response->toStream();

echo stream_get_contents($streamResource); // outputs the content of the response

// later on if you need to, you can access the response from the stream
$response = stream_get_meta_data($streamResource)['wrapper_data']->getResponse();

可扩展性

如果您想扩展基本 HTTP 客户端的行为,可以使用 服务装饰器

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
class MyExtendedHttpClient implements HttpClientInterface
{
    public function __construct(
        private ?HttpClientInterface $decoratedClient = null
    ) {
        $this->decoratedClient ??= HttpClient::create();
    }

    public function request(string $method, string $url, array $options = []): ResponseInterface
    {
        // process and/or change the $method, $url and/or $options as needed
        $response = $this->decoratedClient->request($method, $url, $options);

        // if you call here any method on $response, the HTTP request
        // won't be async; see below for a better way

        return $response;
    }

    public function stream($responses, ?float $timeout = null): ResponseStreamInterface
    {
        return $this->decoratedClient->stream($responses, $timeout);
    }
}

像这样的装饰器在处理请求的参数就足够的情况下很有用。通过装饰 on_progress 选项,您甚至可以实现对响应的基本监控。但是,由于调用响应的方法会强制执行同步操作,因此在 request() 内部这样做会破坏异步。

解决方案是也装饰响应对象本身。TraceableHttpClientTraceableResponse 是很好的起点示例。

为了帮助编写更高级的响应处理器,该组件提供了一个 AsyncDecoratorTrait。此 trait 允许处理从网络返回的块流。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
class MyExtendedHttpClient implements HttpClientInterface
{
    use AsyncDecoratorTrait;

    public function request(string $method, string $url, array $options = []): ResponseInterface
    {
        // process and/or change the $method, $url and/or $options as needed

        $passthru = function (ChunkInterface $chunk, AsyncContext $context): \Generator {
            // do what you want with chunks, e.g. split them
            // in smaller chunks, group them, skip some, etc.

            yield $chunk;
        };

        return new AsyncResponse($this->client, $method, $url, $options, $passthru);
    }
}

由于该 trait 已经实现了构造函数和 stream() 方法,因此您无需添加它们。request() 方法仍应定义;它应返回一个 AsyncResponse

块的自定义处理应在 $passthru 中进行:此生成器是您需要编写逻辑的地方。将为底层客户端产生的每个块调用它。一个什么都不做的 $passthru 只需 yield $chunk;。您也可以通过多次 yield 来产生修改后的块,将块拆分为多个块,甚至通过发出 return; 而不是 yield 来完全跳过一个块。

为了控制流,块 passthru 接收一个 AsyncContext 作为第二个参数。此上下文对象具有读取响应当前状态的方法。它还允许使用创建新内容块、暂停流、取消流、更改响应信息、将当前请求替换为另一个请求或更改块 passthru 本身的方法来更改响应流。

查看在 AsyncDecoratorTraitTest 中实现的测试用例可能是一个很好的开始,以获得各种工作示例,以便更好地理解。以下是它模拟的用例:

  • 重试失败的请求;
  • 发送预检请求,例如用于身份验证需求;
  • 发出子请求并将它们的内容包含在主响应的正文中。

AsyncResponse 中的逻辑有许多安全检查,如果块 passthru 行为不正确,则会抛出 LogicException;例如,如果在 isLast() 块之后 yield 了一个块,或者在 isFirst() 块之前 yield 了一个内容块等。

测试

此组件包含 MockHttpClientMockResponse 类,用于不应发出实际 HTTP 请求的测试中。此类测试可能很有用,因为它们运行速度更快并产生一致的结果,因为它们不依赖于外部服务。通过不发出实际的 HTTP 请求,无需担心服务是否在线或请求更改状态,例如删除资源。

MockHttpClient 实现了 HttpClientInterface,就像此组件中的任何实际 HTTP 客户端一样。当您使用 HttpClientInterface 进行类型提示时,您的代码将在测试之外接受真实的客户端,同时在测试中将其替换为 MockHttpClient

当在 MockHttpClient 上使用 request 方法时,它将使用提供的 MockResponse 进行响应。以下描述了几种使用它的方法。

HTTP 客户端和响应

使用 MockHttpClient 的第一种方法是将响应列表传递给其构造函数。当发出请求时,这些响应将按顺序产生。

1
2
3
4
5
6
7
8
9
10
11
12
use Symfony\Component\HttpClient\MockHttpClient;
use Symfony\Component\HttpClient\Response\MockResponse;

$responses = [
    new MockResponse($body1, $info1),
    new MockResponse($body2, $info2),
];

$client = new MockHttpClient($responses);
// responses are returned in the same order as passed to MockHttpClient
$response1 = $client->request('...'); // returns $responses[0]
$response2 = $client->request('...'); // returns $responses[1]

也可以直接从文件创建 MockResponse,这在将响应快照存储在文件中时特别有用。

1
2
3
use Symfony\Component\HttpClient\Response\MockResponse;

$response = MockResponse::fromFile('tests/fixtures/response.xml');

7.1

fromFile() 方法在 Symfony 7.1 中引入。

使用 MockHttpClient 的另一种方法是传递一个回调,该回调在被调用时动态生成响应。

1
2
3
4
5
6
7
8
9
use Symfony\Component\HttpClient\MockHttpClient;
use Symfony\Component\HttpClient\Response\MockResponse;

$callback = function ($method, $url, $options): MockResponse {
    return new MockResponse('...');
};

$client = new MockHttpClient($callback);
$response = $client->request('...'); // calls $callback to get the response

如果您需要在返回模拟响应之前对请求执行特定的断言,您也可以传递回调列表。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
$expectedRequests = [
    function ($method, $url, $options): MockResponse {
        $this->assertSame('GET', $method);
        $this->assertSame('https://example.com/api/v1/customer', $url);

        return new MockResponse('...');
    },
    function ($method, $url, $options): MockResponse {
        $this->assertSame('POST', $method);
        $this->assertSame('https://example.com/api/v1/customer/1/products', $url);

        return new MockResponse('...');
    },
];

$client = new MockHttpClient($expectedRequests);

// ...

提示

除了使用第一个参数外,您还可以使用 setResponseFactory() 方法设置(响应列表或)回调。

1
2
3
4
5
6
7
$responses = [
    new MockResponse($body1, $info1),
    new MockResponse($body2, $info2),
];

$client = new MockHttpClient();
$client->setResponseFactory($responses);

如果您需要测试 HTTP 状态码与 200 不同的响应,请定义 http_code 选项。

1
2
3
4
5
6
7
8
9
use Symfony\Component\HttpClient\MockHttpClient;
use Symfony\Component\HttpClient\Response\MockResponse;

$client = new MockHttpClient([
    new MockResponse('...', ['http_code' => 500]),
    new MockResponse('...', ['http_code' => 404]),
]);

$response = $client->request('...');

提供给模拟客户端的响应不必是 MockResponse 的实例。任何实现 ResponseInterface 的类都可以工作(例如 $this->createMock(ResponseInterface::class))。

但是,使用 MockResponse 允许模拟分块响应和超时。

1
2
3
4
5
6
7
8
$body = function (): \Generator {
    yield 'hello';
    // empty strings are turned into timeouts so that they are easy to test
    yield '';
    yield 'world';
};

$mockResponse = new MockResponse($body());

最后,您还可以创建一个可调用或可迭代的类来生成响应,并在功能测试中将其用作回调。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
namespace App\Tests;

use Symfony\Component\HttpClient\Response\MockResponse;
use Symfony\Contracts\HttpClient\ResponseInterface;

class MockClientCallback
{
    public function __invoke(string $method, string $url, array $options = []): ResponseInterface
    {
        // load a fixture file or generate data
        // ...
        return new MockResponse($data);
    }
}

然后配置 Symfony 以使用您的回调:

1
2
3
4
5
6
7
8
9
# config/services_test.yaml
services:
    # ...
    App\Tests\MockClientCallback: ~

# config/packages/test/framework.yaml
framework:
    http_client:
        mock_response_factory: App\Tests\MockClientCallback

要返回 json,您通常会这样做:

1
2
3
4
5
6
7
8
9
use Symfony\Component\HttpClient\Response\MockResponse;

$response = new MockResponse(json_encode([
        'foo' => 'bar',
    ]), [
    'response_headers' => [
        'content-type' => 'application/json',
    ],
]);

您可以改用 JsonMockResponse

1
2
3
4
5
use Symfony\Component\HttpClient\Response\JsonMockResponse;

$response = new JsonMockResponse([
    'foo' => 'bar',
]);

MockResponse 一样,您也可以直接从文件创建 JsonMockResponse

1
2
3
use Symfony\Component\HttpClient\Response\JsonMockResponse;

$response = JsonMockResponse::fromFile('tests/fixtures/response.json');

7.1

fromFile() 方法在 Symfony 7.1 中引入。

测试请求数据

MockResponse 类附带一些辅助方法来测试请求:

  • getRequestMethod() - 返回 HTTP 方法;
  • getRequestUrl() - 返回请求将要发送到的 URL;
  • getRequestOptions() - 返回一个包含关于请求的其他信息的数组,例如 header,查询参数,body 内容等等。

使用示例

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
$mockResponse = new MockResponse('', ['http_code' => 204]);
$httpClient = new MockHttpClient($mockResponse, 'https://example.com');

$response = $httpClient->request('DELETE', 'api/article/1337', [
    'headers' => [
        'Accept: */*',
        'Authorization: Basic YWxhZGRpbjpvcGVuc2VzYW1l',
    ],
]);

$mockResponse->getRequestMethod();
// returns "DELETE"

$mockResponse->getRequestUrl();
// returns "https://example.com/api/article/1337"

$mockResponse->getRequestOptions()['headers'];
// returns ["Accept: */*", "Authorization: Basic YWxhZGRpbjpvcGVuc2VzYW1l"]

完整示例

以下独立示例演示了如何在实际应用中使用 HTTP 客户端并对其进行测试

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
// ExternalArticleService.php
use Symfony\Contracts\HttpClient\HttpClientInterface;

final class ExternalArticleService
{
    public function __construct(
        private HttpClientInterface $httpClient,
    ) {
    }

    public function createArticle(array $requestData): array
    {
        $requestJson = json_encode($requestData, JSON_THROW_ON_ERROR);

        $response = $this->httpClient->request('POST', 'api/article', [
            'headers' => [
                'Content-Type: application/json',
                'Accept: application/json',
            ],
            'body' => $requestJson,
        ]);

        if (201 !== $response->getStatusCode()) {
            throw new Exception('Response status code is different than expected.');
        }

        // ... other checks

        $responseJson = $response->getContent();
        $responseData = json_decode($responseJson, true, 512, JSON_THROW_ON_ERROR);

        return $responseData;
    }
}

// ExternalArticleServiceTest.php
use PHPUnit\Framework\TestCase;
use Symfony\Component\HttpClient\MockHttpClient;
use Symfony\Component\HttpClient\Response\MockResponse;

final class ExternalArticleServiceTest extends TestCase
{
    public function testSubmitData(): void
    {
        // Arrange
        $requestData = ['title' => 'Testing with Symfony HTTP Client'];
        $expectedRequestData = json_encode($requestData, JSON_THROW_ON_ERROR);

        $expectedResponseData = ['id' => 12345];
        $mockResponseJson = json_encode($expectedResponseData, JSON_THROW_ON_ERROR);
        $mockResponse = new MockResponse($mockResponseJson, [
            'http_code' => 201,
            'response_headers' => ['Content-Type: application/json'],
        ]);

        $httpClient = new MockHttpClient($mockResponse, 'https://example.com');
        $service = new ExternalArticleService($httpClient);

        // Act
        $responseData = $service->createArticle($requestData);

        // Assert
        $this->assertSame('POST', $mockResponse->getRequestMethod());
        $this->assertSame('https://example.com/api/article', $mockResponse->getRequestUrl());
        $this->assertContains(
            'Content-Type: application/json',
            $mockResponse->getRequestOptions()['headers']
        );
        $this->assertSame($expectedRequestData, $mockResponse->getRequestOptions()['body']);

        $this->assertSame($expectedResponseData, $responseData);
    }
}

使用 HAR 文件进行测试

现代浏览器(通过其网络选项卡)和 HTTP 客户端允许使用 HAR (HTTP 归档) 格式导出单个或多个 HTTP 请求的信息。你可以使用这些 .har 文件来使用 Symfony 的 HTTP 客户端执行测试。

首先,使用浏览器或 HTTP 客户端执行你要测试的 HTTP 请求。然后,将该信息保存为应用程序中某个位置的 .har 文件

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
// ExternalArticleServiceTest.php
use Symfony\Bundle\FrameworkBundle\Test\KernelTestCase;
use Symfony\Component\HttpClient\MockHttpClient;
use Symfony\Component\HttpClient\Response\MockResponse;

final class ExternalArticleServiceTest extends KernelTestCase
{
    public function testSubmitData(): void
    {
        // Arrange
        $fixtureDir = sprintf('%s/tests/fixtures/HTTP', static::getContainer()->getParameter('kernel.project_dir'));
        $factory = new HarFileResponseFactory("$fixtureDir/example.com_archive.har");
        $httpClient = new MockHttpClient($factory, 'https://example.com');
        $service = new ExternalArticleService($httpClient);

        // Act
        $responseData = $service->createArticle($requestData);

        // Assert
        $this->assertSame('the expected response', $responseData);
    }
}

如果你的服务执行多个请求,或者你的 .har 文件包含多个请求/响应对,HarFileResponseFactory 将根据请求方法、URL 和 body(如果有)查找关联的响应。请注意,如果请求 body 或 URI 是随机的/总是变化的(例如,如果它包含当前日期或随机 UUID),这将不起作用

测试网络传输异常

正如 网络错误章节 中解释的那样,当发出 HTTP 请求时,你可能会遇到传输级别的错误。

这就是为什么测试你的应用程序在发生传输错误时的行为方式很有用。MockResponse 允许你以多种方式做到这一点。

为了测试在接收 header 之前发生的错误,在创建 MockResponse 时设置 error 选项值。例如,当无法解析主机名或主机无法访问时,会发生这种类型的传输错误。一旦调用诸如 getStatusCode()getHeaders() 之类的方法,TransportException 将会被抛出。

为了测试在响应正在流式传输时(即,在 header 已经被接收之后)发生的错误,将异常作为 body 参数的一部分提供给 MockResponse。你可以直接使用异常,或者从回调中产生异常。对于这种类型的异常,getStatusCode() 可能指示成功 (200),但访问 getContent() 会失败。

以下示例代码说明了所有三种选项。

body

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
// ExternalArticleServiceTest.php
use PHPUnit\Framework\TestCase;
use Symfony\Component\HttpClient\MockHttpClient;
use Symfony\Component\HttpClient\Response\MockResponse;

final class ExternalArticleServiceTest extends TestCase
{
    // ...

    public function testTransportLevelError(): void
    {
        $requestData = ['title' => 'Testing with Symfony HTTP Client'];
        $httpClient = new MockHttpClient([
            // Mock a transport level error at a time before
            // headers have been received (e. g. host unreachable)
            new MockResponse(info: ['error' => 'host unreachable']),

            // Mock a response with headers indicating
            // success, but a failure while retrieving the body by
            // creating the exception directly in the body...
            new MockResponse([new \RuntimeException('Error at transport level')]),

            // ... or by yielding it from a callback.
            new MockResponse((static function (): \Generator {
                yield new TransportException('Error at transport level');
            })()),
        ]);

        $service = new ExternalArticleService($httpClient);

        try {
            $service->createArticle($requestData);

            // An exception should have been thrown in `createArticle()`, so this line should never be reached
            $this->fail();
        } catch (TransportException $e) {
            $this->assertEquals(new \RuntimeException('Error at transport level'), $e->getPrevious());
            $this->assertSame('Error at transport level', $e->getMessage());
        }
    }
}
本作品,包括代码示例,根据 Creative Commons BY-SA 3.0 许可协议获得许可。
TOC
    版本