Movatterモバイル変換


[0]ホーム

URL:


Skip to content

Navigation Menu

Sign in
Appearance settings

Search code, repositories, users, issues, pull requests...

Provide feedback

We read every piece of feedback, and take your input very seriously.

Saved searches

Use saved searches to filter your results more quickly

Sign up
Appearance settings

[HttpFoundation] Add documentation forStreamedJsonResponse#17301

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to ourterms of service andprivacy statement. We’ll occasionally send you account related emails.

Already on GitHub?Sign in to your account

Conversation

@alexander-schranz
Copy link
Contributor

@alexander-schranzalexander-schranz commentedSep 27, 2022
edited
Loading

Docs for:symfony/symfony#47709

TODO

  • Example of Flush Handling

@alexander-schranzalexander-schranzforce-pushed thefeature/streamed-json-response branch 3 times, most recently from1e14c85 toa414b97CompareSeptember 27, 2022 23:47
@alexander-schranzalexander-schranz changed the titleAdd documentation for StreamedJsonResponse[HttpFoundation] Add documentation for StreamedJsonResponseSep 27, 2022
@OskarStarkOskarStark added the Waiting Code MergeDocs for features pending to be merged labelSep 28, 2022
@carsonbotcarsonbot changed the title[HttpFoundation] Add documentation for StreamedJsonResponseAdd documentation for StreamedJsonResponseSep 28, 2022
@carsonbotcarsonbot added this to thenext milestoneSep 28, 2022
@OskarStarkOskarStark modified the milestones:next,6.2Sep 28, 2022
@OskarStarkOskarStark changed the titleAdd documentation for StreamedJsonResponseAdd documentation forStreamedJsonResponseSep 29, 2022
@wouterjwouterj modified the milestones:6.2,nextOct 18, 2022
chalasr added a commit to symfony/symfony that referenced this pull requestDec 29, 2022
…ent JSON streaming (alexander-schranz)This PR was squashed before being merged into the 6.3 branch.Discussion----------[HttpFoundation] Add `StreamedJsonResponse` for efficient JSON streaming| Q             | A| ------------- | ---| Branch?       | 6.2| Bug fix?      | no| New feature?  | yes <!-- please update src/**/CHANGELOG.md files -->| Deprecations? | no <!-- please update UPGRADE-*.md and src/**/CHANGELOG.md files -->| Tickets       | Fix #... <!-- prefix each issue number with "Fix #", no need to create an issue if none exist, explain below instead -->| License       | MIT| Doc PR        |symfony/symfony-docs#17301When big data are streamed via JSON API it can sometimes be difficult to keep the resources usages low. For this I experimented with a different way of streaming data for JSON responses. It uses combination of `structured array` and `generics` which did result in a lot better result.More can be read about here: [https://github.com/alexander-schranz/efficient-json-streaming-with-symfony-doctrine](https://github.com/alexander-schranz/efficient-json-streaming-with-symfony-doctrine).I thought it maybe can be a great addition to Symfony itself to make this kind of responses easier and that APIs can be made more performant.## Usage<details><summary>First Version (replaced)</summary>```phpclass ArticleListAction {    public function __invoke(EntityManagerInterface  $entityManager): Response    {        $articles = $this->findArticles($entityManager);        return new StreamedJsonResponse(            // json structure with replacers identifiers            [                '_embedded' => [                    'articles' => '__articles__',                ],            ],            // array of generator replacer identifier used as key            [                '__articles__' => $this->findArticles('Article'),            ]        );    }    private function findArticles(EntityManagerInterface  $entityManager): \Generator    {        $queryBuilder = $entityManager->createQueryBuilder();        $queryBuilder->from(Article::class, 'article');        $queryBuilder->select('article.id')            ->addSelect('article.title')            ->addSelect('article.description');        return $queryBuilder->getQuery()->toIterable();    }}```</details>Update Version (thx to `@ro0NL` for the idea):```phpclass ArticleListAction {    public function __invoke(EntityManagerInterface  $entityManager): Response    {        $articles = $this->findArticles($entityManager);        return new StreamedJsonResponse(            // json structure with generators in it which are streamed            [                '_embedded' => [                    'articles' => $this->findArticles('Article'), // returns a generator which is streamed                ],            ],        );    }    private function findArticles(EntityManagerInterface  $entityManager): \Generator    {        $queryBuilder = $entityManager->createQueryBuilder();        $queryBuilder->from(Article::class, 'article');        $queryBuilder->select('article.id')            ->addSelect('article.title')            ->addSelect('article.description');        return $queryBuilder->getQuery()->toIterable();    }}```----As proposed by  `@OskarStark` the Full Content of Blog about ["Efficient JSON Streaming with Symfony and Doctrine"](https://github.com/alexander-schranz/efficient-json-streaming-with-symfony-doctrine/edit/main/README.md):# Efficient JSON Streaming with Symfony and DoctrineAfter reading a tweet about we provide only a few items (max. 100) over ourJSON APIs but providing 4k images for our websites.  I did think about why isthis the case.The main difference first we need to know about how images are streamed.On webservers today is mostly the sendfile feature used. Which is veryefficient as it can stream a file chunk by chunk and don't  need to loadthe whole data.So I'm asking myself how we can achieve the same mechanisms for ourJSON APIs, with a little experiment.As an example we will have a look at a basic entity which has thefollowing fields defined: - id: int - title: string - description: textThe response of our API should look like the following:```json{  "_embedded": {    "articles": [      {        "id": 1,        "title": "Article 1",        "description": "Description 1\nMore description text ...",      },      ...    ]  }}```Normally to provide this API we would do something like this:```php<?phpnamespace App\Controller;use App\Entity\Article;use Doctrine\ORM\EntityManagerInterface;use Symfony\Component\HttpFoundation\JsonResponse;use Symfony\Component\HttpFoundation\Response;class ArticleListAction{    public function __invoke(EntityManagerInterface $entityManager): Response    {        $articles = $this->findArticles($entityManager);        return JsonResponse::fromJsonString(json_encode([            'embedded' => [                'articles' => $articles,            ],            'total' => 100_000,        ], JSON_THROW_ON_ERROR | JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE));    }    // normally this method would live in a repository    private function findArticles(EntityManagerInterface  $entityManager): iterable    {        $queryBuilder = $entityManager->createQueryBuilder();        $queryBuilder->from(Article::class, 'article');        $queryBuilder->select('article.id')            ->addSelect('article.title')            ->addSelect('article.description');        return $queryBuilder->getQuery()->getResult();    }}```In most cases we will add some pagination to the endpoint so our response are not too big.## Making the api more efficientBut there is also a way how we can stream this response in an efficient way.First of all we need to adjust how we load the articles. This can be done by replacethe `getResult` with the more efficient [`toIterable`](https://www.doctrine-project.org/projects/doctrine-orm/en/2.9/reference/batch-processing.html#iterating-results):```diff-        return $queryBuilder->getQuery()->getResult();+        return $queryBuilder->getQuery()->toIterable();```Still the whole JSON need to be in the memory to send it. So we need also refactoringhow we are creating our response. We will replace our `JsonResponse` with the[`StreamedResponse`](https://symfony.com/doc/6.0/components/http_foundation.html#streaming-a-response) object.```phpreturn new StreamedResponse(function() use ($articles) {    // stream json}, 200, ['Content-Type' => 'application/json']);```But the `json` format is not the best format for streaming, so we need to add some hacksso we can make it streamable.First we will create will define the basic structure of our JSON this way:```php$jsonStructure = json_encode([    'embedded' => [        'articles' => ['__REPLACES_ARTICLES__'],    ],    'total' => 100_000,], JSON_THROW_ON_ERROR | JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE);```Instead of the `$articles` we are using a placeholder which we use to split the string intoa `$before` and `$after` variable:```php[$before, $after] = explode('"__REPLACES_ARTICLES__"', $jsonStructure, 2);```Now we are first sending the `$before`:```phpecho $before . PHP_EOL;```Then we stream the articles one by one to it here we need to keep the comma in mind whichwe need to add after every article but not the last one:```phpforeach ($articles as $count => $article) {    if ($count !== 0) {        echo ',' . PHP_EOL; // if not first element we need a separator    }    echo json_encode($article, JSON_THROW_ON_ERROR | JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE);}```Also we will add an additional `flush` after every 500 elements:```phpif ($count % 500 === 0 && $count !== 100_000) { // flush response after every 500    flush();}```After that we will also send the `$after` part:```phpecho PHP_EOL;echo $after;```## The resultSo at the end the whole action looks like the following:```php<?phpnamespace App\Controller;use App\Entity\Article;use Doctrine\ORM\EntityManagerInterface;use Symfony\Component\HttpFoundation\Response;use Symfony\Component\HttpFoundation\StreamedResponse;class ArticleListAction{    public function __invoke(EntityManagerInterface  $entityManager): Response    {        $articles = $this->findArticles($entityManager);        return new StreamedResponse(function() use ($articles) {            // defining our json structure but replaces the articles with a placeholder            $jsonStructure = json_encode([                'embedded' => [                    'articles' => ['__REPLACES_ARTICLES__'],                ],                'total' => 100_000,            ], JSON_THROW_ON_ERROR | JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE);            // split by placeholder            [$before, $after] = explode('"__REPLACES_ARTICLES__"', $jsonStructure, 2);            // send first before part of the json            echo $before . PHP_EOL;            // stream article one by one as own json            foreach ($articles as $count => $article) {                if ($count !== 0) {                    echo ',' . PHP_EOL; // if not first element we need a separator                }                if ($count % 500 === 0 && $count !== 100_000) { // flush response after every 500                    flush();                }                echo json_encode($article, JSON_THROW_ON_ERROR | JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE);            }            // send the after part of the json as last            echo PHP_EOL;            echo $after;        }, 200, ['Content-Type' => 'application/json']);    }    private function findArticles(EntityManagerInterface  $entityManager): iterable    {        $queryBuilder = $entityManager->createQueryBuilder();        $queryBuilder->from(Article::class, 'article');        $queryBuilder->select('article.id')            ->addSelect('article.title')            ->addSelect('article.description');        return $queryBuilder->getQuery()->toIterable();    }}```The metrics for 100000 Articles (nginx + php-fpm 7.4 - Macbook Pro 2013):|                           | Old Implementation | New Implementation ||---------------------------|--------------------|--------------------|| Memory Usage              | 49.53 MB           | 2.10 MB            || Memory Usage Peak         | 59.21 MB           | 2.10 MB            || Time to first Byte        | 478ms              | 28ms               || Time                      | 2.335 s            | 0.584 s            |This way we did not only reduce the memory usage on our serveralso we did make the response faster. The memory usage wasmeasured here with `memory_get_usage` and `memory_get_peak_usage`.The "Time to first Byte" by the browser value and response timesover curl.**Updated 2022-10-02 - (symfony serve + php-fpm 8.1 - Macbook Pro 2021)**|                           | Old Implementation | New Implementation ||---------------------------|--------------------|--------------------|| Memory Usage              | 64.21 MB           | 2.10 MB            || Memory Usage Peak         | 73.89 MB           | 2.10 MB            || Time to first Byte        | 0.203 s            | 0.049 s            || Updated Time (2022-10-02) | 0.233 s            | 0.232 s            |While there is not much different for a single response in the time,the real performance is the lower memory usage. Which will kick in whenyou have a lot of simultaneously requests. On my machine >150 simultaneouslyrequests - which is a high value but will on a normal server be a lot lower.While 150 simultaneously requests crashes in the old implementationthe new implementation still works with 220 simultaneously requests. Whichmeans we got about ~46% more requests possible.## Reading Data in javascriptAs we stream the data we should also make our JavaScript on the otherend the same way - so data need to read in streamed way.Here I'm just following the example from the [Fetch API Processing a text file line by line](https://developer.mozilla.org/en-US/docs/Web/API/Fetch_API/Using_Fetch#processing_a_text_file_line_by_line)So if we look at our [`script.js`](public/script.js) we split the objectline by line and append it to our table. This method is definitely not theway how JSON should be read and parsed. It should only be shown as examplehow the response could be read from a stream.## ConclusionThe implementation looks a little hacky for maintainability it couldbe moved into its own Factory which creates this kind of response.Example:```phpreturn StreamedResponseFactory::create(    [        'embedded' => [            'articles' => ['__REPLACES_ARTICLES__'],        ],        'total' => 100_000,    ],    ['____REPLACES_ARTICLES__' => $articles]);```The JavaScript part something is definitely not ready for productionand if used you should probably creating your own content-type e.g.:`application/json+stream`.  So you are parsing the json this wayonly when you know it is really in this line by line format.There maybe better libraries like [`JSONStream`](https://www.npmjs.com/package/JSONStream)to read data but at current state did test them out. Let me knowif somebody has experience with that and has solutions for it.Atleast what I think everybody should use for providing listsis to use [`toIterable`](https://www.doctrine-project.org/projects/doctrine-orm/en/2.9/reference/batch-processing.html#iterating-results) when possible for your lists when loadingyour data via Doctrine and and select specific fields insteadof using the `ORM` to avoid hydration process to object.Let me know what you think about this experiment and how you currently areproviding your JSON data.The whole experiment here can be checked out and test yourself via [this repository](https://github.com/alexander-schranz/efficient-json-streaming-with-symfony-doctrine).Attend the discussion about this on [Twitter](https://twitter.com/alex_s_/status/1488314080381313025).## Update 2022-09-27Added a [StreamedJsonRepsonse](src/Controller/StreamedJsonResponse.php) class andtry to contribute this implementation to the Symfony core.[https://github.com/symfony/symfony/pull/47709](https://github.com/symfony/symfony/pull/47709)## Update 2022-10-02Updated some statistics with new machine and apache benchmark tests for concurrency requests.Commits-------ecc5355 [HttpFoundation] Add `StreamedJsonResponse` for efficient JSON streaming
@chalasr
Copy link
Member

The code PR has been merged, it would be nice to finish this for 6.3 :)

alexander-schranz reacted with thumbs up emoji

@OskarStarkOskarStark removed the Waiting Code MergeDocs for features pending to be merged labelDec 29, 2022
@OskarStarkOskarStark modified the milestones:next,6.3Dec 29, 2022
@alexander-schranzalexander-schranzforce-pushed thefeature/streamed-json-response branch from993420e to461cb53CompareMay 15, 2023 19:15
@alexander-schranzalexander-schranz changed the titleAdd documentation forStreamedJsonResponseWIP: Add documentation forStreamedJsonResponseMay 15, 2023
@alexander-schranzalexander-schranz changed the base branch from6.2 to6.3May 15, 2023 19:16
@alexander-schranzalexander-schranzforce-pushed thefeature/streamed-json-response branch from461cb53 toad83f47CompareMay 15, 2023 19:30
@alexander-schranz
Copy link
ContributorAuthor

alexander-schranz commentedMay 15, 2023
edited
Loading

The documentation was updated and ready for review.

/cc@OskarStark@chalasr@javiereguiluz

@alexander-schranzalexander-schranz changed the titleWIP: Add documentation forStreamedJsonResponseAdd documentation forStreamedJsonResponseMay 15, 2023
@alexander-schranzalexander-schranz marked this pull request as ready for reviewMay 15, 2023 19:31
@alexander-schranzalexander-schranzforce-pushed thefeature/streamed-json-response branch fromad83f47 to858fd59CompareMay 15, 2023 19:32
@OskarStarkOskarStark requested review fromMatTheCat andjaviereguiluz and removed request forMatTheCatMay 16, 2023 06:52
@carsonbotcarsonbot changed the titleAdd documentation forStreamedJsonResponse[HttpFoundation] Add documentation forStreamedJsonResponseJun 6, 2023
@javiereguiluzjaviereguiluzforce-pushed thefeature/streamed-json-response branch fromd3c074d to8a285e3CompareJune 6, 2023 12:50
@javiereguiluzjaviereguiluz merged commit1091d89 intosymfony:6.3Jun 6, 2023
@javiereguiluz
Copy link
Member

Alexander, thanks a lot for this nice contribution!

Thanks to reviewers too! I did most of the requested changes while merging.

Sign up for freeto join this conversation on GitHub. Already have an account?Sign in to comment

Reviewers

@OskarStarkOskarStarkOskarStark left review comments

@javiereguiluzjaviereguiluzAwaiting requested review from javiereguiluz

+2 more reviewers

@94noni94noni94noni left review comments

@MatTheCatMatTheCatMatTheCat left review comments

Reviewers whose approvals may not affect merge requirements

Assignees

No one assigned

Projects

None yet

Milestone

6.3

Development

Successfully merging this pull request may close these issues.

8 participants

@alexander-schranz@chalasr@javiereguiluz@OskarStark@94noni@MatTheCat@wouterj@carsonbot

[8]ページ先頭

©2009-2025 Movatter.jp