2026年7月30日
开发小记 解决使用suspend函数的Controller无法使用ContentCachingResponseWrapper获取响应信息&发送的响应为空的问题
观前提示:这个问题目前已知会且仅会在Controller函数为Kotlin的suspend可挂起函数且内部使用了其他可挂起函数时出现,如果你使用的是Java那可以直接划走了o(* ̄▽ ̄*)ブ
先说下复现环境
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-reactor")
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-core")
implementation("org.springframework.boot:spring-boot-starter-webmvc")依赖只需要这三个就够了,版本号也不用填,Spring的BOM会自动填充
为了阅读流畅些不会上下反复跳,我就把所有组件写在一起了哦
package com.example.demo
import jakarta.servlet.Filter
import jakarta.servlet.FilterChain
import jakarta.servlet.ServletRequest
import jakarta.servlet.ServletResponse
import jakarta.servlet.http.HttpServletResponse
import kotlinx.coroutines.delay
import org.apache.catalina.webresources.CachedResource
import org.springframework.boot.autoconfigure.SpringBootApplication
import org.springframework.boot.runApplication
import org.springframework.stereotype.Component
import org.springframework.web.bind.annotation.GetMapping
import org.springframework.web.bind.annotation.RestController
import org.springframework.web.util.ContentCachingResponseWrapper
import kotlin.time.Duration.Companion.seconds
@SpringBootApplication
class DemoApplication
@Component
class f: Filter{
override fun doFilter(
request: ServletRequest,
response: ServletResponse,
chain: FilterChain) {
val CachedResponse = ContentCachingResponseWrapper(response as HttpServletResponse)
chain.doFilter(request, CachedResponse)
CachedResponse.copyBodyToResponse() } }
@RestController
class a{
@GetMapping("/hello")
suspend fun hello(): String {
delay(1.seconds)
return "Hello, World!" } }
fun main(args: Array<String>) {
runApplication<DemoApplication>(*args) }很标准的使用对吧,看起来没毛,最后也用copyBodyToResponse把响应内容写回去了。
现在我们来编译、运行、发个请求出去,神奇的一幕出现了

看响应数据那里,Content-Length竟然是0耶,我们发送的 Hello, World! 也失踪不知道上哪去了
经过了漫长且折磨的调试和查资料之后我找到了问题的关键
咱们加几个调试日志看看

注意日志的顺序
过滤开始
开始等待
过滤结束
响应内容:
等待结束
发现问题没有?按照正常流程来说应该先出现 等待结束 再出现 过滤结束,可是现在并不是这样的。
这也就是这个问题的根本原因,当可挂起Controller内部把控制权交出去之后会立刻返回到chain.doFilter,而不是我们所期望的Controller内部return之后再返回
所以…怎么解决呢?
package com.example.demo
import jakarta.servlet.AsyncEvent
import jakarta.servlet.AsyncListener
import jakarta.servlet.Filter
import jakarta.servlet.FilterChain
import jakarta.servlet.ServletRequest
import jakarta.servlet.ServletResponse
import jakarta.servlet.http.HttpServletResponse
import kotlinx.coroutines.delay
import org.apache.catalina.webresources.CachedResource
import org.springframework.boot.autoconfigure.SpringBootApplication
import org.springframework.boot.runApplication
import org.springframework.stereotype.Component
import org.springframework.web.bind.annotation.GetMapping
import org.springframework.web.bind.annotation.RestController
import org.springframework.web.util.ContentCachingResponseWrapper
import kotlin.time.Duration.Companion.seconds
@SpringBootApplication
class DemoApplication
@Component
class f: Filter{
override fun doFilter(
request: ServletRequest,
response: ServletResponse,
chain: FilterChain) {
val CachedResponse = ContentCachingResponseWrapper(response as HttpServletResponse)
println("过滤开始")
chain.doFilter(request, CachedResponse)
if (request.isAsyncStarted) {
request.asyncContext.addListener(object : AsyncListener {
override fun onComplete(event: AsyncEvent?) {
println("异步完成")
println("响应内容:${String(CachedResponse.contentAsByteArray)}")
CachedResponse.copyBodyToResponse() }
override fun onTimeout(event: AsyncEvent?) { }
override fun onError(event: AsyncEvent?) = onTimeout(event)
override fun onStartAsync(event: AsyncEvent?) { } } ) }
println("过滤结束") } }
@RestController
class a{
@GetMapping("/hello")
suspend fun hello(): String {
println("开始等待")
delay(1.seconds)
println("等待结束")
return "Hello, World!" } }
fun main(args: Array<String>) {
runApplication<DemoApplication>(*args) }

你看,这下就对了。
总结一下,我们需要:
- 在
chain.doFilter之后判断request.isAsyncStarted属性 - 如果
isAsyncStarted是true,使用request.asyncContext.addListener函数注册一个监听器,具体按照你的习惯,我这里直接使用object原地做了一个接口实现出来 - 实现内部的
onStartAsync用不到,onError和onTimeout看你的具体情况,最重要的是onComplete - 当
onComplete被触发,说明Controller已经使用return退出,这个时候再去读取ContentCachingResponseWrapper就是安全的了 - 最后别忘了使用
copyBodyToResponse函数把响应数据写回原始对象