SpringBoot 中自定义 Filter 支持 gzip 压缩的请求体

SpringBoot Web 项目要支持响应数据的自动压缩只需要在 application.properties 中配置 server.compression.enabled=true 即可,默认为 false. 这样对于默认 server.compression.min-response-size=2KB 达到 2KB 大小的响应,并且请求头中有 Accept-Encoding: gzipdeflate 就会压缩响应数据。

相关的配置请参考:SpringBoot Server Properties

  1. server.compression.enabled: 默认为  false
  2. server.compression.excluded-user-agents: 针对哪些 user-agents(逗号分隔) 不压缩,默认为空
  3. server.compression.mime-types: 会对哪些响应的 content-type 进行压缩,默认为 text/html, text/xml, text/plain, text/css, text/javascript, application/javascript, application/json, application/xml
  4. 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 方法

收到字节内容后,如果 Content-Encoding 是 gzip, 则解压缩,否则直接转换为字符串。但是希望压缩的内容直接变为一个 JavaBean 的话还需继续人工处理反序列化。例如压缩的 JSON 字符希望能自动转换为 @RequestBody UserDetail userDetail 最好要用一个 RequestFilter。

我们现在对这个 /test-gzi-request 进行测试,用 Python 代码

执行后客户端输出为(响应内容省略)

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. 没有引入 jakarta.servlet.ReadListener
  2. 所有请求最终全放在内存中,用 ByteArrayInputStream,如果能转换成真正的 InputStream 流会好些
  3. 在 SpringBoot 3.4.3 中根本读取不到完整的请求数据(又是 AI 幻觉)

为测试上面的 GzipRequestFilter, 我们改造一下前面的 Controller 方法

再用相同的 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 及之后的代码

inputMessage.getHeaders().getContentLength() 从请求头 Content-Length 来,它决定了只读取多少内容。

该方法在 Spring 6.0.x 中的实现没有限定读取长度

所以在 SpringBoot 3.1.x(Spring 6.0.x) 及之前的版本应该是没问题。

那么我们要对使用 SpringBoot 3.1.x 之后的版本对此 GZipRequestFilter 进行修复,目的是还给一个足够大的 length 去读压缩的请求内容,或者直接触发调用 inputMessage.getBody().readAllBytes()。如果是转换成了 ByteArrayInputStream 可以知道确切的请求长度,如果直接给一个当前系统能接受的最大请求体长度。

下面是经过多番摸索找到的一个实现

import 部分省略了,请自行引入。

这里我们设定了一个当前系统支持的最大的解压缩后的 PayLoad 大小为 5M,其实设置一个非常大的值也没关系,1G 都没问题,反正最终在读取完 gzipInputStream 后就会停下来,默认用 16Kb 的缓存读取。

不再使用 ByteArrayInputStream 把所有字节内容从 request.getInputStream() 中预先读取出来,而只是对 request.getInputStream()  简单的包装为 GZipInputStream, 待到读取内容时从流中逐步获取。

可以覆盖的 HttpServletRequestWrapper 方法其实还可能有

  1. int getContentLength()
  2. long getContentLengthLong()
  3. String getHeader(String name)
  4. int getIntHeader(String name)

最终经过多次尝试确定只需要覆盖 Enumeration<String> getHeaders(String name) 方法。

org.springframework.http.server.ServletServerHttpRequest 中还有一个逻辑,当 getContentLength() < 0 时会调用 servletRequest.getContentLength()

因此也可以在 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...............................................

服务端输出始终是一样的。

进一步测试

客户端的代码变为

测试

客户端输出(完整输出)

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> 对象。

链接:

  1. Spring Boot Put payload with gzip compressed is getting truncated after upgrade to 3.2.0 from 3.1.9
  2. StringHttpMessageConverter truncates output when data is compressed

本文链接 https://yanbin.blog/springboot-custom-request-filter-to-support-gzip-request-body/, 来自 隔叶黄莺 Yanbin Blog

[版权声明] Creative Commons License 本文采用 署名-非商业性使用-相同方式共享 4.0 国际 (CC BY-NC-SA 4.0) 进行许可。

Subscribe
Notify of
guest

0 Comments
Inline Feedbacks
View all comments