Document the state object that is passed around by the HttpClient.

This commit is contained in:
Alexander M. Turek 2019-04-07 11:02:11 +02:00
parent 2243bf5bc1
commit 20f4eb3204
11 changed files with 240 additions and 64 deletions

View File

@ -15,6 +15,8 @@ use Psr\Log\LoggerAwareInterface;
use Psr\Log\LoggerAwareTrait;
use Psr\Log\LoggerInterface;
use Symfony\Component\HttpClient\Exception\TransportException;
use Symfony\Component\HttpClient\Internal\CurlClientState;
use Symfony\Component\HttpClient\Internal\PushedResponse;
use Symfony\Component\HttpClient\Response\CurlResponse;
use Symfony\Component\HttpClient\Response\ResponseStream;
use Symfony\Contracts\HttpClient\HttpClientInterface;
@ -37,6 +39,12 @@ final class CurlHttpClient implements HttpClientInterface, LoggerAwareInterface
use LoggerAwareTrait;
private $defaultOptions = self::OPTIONS_DEFAULTS;
/**
* An internal object to share state between the client and its responses.
*
* @var CurlClientState
*/
private $multi;
/**
@ -56,22 +64,13 @@ final class CurlHttpClient implements HttpClientInterface, LoggerAwareInterface
[, $this->defaultOptions] = self::prepareRequest(null, null, $defaultOptions, self::OPTIONS_DEFAULTS);
}
$mh = curl_multi_init();
$this->multi = $multi = new CurlClientState();
// Don't enable HTTP/1.1 pipelining: it forces responses to be sent in order
if (\defined('CURLPIPE_MULTIPLEX')) {
curl_multi_setopt($mh, CURLMOPT_PIPELINING, CURLPIPE_MULTIPLEX);
curl_multi_setopt($this->multi->handle, CURLMOPT_PIPELINING, CURLPIPE_MULTIPLEX);
}
curl_multi_setopt($mh, CURLMOPT_MAX_HOST_CONNECTIONS, 0 < $maxHostConnections ? $maxHostConnections : PHP_INT_MAX);
// Use an internal stdClass object to share state between the client and its responses
$this->multi = $multi = (object) [
'openHandles' => [],
'handlesActivity' => [],
'handle' => $mh,
'pushedResponses' => [],
'dnsCache' => [[], [], []],
];
curl_multi_setopt($this->multi->handle, CURLMOPT_MAX_HOST_CONNECTIONS, 0 < $maxHostConnections ? $maxHostConnections : PHP_INT_MAX);
// Skip configuring HTTP/2 push when it's unsupported or buggy, see https://bugs.php.net/bug.php?id=77535
if (0 >= $maxPendingPushes || \PHP_VERSION_ID < 70217 || (\PHP_VERSION_ID >= 70300 && \PHP_VERSION_ID < 70304)) {
@ -85,7 +84,7 @@ final class CurlHttpClient implements HttpClientInterface, LoggerAwareInterface
$logger = &$this->logger;
curl_multi_setopt($mh, CURLMOPT_PUSHFUNCTION, static function ($parent, $pushed, array $requestHeaders) use ($multi, $maxPendingPushes, &$logger) {
curl_multi_setopt($this->multi->handle, CURLMOPT_PUSHFUNCTION, static function ($parent, $pushed, array $requestHeaders) use ($multi, $maxPendingPushes, &$logger) {
return self::handlePush($parent, $pushed, $requestHeaders, $multi, $maxPendingPushes, $logger);
});
}
@ -103,7 +102,7 @@ final class CurlHttpClient implements HttpClientInterface, LoggerAwareInterface
$host = parse_url($authority, PHP_URL_HOST);
$url = implode('', $url);
if ([$pushedResponse, $pushedHeaders] = $this->multi->pushedResponses[$url] ?? null) {
if ($pushedResponse = $this->multi->pushedResponses[$url] ?? null) {
unset($this->multi->pushedResponses[$url]);
// Accept pushed responses only if their headers related to authentication match the request
$expectedHeaders = [
@ -113,13 +112,13 @@ final class CurlHttpClient implements HttpClientInterface, LoggerAwareInterface
$options['headers']['range'] ?? null,
];
if ('GET' === $method && $expectedHeaders === $pushedHeaders && !$options['body']) {
if ('GET' === $method && $expectedHeaders === $pushedResponse->headers && !$options['body']) {
$this->logger && $this->logger->debug(sprintf('Connecting request to pushed response: "%s %s"', $method, $url));
// Reinitialize the pushed response with request's options
$pushedResponse->__construct($this->multi, $url, $options, $this->logger);
$pushedResponse->response->__construct($this->multi, $url, $options, $this->logger);
return $pushedResponse;
return $pushedResponse->response;
}
$this->logger && $this->logger->debug(sprintf('Rejecting pushed response for "%s": authorization headers don\'t match the request', $url));
@ -159,14 +158,14 @@ final class CurlHttpClient implements HttpClientInterface, LoggerAwareInterface
}
// curl's resolve feature varies by host:port but ours varies by host only, let's handle this with our own DNS map
if (isset($this->multi->dnsCache[0][$host])) {
$options['resolve'] += [$host => $this->multi->dnsCache[0][$host]];
if (isset($this->multi->dnsCache->hostnames[$host])) {
$options['resolve'] += [$host => $this->multi->dnsCache->hostnames[$host]];
}
if ($options['resolve'] || $this->multi->dnsCache[2]) {
if ($options['resolve'] || $this->multi->dnsCache->evictions) {
// First reset any old DNS cache entries then add the new ones
$resolve = $this->multi->dnsCache[2];
$this->multi->dnsCache[2] = [];
$resolve = $this->multi->dnsCache->evictions;
$this->multi->dnsCache->evictions = [];
$port = parse_url($authority, PHP_URL_PORT) ?: ('http:' === $scheme ? 80 : 443);
if ($resolve && 0x072a00 > curl_version()['version_number']) {
@ -178,8 +177,8 @@ final class CurlHttpClient implements HttpClientInterface, LoggerAwareInterface
foreach ($options['resolve'] as $host => $ip) {
$resolve[] = null === $ip ? "-$host:$port" : "$host:$port:$ip";
$this->multi->dnsCache[0][$host] = $ip;
$this->multi->dnsCache[1]["-$host:$port"] = "-$host:$port";
$this->multi->dnsCache->hostnames[$host] = $ip;
$this->multi->dnsCache->removals["-$host:$port"] = "-$host:$port";
}
$curlopts[CURLOPT_RESOLVE] = $resolve;
@ -299,7 +298,7 @@ final class CurlHttpClient implements HttpClientInterface, LoggerAwareInterface
}
}
private static function handlePush($parent, $pushed, array $requestHeaders, \stdClass $multi, int $maxPendingPushes, ?LoggerInterface $logger): int
private static function handlePush($parent, $pushed, array $requestHeaders, CurlClientState $multi, int $maxPendingPushes, ?LoggerInterface $logger): int
{
$headers = [];
$origin = curl_getinfo($parent, CURLINFO_EFFECTIVE_URL);
@ -336,15 +335,15 @@ final class CurlHttpClient implements HttpClientInterface, LoggerAwareInterface
$url .= $headers[':path'];
$logger && $logger->debug(sprintf('Queueing pushed response: "%s"', $url));
$multi->pushedResponses[$url] = [
$multi->pushedResponses[$url] = new PushedResponse(
new CurlResponse($multi, $pushed),
[
$headers['authorization'] ?? null,
$headers['cookie'] ?? null,
$headers['x-requested-with'] ?? null,
null,
],
];
]
);
return CURL_PUSH_OK;
}

View File

@ -0,0 +1,25 @@
<?php
/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Symfony\Component\HttpClient\Internal;
/**
* Internal representation of the client state.
*
* @author Alexander M. Turek <me@derrabus.de>
*
* @internal
*/
class ClientState
{
public $handlesActivity = [];
public $openHandles = [];
}

View File

@ -0,0 +1,35 @@
<?php
/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Symfony\Component\HttpClient\Internal;
/**
* Internal representation of the cURL client's state.
*
* @author Alexander M. Turek <me@derrabus.de>
*
* @internal
*/
final class CurlClientState extends ClientState
{
/** @var resource */
public $handle;
/** @var PushedResponse[] */
public $pushedResponses = [];
/** @var DnsCache */
public $dnsCache;
public function __construct()
{
$this->handle = curl_multi_init();
$this->dnsCache = new DnsCache();
}
}

View File

@ -0,0 +1,39 @@
<?php
/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Symfony\Component\HttpClient\Internal;
/**
* Cache for resolved DNS queries.
*
* @author Alexander M. Turek <me@derrabus.de>
*
* @internal
*/
final class DnsCache
{
/**
* Resolved hostnames (hostname => IP address).
*
* @var string[]
*/
public $hostnames = [];
/**
* @var string[]
*/
public $removals = [];
/**
* @var string[]
*/
public $evictions = [];
}

View File

@ -0,0 +1,44 @@
<?php
/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Symfony\Component\HttpClient\Internal;
use Symfony\Component\HttpClient\Response\NativeResponse;
/**
* Internal representation of the native client's state.
*
* @author Alexander M. Turek <me@derrabus.de>
*
* @internal
*/
final class NativeClientState extends ClientState
{
/** @var int */
public $id;
/** @var NativeResponse[] */
public $pendingResponses = [];
/** @var int */
public $maxHostConnections = PHP_INT_MAX;
/** @var int */
public $responseCount = 0;
/** @var string[] */
public $dnsCache = [];
/** @var resource[] */
public $handles = [];
/** @var bool */
public $sleep = false;
public function __construct()
{
$this->id = random_int(PHP_INT_MIN, PHP_INT_MAX);
}
}

View File

@ -0,0 +1,36 @@
<?php
/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Symfony\Component\HttpClient\Internal;
use Symfony\Component\HttpClient\Response\CurlResponse;
/**
* A pushed response with headers.
*
* @author Alexander M. Turek <me@derrabus.de>
*
* @internal
*/
final class PushedResponse
{
/** @var CurlResponse */
public $response;
/** @var string[] */
public $headers;
public function __construct(CurlResponse $response, array $headers)
{
$this->response = $response;
$this->headers = $headers;
}
}

View File

@ -14,6 +14,7 @@ namespace Symfony\Component\HttpClient;
use Psr\Log\LoggerAwareInterface;
use Psr\Log\LoggerAwareTrait;
use Symfony\Component\HttpClient\Exception\TransportException;
use Symfony\Component\HttpClient\Internal\NativeClientState;
use Symfony\Component\HttpClient\Response\NativeResponse;
use Symfony\Component\HttpClient\Response\ResponseStream;
use Symfony\Contracts\HttpClient\HttpClientInterface;
@ -36,6 +37,8 @@ final class NativeHttpClient implements HttpClientInterface, LoggerAwareInterfac
use LoggerAwareTrait;
private $defaultOptions = self::OPTIONS_DEFAULTS;
/** @var NativeClientState */
private $multi;
/**
@ -50,18 +53,8 @@ final class NativeHttpClient implements HttpClientInterface, LoggerAwareInterfac
[, $this->defaultOptions] = self::prepareRequest(null, null, $defaultOptions, self::OPTIONS_DEFAULTS);
}
// Use an internal stdClass object to share state between the client and its responses
$this->multi = (object) [
'openHandles' => [],
'handlesActivity' => [],
'pendingResponses' => [],
'maxHostConnections' => 0 < $maxHostConnections ? $maxHostConnections : PHP_INT_MAX,
'responseCount' => 0,
'dnsCache' => [],
'handles' => [],
'sleep' => false,
'id' => random_int(PHP_INT_MIN, PHP_INT_MAX),
];
$this->multi = new NativeClientState();
$this->multi->maxHostConnections = 0 < $maxHostConnections ? $maxHostConnections : PHP_INT_MAX;
}
/**
@ -292,7 +285,7 @@ final class NativeHttpClient implements HttpClientInterface, LoggerAwareInterfac
/**
* Resolves the IP of the host using the local DNS cache if possible.
*/
private static function dnsResolve(array $url, \stdClass $multi, array &$info, ?\Closure $onProgress): array
private static function dnsResolve(array $url, NativeClientState $multi, array &$info, ?\Closure $onProgress): array
{
if ($port = parse_url($url['authority'], PHP_URL_PORT) ?: '') {
$info['primary_port'] = $port;
@ -343,7 +336,7 @@ final class NativeHttpClient implements HttpClientInterface, LoggerAwareInterfac
}
}
return static function (\stdClass $multi, ?string $location, $context) use ($redirectHeaders, $proxy, $noProxy, &$info, $maxRedirects, $onProgress): ?string {
return static function (NativeClientState $multi, ?string $location, $context) use ($redirectHeaders, $proxy, $noProxy, &$info, $maxRedirects, $onProgress): ?string {
if (null === $location || $info['http_code'] < 300 || 400 <= $info['http_code']) {
$info['redirect_url'] = null;

View File

@ -14,6 +14,7 @@ namespace Symfony\Component\HttpClient\Response;
use Psr\Log\LoggerInterface;
use Symfony\Component\HttpClient\Chunk\FirstChunk;
use Symfony\Component\HttpClient\Exception\TransportException;
use Symfony\Component\HttpClient\Internal\CurlClientState;
use Symfony\Contracts\HttpClient\ResponseInterface;
/**
@ -26,11 +27,12 @@ final class CurlResponse implements ResponseInterface
use ResponseTrait;
private static $performing = false;
private $multi;
/**
* @internal
*/
public function __construct(\stdClass $multi, $ch, array $options = null, LoggerInterface $logger = null, string $method = 'GET', callable $resolveRedirect = null)
public function __construct(CurlClientState $multi, $ch, array $options = null, LoggerInterface $logger = null, string $method = 'GET', callable $resolveRedirect = null)
{
$this->multi = $multi;
@ -186,8 +188,8 @@ final class CurlResponse implements ResponseInterface
$this->multi->pushedResponses = [];
// Schedule DNS cache eviction for the next request
$this->multi->dnsCache[2] = $this->multi->dnsCache[2] ?: $this->multi->dnsCache[1];
$this->multi->dnsCache[1] = $this->multi->dnsCache[0] = [];
$this->multi->dnsCache->evictions = $this->multi->dnsCache->evictions ?: $this->multi->dnsCache->removals;
$this->multi->dnsCache->removals = $this->multi->dnsCache->hostnames = [];
}
}
}
@ -195,7 +197,7 @@ final class CurlResponse implements ResponseInterface
/**
* {@inheritdoc}
*/
protected function close(): void
private function close(): void
{
unset($this->multi->openHandles[$this->id], $this->multi->handlesActivity[$this->id]);
curl_multi_remove_handle($this->multi->handle, $this->handle);
@ -213,7 +215,7 @@ final class CurlResponse implements ResponseInterface
/**
* {@inheritdoc}
*/
protected static function schedule(self $response, array &$runningResponses): void
private static function schedule(self $response, array &$runningResponses): void
{
if (isset($runningResponses[$i = (int) $response->multi->handle])) {
$runningResponses[$i][1][$response->id] = $response;
@ -231,7 +233,7 @@ final class CurlResponse implements ResponseInterface
/**
* {@inheritdoc}
*/
protected static function perform(\stdClass $multi, array &$responses = null): void
private static function perform(CurlClientState $multi, array &$responses = null): void
{
if (self::$performing) {
return;
@ -253,7 +255,7 @@ final class CurlResponse implements ResponseInterface
/**
* {@inheritdoc}
*/
protected static function select(\stdClass $multi, float $timeout): int
private static function select(CurlClientState $multi, float $timeout): int
{
return curl_multi_select($multi->handle, $timeout);
}
@ -261,7 +263,7 @@ final class CurlResponse implements ResponseInterface
/**
* Parses header lines as curl yields them to us.
*/
private static function parseHeaderLine($ch, string $data, array &$info, array &$headers, ?array $options, \stdClass $multi, int $id, ?string &$location, ?callable $resolveRedirect, ?LoggerInterface $logger): int
private static function parseHeaderLine($ch, string $data, array &$info, array &$headers, ?array $options, CurlClientState $multi, int $id, ?string &$location, ?callable $resolveRedirect, ?LoggerInterface $logger): int
{
if (!\in_array($waitFor = @curl_getinfo($ch, CURLINFO_PRIVATE), ['headers', 'destruct'], true)) {
return \strlen($data); // Ignore HTTP trailers
@ -295,11 +297,11 @@ final class CurlResponse implements ResponseInterface
$info['redirect_url'] = $resolveRedirect($ch, $location);
$url = parse_url($location ?? ':');
if (isset($url['host']) && null !== $ip = $multi->dnsCache[0][$url['host'] = strtolower($url['host'])] ?? null) {
if (isset($url['host']) && null !== $ip = $multi->dnsCache->hostnames[$url['host'] = strtolower($url['host'])] ?? null) {
// Populate DNS cache for redirects if needed
$port = $url['port'] ?? ('http' === ($url['scheme'] ?? parse_url(curl_getinfo($ch, CURLINFO_EFFECTIVE_URL), PHP_URL_SCHEME)) ? 80 : 443);
curl_setopt($ch, CURLOPT_RESOLVE, ["{$url['host']}:$port:$ip"]);
$multi->dnsCache[1]["-{$url['host']}:$port"] = "-{$url['host']}:$port";
$multi->dnsCache->removals["-{$url['host']}:$port"] = "-{$url['host']}:$port";
}
}

View File

@ -15,6 +15,7 @@ use Symfony\Component\HttpClient\Chunk\ErrorChunk;
use Symfony\Component\HttpClient\Chunk\FirstChunk;
use Symfony\Component\HttpClient\Exception\InvalidArgumentException;
use Symfony\Component\HttpClient\Exception\TransportException;
use Symfony\Component\HttpClient\Internal\ClientState;
use Symfony\Contracts\HttpClient\ResponseInterface;
/**
@ -130,10 +131,7 @@ class MockResponse implements ResponseInterface
throw new InvalidArgumentException('MockResponse instances must be issued by MockHttpClient before processing.');
}
$multi = self::$mainMulti ?? self::$mainMulti = (object) [
'handlesActivity' => [],
'openHandles' => [],
];
$multi = self::$mainMulti ?? self::$mainMulti = new ClientState();
if (!isset($runningResponses[0])) {
$runningResponses[0] = [$multi, []];
@ -145,7 +143,7 @@ class MockResponse implements ResponseInterface
/**
* {@inheritdoc}
*/
protected static function perform(\stdClass $multi, array &$responses): void
protected static function perform(ClientState $multi, array &$responses): void
{
foreach ($responses as $response) {
$id = $response->id;
@ -185,7 +183,7 @@ class MockResponse implements ResponseInterface
/**
* {@inheritdoc}
*/
protected static function select(\stdClass $multi, float $timeout): int
protected static function select(ClientState $multi, float $timeout): int
{
return 42;
}

View File

@ -14,6 +14,7 @@ namespace Symfony\Component\HttpClient\Response;
use Psr\Log\LoggerInterface;
use Symfony\Component\HttpClient\Chunk\FirstChunk;
use Symfony\Component\HttpClient\Exception\TransportException;
use Symfony\Component\HttpClient\Internal\NativeClientState;
use Symfony\Contracts\HttpClient\ResponseInterface;
/**
@ -32,11 +33,12 @@ final class NativeResponse implements ResponseInterface
private $remaining;
private $buffer;
private $inflate;
private $multi;
/**
* @internal
*/
public function __construct(\stdClass $multi, $context, string $url, $options, bool $gzipEnabled, array &$info, callable $resolveRedirect, ?callable $onProgress, ?LoggerInterface $logger)
public function __construct(NativeClientState $multi, $context, string $url, $options, bool $gzipEnabled, array &$info, callable $resolveRedirect, ?callable $onProgress, ?LoggerInterface $logger)
{
$this->multi = $multi;
$this->id = (int) $context;
@ -193,7 +195,7 @@ final class NativeResponse implements ResponseInterface
/**
* {@inheritdoc}
*/
private static function perform(\stdClass $multi, array &$responses = null): void
private static function perform(NativeClientState $multi, array &$responses = null): void
{
// List of native handles for stream_select()
if (null !== $responses) {
@ -283,6 +285,7 @@ final class NativeResponse implements ResponseInterface
if ($multi->pendingResponses && \count($multi->handles) < $multi->maxHostConnections) {
// Open the next pending request - this is a blocking operation so we do only one of them
/** @var self $response */
$response = array_shift($multi->pendingResponses);
$response->open();
$responses[$response->id] = $response;
@ -305,7 +308,7 @@ final class NativeResponse implements ResponseInterface
/**
* {@inheritdoc}
*/
private static function select(\stdClass $multi, float $timeout): int
private static function select(NativeClientState $multi, float $timeout): int
{
$_ = [];

View File

@ -20,6 +20,7 @@ use Symfony\Component\HttpClient\Exception\JsonException;
use Symfony\Component\HttpClient\Exception\RedirectionException;
use Symfony\Component\HttpClient\Exception\ServerException;
use Symfony\Component\HttpClient\Exception\TransportException;
use Symfony\Component\HttpClient\Internal\ClientState;
/**
* Implements the common logic for response classes.
@ -49,7 +50,7 @@ trait ResponseTrait
'error' => null,
];
private $multi;
/** @var resource */
private $handle;
private $id;
private $timeout;
@ -181,12 +182,12 @@ trait ResponseTrait
/**
* Performs all pending non-blocking operations.
*/
abstract protected static function perform(\stdClass $multi, array &$responses): void;
abstract protected static function perform(ClientState $multi, array &$responses): void;
/**
* Waits for network activity.
*/
abstract protected static function select(\stdClass $multi, float $timeout): int;
abstract protected static function select(ClientState $multi, float $timeout): int;
private static function addResponseHeaders(array $responseHeaders, array &$info, array &$headers): void
{
@ -254,6 +255,7 @@ trait ResponseTrait
$timeoutMax = 0;
$timeoutMin = $timeout ?? INF;
/** @var ClientState $multi */
foreach ($runningResponses as $i => [$multi]) {
$responses = &$runningResponses[$i][1];
self::perform($multi, $responses);