跳到內容

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 合併到作用域用戶端基本 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:the-password@example.com

注意

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';

Cookie

此組件提供的 HTTP 用戶端是無狀態的,但處理 Cookie 需要有狀態的儲存空間(因為回應可以更新 Cookie,並且必須將其用於後續請求)。這就是此組件不自動處理 Cookie 的原因。

您可以 使用 BrowserKit 組件傳送 Cookie,它與 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 套件。

create() 方法在 cURL PHP 擴充功能 啟用時會選擇 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 擴充功能

如果伺服器確實以 gzipped 回應回應,則會透明地解碼。若要停用 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 類別,因此需要在您的應用程式中安裝 速率限制器元件

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 中引入。

取用伺服器發送事件

伺服器傳送事件是一種網際網路標準,用於將資料推送到網頁。它的 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 Client) 規範,該類別是一個轉接器,用於將 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.dev.org.tw/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.dev.org.tw',
        'headers' => [
            'Accept' => 'application/json',
        ],
    ]);

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

// ...

HTTPlug

HTTPlug v1 規範在 PSR-18 之前發布,並且已被 PSR-18 取代。因此,您不應在新編寫的程式碼中使用它。由於 HttplugClient 類別,此元件仍然可以與需要它的函式庫互通。與實作 PSR-17 相關部分的 Psr18Client 類似,HttplugClient 也實作了相關 php-http/message-factory 套件中定義的 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.dev.org.tw/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;。您也可以產生修改後的區塊,透過多次產生將區塊分割成多個區塊,甚至透過發出 return; 而不是產生來完全跳過區塊。

為了控制串流,區塊 passthru 接收 AsyncContext 作為第二個引數。此內容物件具有讀取回應當前狀態的方法。它也允許使用建立新內容區塊、暫停串流、取消串流、變更回應資訊、將當前請求替換為另一個請求或變更區塊 passthru 本身的方法來變更回應串流。

檢查 AsyncDecoratorTraitTest 中實作的測試案例可能是開始獲得各種工作範例以更好地理解的好方法。以下是它模擬的用例

  • 重試失敗的請求;
  • 傳送預檢請求,例如,用於身份驗證需求;
  • 發出子請求並將其內容包含在主要回應的正文中。

AsyncResponse 中的邏輯具有許多安全檢查,如果區塊 passthru 行為不正確,則會擲回 LogicException;例如,如果在 isLast() 區塊之後產生區塊,或者如果在 isFirst() 區塊之前產生內容區塊等。

測試

此元件包含 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() - 傳回包含請求其他資訊的陣列,例如標頭、查詢參數、內文內容等。

使用範例

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 Archive) 格式匯出一個或多個 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 和內文(如果有的話)找到關聯的回應。請注意,如果請求內文或 URI 是隨機的/總是變更的(例如,如果它包含目前日期或隨機 UUID),這將無法運作

測試網路傳輸例外

網路錯誤章節中所述,當發出 HTTP 請求時,您可能會遇到傳輸層級的錯誤。

這就是為什麼測試您的應用程式在發生傳輸錯誤時的行為很有用。 MockResponse 允許您以多種方式執行此操作。

為了測試在收到標頭之前發生的錯誤,請在建立 MockResponse 時設定 error 選項值。當主機名稱無法解析或主機無法連線時,會發生此類傳輸錯誤。一旦呼叫 getStatusCode()getHeaders() 等方法,就會立即拋出 TransportException

為了測試在串流回應時(即在已收到標頭之後)發生的錯誤,請將例外狀況作為 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
    版本