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

Commit626eafe

Browse files
alexander-schranzGromNaNro0NLstofderrabus
committed
Add StreamedJsonResponse for efficient JSON streaming
Co-authored-by: Alexander Schranz <alexander@sulu.io>Co-authored-by: Jérôme Tamarelle <jerome@tamarelle.net>Co-authored-by: Roland Franssen <franssen.roland@gmail.com>Co-authored-by: Christophe Coevoet <stof@notk.org>Co-authored-by: Alexander M. tTurek <me@derrabus.de>Co-authored-by: Jules Pietri <heah@heahprod.com>Co-authored-by: Oskar Stark <oskarstark@googlemail.com>Co-authored-by: Robin Chalas <robin.chalas@gmail.com>Co-authored-by: Jérémy Derussé <jeremy@derusse.com>Co-authored-by: Nicolas Grekas <nicolas.grekas@gmail.com>
1 parentc782174 commit626eafe

File tree

3 files changed

+382
-0
lines changed

3 files changed

+382
-0
lines changed

‎src/Symfony/Component/HttpFoundation/CHANGELOG.md‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ CHANGELOG
44
6.2
55
---
66

7+
* Add`StreamedJsonResponse` class for efficient JSON streaming
78
* The HTTP cache store uses the`xxh128` algorithm
89
* Deprecate calling`JsonResponse::setCallback()`,`Response::setExpires/setLastModified/setEtag()`,`MockArraySessionStorage/NativeSessionStorage::setMetadataBag()`,`NativeSessionStorage::setSaveHandler()` without arguments
910
* Add request matchers under the`Symfony\Component\HttpFoundation\RequestMatcher` namespace
Lines changed: 139 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,139 @@
1+
<?php
2+
3+
/*
4+
* This file is part of the Symfony package.
5+
*
6+
* (c) Fabien Potencier <fabien@symfony.com>
7+
*
8+
* For the full copyright and license information, please view the LICENSE
9+
* file that was distributed with this source code.
10+
*/
11+
12+
namespaceSymfony\Component\HttpFoundation;
13+
14+
/**
15+
* StreamedJsonResponse represents a streamed HTTP response for JSON.
16+
*
17+
* A StreamedJsonResponse uses a structure and generics to create an
18+
* efficient resource-saving JSON response.
19+
*
20+
* It is recommended to use flush() function after a specific number of items to directly stream the data.
21+
*
22+
* @see flush()
23+
*
24+
* @author Alexander Schranz <alexander@sulu.io>
25+
*
26+
* Example usage:
27+
*
28+
* function loadArticles(): \Generator
29+
* // some streamed loading
30+
* yield ['title' => 'Article 1'];
31+
* yield ['title' => 'Article 2'];
32+
* yield ['title' => 'Article 3'];
33+
* // recommended to use flush() after every specific number of items
34+
* }),
35+
*
36+
* $response = new StreamedJsonResponse(
37+
* // json structure with generators in which will be streamed
38+
* [
39+
* '_embedded' => [
40+
* 'articles' => loadArticles(), // any generator which you want to stream as list of data
41+
* ],
42+
* ],
43+
* );
44+
*/
45+
class StreamedJsonResponseextends StreamedResponse
46+
{
47+
privateconstPLACEHOLDER ='__symfony_json__';
48+
49+
/**
50+
* @param mixed[] $data JSON Data containing PHP generators which will be streamed as list of data
51+
* @param int $status The HTTP status code (200 "OK" by default)
52+
* @param array<string, string|string[]> $headers An array of HTTP headers
53+
* @param int $encodingOptions Flags for the json_encode() function
54+
*/
55+
publicfunction__construct(
56+
privatereadonlyarray$data,
57+
int$status =200,
58+
array$headers = [],
59+
privateint$encodingOptions = JsonResponse::DEFAULT_ENCODING_OPTIONS,
60+
) {
61+
parent::__construct($this->stream(...),$status,$headers);
62+
63+
if (!$this->headers->get('Content-Type')) {
64+
$this->headers->set('Content-Type','application/json');
65+
}
66+
}
67+
68+
privatefunctionstream():void
69+
{
70+
$generators = [];
71+
$structure =$this->data;
72+
73+
array_walk_recursive($structure,function (&$item,$key)use (&$generators) {
74+
if (self::PLACEHOLDER ===$key) {
75+
// if the placeholder is already in the structure it should be replaced with a new one that explode
76+
// works like expected for the structure
77+
$generators[] =$key;
78+
}
79+
80+
// generators should be used but for better DX all kind of Traversable and objects are supported
81+
if (is_object($item)) {
82+
$generators[] =$item;
83+
$item =self::PLACEHOLDER;
84+
}elseif (self::PLACEHOLDER ===$item) {
85+
// if the placeholder is already in the structure it should be replaced with a new one that explode
86+
// works like expected for the structure
87+
$generators[] =$item;
88+
}
89+
});
90+
91+
$jsonEncodingOptions = \JSON_THROW_ON_ERROR |$this->encodingOptions;
92+
$keyEncodingOptions =$jsonEncodingOptions & ~\JSON_NUMERIC_CHECK;
93+
94+
$jsonParts =explode('"'.self::PLACEHOLDER.'"',json_encode($structure,$jsonEncodingOptions));
95+
96+
foreach ($generatorsas$index =>$generator) {
97+
// send first and between parts of the structure
98+
echo$jsonParts[$index];
99+
100+
if ($generatorinstanceof \JsonSerializable || !$generatorinstanceof \Traversable) {
101+
// the placeholders, JsonSerializable and none traversable items in the structure are rendered here
102+
echojson_encode($generator,$jsonEncodingOptions);
103+
104+
continue;
105+
}
106+
107+
$isFirstItem =true;
108+
$startTag ='[';
109+
110+
foreach ($generatoras$key =>$item) {
111+
if ($isFirstItem) {
112+
$isFirstItem =false;
113+
// depending on the first elements key the generator is detected as a list or map
114+
// we can not check for a whole list or map because that would hurt the performance
115+
// of the streamed response which is the main goal of this response class
116+
if (0 !==$key) {
117+
$startTag ='{';
118+
}
119+
120+
echo$startTag;
121+
}else {
122+
// if not first element of the generic, a separator is required between the elements
123+
echo',';
124+
}
125+
126+
if ('{' ===$startTag) {
127+
echojson_encode((string)$key,$keyEncodingOptions).':';
128+
}
129+
130+
echojson_encode($item,$jsonEncodingOptions);
131+
}
132+
133+
echo'[' ===$startTag ?']' :'}';
134+
}
135+
136+
// send last part of the structure
137+
echo$jsonParts[array_key_last($jsonParts)];
138+
}
139+
}
Lines changed: 242 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,242 @@
1+
<?php
2+
3+
/*
4+
* This file is part of the Symfony package.
5+
*
6+
* (c) Fabien Potencier <fabien@symfony.com>
7+
*
8+
* For the full copyright and license information, please view the LICENSE
9+
* file that was distributed with this source code.
10+
*/
11+
12+
namespaceSymfony\Component\HttpFoundation\Tests;
13+
14+
usePHPUnit\Framework\TestCase;
15+
useSymfony\Component\HttpFoundation\StreamedJsonResponse;
16+
17+
class StreamedJsonResponseTestextends TestCase
18+
{
19+
publicfunctiontestResponseSimpleList()
20+
{
21+
$content =$this->createSendResponse(
22+
[
23+
'_embedded' => [
24+
'articles' =>$this->generatorSimple('Article'),
25+
'news' =>$this->generatorSimple('News'),
26+
],
27+
],
28+
);
29+
30+
$this->assertSame('{"_embedded":{"articles":["Article 1","Article 2","Article 3"],"news":["News 1","News 2","News 3"]}}',$content);
31+
}
32+
33+
publicfunctiontestResponseObjectsList()
34+
{
35+
$content =$this->createSendResponse(
36+
[
37+
'_embedded' => [
38+
'articles' =>$this->generatorArray('Article'),
39+
],
40+
],
41+
);
42+
43+
$this->assertSame('{"_embedded":{"articles":[{"title":"Article 1"},{"title":"Article 2"},{"title":"Article 3"}]}}',$content);
44+
}
45+
46+
publicfunctiontestResponseWithoutGenerator()
47+
{
48+
// while it is not the intended usage, all kind of iterables should be supported for good DX
49+
$content =$this->createSendResponse(
50+
[
51+
'_embedded' => [
52+
'articles' => ['Article 1','Article 2','Article 3'],
53+
],
54+
],
55+
);
56+
57+
$this->assertSame('{"_embedded":{"articles":["Article 1","Article 2","Article 3"]}}',$content);
58+
}
59+
60+
publicfunctiontestResponseWithPlaceholder()
61+
{
62+
// the placeholder must not conflict with generator injection
63+
$content =$this->createSendResponse(
64+
[
65+
'_embedded' => [
66+
'articles' =>$this->generatorArray('Article'),
67+
'placeholder' =>'__symfony_json__',
68+
'news' =>$this->generatorSimple('News'),
69+
],
70+
'placeholder' =>'__symfony_json__',
71+
],
72+
);
73+
74+
$this->assertSame('{"_embedded":{"articles":[{"title":"Article 1"},{"title":"Article 2"},{"title":"Article 3"}],"placeholder":"__symfony_json__","news":["News 1","News 2","News 3"]},"placeholder":"__symfony_json__"}',$content);
75+
}
76+
77+
publicfunctiontestResponseWithMixedKeyType()
78+
{
79+
$content =$this->createSendResponse(
80+
[
81+
'_embedded' => [
82+
'list' => (function ():\Generator {
83+
yield0 =>'test';
84+
yield'key' =>'value';
85+
})(),
86+
'map' => (function ():\Generator {
87+
yield'key' =>'value';
88+
yield0 =>'test';
89+
})(),
90+
'integer' => (function ():\Generator {
91+
yield1 =>'one';
92+
yield3 =>'three';
93+
})(),
94+
],
95+
]
96+
);
97+
98+
$this->assertSame('{"_embedded":{"list":["test","value"],"map":{"key":"value","0":"test"},"integer":{"1":"one","3":"three"}}}',$content);
99+
}
100+
101+
publicfunctiontestResponseOtherTraversable()
102+
{
103+
$arrayObject =new \ArrayObject(['__symfony_json__' =>'__symfony_json__']);
104+
105+
$iteratorAggregate =newclass()implements \IteratorAggregate {
106+
publicfunctiongetIterator():\Traversable
107+
{
108+
returnnew \ArrayIterator(['__symfony_json__']);
109+
}
110+
};
111+
112+
$jsonSerializable =newclass()implements \IteratorAggregate, \JsonSerializable {
113+
publicfunctiongetIterator():\Traversable
114+
{
115+
returnnew \ArrayIterator(['This should be ignored']);
116+
}
117+
118+
publicfunctionjsonSerialize():mixed
119+
{
120+
return ['__symfony_json__' =>'__symfony_json__'];
121+
}
122+
};
123+
124+
// while Generators should be used for performance reasons, the object should also work with any Traversable
125+
// to make things easier for a developer
126+
$content =$this->createSendResponse(
127+
[
128+
'arrayObject' =>$arrayObject,
129+
'iteratorAggregate' =>$iteratorAggregate,
130+
'jsonSerializable' =>$jsonSerializable,
131+
// add a Generator to make sure it still work in combination with other Traversable objects
132+
'articles' =>$this->generatorArray('Article'),
133+
],
134+
);
135+
136+
$this->assertSame('{"arrayObject":{"__symfony_json__":"__symfony_json__"},"iteratorAggregate":["__symfony_json__"],"jsonSerializable":{"__symfony_json__":"__symfony_json__"},"articles":[{"title":"Article 1"},{"title":"Article 2"},{"title":"Article 3"}]}',$content);
137+
}
138+
139+
publicfunctiontestPlaceholderAsKeyAndValueInStructure()
140+
{
141+
$content =$this->createSendResponse(
142+
[
143+
'__symfony_json__' =>'__symfony_json__',
144+
'articles' =>$this->generatorArray('Article'),
145+
],
146+
);
147+
148+
$this->assertSame('{"__symfony_json__":"__symfony_json__","articles":[{"title":"Article 1"},{"title":"Article 2"},{"title":"Article 3"}]}',$content);
149+
}
150+
151+
publicfunctiontestResponseStatusCode()
152+
{
153+
$response =newStreamedJsonResponse([],201);
154+
155+
$this->assertSame(201,$response->getStatusCode());
156+
}
157+
158+
publicfunctiontestPlaceholderAsObjectStructure()
159+
{
160+
$object =newclass() {
161+
public$__symfony_json__ ='foo';
162+
public$bar ='__symfony_json__';
163+
};
164+
165+
$content =$this->createSendResponse(
166+
[
167+
'object' =>$object,
168+
// add a Generator to make sure it still work in combination with other object holding placeholders
169+
'articles' =>$this->generatorArray('Article'),
170+
],
171+
);
172+
173+
$this->assertSame('{"object":{"__symfony_json__":"foo","bar":"__symfony_json__"},"articles":[{"title":"Article 1"},{"title":"Article 2"},{"title":"Article 3"}]}',$content);
174+
}
175+
176+
177+
publicfunctiontestResponseHeaders()
178+
{
179+
$response =newStreamedJsonResponse([],200, ['X-Test' =>'Test']);
180+
181+
$this->assertSame('Test',$response->headers->get('X-Test'));
182+
}
183+
184+
publicfunctiontestCustomContentType()
185+
{
186+
$response =newStreamedJsonResponse([],200, ['Content-Type' =>'application/json+stream']);
187+
188+
$this->assertSame('application/json+stream',$response->headers->get('Content-Type'));
189+
}
190+
191+
publicfunctiontestEncodingOptions()
192+
{
193+
$response =newStreamedJsonResponse([
194+
'_embedded' => [
195+
'count' =>'2',// options are applied to the initial json encode
196+
'values' => (function ():\Generator {
197+
yield'with/unescaped/slash' =>'With/a/slash';// options are applied to key and values
198+
yield'3' =>'3';// numeric check for value, but not for the key
199+
})(),
200+
],
201+
], encodingOptions: \JSON_UNESCAPED_SLASHES | \JSON_NUMERIC_CHECK);
202+
203+
ob_start();
204+
$response->send();
205+
$content =ob_get_clean();
206+
207+
$this->assertSame('{"_embedded":{"count":2,"values":{"with/unescaped/slash":"With/a/slash","3":3}}}',$content);
208+
}
209+
210+
/**
211+
* @param mixed[] $data
212+
*/
213+
privatefunctioncreateSendResponse(array$data):string
214+
{
215+
$response =newStreamedJsonResponse($data);
216+
217+
ob_start();
218+
$response->send();
219+
220+
returnob_get_clean();
221+
}
222+
223+
/**
224+
* @return \Generator<int, string>
225+
*/
226+
privatefunctiongeneratorSimple(string$test):\Generator
227+
{
228+
yield$test.' 1';
229+
yield$test.' 2';
230+
yield$test.' 3';
231+
}
232+
233+
/**
234+
* @return \Generator<int, array{title: string}>
235+
*/
236+
privatefunctiongeneratorArray(string$test):\Generator
237+
{
238+
yield ['title' =>$test.' 1'];
239+
yield ['title' =>$test.' 2'];
240+
yield ['title' =>$test.' 3'];
241+
}
242+
}

0 commit comments

Comments
 (0)

[8]ページ先頭

©2009-2025 Movatter.jp