SpringBoot Web 项目要支持响应数据的自动压缩只需要在 application.properties
中配置 server.compression.enabled=true
即可,默认为 false. 这样对于默认 server.compression.min-response-size=2KB
达到 2KB 大小的响应,并且请求头中有 Accept-Encoding: gzip
或 deflate
就会压缩响应数据。
相关的配置请参考:SpringBoot Server Properties
- server.compression.enabled: 默认为 false
- server.compression.excluded-user-agents: 针对哪些 user-agents(逗号分隔) 不压缩,默认为空
- server.compression.mime-types: 会对哪些响应的 content-type 进行压缩,默认为 text/html, text/xml, text/plain, text/css, text/javascript, application/javascript, application/json, application/xml
- server.compression.min-response-size: 默认为 2KB
文本数据压缩比可达到百分之七八十,对于节约网络消耗来说是非常可关的,不过要些许 CPU 资源。说完了响应的自动压缩,如果请求数据较大也应考虑对请求进行压缩。比如客户端发送请求时带上 Content-Encoding:gzip
, 并且请求内容是 gzip 压缩的。
提示:如果 SpringBoot Web 是放在 AWS API Gateway 后端,那么 AWS API Gateway 会在看到请求头 Content-Encoding 的值为 gzip, compress, deflate, 或 br, 会自动解压缩请求数据,然后转发解压缩后的数据到后端,这时候 SpringBoot Web 无需进行请求数据的解压处理。不过对响应数据的压缩是 SpringBoot Web 要做的,AWS API Gateway 并无该功能。
SpringBoot 默认不支持自动解压缩请求内容,如果手动在 Controller 方法中,可以接收字节数组,然后自行解压缩。比如定义如下的 Controller 方法
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 |
@PostMapping("/test-gzip-request") public String testGzipRequest(HttpServletRequest request, @RequestBody byte[] requestBody) throws IOException { System.out.println("server content-encoding: " + request.getHeader("Content-Encoding")); System.out.println("server requestLength: " + request.getContentLength()); if (StringUtils.containsIgnoreCase(request.getHeader(CONTENT_ENCODING), "gzip")) { try (GZIPInputStream gzipInputStream = new GZIPInputStream(new ByteArrayInputStream(requestBody))) { ByteArrayOutputStream baos = new ByteArrayOutputStream(); byte[] buffer = new byte[1024]; int len; while ((len = gzipInputStream.read(buffer)) != -1) { baos.write(buffer, 0, len); } return baos.toString(); } } return new String(requestBody); } |
收到字节内容后,如果 Content-Encoding 是 gzip, 则解压缩,否则直接转换为字符串。但是希望压缩的内容直接变为一个 JavaBean 的话还需继续人工处理反序列化。例如压缩的 JSON 字符希望能自动转换为 @RequestBody UserDetail userDetail
最好要用一个 RequestFilter。
我们现在对这个 /test-gzi-request 进行测试,用 Python 代码
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 |
import requests import gzip request_body = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789" * 10 print("client request_body length: ", len(request_body)) gzip_request_body = gzip.compress(request_body) print("client gzip request_body length: ", len(gzip_request_body)) res = requests.post("http://localhost:8080/test-gzip-request", data=gzip_request_body, headers={"Content-Encoding": "gzip"}) print("client response_body length: ", len(res.text)) print(res.text) |
执行后客户端输出为(响应内容省略)
client request_body length: 620
client gzip request_body length: 89
client response_body length: 620
ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789ABC...............................................
服务端为
server content-encoding: gzip
server requestLength: 89
这里可以看到因为请求内容重复度很高,所以压缩比也很高,从 620 降至 89。如果实际应用中需发送大内容的 JSON 或 XML 文本,应适当考虑要求客户端对请求进行压缩传输。
下面用自定义 RequestFilter 的方式来自动化处理 gzip 请求内容的解压操作,如果用 ChatGPT 搜索 SpringBoot 自定义 GzipRequestFilter 的话大概率为找到如下的实现
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 |
import jakarta.servlet.FilterChain; import jakarta.servlet.ServletException; import jakarta.servlet.ServletInputStream; import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpServletRequestWrapper; import jakarta.servlet.http.HttpServletResponse; import org.springframework.stereotype.Component; import org.springframework.web.filter.OncePerRequestFilter; import java.io.*; import java.util.zip.GZIPInputStream; @Component public class GzipRequestFilter extends OncePerRequestFilter { @Override protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException { String contentEncoding = request.getHeader("Content-Encoding"); if ("gzip".equalsIgnoreCase(contentEncoding)) { HttpServletRequest decompressedRequest = new GzipServletRequestWrapper(request); filterChain.doFilter(decompressedRequest, response); } else { filterChain.doFilter(request, response); } } private static class GzipServletRequestWrapper extends HttpServletRequestWrapper { private final byte[] decompressedBody; public GzipServletRequestWrapper(HttpServletRequest request) throws IOException { super(request); try (GZIPInputStream gzipInputStream = new GZIPInputStream(request.getInputStream()); ByteArrayOutputStream byteStream = new ByteArrayOutputStream()) { byte[] buffer = new byte[1024]; int len; while ((len = gzipInputStream.read(buffer)) > 0) { byteStream.write(buffer, 0, len); } this.decompressedBody = byteStream.toByteArray(); } } @Override public ServletInputStream getInputStream() { ByteArrayInputStream byteArrayInputStream = new ByteArrayInputStream(decompressedBody); return new ServletInputStream() { @Override public boolean isFinished() { return byteArrayInputStream.available() == 0; } @Override public boolean isReady() { return true; } @Override public void setReadListener(ReadListener readListener) { // Not needed } @Override public int read() { return byteArrayInputStream.read(); } }; } @Override public BufferedReader getReader() { return new BufferedReader(new InputStreamReader(getInputStream())); } } } |
这段代码有三个主要的问题
- 没有引入
jakarta.servlet.ReadListener
- 所有请求最终全放在内存中,用 ByteArrayInputStream,如果能转换成真正的 InputStream 流会好些
- 在 SpringBoot 3.4.3 中根本读取不到完整的请求数据(又是 AI 幻觉)
为测试上面的 GzipRequestFilter, 我们改造一下前面的 Controller 方法
1 2 3 4 5 6 |
@PostMapping("/test-gzip-request") public String testGzipRequest(HttpServletRequest request, @RequestBody String requestBody) { System.out.println("server content-encoding: " + request.getHeader("Content-Encoding")); System.out.println("server requestLength: " + request.getContentLength()); return requestBody; } |
再用相同的 Python 代码进行测试
客户端输出为(响应为不完整的输出)
client request_body length: 620
client gzip request_body length: 89
client response_body length: 89
ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZa
内容缺失了
服务端输出不变
server content-encoding: gzip
server requestLength: 89
原因就是 SpringBoot 根据压缩请求的 Content-Length: 89 从解压内容中只读取了同样 89 个字符,超出部分被忽略掉了。也就是说这里的 new ServletInputStream#read() 方法只被调用了 89 次,尽管有 isFinished() 方法好像可以辅助判断是否读到的末尾,而实际上该方法未被调用。
最好的办法仍然是查源代码,找到 SpringBoot 的 StringHttpMessageConverter#readInternal(Class, HttpInputMessage). 这是 Spring 6.1.x 及之后的代码
1 2 3 4 5 6 7 8 |
@Override protected String readInternal(Class<? extends String> clazz, HttpInputMessage inputMessage) throws IOException { Charset charset = getContentTypeCharset(inputMessage.getHeaders().getContentType()); long length = inputMessage.getHeaders().getContentLength(); byte[] bytes = (length >= 0 && length <= Integer.MAX_VALUE ? inputMessage.getBody().readNBytes((int) length) : inputMessage.getBody().readAllBytes()); return new String(bytes, charset); } |
inputMessage.getHeaders().getContentLength() 从请求头 Content-Length 来,它决定了只读取多少内容。
该方法在 Spring 6.0.x 中的实现没有限定读取长度
1 2 3 4 5 |
@Override protected String readInternal(Class<? extends String> clazz, HttpInputMessage inputMessage) throws IOException { Charset charset = getContentTypeCharset(inputMessage.getHeaders().getContentType()); return StreamUtils.copyToString(inputMessage.getBody(), charset); } |
所以在 SpringBoot 3.1.x(Spring 6.0.x) 及之前的版本应该是没问题。
那么我们要对使用 SpringBoot 3.1.x 之后的版本对此 GZipRequestFilter 进行修复,目的是还给一个足够大的 length
去读压缩的请求内容,或者直接触发调用 inputMessage.getBody().readAllBytes()。如果是转换成了 ByteArrayInputStream 可以知道确切的请求长度,如果直接给一个当前系统能接受的最大请求体长度。
下面是经过多番摸索找到的一个实现
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 |
@Component public class GzipRequestFilters extends OncePerRequestFilter { private static final String MAX_PAYLOAD_SIZE = String.valueOf(5 * 1024 * 1024); @Override protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException { if (request.getContentLength() > 0 && StringUtils.containsIgnoreCase(request.getHeader(CONTENT_ENCODING), "gzip")) { HttpServletRequest decompressedRequest = new GzipServletRequestWrapper(request); filterChain.doFilter(decompressedRequest, response); } else { filterChain.doFilter(request, response); } } private static class GzipServletRequestWrapper extends HttpServletRequestWrapper { private final GZIPInputStream gzipInputStream; public GzipServletRequestWrapper(HttpServletRequest request) throws IOException { super(request); this.gzipInputStream = new GZIPInputStream(request.getInputStream()); } @Override public Enumeration<String> getHeaders(String name) { if (HttpHeaders.CONTENT_LENGTH.equalsIgnoreCase(name)) { return Collections.enumeration(Collections.singleton(MAX_PAYLOAD_SIZE)); } return super.getHeaders(name); } @Override public ServletInputStream getInputStream() { return new ServletInputStream() { private boolean finished = false; @Override public int read() throws IOException { int data = GzipServletRequestWrapper.this.gzipInputStream.read(); if (data == -1) { this.finished = true; } return data; } @Override public boolean isFinished() { return finished; } @Override public boolean isReady() { return true; } @Override public void setReadListener(ReadListener listener) { throw new UnsupportedOperationException(); } }; } } } |
import 部分省略了,请自行引入。
这里我们设定了一个当前系统支持的最大的解压缩后的 PayLoad 大小为 5M,其实设置一个非常大的值也没关系,1G 都没问题,反正最终在读取完 gzipInputStream 后就会停下来,默认用 16Kb 的缓存读取。
不再使用 ByteArrayInputStream 把所有字节内容从 request.getInputStream() 中预先读取出来,而只是对 request.getInputStream() 简单的包装为 GZipInputStream, 待到读取内容时从流中逐步获取。
可以覆盖的 HttpServletRequestWrapper 方法其实还可能有
- int getContentLength()
- long getContentLengthLong()
- String getHeader(String name)
- int getIntHeader(String name)
最终经过多次尝试确定只需要覆盖 Enumeration<String> getHeaders(String name) 方法。
在 org.springframework.http.server.ServletServerHttpRequest 中还有一个逻辑,当 getContentLength() < 0 时会调用 servletRequest.getContentLength()
1 2 3 4 5 6 |
if (this.headers.getContentLength() < 0) { int requestContentLength = this.servletRequest.getContentLength(); if (requestContentLength != -1) { this.headers.setContentLength(requestContentLength); } } |
因此也可以在 Enumeration<String> getHeaders(String name) 返回 ContentLength -1, 同时覆盖 int getContentLength() 给定一个长度。
反正就是不能使用压缩后的 Content-Length 去读取解压后的内容,如果转换 request.getInputStream() 为 ByteArrayInputStream 的话可以用未压缩请求的长度,或给定一个足够大的数值。
再执行客户端 Python 代码
客户端输出为(响应内容省略)
client request_body length: 620
client gzip request_body length: 89
client response_body length: 620
ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789ABC...............................................
服务端输出始终是一样的。
进一步测试
1 2 3 4 5 6 |
@PostMapping("/test-gzip-request") public String testGzipRequest(HttpServletRequest request, @RequestBody Map<String, String> requestBody) { System.out.println("server content-encoding: " + request.getHeader("Content-Encoding")); System.out.println("server requestLength: " + request.getContentLength()); return requestBody.get("data"); } |
客户端的代码变为
1 2 3 4 5 6 7 8 9 10 11 12 |
request_body = '{"data": "%s"}' % ("ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789" * 10) print("client request_body length: ", len(request_body)) gzip_request_body = gzip.compress(request_body.encode()) print("client gzip request_body length: ", len(gzip_request_body)) res = requests.post("http://localhost:8080/test-gzip-request", data=gzip_request_body, headers={"Content-Encoding": "gzip", "Content-Type": "application/json"}) print("client response_body length: ", len(res.text)) print(res.text) |
测试
客户端输出(完整输出)
client request_body length: 632
client gzip request_body length: 100
client response_body length: 620
ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789A......
服务端
server content-encoding: gzip
server requestLength: 100
说明 GZipRequestFilter 能正常解压缩请求内容,并映射成 Controller 的 Map<String, String> 对象。
链接: