Skip to content

Commit 32e9f3f

Browse files
committed
Implement lazy response decompression with ResponseDecoder interface
This commit addresses vlsi's architectural feedback on PR apache#6389 by implementing a ResponseDecoder interface pattern to decouple decompression logic from SampleResult. Key Changes: - Created ResponseDecoder interface in core module for pluggable decoders - Implemented PlainResponseDecoder for uncompressed responses - Created ResponseDecoderFactory in HTTP module with support for: * gzip/x-gzip compression (with relax mode support) * deflate compression (with relax mode support) * Brotli compression - Modified SampleResult to: * Store raw (possibly compressed) response data * Track content encoding via new responseContentEncoding field * Lazily decompress on getResponseData() using registered decoders * Cache decompressed data to avoid repeated decompression * Use a registry pattern to avoid circular dependencies - Updated HTTPHC4Impl to: * Disable automatic decompression (removed RESPONSE_CONTENT_ENCODING interceptor) * Extract and store Content-Encoding header value * Store raw compressed response data - Updated HTTPJavaImpl to: * Remove inline gzip decompression * Extract and store Content-Encoding header value * Store raw compressed response data Benefits: - Decoupled architecture as suggested by vlsi - Memory efficiency: only decompress when data is actually accessed - CPU efficiency: avoid unnecessary decompression for responses that aren't read - Maintains backward compatibility - Supports all existing compression formats (gzip, deflate, brotli) The implementation uses a registry pattern where the HTTP module registers its decoders with SampleResult, avoiding circular dependencies between core and protocol modules.
1 parent b4ccab3 commit 32e9f3f

6 files changed

Lines changed: 435 additions & 15 deletions

File tree

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
/*
2+
* Licensed to the Apache Software Foundation (ASF) under one or more
3+
* contributor license agreements. See the NOTICE file distributed with
4+
* this work for additional information regarding copyright ownership.
5+
* The ASF licenses this file to you under the Apache License, Version 2.0
6+
* (the "License"); you may not use this file except in compliance with
7+
* the License. You may obtain a copy of the License at
8+
*
9+
* http://www.apache.org/licenses/LICENSE-2.0
10+
*
11+
* Unless required by applicable law or agreed to in writing, software
12+
* distributed under the License is distributed on an "AS IS" BASIS,
13+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
14+
* See the License for the specific language governing permissions and
15+
* limitations under the License.
16+
*/
17+
18+
package org.apache.jmeter.samplers;
19+
20+
/**
21+
* Decoder for uncompressed response data.
22+
* Simply returns the data as-is without any transformation.
23+
*
24+
* @since 6.0
25+
*/
26+
public class PlainResponseDecoder implements ResponseDecoder {
27+
28+
/** Singleton instance */
29+
public static final PlainResponseDecoder INSTANCE = new PlainResponseDecoder();
30+
31+
private PlainResponseDecoder() {
32+
// Singleton
33+
}
34+
35+
@Override
36+
public byte[] decode(byte[] compressedData) {
37+
return compressedData;
38+
}
39+
40+
@Override
41+
public String getContentEncoding() {
42+
return null;
43+
}
44+
45+
@Override
46+
public boolean isCompressed() {
47+
return false;
48+
}
49+
}
Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
/*
2+
* Licensed to the Apache Software Foundation (ASF) under one or more
3+
* contributor license agreements. See the NOTICE file distributed with
4+
* this work for additional information regarding copyright ownership.
5+
* The ASF licenses this file to you under the Apache License, Version 2.0
6+
* (the "License"); you may not use this file except in compliance with
7+
* the License. You may obtain a copy of the License at
8+
*
9+
* http://www.apache.org/licenses/LICENSE-2.0
10+
*
11+
* Unless required by applicable law or agreed to in writing, software
12+
* distributed under the License is distributed on an "AS IS" BASIS,
13+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
14+
* See the License for the specific language governing permissions and
15+
* limitations under the License.
16+
*/
17+
18+
package org.apache.jmeter.samplers;
19+
20+
import java.io.IOException;
21+
22+
/**
23+
* Interface for decoding response data that may be compressed.
24+
* This allows for lazy decompression of HTTP response bodies,
25+
* improving memory and CPU efficiency by only decompressing when needed.
26+
*
27+
* @since 6.0
28+
*/
29+
public interface ResponseDecoder {
30+
31+
/**
32+
* Decode (decompress if necessary) the provided response data.
33+
*
34+
* @param compressedData the raw response data, which may be compressed
35+
* @return the decompressed response data
36+
* @throws IOException if decompression fails
37+
*/
38+
byte[] decode(byte[] compressedData) throws IOException;
39+
40+
/**
41+
* Get the content encoding type handled by this decoder.
42+
*
43+
* @return the content encoding name (e.g., "gzip", "deflate", "br", or null for no encoding)
44+
*/
45+
String getContentEncoding();
46+
47+
/**
48+
* Check if this decoder actually performs decompression.
49+
*
50+
* @return true if the decoder decompresses data, false if it returns data as-is
51+
*/
52+
default boolean isCompressed() {
53+
return getContentEncoding() != null && !getContentEncoding().isEmpty();
54+
}
55+
}

src/core/src/main/java/org/apache/jmeter/samplers/SampleResult.java

Lines changed: 104 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -25,9 +25,12 @@
2525
import java.nio.charset.StandardCharsets;
2626
import java.util.ArrayList;
2727
import java.util.List;
28+
import java.util.Locale;
29+
import java.util.Map;
2830
import java.util.Set;
2931
import java.util.concurrent.ConcurrentHashMap;
3032
import java.util.concurrent.TimeUnit;
33+
import java.util.function.Function;
3134

3235
import org.apache.jmeter.assertions.AssertionResult;
3336
import org.apache.jmeter.gui.Searchable;
@@ -95,6 +98,13 @@ public class SampleResult implements Serializable, Cloneable, Searchable {
9598

9699
private static final boolean DISABLE_SUBRESULTS_RENAMING = JMeterUtils.getPropDefault("subresults.disable_renaming", false);
97100

101+
/**
102+
* Registry of response decoders by content-encoding type.
103+
* Allows HTTP module to register decompression handlers without creating circular dependencies.
104+
* @since 6.0
105+
*/
106+
private static final Map<String, Function<byte[], byte[]>> RESPONSE_DECODERS = new ConcurrentHashMap<>();
107+
98108
// List of types that are known to be binary
99109
private static final String[] BINARY_TYPES = {
100110
"image/", //$NON-NLS-1$
@@ -276,6 +286,20 @@ public class SampleResult implements Serializable, Cloneable, Searchable {
276286
*/
277287
private transient volatile String responseDataAsString;
278288

289+
/**
290+
* The Content-Encoding header value from the response (e.g., "gzip", "deflate", "br").
291+
* If null or empty, the response is not compressed.
292+
* @since 6.0
293+
*/
294+
private String responseContentEncoding;
295+
296+
/**
297+
* Cache for decompressed response data to avoid repeated decompression.
298+
* Only used when responseContentEncoding is not null/empty.
299+
* @since 6.0
300+
*/
301+
private transient volatile byte[] decompressedResponseData;
302+
279303
public SampleResult() {
280304
this(USE_NANO_TIME, NANOTHREAD_SLEEP);
281305
}
@@ -322,6 +346,8 @@ public SampleResult(SampleResult res) {
322346
responseCode = res.responseCode;//OK
323347
responseData = res.responseData;//OK
324348
responseDataAsString = null;
349+
responseContentEncoding = res.responseContentEncoding;//OK
350+
decompressedResponseData = null;
325351
responseHeaders = res.responseHeaders;//OK
326352
responseMessage = res.responseMessage;//OK
327353

@@ -736,6 +762,7 @@ public SampleResult[] getSubResults() {
736762
*/
737763
public void setResponseData(byte[] response) {
738764
responseDataAsString = null;
765+
decompressedResponseData = null;
739766
responseData = response == null ? EMPTY_BA : response;
740767
}
741768

@@ -783,17 +810,92 @@ public void setResponseData(final String response, final String encoding) {
783810
/**
784811
* Gets the responseData attribute of the SampleResult object.
785812
* <p>
813+
* If the response data is compressed (indicated by {@link #getResponseContentEncoding()}),
814+
* this method will lazily decompress it using the registered decoder for that encoding.
815+
* The decompressed result is cached to avoid repeated decompression.
816+
* </p>
817+
* <p>
786818
* Note that some samplers may not store all the data, in which case
787819
* getResponseData().length will be incorrect.
788820
*
789821
* Instead, always use {@link #getBytes()} to obtain the sample result byte count.
790822
* </p>
791-
* @return the responseData value (cannot be null)
823+
* @return the responseData value (cannot be null), decompressed if necessary
792824
*/
793825
public byte[] getResponseData() {
826+
// If no content encoding, return raw data
827+
if (responseContentEncoding == null || responseContentEncoding.isEmpty()) {
828+
return responseData;
829+
}
830+
831+
// Check if we've already decompressed
832+
if (decompressedResponseData != null) {
833+
return decompressedResponseData;
834+
}
835+
836+
// Attempt to decompress
837+
String encoding = responseContentEncoding.toLowerCase(Locale.ENGLISH).trim();
838+
Function<byte[], byte[]> decoder = RESPONSE_DECODERS.get(encoding);
839+
840+
if (decoder != null) {
841+
try {
842+
decompressedResponseData = decoder.apply(responseData);
843+
return decompressedResponseData;
844+
} catch (Exception e) {
845+
log.warn("Failed to decompress response data with encoding '{}': {}",
846+
responseContentEncoding, e.getMessage(), e);
847+
// Fall through to return raw data
848+
}
849+
} else {
850+
log.warn("No decoder registered for content encoding: {}", responseContentEncoding);
851+
}
852+
853+
// If decompression failed or no decoder available, return raw data
794854
return responseData;
795855
}
796856

857+
/**
858+
* Register a decoder function for a specific content-encoding type.
859+
* This allows the HTTP module to provide decompression capabilities without
860+
* creating circular dependencies with the core module.
861+
*
862+
* @param contentEncoding the content-encoding name (e.g., "gzip", "deflate", "br")
863+
* @param decoder the function that decompresses byte[] to byte[]
864+
* @since 6.0
865+
*/
866+
public static void registerResponseDecoder(String contentEncoding, Function<byte[], byte[]> decoder) {
867+
if (contentEncoding != null && decoder != null) {
868+
String key = contentEncoding.toLowerCase(Locale.ENGLISH).trim();
869+
RESPONSE_DECODERS.put(key, decoder);
870+
log.debug("Registered response decoder for content-encoding: {}", contentEncoding);
871+
}
872+
}
873+
874+
/**
875+
* Get the response content encoding (e.g., "gzip", "deflate", "br").
876+
* When set, indicates that the response data stored via {@link #setResponseData(byte[])}
877+
* is in compressed format and will be lazily decompressed when {@link #getResponseData()} is called.
878+
*
879+
* @return the content encoding, or null if the response is not compressed
880+
* @since 6.0
881+
*/
882+
public String getResponseContentEncoding() {
883+
return responseContentEncoding;
884+
}
885+
886+
/**
887+
* Set the response content encoding (e.g., "gzip", "deflate", "br").
888+
* This should be set when storing compressed response data to enable lazy decompression.
889+
* Clear the decompressed data cache when changing the encoding.
890+
*
891+
* @param contentEncoding the content encoding value from the Content-Encoding header
892+
* @since 6.0
893+
*/
894+
public void setResponseContentEncoding(String contentEncoding) {
895+
this.responseContentEncoding = contentEncoding;
896+
this.decompressedResponseData = null;
897+
}
898+
797899
/**
798900
* Gets the responseData of the SampleResult object as a String
799901
*
@@ -1598,6 +1700,7 @@ public void setStartNextThreadLoop(boolean startNextThreadLoop) {
15981700
*/
15991701
public void cleanAfterSample() {
16001702
this.responseDataAsString = null;
1703+
this.decompressedResponseData = null;
16011704
}
16021705

16031706
@Override

src/protocol/http/src/main/java/org/apache/jmeter/protocol/http/sampler/HTTPHC4Impl.java

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -673,8 +673,16 @@ protected HTTPSampleResult sample(URL url, String method,
673673
res.setContentType(ct);
674674
res.setEncodingAndType(ct);
675675
}
676+
677+
// Extract Content-Encoding for lazy decompression
678+
Header contentEncoding = httpResponse.getLastHeader(HTTPConstants.HEADER_CONTENT_ENCODING);
679+
if (contentEncoding != null) {
680+
res.setResponseContentEncoding(contentEncoding.getValue());
681+
}
682+
676683
HttpEntity entity = httpResponse.getEntity();
677684
if (entity != null) {
685+
// Read raw (possibly compressed) data for lazy decompression
678686
res.setResponseData(readResponse(res, entity.getContent(), entity.getContentLength()));
679687
}
680688

@@ -1157,7 +1165,8 @@ private MutableTriple<CloseableHttpClient, AuthState, PoolingHttpClientConnectio
11571165
}
11581166
builder.setDefaultCredentialsProvider(credsProvider);
11591167
}
1160-
builder.disableContentCompression().addInterceptorLast(RESPONSE_CONTENT_ENCODING);
1168+
// Disable content compression to store raw compressed data for lazy decompression
1169+
builder.disableContentCompression();
11611170
if(BASIC_AUTH_PREEMPTIVE) {
11621171
builder.addInterceptorFirst(PREEMPTIVE_AUTH_INTERCEPTOR);
11631172
} else {

src/protocol/http/src/main/java/org/apache/jmeter/protocol/http/sampler/HTTPJavaImpl.java

Lines changed: 10 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,6 @@
3131
import java.util.List;
3232
import java.util.Map;
3333
import java.util.function.Predicate;
34-
import java.util.zip.GZIPInputStream;
3534

3635
import org.apache.commons.io.input.CountingInputStream;
3736
import org.apache.jmeter.protocol.http.control.AuthManager;
@@ -239,16 +238,17 @@ protected byte[] readResponse(HttpURLConnection conn, SampleResult res) throws I
239238
return NULL_BA;
240239
}
241240

242-
// works OK even if ContentEncoding is null
243-
boolean gzipped = HTTPConstants.ENCODING_GZIP.equals(conn.getContentEncoding());
241+
// Store content encoding for lazy decompression
242+
String contentEncoding = conn.getContentEncoding();
243+
if (contentEncoding != null && !contentEncoding.isEmpty()) {
244+
res.setResponseContentEncoding(contentEncoding);
245+
}
246+
244247
CountingInputStream instream = null;
245248
try {
249+
// Read raw (possibly compressed) data for lazy decompression
246250
instream = new CountingInputStream(conn.getInputStream());
247-
if (gzipped) {
248-
in = new GZIPInputStream(instream);
249-
} else {
250-
in = instream;
251-
}
251+
in = instream;
252252
} catch (IOException e) {
253253
if (! (e.getCause() instanceof FileNotFoundException))
254254
{
@@ -276,11 +276,8 @@ protected byte[] readResponse(HttpURLConnection conn, SampleResult res) throws I
276276
log.info("Error Response Code: {}", conn.getResponseCode());
277277
}
278278

279-
if (gzipped) {
280-
in = new GZIPInputStream(errorStream);
281-
} else {
282-
in = errorStream;
283-
}
279+
// Read raw error stream for lazy decompression
280+
in = errorStream;
284281
} catch (Exception e) {
285282
log.error("readResponse: {}", e.toString());
286283
Throwable cause = e.getCause();

0 commit comments

Comments
 (0)