Skip to content

Commit e3a8535

Browse files
jvangaalenvlsi
authored andcommitted
Store raw body in responseData and only decompress when responseBody is accessed
1 parent 6bab075 commit e3a8535

19 files changed

Lines changed: 1138 additions & 314 deletions

File tree

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

Lines changed: 26 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@
1717

1818
package org.apache.jmeter.samplers;
1919

20+
import java.io.IOException;
2021
import java.io.Serializable;
2122
import java.io.UnsupportedEncodingException;
2223
import java.net.HttpURLConnection;
@@ -161,6 +162,8 @@ public class SampleResult implements Serializable, Cloneable, Searchable {
161162

162163
private byte[] responseData = EMPTY_BA;
163164

165+
private String contentEncoding; // Stores gzip/deflate encoding if response is compressed
166+
164167
private String responseCode = "";// Never return null
165168

166169
private String label = "";// Never return null
@@ -792,6 +795,16 @@ public void setResponseData(final String response, final String encoding) {
792795
* @return the responseData value (cannot be null)
793796
*/
794797
public byte[] getResponseData() {
798+
if (responseData == null) {
799+
return EMPTY_BA;
800+
}
801+
if (contentEncoding != null && responseData.length > 0) {
802+
try {
803+
return ResponseDecoderRegistry.decode(contentEncoding, responseData);
804+
} catch (IOException e) {
805+
log.warn("Failed to decompress response data", e);
806+
}
807+
}
795808
return responseData;
796809
}
797810

@@ -803,12 +816,12 @@ public byte[] getResponseData() {
803816
public String getResponseDataAsString() {
804817
try {
805818
if(responseDataAsString == null) {
806-
responseDataAsString= new String(responseData,getDataEncodingWithDefault());
819+
responseDataAsString= new String(getResponseData(),getDataEncodingWithDefault());
807820
}
808821
return responseDataAsString;
809822
} catch (UnsupportedEncodingException e) {
810823
log.warn("Using platform default as {} caused {}", getDataEncodingWithDefault(), e.getLocalizedMessage());
811-
return new String(responseData,Charset.defaultCharset()); // N.B. default charset is used deliberately here
824+
return new String(getResponseData(),Charset.defaultCharset()); // N.B. default charset is used deliberately here
812825
}
813826
}
814827

@@ -1666,4 +1679,15 @@ public TestLogicalAction getTestLogicalAction() {
16661679
public void setTestLogicalAction(TestLogicalAction testLogicalAction) {
16671680
this.testLogicalAction = testLogicalAction;
16681681
}
1682+
1683+
/**
1684+
* Sets the response data and its contentEncoding.
1685+
* @param data The response data
1686+
* @param contentEncoding The content contentEncoding (e.g. gzip, deflate)
1687+
*/
1688+
public void setResponseData(byte[] data, String contentEncoding) {
1689+
responseData = data == null ? EMPTY_BA : data;
1690+
this.contentEncoding = contentEncoding;
1691+
responseDataAsString = null;
1692+
}
16691693
}
Lines changed: 95 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,95 @@
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 org.apache.jorphan.io.DirectAccessByteArrayOutputStream
21+
import org.apache.jorphan.reflect.JMeterService
22+
import org.apiguardian.api.API
23+
import java.io.ByteArrayInputStream
24+
import java.io.InputStream
25+
26+
/**
27+
* Interface for response data decoders that handle different content encodings.
28+
* Implementations can be automatically discovered via [java.util.ServiceLoader].
29+
*
30+
* To add a custom decoder:
31+
* 1. Implement this interface
32+
* 2. Create `META-INF/services/org.apache.jmeter.samplers.ResponseDecoder` file
33+
* 4. Add your implementation's fully qualified class name to the file
34+
*
35+
* Example decoders: gzip, deflate, brotli
36+
*
37+
* @since 6.0.0
38+
*/
39+
@JMeterService
40+
@API(status = API.Status.EXPERIMENTAL, since = "6.0.0")
41+
public interface ResponseDecoder {
42+
43+
/**
44+
* Returns the content encodings handled by this decoder.
45+
* These should match Content-Encoding header values (case-insensitive).
46+
*
47+
* A decoder can handle multiple encoding names (e.g., "gzip" and "x-gzip").
48+
*
49+
* Examples: ["gzip", "x-gzip"], ["deflate"], ["br"]
50+
*
51+
* @return list of encoding names this decoder handles (must not be null or empty)
52+
*/
53+
public val encodings: List<String>
54+
55+
/**
56+
* Decodes (decompresses) the given compressed data.
57+
*
58+
* @param compressed the compressed data to decode
59+
* @return the decompressed data
60+
* @throws java.io.IOException if decompression fails
61+
*/
62+
public fun decode(compressed: ByteArray): ByteArray {
63+
val out = DirectAccessByteArrayOutputStream()
64+
decodeStream(ByteArrayInputStream(compressed)).use {
65+
it.transferTo(out)
66+
}
67+
return out.toByteArray()
68+
}
69+
70+
/**
71+
* Creates a decompressing InputStream that wraps the given compressed input stream.
72+
* This allows streaming decompression without buffering the entire response in memory.
73+
*
74+
* Used for scenarios like MD5 computation on decompressed data, where we want to
75+
* compute the hash on-the-fly without storing the entire decompressed response.
76+
*
77+
* @param input the compressed input stream to wrap
78+
* @return an InputStream that decompresses data as it's read
79+
* @throws java.io.IOException if the decompressing stream cannot be created
80+
*/
81+
public fun decodeStream(input: InputStream): InputStream
82+
83+
/**
84+
* Returns the priority of this decoder.
85+
* When multiple decoders are registered for the same encoding,
86+
* the one with the highest priority is used.
87+
*
88+
* Default priority is 0. Built-in decoders use priority 0.
89+
* Plugins can override built-in decoders by returning a higher priority.
90+
*
91+
* @return priority value (higher = preferred), default is 0
92+
*/
93+
public val priority: Int
94+
get() = 0
95+
}
Lines changed: 188 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,188 @@
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 org.apache.jmeter.samplers.decoders.DeflateDecoder
21+
import org.apache.jmeter.samplers.decoders.GzipDecoder
22+
import org.apache.jmeter.util.JMeterUtils
23+
import org.apache.jorphan.reflect.LogAndIgnoreServiceLoadExceptionHandler
24+
import org.apiguardian.api.API
25+
import org.slf4j.LoggerFactory
26+
import java.io.IOException
27+
import java.io.InputStream
28+
import java.util.Locale
29+
import java.util.ServiceLoader
30+
import java.util.concurrent.ConcurrentHashMap
31+
32+
/**
33+
* Registry for [ResponseDecoder] implementations.
34+
* Provides centralized management of response decoders for different content encodings.
35+
*
36+
* Decoders are discovered via:
37+
* - Built-in decoders (gzip, deflate)
38+
* - ServiceLoader mechanism (META-INF/services)
39+
*
40+
* Thread-safe singleton registry.
41+
*
42+
* @since 6.0.0
43+
*/
44+
@API(status = API.Status.EXPERIMENTAL, since = "6.0.0")
45+
public object ResponseDecoderRegistry {
46+
47+
private val log = LoggerFactory.getLogger(ResponseDecoderRegistry::class.java)
48+
49+
/**
50+
* Map of encoding name (lowercase) to decoder implementation.
51+
* Uses ConcurrentHashMap for thread-safe access.
52+
*/
53+
private val decoders = ConcurrentHashMap<String, ResponseDecoder>()
54+
55+
init {
56+
// Register built-in decoders, this ensures the decoders are there even if service registration fails
57+
registerDecoder(GzipDecoder())
58+
registerDecoder(DeflateDecoder())
59+
60+
// Load decoders via ServiceLoader
61+
loadServiceLoaderDecoders()
62+
}
63+
64+
/**
65+
* Loads decoders using ServiceLoader mechanism.
66+
*/
67+
private fun loadServiceLoaderDecoders() {
68+
try {
69+
JMeterUtils.loadServicesAndScanJars(
70+
ResponseDecoder::class.java,
71+
ServiceLoader.load(ResponseDecoder::class.java),
72+
Thread.currentThread().contextClassLoader,
73+
LogAndIgnoreServiceLoadExceptionHandler(log)
74+
).forEach { registerDecoder(it) }
75+
} catch (e: Exception) {
76+
log.error("Error loading ResponseDecoder services", e)
77+
}
78+
}
79+
80+
/**
81+
* Registers a decoder for all its encoding types.
82+
* If a decoder already exists for an encoding, the one with higher priority is kept.
83+
*
84+
* @param decoder the decoder to register
85+
*/
86+
@JvmStatic
87+
public fun registerDecoder(decoder: ResponseDecoder) {
88+
val encodings = decoder.encodings
89+
if (encodings.isEmpty()) {
90+
log.warn("Decoder {} has null or empty encodings list, skipping registration", decoder.javaClass.name)
91+
return
92+
}
93+
94+
for (encoding in encodings) {
95+
val key = encoding.lowercase(Locale.ROOT)
96+
97+
decoders.merge(key, decoder) { existing, newDecoder ->
98+
// Keep the decoder with higher priority
99+
if (newDecoder.priority > existing.priority) {
100+
log.info(
101+
"Replacing decoder for '{}': {} (priority {}) -> {} (priority {})",
102+
encoding,
103+
existing.javaClass.simpleName, existing.priority,
104+
newDecoder.javaClass.simpleName, newDecoder.priority
105+
)
106+
newDecoder
107+
} else {
108+
log.debug(
109+
"Keeping existing decoder for '{}': {} (priority {}) over {} (priority {})",
110+
encoding,
111+
existing.javaClass.simpleName, existing.priority,
112+
newDecoder.javaClass.simpleName, newDecoder.priority
113+
)
114+
existing
115+
}
116+
}
117+
}
118+
}
119+
120+
/**
121+
* Decodes the given data using the decoder registered for the specified encoding.
122+
* If no decoder is found for the encoding, returns the data unchanged.
123+
*
124+
* @param encoding the content encoding (e.g., "gzip", "deflate", "br")
125+
* @param data the data to decode
126+
* @return decoded data, or original data if no decoder found or encoding is null
127+
* @throws IOException if decoding fails
128+
*/
129+
@JvmStatic
130+
@Throws(IOException::class)
131+
public fun decode(encoding: String?, data: ByteArray?): ByteArray {
132+
if (encoding.isNullOrEmpty() || data == null || data.isEmpty()) {
133+
return data ?: ByteArray(0)
134+
}
135+
136+
val decoder = decoders[encoding] ?: decoders[encoding.lowercase(Locale.ROOT)]
137+
138+
if (decoder == null) {
139+
log.debug("No decoder found for encoding '{}', returning data unchanged", encoding)
140+
return data
141+
}
142+
143+
return decoder.decode(data)
144+
}
145+
146+
/**
147+
* Creates a decompressing InputStream that wraps the given input stream using the decoder
148+
* registered for the specified encoding.
149+
*
150+
* This enables streaming decompression without buffering the entire response in memory,
151+
* which is useful for computing checksums on decompressed data or processing large responses.
152+
*
153+
* If no decoder is found for the encoding, returns the original input stream unchanged.
154+
*
155+
* @param encoding the content encoding (e.g., "gzip", "deflate", "br")
156+
* @param input the input stream to wrap with decompression
157+
* @return a decompressing InputStream, or the original stream if no decoder found or encoding is null
158+
* @throws IOException if the decompressing stream cannot be created
159+
* @since 6.0.0
160+
*/
161+
@JvmStatic
162+
@Throws(IOException::class)
163+
public fun decodeStream(encoding: String?, input: InputStream): InputStream {
164+
if (encoding.isNullOrEmpty()) {
165+
return input
166+
}
167+
168+
val decoder = decoders[encoding] ?: decoders[encoding.lowercase(Locale.ROOT)]
169+
170+
if (decoder == null) {
171+
log.debug("No decoder found for encoding '{}', returning input stream unchanged", encoding)
172+
return input
173+
}
174+
175+
return decoder.decodeStream(input)
176+
}
177+
178+
/**
179+
* Checks if a decoder is registered for the given encoding.
180+
* Primarily for testing purposes.
181+
*
182+
* @param encoding the encoding to check
183+
* @return true if a decoder is registered for this encoding
184+
*/
185+
@JvmStatic
186+
public fun hasDecoder(encoding: String): Boolean =
187+
decoders.containsKey(encoding.lowercase(Locale.ROOT))
188+
}

0 commit comments

Comments
 (0)