From 55954d9b721f44c3c117ce56751a66b3de892543 Mon Sep 17 00:00:00 2001 From: Benoit Viguier Date: Tue, 12 Nov 2019 15:36:04 +0100 Subject: [PATCH 1/4] Added new Adapter\Symfony\HttpClient --- composer.json | 16 ++- src/Adapter/Symfony/HttpClient.php | 155 +++++++++++++++++++++++ tests/Adapter/Symfony/HttpClientTest.php | 43 +++++++ tests/HttpClientTest.php | 27 +++- 4 files changed, 230 insertions(+), 11 deletions(-) create mode 100644 src/Adapter/Symfony/HttpClient.php create mode 100644 tests/Adapter/Symfony/HttpClientTest.php diff --git a/composer.json b/composer.json index aff3c7b..55859c0 100644 --- a/composer.json +++ b/composer.json @@ -30,13 +30,19 @@ "ext-curl": "^7.1", "react/event-loop": "^1.0", "react/promise": "^2.7", - "phpstan/phpstan": "^0.10.5" + "phpstan/phpstan": "^0.10.5", + "symfony/http-client": "^4.3", + "psr/http-factory": "^1.0", + "http-interop/http-factory-guzzle": "^1.0" }, "suggest": { - "amphp/amp": "Needed to use Tornado\\Adapter\\Amp\\EventLoop", - "react/event-loop": "Needed to use Tornado\\Adapter\\ReactPhp\\EventLoop", - "react/promise": "Needed to use Tornado\\Adapter\\ReactPhp\\EventLoop", - "guzzlehttp/guzzle": "Needed to use Tornado\\Adapter\\Guzzle\\HttpClient" + "ext-curl": "Required to use Curl and HTTP2 features", + "amphp/amp": "Required to use Tornado\\Adapter\\Amp\\EventLoop", + "react/event-loop": "Required to use Tornado\\Adapter\\ReactPhp\\EventLoop", + "react/promise": "Required to use Tornado\\Adapter\\ReactPhp\\EventLoop", + "guzzlehttp/guzzle": "Required to use Tornado\\Adapter\\Guzzle\\HttpClient", + "symfony/http-client": "Required to use Tornado\\Adapter\\Symfony\\HttpClient", + "psr/http-factory": "Required to use Tornado\\Adapter\\Symfony\\HttpClient" }, "config": { "bin-dir": "bin/" diff --git a/src/Adapter/Symfony/HttpClient.php b/src/Adapter/Symfony/HttpClient.php new file mode 100644 index 0000000..127a665 --- /dev/null +++ b/src/Adapter/Symfony/HttpClient.php @@ -0,0 +1,155 @@ +symfonyClient = $symfonyClient; + $this->eventLoop = $eventLoop; + $this->responseFactory = $responseFactory; + $this->streamFactory = $streamFactory; + } + + public function sendRequest(RequestInterface $request): Promise + { + $body = $request->getBody(); + if ($body->isSeekable()) { + $body->seek(0); + } + + $requestId = $this->lastRequestId = ++$this->lastRequestId % PHP_INT_MAX; + try { + $this->jobs[$requestId] = $this->symfonyClient->request($request->getMethod(), (string) $request->getUri(), [ + 'headers' => $request->getHeaders(), + 'body' => $body->getContents(), + 'http_version' => $request->getProtocolVersion(), + 'user_data' => [ + $deferred = $this->eventLoop->deferred(), + $requestId, + ], + ]); + } catch (\Exception $exception) { + return $this->eventLoop->promiseRejected($exception); + } + + // Register the internal event loop only for the first request + if (count($this->jobs) === 1) { + $this->eventLoop->async($this->symfonyEventLoop()); + } + + return $deferred->getPromise(); + } + + private function symfonyEventLoop(): \Generator + { + do { + yield $this->eventLoop->idle(); + + $currentJobs = $this->jobs; + $this->jobs = []; + /** + * @var SfResponseInterface + * @var ChunkInterface $chunk + */ + foreach ($this->symfonyClient->stream($currentJobs, 0) as $response => $chunk) { + /** @var Deferred $deferred */ + [$deferred, $requestId] = $response->getInfo('user_data'); + + try { + if ($chunk->isTimeout() || !$chunk->isLast()) { + // To prevent the client to throw an exception + // https://github.com/symfony/symfony/issues/32673#issuecomment-548327270 + $response->getStatusCode(); + $this->jobs[$requestId] = $response; + continue; + } + + // the full content of $response just completed + // $response->getContent() is now a non-blocking call + $deferred->resolve($this->toPsrResponse($response)); + + // Stream loop may yield the same response several times, + // then the response may already by in the list of responses to process. + // To prevent to resolve it twice, remove it. + unset($this->jobs[$requestId]); + } catch (\Throwable $exception) { + $deferred->reject($exception); + } + } + } while ($this->jobs); + } + + /** + * Inspired from https://github.com/symfony/http-client/blob/master/Psr18Client.php + * + * @throws \Symfony\Contracts\HttpClient\Exception\ClientExceptionInterface + * @throws \Symfony\Contracts\HttpClient\Exception\RedirectionExceptionInterface + * @throws \Symfony\Contracts\HttpClient\Exception\ServerExceptionInterface + * @throws \Symfony\Contracts\HttpClient\Exception\TransportExceptionInterface + */ + private function toPsrResponse(SfResponseInterface $response): ResponseInterface + { + $psrResponse = $this->responseFactory->createResponse($response->getStatusCode()); + foreach ($response->getHeaders(false) as $name => $values) { + foreach ($values as $value) { + $psrResponse = $psrResponse->withAddedHeader($name, $value); + } + } + + $body = $this->streamFactory->createStream($response->getContent(false)); + + if ($body->isSeekable()) { + $body->seek(0); + } + + return $psrResponse->withBody($body); + } +} + +/** + * @internal + */ +class InternalSymfonyJob +{ + /** @var Deferred */ + public $deferred; + /** @var SfResponseInterface */ + public $response; + + public function __construct(Deferred $deferred, SfResponseInterface $response) + { + $this->deferred = $deferred; + $this->response = $response; + } +} diff --git a/tests/Adapter/Symfony/HttpClientTest.php b/tests/Adapter/Symfony/HttpClientTest.php new file mode 100644 index 0000000..5f8a7de --- /dev/null +++ b/tests/Adapter/Symfony/HttpClientTest.php @@ -0,0 +1,43 @@ +getBody(), + [ + 'response_headers' => $response->getHeaders(), + 'redirect_count' => 0, + 'redirect_url' => null, + 'start_time' => microtime(true), + 'http_method' => $method, + 'http_code' => $response->getStatusCode(), + 'error' => null, + 'user_data' => $options['user_data'], + 'url' => $url, + ] + ); + }; + + return new \M6Web\Tornado\Adapter\Symfony\HttpClient( + new \Symfony\Component\HttpClient\MockHttpClient($callback), + $eventLoop, + new \Http\Factory\Guzzle\ResponseFactory(), + new \Http\Factory\Guzzle\StreamFactory() + ); + } +} diff --git a/tests/HttpClientTest.php b/tests/HttpClientTest.php index 99a88cd..226dff0 100644 --- a/tests/HttpClientTest.php +++ b/tests/HttpClientTest.php @@ -11,10 +11,7 @@ abstract class HttpClientTest extends TestCase { /** - * @param EventLoop $eventLoop - * @param array $responsesOrExceptions Psr7\Response to return, or \Exception to throw - * - * @return HttpClient + * @param array $responsesOrExceptions Psr7\Response to return, or \Exception to throw */ abstract protected function createHttpClient(EventLoop $eventLoop, array $responsesOrExceptions): HttpClient; @@ -33,14 +30,14 @@ public function testGetValidUrl(EventLoop $eventLoop) { $httpClient = $this->createHttpClient( $eventLoop, - [new Response(200, [], 'Example Domain')] + [new Response(200, [], 'This is a test')] ); $request = new Request('GET', 'http://www.example.com'); $response = $eventLoop->wait($httpClient->sendRequest($request)); $this->assertSame(200, $response->getStatusCode()); - $this->assertContains('Example Domain', (string) $response->getBody()); + $this->assertContains('This is a test', (string) $response->getBody()); } /** @@ -60,6 +57,24 @@ public function testGetNotFoundUrl(EventLoop $eventLoop) $this->assertSame(404, $response->getStatusCode()); } + /** + * @dataProvider eventLoopProvider + */ + public function testGetServerErrorUrl(EventLoop $eventLoop) + { + $httpClient = $this->createHttpClient( + $eventLoop, + [new Response(500, [], 'Error')] + ); + + $request = new Request('GET', 'http://www.example.com/500'); + + $response = $eventLoop->wait($httpClient->sendRequest($request)); + + $this->assertSame(500, $response->getStatusCode()); + $this->assertContains('Error', (string) $response->getBody()); + } + /** * @dataProvider eventLoopProvider */ From 23c808be310f12cc0fbe39a065c572f7abe506aa Mon Sep 17 00:00:00 2001 From: Benoit Viguier Date: Tue, 12 Nov 2019 15:53:09 +0100 Subject: [PATCH 2/4] Modified examples to include Symfony HttpClient --- examples/03-http-client.php | 28 ++++++++++++-------- examples/tests/ExamplesTest.php | 47 ++++++++++++++++++++++++++++++--- 2 files changed, 60 insertions(+), 15 deletions(-) diff --git a/examples/03-http-client.php b/examples/03-http-client.php index 7f98319..d3fa60d 100755 --- a/examples/03-http-client.php +++ b/examples/03-http-client.php @@ -10,7 +10,7 @@ function monitorRequest(EventLoop $eventLoop, HttpClient $httpClient, string $uri): \Generator { // Let's use Guzzle Psr7 implementation - $request = new \GuzzleHttp\Psr7\Request('GET', $uri); + $request = new \GuzzleHttp\Psr7\Request('GET', $uri, [], null, '2.0'); $start = microtime(true); /** @var \Psr\Http\Message\ResponseInterface */ @@ -26,22 +26,28 @@ function monitorRequest(EventLoop $eventLoop, HttpClient $httpClient, string $ur //$eventLoop = new Adapter\Amp\EventLoop(); //$eventLoop = new Adapter\ReactPhp\EventLoop(new React\EventLoop\StreamSelectLoop()); -// Tornado provides only one HttpClient implementation, using Guzzle -$httpClient = new Adapter\Guzzle\HttpClient($eventLoop, new Adapter\Guzzle\CurlMultiClientWrapper()); +// Choose your adapter +$httpClient = new Adapter\Symfony\HttpClient(new \Symfony\Component\HttpClient\CurlHttpClient(), $eventLoop, new \Http\Factory\Guzzle\ResponseFactory(), new \Http\Factory\Guzzle\StreamFactory()); +//$httpClient = new Adapter\Guzzle\HttpClient($eventLoop, new Adapter\Guzzle\CurlMultiClientWrapper()); // Let's call several endpoints… concurrently! echo "Let's start!\n"; echo "Requests in progress…\n"; $start = microtime(true); -$results = $eventLoop->wait( - $eventLoop->promiseAll( - $eventLoop->async(monitorRequest($eventLoop, $httpClient, 'http://httpbin.org/status/404')), - $eventLoop->async(monitorRequest($eventLoop, $httpClient, 'http://www.google.com')), - $eventLoop->async(monitorRequest($eventLoop, $httpClient, 'http://www.example.com')) - ) -); +$promises = []; +// You can download up to 379 parts. +// Check https://http2.akamai.com/demo for the full HTTP2 demonstration. +for ($i = 0; $i < 10; $i++) { + $promises[] = $eventLoop->async(monitorRequest( + $eventLoop, + $httpClient, + "https://http2.akamai.com/demo/tile-$i.png" + )); +} + +$results = $eventLoop->wait($eventLoop->promiseAll(...$promises)); $duration = microtime(true) - $start; -echo "Global duration: $duration\n"; echo implode(PHP_EOL, $results).PHP_EOL; +echo "Global duration: $duration\n"; echo "Finished!\n"; diff --git a/examples/tests/ExamplesTest.php b/examples/tests/ExamplesTest.php index 793d0ba..f09cb8f 100644 --- a/examples/tests/ExamplesTest.php +++ b/examples/tests/ExamplesTest.php @@ -50,13 +50,29 @@ public function testExampleShouldRun(string $exampleFile, string $eventloopName, } } + private function extractExampleCode(string $exampleFile): iterable + { + $originalContent = file($exampleFile); + + foreach ($this->selectEventLoop($originalContent) as $nameEL => $contentEL) { + $exampleUseHttpClient = false; + + foreach ($this->selectHttpClient($contentEL) as $nameHC => $contentELHC) { + $exampleUseHttpClient = true; + yield "$nameEL - $nameHC" => implode('', $contentELHC); + } + + if (!$exampleUseHttpClient) { + yield $nameEL => implode('', $contentEL); + } + } + } + /** * Very naive approach to iterate over various eventLoop implementations. */ - private function extractExampleCode(string $exampleFiles): iterable + private function selectEventLoop(array $originalContent): iterable { - $originalContent = file($exampleFiles); - foreach ($originalContent as &$line) { if (false === strpos($line, '$eventLoop = new ')) { continue; @@ -68,7 +84,30 @@ private function extractExampleCode(string $exampleFiles): iterable // Enable current eventLoop $line = ltrim($line, '/'); - yield $name => implode('', $originalContent); + yield $name => $originalContent; + + // Disable this eventLoop + $line = "//$line"; + } + } + + /** + * Very naive approach to iterate over various httpClient implementations. + */ + private function selectHttpClient(array $originalContent): iterable + { + foreach ($originalContent as &$line) { + if (false === strpos($line, '$httpClient = new ')) { + continue; + } + + // Extract relevant name + $name = strstr(strstr($line, '(', true), 'Adapter\\'); + + // Enable current eventLoop + $line = ltrim($line, '/'); + + yield $name => $originalContent; // Disable this eventLoop $line = "//$line"; From f1bd1c3a7000a5cbf66fa4a8a0ad4f1fa2a9ded8 Mon Sep 17 00:00:00 2001 From: Benoit Viguier Date: Tue, 12 Nov 2019 17:09:32 +0100 Subject: [PATCH 3/4] Updated Code style --- src/EventLoop.php | 6 ------ 1 file changed, 6 deletions(-) diff --git a/src/EventLoop.php b/src/EventLoop.php index 4063fae..d93d742 100644 --- a/src/EventLoop.php +++ b/src/EventLoop.php @@ -30,8 +30,6 @@ public function promiseAll(Promise ...$promises): Promise; * * @param \Traversable|array $traversable Input elements * @param callable $function must return a generator from an input value, and an optional key - * - * @return Promise */ public function promiseForeach($traversable, callable $function): Promise; @@ -71,8 +69,6 @@ public function deferred(): Deferred; * Returns a promise that will be resolved with the input stream when it becomes readable. * * @param resource $stream - * - * @return Promise */ public function readable($stream): Promise; @@ -80,8 +76,6 @@ public function readable($stream): Promise; * Returns a promise that will be resolved with the input stream when it becomes writable. * * @param resource $stream - * - * @return Promise */ public function writable($stream): Promise; } From 16609e5ded54e4ffc1985fe11cbf6fa8ea517210 Mon Sep 17 00:00:00 2001 From: Benoit Viguier Date: Wed, 20 Nov 2019 16:35:32 +0100 Subject: [PATCH 4/4] Fixed HTTP/2 fallback in Guzzle\HttpClient --- src/Adapter/Guzzle/HttpClient.php | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/src/Adapter/Guzzle/HttpClient.php b/src/Adapter/Guzzle/HttpClient.php index d2db8d2..5ed0caf 100644 --- a/src/Adapter/Guzzle/HttpClient.php +++ b/src/Adapter/Guzzle/HttpClient.php @@ -33,6 +33,7 @@ public function __construct(EventLoop $eventLoop, GuzzleClientWrapper $clientWra */ public function sendRequest(RequestInterface $request): Promise { + $request = $this->http2fallback($request); $deferred = $this->eventLoop->deferred(); $this->clientWrapper->getClient()->sendAsync($request)->then( @@ -59,6 +60,25 @@ function (\Exception $exception) use ($deferred) { return $deferred->getPromise(); } + private function http2fallback(RequestInterface $request): RequestInterface + { + if ($request->getProtocolVersion() !== '2.0') { + return $request; + } + + // Check that HTTP/2 is effectively supported by the system, and fallback to HTTP/1.1 if needed. + // Inspired from https://github.com/symfony/http-client/blob/master/CurlHttpClient.php + if ( + 'https' !== $request->getUri()->getScheme() + || !\defined('CURL_VERSION_HTTP2') + || !(CURL_VERSION_HTTP2 & curl_version()['features']) + ) { + return $request->withProtocolVersion('1.1'); + } + + return $request; + } + private function guzzleEventLoop(): \Generator { do {