Skip to content

Commit fb69545

Browse files
authored
Merge pull request #292 from artem-ag/optimize-response-writers
Optimize single & batch query response writers to reduce memory allocation overhead
2 parents b650bb3 + 538b8c2 commit fb69545

File tree

4 files changed

+101
-18
lines changed

4 files changed

+101
-18
lines changed

graphql-java-kickstart/src/main/java/graphql/kickstart/execution/GraphQLObjectMapper.java

+13
Original file line numberDiff line numberDiff line change
@@ -111,6 +111,19 @@ public void serializeResultAsJson(Writer writer, ExecutionResult executionResult
111111
getJacksonMapper().writeValue(writer, createResultFromExecutionResult(executionResult));
112112
}
113113

114+
/**
115+
* Serializes result as bytes in UTF-8 encoding instead of string.
116+
*
117+
* @param executionResult query execution result to serialize.
118+
* @return result serialized into Json representation in UTF-8 encoding, converted into {@code
119+
* byte[]}.
120+
*/
121+
@SneakyThrows
122+
public byte[] serializeResultAsBytes(ExecutionResult executionResult) {
123+
return getJacksonMapper()
124+
.writeValueAsBytes(createResultFromExecutionResult(executionResult));
125+
}
126+
114127
public boolean areErrorsPresent(ExecutionResult executionResult) {
115128
return graphQLErrorHandlerSupplier.get().errorsPresent(executionResult.getErrors());
116129
}

graphql-java-servlet/src/main/java/graphql/kickstart/servlet/BatchedQueryResponseWriter.java

+31-15
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44
import graphql.kickstart.execution.GraphQLObjectMapper;
55
import java.io.IOException;
66
import java.nio.charset.StandardCharsets;
7-
import java.util.Iterator;
7+
import java.util.ArrayList;
88
import java.util.List;
99
import javax.servlet.http.HttpServletRequest;
1010
import javax.servlet.http.HttpServletResponse;
@@ -14,32 +14,48 @@
1414
@Slf4j
1515
@RequiredArgsConstructor
1616
class BatchedQueryResponseWriter implements QueryResponseWriter {
17-
1817
private final List<ExecutionResult> results;
1918
private final GraphQLObjectMapper graphQLObjectMapper;
2019

2120
@Override
2221
public void write(HttpServletRequest request, HttpServletResponse response) throws IOException {
22+
response.setCharacterEncoding(StandardCharsets.UTF_8.name());
2323
response.setContentType(HttpRequestHandler.APPLICATION_JSON_UTF8);
2424
response.setStatus(HttpRequestHandler.STATUS_OK);
2525

26-
Iterator<ExecutionResult> executionInputIterator = results.iterator();
27-
StringBuilder responseBuilder = new StringBuilder();
28-
responseBuilder.append('[');
29-
while (executionInputIterator.hasNext()) {
30-
responseBuilder
31-
.append(graphQLObjectMapper.serializeResultAsJson(executionInputIterator.next()));
32-
if (executionInputIterator.hasNext()) {
33-
responseBuilder.append(',');
26+
// Use direct serialization to byte arrays and avoid any string concatenation to save multiple
27+
// GiB of memory allocation during large response processing.
28+
List<byte[]> serializedResults = new ArrayList<>(2 * results.size() + 1);
29+
30+
if (results.size() > 0) {
31+
serializedResults.add("[".getBytes(StandardCharsets.UTF_8));
32+
} else {
33+
serializedResults.add("[]".getBytes(StandardCharsets.UTF_8));
34+
}
35+
long totalLength = serializedResults.get(0).length;
36+
37+
// '[', ',' and ']' are all 1 byte in UTF-8.
38+
for (int i = 0; i < results.size(); i++) {
39+
byte[] currentResult = graphQLObjectMapper.serializeResultAsBytes(results.get(i));
40+
serializedResults.add(currentResult);
41+
42+
if (i != results.size() - 1) {
43+
serializedResults.add(",".getBytes(StandardCharsets.UTF_8));
44+
} else {
45+
serializedResults.add("]".getBytes(StandardCharsets.UTF_8));
3446
}
47+
totalLength += currentResult.length + 1; // result.length + ',' or ']'
3548
}
36-
responseBuilder.append(']');
3749

38-
String responseContent = responseBuilder.toString();
39-
byte[] contentBytes = responseContent.getBytes(StandardCharsets.UTF_8);
50+
if (totalLength > Integer.MAX_VALUE) {
51+
throw new IllegalStateException(
52+
"Response size exceed 2GiB. Query will fail. Seen size: " + totalLength);
53+
}
54+
response.setContentLength((int) totalLength);
4055

41-
response.setContentLength(contentBytes.length);
42-
response.getOutputStream().write(contentBytes);
56+
for (byte[] result : serializedResults) {
57+
response.getOutputStream().write(result);
58+
}
4359
}
4460

4561
}

graphql-java-servlet/src/main/java/graphql/kickstart/servlet/SingleQueryResponseWriter.java

+2-3
Original file line numberDiff line numberDiff line change
@@ -19,10 +19,9 @@ public void write(HttpServletRequest request, HttpServletResponse response) thro
1919
response.setContentType(HttpRequestHandler.APPLICATION_JSON_UTF8);
2020
response.setStatus(HttpRequestHandler.STATUS_OK);
2121
response.setCharacterEncoding(StandardCharsets.UTF_8.name());
22-
String responseContent = graphQLObjectMapper.serializeResultAsJson(result);
23-
byte[] contentBytes = responseContent.getBytes(StandardCharsets.UTF_8);
22+
23+
byte[] contentBytes = graphQLObjectMapper.serializeResultAsBytes(result);
2424
response.setContentLength(contentBytes.length);
2525
response.getOutputStream().write(contentBytes);
2626
}
27-
2827
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
package graphql.kickstart.servlet
2+
3+
import com.fasterxml.jackson.databind.ObjectMapper
4+
import graphql.ExecutionResultImpl
5+
import graphql.kickstart.execution.GraphQLObjectMapper
6+
import spock.lang.Specification
7+
import spock.lang.Unroll
8+
9+
import javax.servlet.ServletOutputStream
10+
import javax.servlet.http.HttpServletRequest
11+
import javax.servlet.http.HttpServletResponse
12+
import java.nio.charset.StandardCharsets
13+
14+
class BatchedQueryResponseWriterTest extends Specification {
15+
16+
@Unroll
17+
def "should write utf8 results into the response with content #result"() {
18+
given:
19+
def byteArrayOutputStream = new ByteArrayOutputStream()
20+
def graphQLObjectMapperMock = GraphQLObjectMapper.newBuilder().withObjectMapperProvider({ new ObjectMapper() }).build()
21+
graphQLObjectMapperMock.getJacksonMapper() >> new ObjectMapper()
22+
23+
def requestMock = Mock(HttpServletRequest)
24+
def responseMock = Mock(HttpServletResponse)
25+
def servletOutputStreamMock = Mock(ServletOutputStream)
26+
27+
responseMock.getOutputStream() >> servletOutputStreamMock
28+
29+
1 * responseMock.setContentLength(expectedContentLengh)
30+
1 * responseMock.setCharacterEncoding(StandardCharsets.UTF_8.name())
31+
(1.._) * servletOutputStreamMock.write(_) >> { value ->
32+
byteArrayOutputStream.write((byte[])(value[0]))
33+
}
34+
35+
def executionResultList = new ArrayList()
36+
for (LinkedHashMap<Object, Object> value : result) {
37+
executionResultList.add(new ExecutionResultImpl(value, []))
38+
}
39+
40+
def writer = new BatchedQueryResponseWriter(executionResultList, graphQLObjectMapperMock)
41+
42+
when:
43+
writer.write(requestMock, responseMock)
44+
45+
then:
46+
byteArrayOutputStream.toString(StandardCharsets.UTF_8.name()) == expectedResponseContent
47+
48+
where:
49+
result || expectedContentLengh | expectedResponseContent
50+
[[testValue: "abcde"]] || 32 | """[{"data":{"testValue":"abcde"}}]"""
51+
[[testValue: "äöüüöß"]] || 39 | """[{"data":{"testValue":"äöüüöß"}}]"""
52+
[] || 2 | """[]"""
53+
[[k1: "äöüüöß"], [k2: "a"]] || 52 | """[{"data":{"k1":"äöüüöß"}},{"data":{"k2":"a"}}]"""
54+
}
55+
}

0 commit comments

Comments
 (0)