You need to enable JavaScript to run this app.
优惠活动
大模型
产品
解决方案
定价
更多

Spring Boot 3.1.3升级至3.1.4后Gzip解压内容截断问题求助

Spring Boot 3.1.4+升级后Gzip解压内容截断问题排查与解决建议

问题概述

将Spring Boot从3.1.3升级到3.1.4及更高版本后,API在解压Gzip压缩数据时出现内容截断问题。调试发现:StringHttpMessageConverter.readInternal调用时,请求头中的Content-Length为压缩后的377字节,但解压后实际内容应为1076字节,导致请求体被截断。问题根源是Spring Framework的一项内部变更,该变更让消息转换器更严格依赖Content-Length值来读取请求内容。

相关代码

调用方Gzip压缩代码

private fun getGzip(body: ByteArray?): ByteArray {
    ByteArrayOutputStream().use { byteStream ->
        GZIPOutputStream(byteStream).use { zipStream ->
            zipStream.write(body!!)
            zipStream.finish()
            return byteStream.toByteArray()
        }
    }
}

应用内Gzip解压过滤器代码

@Component
@Order(0)
open class GzipDecompressFilter : OncePerRequestFilter() {

    override fun doFilterInternal(request: HttpServletRequest, response: HttpServletResponse, chain: FilterChain) {
        var httpServletRequest = request

        val contentEncoding = httpServletRequest.getHeader("Content-Encoding")
        if (contentEncoding != null && contentEncoding.indexOf("gzip") > -1) {
            try {
                val decompressStream: InputStream = StreamHelper.decompressStream(httpServletRequest.inputStream)
                httpServletRequest = object : HttpServletRequestWrapper(httpServletRequest) {
                    override fun getInputStream(): ServletInputStream {
                        return DecompressServletInputStream(decompressStream)
                    }

                    override fun getReader(): BufferedReader {
                        return BufferedReader(InputStreamReader(decompressStream))
                    }

                    // 新增:重写内容长度相关方法,返回-1表示未知长度
                    override fun getContentLength(): Int {
                        return -1
                    }

                    override fun getContentLengthLong(): Long {
                        return -1
                    }
                }
            } catch (_: IOException) {
            }
        }

        chain.doFilter(httpServletRequest, response)
    }
}

open class DecompressServletInputStream(private val inputStream: InputStream) : ServletInputStream() {
    private var finished = false

    override fun read(): Int {
        val data = inputStream.read()
        if (data == -1) {
            finished = true
        }
        return data
    }

    override fun isFinished(): Boolean {
        return finished
    }

    override fun isReady(): Boolean {
        return true
    }

    override fun setReadListener(listener: ReadListener?) {
        throw UnsupportedOperationException()
    }
}

object StreamHelper {

    private const val GZIP_ID1: Byte = 0x1f

    private const val GZIP_ID2 = 0x8b.toByte()

    fun decompressStream(input: InputStream?): InputStream {
        val pushbackInput = PushbackInputStream(input, 2)
        val signature = ByteArray(2)
        // 补充处理读取不足2字节的情况
        val readBytes = pushbackInput.read(signature)
        if (readBytes == 2) {
            pushbackInput.unread(signature)
            return if (signature[0] == GZIP_ID1 && signature[1] == GZIP_ID2) {
                GZIPInputStream(pushbackInput)
            } else pushbackInput
        } else {
            pushbackInput.unread(signature, 0, readBytes)
            return pushbackInput
        }
    }
}

控制器简化代码

@CrossOrigin
@RestController
@RequestMapping(
    value = ["/api"],
    method = [RequestMethod.POST],
    consumes = [MediaType.APPLICATION_JSON_VALUE],
    produces = ["application/json;charset=UTF-8"]
)
class ServiceController {

    @Autowired
    private lateinit var serviceBi: ServiceBi

    @Autowired
    private lateinit var objectMapper: ObjectMapper

    @RequestMapping(value = ["/endpoint"])
    @ApiResponse(content = [Content(schema = Schema(hidden = true))])
    fun invokeEndpoint(@RequestBody req: Request): Response {
        try {
            // 修正笔误:使用req而非request
            return serviceBi.endpoint(req)!!
        } catch (e: Exception) {
            throw e
        }
    }
}

测试代码

@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
class ServiceControllerTest {

    @LocalServerPort
    private val port = 8989

    @Autowired
    private lateinit var restTemplate: TestRestTemplate

    @Test
    fun testEndpoint() {
        val response = restTemplate.exchange(
            "http://localhost:$port/api/endpoint",
            HttpMethod.POST,
            HttpEntity(getGzip(request().toByteArray()), makeHttpHeaders()),
            String::class.java
        )
    }

    private fun makeHttpHeaders(): HttpHeaders {
        val httpHeaders = HttpHeaders()
        httpHeaders.contentType = MediaType.APPLICATION_JSON
        httpHeaders.add("Content-Encoding", "gzip")
        return httpHeaders
    }

    private fun getGzip(body: ByteArray?): ByteArray {
        ByteArrayOutputStream().use { byteStream ->
            GZIPOutputStream(byteStream).use { zipStream ->
                zipStream.write(body!!)
                zipStream.finish()
                return byteStream.toByteArray()
            }
        }
    }

    private fun request(): String = "long json"
}

核心解决思路

  • 重写内容长度方法:自定义的HttpServletRequestWrapper必须重写getContentLength()和getContentLengthLong(),返回-1告知Spring框架内容长度未知,使其通过流式读取完整的解压后内容,而非依赖原始的压缩后Content-Length值。
  • 修复流结束判断:DecompressServletInputStream的isFinished()方法需正确跟踪流的读取状态,避免框架误以为流未结束或提前终止读取。
  • 完善流校验逻辑:在StreamHelper中补充处理读取Gzip签名不足2字节的边界情况,避免潜在的IO异常。

内容的提问来源于stack exchange,提问作者user12124207

相关产品推荐
方舟 Agent Plan

超全模态模型 × Harness 升级,最新支持 Deepseek-V4.1-Flash、GLM-5.3 系列、Doubao-Seedream-5.0-pro、Kimi-K3 (部分), 限时 9.9 元起

最近更新时间:2026.06.26 06:05:20