Skip to content

Commit 094e4a6

Browse files
authored
[rest] stream json without starting a new thread (#4136)
Signed-off-by: Jörg Sautter <[email protected]>
1 parent 9429299 commit 094e4a6

File tree

5 files changed

+74
-49
lines changed

5 files changed

+74
-49
lines changed

Diff for: bundles/org.openhab.core.io.rest.core/src/test/java/org/openhab/core/io/rest/core/internal/config/ConfigDescriptionResourceTest.java

+17-2
Original file line numberDiff line numberDiff line change
@@ -17,13 +17,15 @@
1717
import static org.mockito.ArgumentMatchers.*;
1818
import static org.mockito.Mockito.when;
1919

20+
import java.io.ByteArrayOutputStream;
2021
import java.io.IOException;
2122
import java.io.InputStream;
2223
import java.net.URI;
2324
import java.nio.charset.StandardCharsets;
2425
import java.util.List;
2526

2627
import javax.ws.rs.core.Response;
28+
import javax.ws.rs.core.StreamingOutput;
2729

2830
import org.eclipse.jdt.annotation.NonNullByDefault;
2931
import org.junit.jupiter.api.BeforeEach;
@@ -79,15 +81,15 @@ public void beforeEach() {
7981
public void shouldReturnAllConfigDescriptions() throws IOException {
8082
Response response = resource.getAll(null, null);
8183
assertThat(response.getStatus(), is(200));
82-
assertThat(new String(((InputStream) response.getEntity()).readAllBytes(), StandardCharsets.UTF_8), is(
84+
assertThat(toString(response.getEntity()), is(
8385
"[{\"uri\":\"system:i18n\",\"parameters\":[{\"default\":\"test\",\"name\":\"name\",\"required\":false,\"type\":\"TEXT\",\"readOnly\":false,\"multiple\":false,\"advanced\":false,\"verify\":false,\"limitToOptions\":true,\"options\":[],\"filterCriteria\":[]}],\"parameterGroups\":[]},{\"uri\":\"system:ephemeris\",\"parameters\":[{\"name\":\"country\",\"required\":false,\"type\":\"TEXT\",\"readOnly\":false,\"multiple\":false,\"advanced\":false,\"verify\":false,\"limitToOptions\":true,\"options\":[],\"filterCriteria\":[]}],\"parameterGroups\":[]}]"));
8486
}
8587

8688
@Test
8789
public void shouldReturnAConfigDescription() throws IOException {
8890
Response response = resource.getByURI(null, CONFIG_DESCRIPTION_SYSTEM_I18N_URI);
8991
assertThat(response.getStatus(), is(200));
90-
assertThat(new String(((InputStream) response.getEntity()).readAllBytes(), StandardCharsets.UTF_8), is(
92+
assertThat(toString(response.getEntity()), is(
9193
"{\"uri\":\"system:i18n\",\"parameters\":[{\"default\":\"test\",\"name\":\"name\",\"required\":false,\"type\":\"TEXT\",\"readOnly\":false,\"multiple\":false,\"advanced\":false,\"verify\":false,\"limitToOptions\":true,\"options\":[],\"filterCriteria\":[]}],\"parameterGroups\":[]}"));
9294
}
9395

@@ -96,4 +98,17 @@ public void shouldReturnStatus404() {
9698
Response response = resource.getByURI(null, "uri:invalid");
9799
assertThat(response.getStatus(), is(404));
98100
}
101+
102+
public String toString(Object entity) throws IOException {
103+
byte[] bytes;
104+
if (entity instanceof StreamingOutput streaming) {
105+
try (ByteArrayOutputStream buffer = new ByteArrayOutputStream()) {
106+
streaming.write(buffer);
107+
bytes = buffer.toByteArray();
108+
}
109+
} else {
110+
bytes = ((InputStream) entity).readAllBytes();
111+
}
112+
return new String(bytes, StandardCharsets.UTF_8);
113+
}
99114
}

Diff for: bundles/org.openhab.core.io.rest/src/main/java/org/openhab/core/io/rest/JSONResponse.java

+8-25
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@
2323
import javax.ws.rs.core.MediaType;
2424
import javax.ws.rs.core.Response;
2525
import javax.ws.rs.core.Response.ResponseBuilder;
26+
import javax.ws.rs.core.StreamingOutput;
2627

2728
import org.openhab.core.library.types.DateTimeType;
2829
import org.slf4j.Logger;
@@ -31,7 +32,6 @@
3132
import com.google.gson.Gson;
3233
import com.google.gson.GsonBuilder;
3334
import com.google.gson.JsonElement;
34-
import com.google.gson.JsonIOException;
3535
import com.google.gson.JsonObject;
3636
import com.google.gson.stream.JsonWriter;
3737

@@ -40,6 +40,7 @@
4040
*
4141
* @author Joerg Plewe - Initial contribution
4242
* @author Henning Treu - Provide streaming capabilities
43+
* @author Jörg Sautter - Improve streaming capabilities
4344
*/
4445
public class JSONResponse {
4546

@@ -152,33 +153,15 @@ private Response createResponse(Response.StatusType status, final Object entity)
152153
return rp.build();
153154
}
154155

155-
// The PipedOutputStream will only be closed by the writing thread
156-
// since closing it during this method call would be too early.
157-
// The receiver of the response will read from the pipe after this method returns.
158-
PipedOutputStream out = new PipedOutputStream();
159-
160-
try {
161-
// we will not actively close the PipedInputStream since it is read by the receiving end
162-
// and will be GC'ed once the response is consumed.
163-
PipedJSONInputStream in = new PipedJSONInputStream(out);
164-
rp.entity(in);
165-
} catch (IOException e) {
166-
throw new IllegalStateException(e);
167-
}
156+
rp.entity((StreamingOutput) (target) -> {
157+
// target must not be closed, see javadoc of javax.ws.rs.ext.MessageBodyWriter
158+
JsonWriter jsonWriter = new JsonWriter(
159+
new BufferedWriter(new OutputStreamWriter(target, StandardCharsets.UTF_8)));
168160

169-
Thread writerThread = new Thread(() -> {
170-
try (JsonWriter jsonWriter = new JsonWriter(
171-
new BufferedWriter(new OutputStreamWriter(out, StandardCharsets.UTF_8)))) {
172-
gson.toJson(entity, entity.getClass(), jsonWriter);
173-
jsonWriter.flush();
174-
} catch (IOException | JsonIOException e) {
175-
logger.debug("Error streaming JSON through PipedInputStream / PipedOutputStream.", e);
176-
}
161+
gson.toJson(entity, entity.getClass(), jsonWriter);
162+
jsonWriter.flush();
177163
});
178164

179-
writerThread.setDaemon(true); // daemonize thread to permit the JVM shutdown even if we stream JSON.
180-
writerThread.start();
181-
182165
return rp.build();
183166
}
184167

Diff for: bundles/org.openhab.core.io.rest/src/test/java/org/openhab/core/io/rest/JSONResponseTest.java

+10-9
Original file line numberDiff line numberDiff line change
@@ -17,8 +17,8 @@
1717
import static org.hamcrest.object.IsCompatibleType.typeCompatibleWith;
1818
import static org.junit.jupiter.api.Assertions.assertTrue;
1919

20+
import java.io.ByteArrayOutputStream;
2021
import java.io.IOException;
21-
import java.io.InputStream;
2222
import java.math.BigDecimal;
2323
import java.nio.charset.StandardCharsets;
2424
import java.util.ArrayList;
@@ -27,6 +27,7 @@
2727
import javax.ws.rs.core.MediaType;
2828
import javax.ws.rs.core.Response;
2929
import javax.ws.rs.core.Response.Status;
30+
import javax.ws.rs.core.StreamingOutput;
3031

3132
import org.eclipse.jdt.annotation.NonNullByDefault;
3233
import org.junit.jupiter.api.Test;
@@ -92,12 +93,11 @@ public void shouldCreateSuccessResponseWithStreamEntity() throws IOException {
9293
assertThat(response.getMediaType(), is(MediaType.APPLICATION_JSON_TYPE));
9394

9495
Object entity = response.getEntity();
95-
assertThat(entity.getClass(), is(typeCompatibleWith(InputStream.class)));
96+
assertThat(entity.getClass(), is(typeCompatibleWith(StreamingOutput.class)));
9697

97-
try (InputStream entityInStream = (InputStream) entity) {
98-
byte[] entityValue = new byte[ENTITY_JSON_VALUE.length()];
99-
entityInStream.read(entityValue);
100-
assertThat(new String(entityValue), is(ENTITY_JSON_VALUE));
98+
try (ByteArrayOutputStream buffer = new ByteArrayOutputStream()) {
99+
((StreamingOutput) entity).write(buffer);
100+
assertThat(new String(buffer.toByteArray(), StandardCharsets.UTF_8), is(ENTITY_JSON_VALUE));
101101
}
102102
}
103103

@@ -120,10 +120,11 @@ public void shouldCreateSuccessResponseWithLargeStreamEntity() throws IOExceptio
120120
assertThat(response.getMediaType(), is(MediaType.APPLICATION_JSON_TYPE));
121121

122122
Object entity = response.getEntity();
123-
assertThat(entity.getClass(), is(typeCompatibleWith(InputStream.class)));
123+
assertThat(entity.getClass(), is(typeCompatibleWith(StreamingOutput.class)));
124124

125-
try (InputStream entityInStream = (InputStream) entity) {
126-
String largeEntityJSON = new String(entityInStream.readAllBytes(), StandardCharsets.UTF_8);
125+
try (ByteArrayOutputStream buffer = new ByteArrayOutputStream()) {
126+
((StreamingOutput) entity).write(buffer);
127+
String largeEntityJSON = new String(buffer.toByteArray(), StandardCharsets.UTF_8);
127128
assertThat(largeEntityJSON, is(notNullValue()));
128129
assertTrue(largeEntityJSON.startsWith("{"));
129130
assertTrue(largeEntityJSON.endsWith("}"));

Diff for: itests/org.openhab.core.io.rest.core.tests/src/main/java/org/openhab/core/io/rest/core/internal/item/ItemResourceOSGiTest.java

+20-6
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@
2020
import static org.mockito.ArgumentMatchers.*;
2121
import static org.mockito.Mockito.*;
2222

23+
import java.io.ByteArrayOutputStream;
2324
import java.io.IOException;
2425
import java.io.InputStream;
2526
import java.net.URI;
@@ -33,6 +34,7 @@
3334
import javax.ws.rs.core.Request;
3435
import javax.ws.rs.core.Response;
3536
import javax.ws.rs.core.Response.Status;
37+
import javax.ws.rs.core.StreamingOutput;
3638
import javax.ws.rs.core.UriBuilder;
3739
import javax.ws.rs.core.UriInfo;
3840

@@ -204,8 +206,7 @@ public void shouldIncludeRequestedFieldsOnly() throws Exception {
204206
Response response = itemResource.getItems(uriInfoMock, httpHeadersMock, request, null, null, "MyTag", null,
205207
false, "type,name", false);
206208

207-
JsonElement result = JsonParser
208-
.parseString(new String(((InputStream) response.getEntity()).readAllBytes(), StandardCharsets.UTF_8));
209+
JsonElement result = JsonParser.parseString(toString(response.getEntity()));
209210
JsonElement expected = JsonParser.parseString("[{type: \"Switch\", name: \"Switch\"}]");
210211
assertEquals(expected, result);
211212
}
@@ -227,12 +228,12 @@ public void shouldProvideReturnCodesForTagHandling() {
227228
}
228229

229230
private List<String> readItemNamesFromResponse(Response response) throws IOException {
230-
String jsonResponse = new String(((InputStream) response.getEntity()).readAllBytes(), StandardCharsets.UTF_8);
231+
String jsonResponse = toString(response.getEntity());
231232
return JsonPath.read(jsonResponse, "$..name");
232233
}
233234

234235
private List<String> readItemLabelsFromResponse(Response response) throws IOException, TransformationException {
235-
String jsonResponse = new String(((InputStream) response.getEntity()).readAllBytes(), StandardCharsets.UTF_8);
236+
String jsonResponse = toString(response.getEntity());
236237
return JsonPath.read(jsonResponse, "$..label");
237238
}
238239

@@ -256,7 +257,7 @@ public void addMultipleItems() throws IOException {
256257
items = itemList.toArray(items);
257258
Response response = itemResource.createOrUpdateItems(items);
258259

259-
String jsonResponse = new String(((InputStream) response.getEntity()).readAllBytes(), StandardCharsets.UTF_8);
260+
String jsonResponse = toString(response.getEntity());
260261
List<String> statusCodes = JsonPath.read(jsonResponse, "$..status");
261262

262263
// expect 2x created
@@ -274,7 +275,7 @@ public void addMultipleItems() throws IOException {
274275
items = itemList.toArray(items);
275276
response = itemResource.createOrUpdateItems(items);
276277

277-
jsonResponse = new String(((InputStream) response.getEntity()).readAllBytes(), StandardCharsets.UTF_8);
278+
jsonResponse = toString(response.getEntity());
278279
statusCodes = JsonPath.read(jsonResponse, "$..status");
279280

280281
// expect error and updated
@@ -380,4 +381,17 @@ public void findTagTest(String itemName, String semanticClassName, @Nullable Mat
380381
assertThat(response.getStatus(), is(404));
381382
}
382383
}
384+
385+
public String toString(Object entity) throws IOException {
386+
byte[] bytes;
387+
if (entity instanceof StreamingOutput streaming) {
388+
try (ByteArrayOutputStream buffer = new ByteArrayOutputStream()) {
389+
streaming.write(buffer);
390+
bytes = buffer.toByteArray();
391+
}
392+
} else {
393+
bytes = ((InputStream) entity).readAllBytes();
394+
}
395+
return new String(bytes, StandardCharsets.UTF_8);
396+
}
383397
}

Diff for: itests/org.openhab.core.io.rest.core.tests/src/main/java/org/openhab/core/io/rest/core/internal/link/ItemChannelLinkResourceOSGiTest.java

+19-7
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@
1919
import static org.mockito.ArgumentMatchers.*;
2020
import static org.mockito.Mockito.when;
2121

22+
import java.io.ByteArrayOutputStream;
2223
import java.io.IOException;
2324
import java.io.InputStream;
2425
import java.net.URI;
@@ -27,6 +28,7 @@
2728

2829
import javax.ws.rs.core.HttpHeaders;
2930
import javax.ws.rs.core.Response;
31+
import javax.ws.rs.core.StreamingOutput;
3032
import javax.ws.rs.core.UriBuilder;
3133
import javax.ws.rs.core.UriInfo;
3234

@@ -148,29 +150,39 @@ public void shouldReturnLink() throws Exception {
148150
public void shouldIncludeEditableFields() throws IOException, JsonSyntaxException {
149151
managedItemChannelLinkProvider.add(link1);
150152
Response response = itemChannelLinkResource.getLink(ITEM_NAME1, CHANNEL_UID1);
151-
JsonElement result = JsonParser
152-
.parseString(new String(((InputStream) response.getEntity()).readAllBytes(), StandardCharsets.UTF_8));
153+
JsonElement result = JsonParser.parseString(toString(response.getEntity()));
153154
JsonElement expected = JsonParser.parseString("{channelUID:\"" + CHANNEL_UID1
154155
+ "\", configuration:{}, editable:true, itemName:\"" + ITEM_NAME1 + "\"}");
155156
assertEquals(expected, result);
156157

157158
response = itemChannelLinkResource.getAll(CHANNEL_UID1, ITEM_NAME1);
158-
result = JsonParser
159-
.parseString(new String(((InputStream) response.getEntity()).readAllBytes(), StandardCharsets.UTF_8));
159+
result = JsonParser.parseString(toString(response.getEntity()));
160160
expected = JsonParser.parseString("[{channelUID:\"" + CHANNEL_UID1
161161
+ "\", configuration:{}, editable:true, itemName:\"" + ITEM_NAME1 + "\"}]");
162162
assertEquals(expected, result);
163163

164164
response = itemChannelLinkResource.getLink(ITEM_NAME2, CHANNEL_UID2);
165-
result = JsonParser
166-
.parseString(new String(((InputStream) response.getEntity()).readAllBytes(), StandardCharsets.UTF_8));
165+
result = JsonParser.parseString(toString(response.getEntity()));
167166
expected = JsonParser.parseString("{channelUID:\"" + CHANNEL_UID2
168167
+ "\", configuration:{}, editable:false, itemName:\"" + ITEM_NAME2 + "\", configuration:{}}");
169168
assertEquals(expected, result);
170169
}
171170

172171
private List<String> readItemNamesFromResponse(Response response) throws IOException {
173-
String jsonResponse = new String(((InputStream) response.getEntity()).readAllBytes(), StandardCharsets.UTF_8);
172+
String jsonResponse = toString(response.getEntity());
174173
return JsonPath.read(jsonResponse, "$..itemName");
175174
}
175+
176+
public String toString(Object entity) throws IOException {
177+
byte[] bytes;
178+
if (entity instanceof StreamingOutput streaming) {
179+
try (ByteArrayOutputStream buffer = new ByteArrayOutputStream()) {
180+
streaming.write(buffer);
181+
bytes = buffer.toByteArray();
182+
}
183+
} else {
184+
bytes = ((InputStream) entity).readAllBytes();
185+
}
186+
return new String(bytes, StandardCharsets.UTF_8);
187+
}
176188
}

0 commit comments

Comments
 (0)