Upgrade to Pro — share decks privately, control downloads, hide ads and more …

Juggle asynchronously with Symfony HttpClient

Juggle asynchronously with Symfony HttpClient

Making HTTP requests is a common practice, but managing their responses can quickly become a performance bottleneck. In this talk, we will explore a little-known tool, the AsyncDecoratorTrait feature of the Symfony HttpClient component, which provides more flexibility in handling request responses while preserving asynchronicity. We will discuss the application contexts in which this tool is useful and its advantages and limitations, as well as how it differs from decoration at the request or response level. Are you ready to learn more about improving HTTP response management? Let's dive in!

https://github.com/alli83/AsyncDecoratorTrait_use_case

a_guilhem

June 19, 2023
Tweet

More Decks by a_guilhem

Other Decks in Programming

Transcript

  1. Juggle asynchronously with Symfony HttpClient Symfony HttpClient : Some Reminders

    What is the AsyncDecoratorTrait From a practical point of view
  2. HttpClient: some reminders - A standalone component - To consume

    APIs - Through synchronous and asynchronous operations - « Native PHP Streams » et cURL.
  3. HttpClient: some reminders - http_client service - stateless - automatically

    injected via type hint HttpClientInterface - Symfony/http-client-contracts: set of abstractions extracted from the component - Heaps of options public function __construct( private HttpClientInterface $client, ) { } public function test(): array { $response = $this->client->request( 'GET', ‘https://url..’, […] );
  4. - Responses are lazy - Requests are concurrent - Multiplexing

    : stream - The errors are handled by default - Interoperable et extensible foreach ($client->stream($responses)
 as $response => $chunk
 ) { if ($chunk->isFirst()) { … } elseif ($chunk->isLast()) { … } else { … } } HttpClient: some reminders
  5. - A need well asserted - Decorators / middlewares /

    event ? - To preserve the asynchronous dimension Manipulating chunks
  6. AsyncDecoratorTrait - For HttpClient decorators - Decorators that do not

    consume the response to preserve the asynchronous dimension
  7. - It makes writing decorators easier and lighter => Symfony\…\HttpClient\DecoratorTrait

    - It's also concerning the response that tells us to wire up
 => Symfony\...\HttpClient\Response\AsyncResponse AsyncDecoratorTrait
  8. AsyncDecoratorTrait It will also come to connect itself in the

    stream method of the AsyncResponse A point of entry Core of the business logic
  9. AsyncResponse: What is it ? class MyExtendedHttpClient implements HttpClientInterface {

    use AsyncDecoratorTrait; public function request(string $method, string $url, array $options = []): ResponseInterface { $passthru = function (ChunkInterface $chunk, AsyncContext $context) { yield $chunk; }; return new AsyncResponse($this->client, $method, $url, $options, $passthru); } }
  10. AsyncResponse: How does it work? The passthru callable /** *

    @param ?callable(ChunkInterface, AsyncContext): ?\Iterator $passthru */ public function __construct(HttpClientInterface $client, string $method, string $url, array $options, callable $passthru = null) { - AsyncResponse: a non trivial last argument
  11. - It represents the part of customizable logic - Has

    a dynamic and evolving de fi nition - A de fi nition which must respect certain rules $passthru = function (ChunkInterface $chunk, AsyncContext $context) { // do what you want with chunks yield $chunk; }; AsyncResponse: How does it work? The passthru callable
  12. How do you relate this (callable) iterator to the AsyncResponse

    ? AsyncResponse: How does it work? The passthru callable
  13. class AsyncContext { public function __construct( private ?callable &$passthru, private

    HttpClientInterface $client, private ResponseInterface &$response, private array &$info, private /*resource*/ $content, private int $offset, ) { } AsyncResponse: How does it work? The passthru callable, a chunk and a context
  14. - A DTO which will allow us to drive the

    AsyncResponse - It's a way of modifying the response fl ow at a substantial level but also at a temporal level - It will permit us to interrogate the response without consuming it - It will give us the possibility to act on the response (cancel, replace a response, etc.) - It allows us to act on the iterator which controls the fl ow AsyncResponse: How does it work? The passthru callable, a chunk and a context
  15. How AsyncResponse works A guardian of the consistency of the

    fl ow of chunks: - No double fi rst chunk - Nothing yielded after the lastChunk etc…. A generator that will emit 0, 1 or N chunks in its output
  16. Transclusion case - We had a main request on a

    private API and we retrieved results with paging - We needed to add two responses from two other API endpoints
  17. public function index(MyExtendedHttpClient $client): Response { $response = $client->request( 'GET',

    ‘https://my-url.com/collection', [ // options you need etc 'user_data' => [ 'add' => [ 'availabilities' => 'https://my-url.com/collections/{id}/something', 'posts' => 'https://my-url.com/collections/{id}/somethingelse', ], 'concurrency' => null ], ] ); … } Transclusion case
  18. class MyExtendedHttpClient implements HttpClientInterface { use AsyncDecoratorTrait; … public function

    request(string $method, string $url, array $options = []): ResponseInterface { … $passthru = function (ChunkInterface $chunk, AsyncContext $context) use ($options) { … } return new AsyncResponse($this->client, $method, $url, $options, $passthru); } … } Transclusion case
  19. - Search on a favorite sector and if we take

    a 404 that is returned when you are on page 1, we will search for users in another nearby sector. - If we take a 404 and we are searching on pages 2, 3 or more, we do not restart and keep the error. First challenge: Transclusion case
  20. $passthru = function (ChunkInterface $chunk, AsyncContext $context) use ($options) {

    static $content = ''; if ($chunk->isFirst()) { if (404 === $context->getStatusCode()) { $page = preg_split('/page=(\d)/', $context->getInfo()['original_url'], -1, PREG_SPLIT_DELIM_CAPTURE)[1]; if (1 === (int) $page) { // look for another sector // do what you want $context->getResponse()->cancel(); $context->replaceRequest( 'GET', ‘https://my-url.com/collection', $options ); return; } $context->passthru(); } yield $chunk; } $content .= $chunk->getContent(); if(!$chunk->isLast()) { return; } … Cancel the response We make sure that the context replaces the request by another request It allows us to put our passthru back in a neutral position and receive a 404
  21. [\n {\n "id": 1,\n "name": "Leanne Graham",\n "username": "Bret",\n "email":

    "[email protected]",\n "address": {\n "street": "Kulas Light",\n "suite": "Apt. 556",\n "city": "Gwenborough",\n "zipcode": "92998-3874",\n "geo": {\n "lat": "-37.3159",\n "lng": "81.1496"\n }\n },\n "phone": "1-770-736-8031 x56442",\n },\n {\n …… }] JSON from main request if no error
  22. Second challenge: - What’s the best way to approach the

    processing of this JSON ? - Looking for performance and fl uidity when handling JSON is not always the easiest - We have to manipulate the response chunks to perform insertions of properties. Transclusion case
  23. We will have to parse this JSON to have blocks

    that are going to be in a form that can be handled as much as possible without any need for too heavy operations Why? Transclusion case
  24. array:10 [▼ 0 => '{'id':1,'name':'Leanne Graham','username':'Bret','email':'Sincere@april . biz','address':{'street':'Kulas Light','suite':'Apt .

    556','city':'Gwenborough','zipcode' ▶’ 1 => '{'id':2,'name':'Ervin Howell','username':'Antonette','email':'Shanna@melissa . tv','address':{'street':'Victor Plains','suite':'Suite 879','city':'Wisokyburgh','z ▶' 2 => ‘{…} … ] JSON: parsedMainResponse
  25. public static function updateData(array $parsedMainResponse, AsyncContext $context, array $options): array

    { … foreach ($parsedMainResponse as $mainKey => $subChunk) { ... $tempAdditionalResponses = []; … foreach ($toAdd as $key => $url) { preg_match('/"id":(\d+)/',$subChunk, $match); … $tempAdditionalResponses[] = sprintf(', "%s":', $key); $additionalResponse = $context->replaceRequest( 'GET', $url, $options ); $tempAdditionalResponses[] = $additionalResponse; } $tempAdditionalResponses[] = substr($subChunk, -1); … $blocks[] = $tempAdditionalResponses; } $blocks[] = $original; return $blocks; } 1. 1. To identify our main request thanks to the context 2. 2. To manipulate the parts to consider a place for the insertions. 3. 3. It will be necessary to add a curly bracket then a separator “,” 4. 4. We have to make sure that it ends well on the original main request.
  26. array:7 [▼ 0 => '{'id':1,'name':'Leanne Graham','username':'Bret','email':'Sincere@april . biz','address': {'street':'Kulas Light','suite':'Apt

    . 556','city':'Gwenborough','zipcode' ▶' 1 => ', 'availabilities':' 2 => Symfony\Component\HttpClient\Response\TraceableResponse {#514 ▶} 3 => ', 'posts':' 4 => Symfony\Component\HttpClient\Response\TraceableResponse {#557 ▶} 5 => '}' 6 => ',' ] One of the blocks Transclusion case
  27. //$parsedMainResponse = halaxa/json-machine ease json manipulation w/ PassThruDecoder yield $context->createChunk('[');

    // $part = block while (null !== $chunk = self::passthru($context, $part)->current()) { yield $chunk; } $context->passthru(static function (ChunkInterface $chunk, AsyncContext $context) use (&$parsedMainResponse, &$part, &$blocks, $options) { … } Concurrent requests + identification of the first block An iteration until it reaches a ResponseInterface : AsyncContext - replaceReponse The passthru in which you are currently will be modified We are here Transclusion case
  28. Recap: Chunks from the main request First block Until it

    reaches a ResponseInterface is LastChunk ? Action: replace the response via AsyncContext We are here we cut the response of the main request and we put a follow-up of the blocks that we are handling Transclusion case
  29. Third challenge: - There must always be continuity to reconstruct

    the main response until treatment is complete. - Three important points will be dealt with: - When a fi rst chunk is issued - When a lastChunk is issued - And everything else Transclusion case
  30. - Has already been "issued" and cannot issue more than

    one fi rstChunk - It’s important to emit => in our case, this will be the responses to the sub- requests. if ($chunk->isFirst()) { return; } yield $chunk; Transclusion case
  31. What about the last chunk ? Should we issue it

    ? Should we manipulate it ? Badly formed request Yes but how ? Transclusion case
  32. At lastChunk, we will be in this situation array:4 [▼

    3 => ', 'posts':' 4 => Symfony\Component\HttpClient\ Response\TraceableResponse {#550 ▶} 5 => '}' 6 => ',' ] Block array:7 [▼ 0 => '{'id':1,'name':'Leanne Graham','username':'Bret','email':'Sinc ere@april . biz','address': {'street':'Kulas Light','suite':'Apt . 556','city':'Gwenborough','zipcode' ▶' 1 => ', 'availabilities':' 2 => Symfony\Component\HttpClient\Response\T raceableResponse {#514 ▶} 3 => ', 'posts':' 4 => Symfony\Component\HttpClient\Response\T raceableResponse {#557 ▶} 5 => '}' 6 => ',' ] Transclusion case Block
  33. Block Create a chunk in order to emit it Replace

    the response and all the chunks will be yielded With the AsyncContext array:4 [▼ 3 => ', 'posts':' 4 => Symfony\Component\HttpClient\ Response\TraceableResponse {#550 ▶} 5 => '}' 6 => ',' ] Transclusion case Create a chunk in order to emit it
  34. Block is LastChunk? Or Block is finished Block n+1 LastChunk

    Transclusion case Chunks from the main request We cut the response of the main request and we put a follow-up of the blocks that we are handling Until it reaches a ResponseInterface Action: replace the response via AsyncContext Ignore FirstChunk
  35. Important to identify when it ends - And emit the

    lastChunk - You have to go back to the response for which you fi rst made the request Important to reconstruct the JSON ! Syntax Error ! Transclusion case
  36. - We obtained some amazing performances - So you have

    all the requests that are issued in a concurrent way - It’s extremely fast - You have a response that is easy to manipulate and you keep an asynchronous frame. But if you have a rate limiter on the number of incoming requests ? Transclusion case
  37. public static function updateData(array $parsedMainResponse, AsyncContext $context, array $options): array

    { … $concurrencyLimit = (int) $options['user_data']['concurrency']; … foreach ($parsedMainResponse as $mainKey => $subChunk) { if ($concurrencyLimit && $concurrencyLimit < $mainKey) { break; } … } … } Transclusion case
  38. is LastChunk ? Or A limited number of blocks LastChunk

    Ignore firstChunk Transclusion case Chunks from the main request We cut the response of the main request and we put a follow-up of the blocks that we are handling Block Block is finished Until it reaches a ResponseInterface Action: replace the response via AsyncContext Block n+1
  39. - We have several levels that coincide with the limit

    we have put on the number of concurrent requests - You’re staying on a very good performance - AsyncContext also allows you to pause through one of its methods if necessary Transclusion case
  40. - Is AsyncDecoratorTrait indispensable ? - A winning combo «

    generator » + AsyncContext + AsyncResponse - But not for all situations either - A great performance when used in the right situation Juggle asynchronously with Symfony HttpClient