先说明一下这不是一篇讲“怎么调一个接口”的流水账而是我最近在一个基于 Spring Cloud Gateway 2.2.0.RELEASE 的日志网关项目里踩了一下午坑之后沉淀下来的完整记录。标题里那句话其实就是我当时的任务在网关层拿到 POST 请求里的 body 参数再把后端服务处理完后返回的响应参数也拿一份。听起来是不是很简单Spring MVC 里一个RequestBody就完事的事放到响应式网关里直接让人血压升高。如果你也在做网关日志、参数签名校验、或者想根据请求体内容做路由/风控并且在 gateway 2.2.0.RELEASE 版本上被“body 读一次就没了”“响应体怎么都抓不全”“中文乱码”折磨过这篇文章应该能让你少走两小时弯路。我会把底层原因、完整实现、每个关键代码段都拆开讲最后附上我这几天遇到的典型问题和排查方法可以直接抄作业。1. 项目背景与核心问题拆解1.1 这个版本为什么让“读body”变得麻烦我们项目里网关承担的不只是转发还要对每一个 POST 请求做日志记录记录内容包含三部分请求路径、请求 body、后端返回的响应 body。前面的路径和 header 都很好拿exchange.getRequest().getPath()、getHeaders()随手一调就有了。问题就出在 body 上。Spring Cloud Gateway 从设计上就不是基于 Servlet 的底层跑的是 Spring WebFlux 的 Netty 引擎。在 Servlet 模型里HttpServletRequest的getInputStream()可以反复读只要配合HttpServletRequestWrapper缓存就行但 WebFlux 里的ServerHttpRequest.getBody()返回的是FluxDataBuffer——一个响应式流。流的特点是“拉了就走走了就没了”。第一次subscribe/block消费完之后这个流的元素就被消化掉了你再往后传递下游拿到的是空流。我最早就是没想明白这一点直接在GlobalFilter里写String body new String(bodyBytes, StandardCharsets.UTF_8);然后把这个字符串存到日志列表里再去chain.filter(exchange)。结果下游服务日志打出来全是 “request body is empty”或者干脆报 “Required request body is missing”。1.2 先弄清楚 2.2.0.RELEASE 提供了什么Spring Cloud Gateway 的 2.2.0.RELEASE 对应 Spring Boot 2.2.x、Spring WebFlux 5.2.x。这个版本其实已经引入了readBody(Class)这样的便捷方法也支持通过spring.codec.max-in-memory-size调整请求体缓存上限。但官方给的readBody更适合做“读取后判断”不适合“读取后还要继续转发”或者“读取后修改 body 再转发”。所以我在实操里把方案分成了两种轻量场景拿到 body 只是为了做鉴权/校验校验结束网关直接给响应、不再继续调用下游服务。常规场景拿到 body 做日志/处理之后还要原样或改完后转发给后端服务。第一种用readBody很舒服第二种必须自己包装饰器把 body 缓存住再重新喂给请求流。我下面的实现主要围绕第二种因为真实项目里第二种才是大多数。2. 网关请求体获取的底层原理与实现路径2.1 为什么不能直接消费 Flux先花半分钟看一下数据流长什么样。ServerHttpRequest.getBody()的返回类型是FluxDataBufferDataBuffer 可以理解成一份字节数块的置槽一个较大的 POST body 在网络传输过程中会被拆成好多个 chunk每个 chunk 是一个 DataBuffer。在响应式编程里Flux 是一个冷流只有被订阅时才真正开始产生数据。如果你在过滤器里用DataBufferUtils.join(request.getBody())把整个流拼成一个 DataBuffer这个操作会订阅流、把数据全部收集起来那么之后原始 request 再被下游读取时里面的 Flow 已经结束了没有任何数据可发。用生活里的话说就是一个水管里的水已经被你放光了下游再开水龙头只有空气。想要下游还有水你必须先把水抽到一个大水箱里再给下游重新接一根水管。2.2 自定义 ServerHttpRequestDecorator 缓存请求体我最终采用的方式是重写请求体流用一个缓存了完整 body 字节数组的 request 替换掉原始 request这样下游每次getBody()都能从缓存里重新生成一个 Flux 拿到数据。先看核心类import org.springframework.core.io.buffer.DataBuffer; import org.springframework.core.io.buffer.DataBufferFactory; import org.springframework.core.io.buffer.DataBufferUtils; import org.springframework.http.server.reactive.ServerHttpRequest; import org.springframework.http.server.reactive.ServerHttpRequestDecorator; import reactor.core.publisher.Flux; import java.nio.charset.StandardCharsets; public class CachedBodyRequest extends ServerHttpRequestDecorator { private final byte[] rawBody; public CachedBodyRequest(ServerHttpRequest delegate, byte[] rawBody) { super(delegate); this.rawBody rawBody; } Override public FluxDataBuffer getBody() { return Flux.defer(() - { DataBufferFactory factory new DefaultDataBufferFactory(); DataBuffer buffer factory.wrap(rawBody); return Flux.just(buffer); }); } }关键点在Flux.defer。它的作用是在每次有人订阅getBody()时才重新构造 DataBuffer而不是在装饰器创建的时候就把 buffer 定死。否则如果下游订阅多次、或者数据被重复消费可能会出现 buffer 引用计数问题。那 rawBody 从哪来在过滤器里拿到原始 request 之后先读一次存起来import org.springframework.core.io.buffer.DataBuffer; import org.springframework.core.io.buffer.DataBufferUtils; import reactor.core.publisher.Mono; private Monobyte[] resolveRawBody(ServerHttpRequest request) { return DataBufferUtils.join(request.getBody()) .map(dataBuffer - { byte[] bytes new byte[dataBuffer.readableByteCount()]; dataBuffer.read(bytes); DataBufferUtils.release(dataBuffer); return bytes; }); }这里特别注意两件事第一DataBufferUtils.release(dataBuffer)必须调用。DataBuffer 底层很多是池化的 Direct Memory不用掉引用计数就会一直占着内存并发一高直接内存泄漏或 OOM。第二读完之后拿到的是byte[]后续转字符串的编码要在这一刻就处理掉不要每次new String(bytes)都用默认编码。接着在GlobalFilter里的链路Override public MonoVoid filter(ServerWebExchange exchange, GatewayFilterChain chain) { ServerHttpRequest request exchange.getRequest(); String method request.getMethod().name(); if (POST.equalsIgnoreCase(method)) { return resolveRawBody(request) .flatMap(rawBytes - { CachedBodyRequest cachedRequest new CachedBodyRequest(request, rawBytes); ServerWebExchange mutatedExchange exchange.mutate() .request(cachedRequest) .build(); // 这里你可以做日志、签名校验、参数解析等操作 // 比如把原始body放进attributes后面响应阶段还能依赖它 mutatedExchange.getAttributes().put(cachedRequestBody, rawBytes); return chain.filter(mutatedExchange); }); } return chain.filter(exchange); }经过这一步mutatedExchange往下传给所有后续过滤器、路由目标和后端服务。后端通过原始的request.getBody()拿到的就是从缓存里重新生成的数据不会再出现“空 body”问题。2.3 关于 readBody 方案的适用边界2.2.0.RELEASE 版本的ServerHttpRequest.readBody(Class)是官方提供的便捷方式它的好处是框架帮你做了 body 的读取和反序列化缺点有两个第一它会消费原始 body 流如果你不额外处理下游就拿不到 body 了。在只做“读取判断 直接 return 响应”的场景没问题但要转发就有问题。第二它和自定义装饰器混用的时候要注意顺序。比如某个全局过滤器调用了readBody后面的内置过滤器比如ModifyRequestBodyGatewayFilterFactory也是靠装饰器机制处理 body 的两者叠加容易踩到“body 被读取了但装饰器拿不到流”的怪问题。我的建议是项目里如果需要 body 转发日志直接自定义装饰器只有那种“我从 header 就可以判定请求非法根本不需要让请求到达下游”的接口才考虑readBody快速读一次。3. 响应参数获取的实现拆解3.1 先搞明白 writeWith 是怎么回事请求 body 的问题解决后难题又来了后端处理完返回的响应数据要怎么在网关看到响应在 WebFlux 中也是响应式流通过ServerHttpResponse.getBody()暴露写入通道。但你不能简单地去订阅这个流——响应流是被 Netty 的写事件驱动的它只被订阅一次。正确做法是装饰ServerHttpResponseimport org.springframework.http.server.reactive.ServerHttpResponseDecorator; import reactor.core.publisher.Flux; import org.reactivestreams.Publisher; import reactor.core.publisher.Mono; import org.springframework.core.io.buffer.DataBuffer; import org.springframework.core.io.buffer.DataBufferUtils; import org.springframework.core.io.buffer.DefaultDataBufferFactory;核心重写writeWith(Publisher? extends DataBuffer)。这个方法在网关把响应数据写回客户端时被调用。我需要在真正写入之前偷偷复制一份数据出来。注意不能直接写“把整个响应流 block 成 String再 non-block 写回去”——这会把网关拖死尤其是大响应、高并发场景下。响应式的正确姿势是对原始 Flux 做变换在数据经过的时候截获并复制再原样放回。3.2 完整响应拦截代码与缓冲区合并下面是我在项目中实际使用的响应装饰器核心思路是buffer()合并每个数据块拼成一份完整字节数组后记录日志再原样流向下游public class CachedBodyResponse extends ServerHttpResponseDecorator { private final String requestId; public CachedBodyResponse(ServerHttpResponse delegate, String requestId) { super(delegate); this.requestId requestId; } Override public MonoVoid writeWith(Publisher? extends DataBuffer body) { if (body instanceof Flux) { Flux? extends DataBuffer fluxBody Flux.from(body); return super.writeWith(fluxBody.buffer().map(dataBuffers - { // 1. 合并所有数据块到 byte[] DataBufferFactory factory new DefaultDataBufferFactory(); int length 0; for (DataBuffer dataBuffer : dataBuffers) { length dataBuffer.readableByteCount(); } byte[] totalBytes new byte[length]; int offset 0; for (DataBuffer dataBuffer : dataBuffers) { int readable dataBuffer.readableByteCount(); dataBuffer.read(totalBytes, offset, readable); offset readable; } // 2. 记录后端返回的响应内容 String responseBody new String(totalBytes, StandardCharsets.UTF_8); // 这里配合外部日志异步打印或者写入某个本地队列 // 3. 重新包装成一个 DataBuffer 传回下游 return factory.wrap(totalBytes); })); } return super.writeWith(body); } }这段代码有个小细节值得说。为什么用fluxBody.buffer()因为响应体和请求体一样是分块传输的一个 JSON 可能被拆成多个 DataBuffer。如果你直接用doOnNext单个记录你会看到日志里的响应内容每次只有一小段合起来才是一个完整 JSON。buffer()会把这一批分块先收集成ListDataBuffer再到map里统一合并、拼字符串。在真正落项目的时候我又做了一点改造没有直接在这里输出日志而是先把responseBody和请求阶段缓存的cachedRequestBody一起塞进一个简单的上下文对象然后异步交给日志上报线程。为什么异步因为这里每一次响应都要拼一次字节数组如果还在响应线程里做 IO/网络写入性能会很难看。网关本来就忌讳在响应路径上做非必要阻塞。3.3 别忘了把响应装饰器挂到 exchange 上请求和响应两边都有了装饰器之后在GlobalFilter里需要把 exchange 里的响应也替换掉ServerWebExchange mutatedExchange exchange.mutate() .request(cachedRequest) .response(new CachedBodyResponse(exchange.getResponse(), requestId)) .build();然后chain.filter(mutatedExchange)继续往下走。这样后端返回的数据在流经网关写回客户端时就会被我们的装饰器截获并记录。响应装饰器里记录数据有几个容易忽略的点我单列一下如果你的网关开启了压缩spring.cloud.gateway.compression.enabled那么响应内容的编码是 gzip直接new String(bytes)得到的不是可读文本。这种情况需要先解压再记录或者干脆不记录原始响应的 body只记录 header。如果下游返回的文件流很大把整个 body 组装成 byte[] 再记录会占用大量内存。我实际建议只对大流量接口做抽样记录或者限制记录的最大字节数比如超过 1MB 只记录长度。响应写回后不能再修改 header比如Content-Length。如果你在装饰器里改了 body 的长度会导致下游客户端解析失败。上面代码是原样回写所以没问题。4. 典型踩坑记录与排查实践4.1 下游收到空 body缓存方案没生效表现网关日志里有请求 body后端服务收到的 body 为空。排查GlobalFilter里已经用了CachedBodyRequest但下游是空的说明真正传下去的 exchange 可能没有用 mutated 之后的 request。注意exchange.mutate()返回一个新的 exchange并不会原地修改原 exchange。如果你在代码里写了chain.filter(exchange)而不是chain.filter(mutatedExchange)等于白干了。另外要注意如果过滤器里面提前调用了request.getBody()比如你在读 body 时不小心把同一个流订阅了两遍也会出现这种问题。我建议对原始 request 的消费只做一次并且用DataBufferUtils.join一次性收集不要在收集前做其他订阅操作。4.2 中文乱码编码要在源头指定表现日志里请求 body 的中文全部变成“???”。排查new String(bytes)默认编码是 JVM 的file.encoding在服务器上经常是ISO-8859-1改起来很痛苦。正确做法是从Content-Type里拿 charsetMediaType mediaType request.getHeaders().getContentType(); Charset charset mediaType ! null mediaType.getCharset() ! null ? mediaType.getCharset() : StandardCharsets.UTF_8; String bodyStr new String(bytes, charset);另外要注意表单提交application/x-www-form-urlencoded;charsetUTF-8和 JSONapplication/json;charsetUTF-8都可能有 charset 后缀。没有 charset 的直接默认 UTF-8这是 WebFlux 的默认行为不要想当然地按系统编码去猜。4.3 大请求体报 DataBufferLimitException表现请求体没超过几 MB却抛了类似DataBufferLimitException: Exceeded limit on max bytes to buffer的异常。原因网关的 codec 默认max-in-memory-size是 256KB当你用DataBufferUtils.join或者 StringDecoder 去缓存大的请求体时超过这个限制就会抛异常。解决在application.yml里调大spring: codec: max-in-memory-size: 10MB注意这是全局的会影响响应解析、网关 route 等。如果你不想全局调大可以单独在缓存 body 时按字节流手动拼接绕开自动解码限制。但那样要做的事更多我先说结论绝大多数内部系统把max-in-memory-size调到 10MB 是够用的如果经常出现超过 10MB 的请求你更要担心的其实是网关内存。4.4 响应内容只记录到了第一段表现后端返回的是完整 JSON但日志里只有开头一小段。原因没有做buffer()合并直接在doOnNext里逐段转字符串。每个 DataBuffer 只包含响应体的一部分连续打印出来就会断成好多段。解决用第 3 节的方式先buffer()收集完整一批再合并 byte[]。这里要注意buffer()是按时间窗口聚合的正常情况下它能把一次响应写回的数据块聚合成一个列表。如果响应非常大可能会触发多次writeAndFlushWith或分批 write我的做法是拼接 segment 到本地缓存等响应结束后统一组装日志。4.5 不要用 ThreadLocal 存请求上下文表现异步日志里偶尔拿不到请求 ID或者拿到的是上一个请求的数据。原因响应式编程一个请求可能横跨多个线程而且同一线程可能处理多个请求。用ThreadLocal存 exchange 相关的上下文是我见过最惨烈的翻车现场因为 Netty 的 EventLoop 线程是复用的一旦某个异步任务没有清理 ThreadLocal脏数据就会被带到下一个请求。解决需要跨阶段传递的数据一律放exchange.getAttributes()只在真正同步的局部作用域使用临时变量。我在项目里用一个自定义的上下文对象挂在 attribute 上请求阶段存requestBody响应阶段读出来合并responseBody全程用 attribute 通信干干净净。4.6 常见问题速查表现象可能原因解决方案下游收不到 POST body原始 body 流被消费但未缓存替换使用 CachedBodyRequest 装饰器并 set 到 mutatedExchange网关只能读 body后端也读不到修改了 request 但没传给 chain.filter确保 return chain.filter(mutatedExchange)中文乱码new String 默认编码不对按 Content-Type 的 charset 解码默认 UTF-8DataBufferLimitExceptioncodec 缓存上限只有 256KB调大 spring.codec.max-in-memory-size响应日志只有一段没合并多个 DataBuffer用 fluxBody.buffer() 聚合后再转字符串异步日志数据错乱用 ThreadLocal 存请求上下文数据放 exchange.getAttributes()响应打印失真网关开启了压缩先解压 gzip 再记录或只记录 header5. 实际项目中的扩展思考5.1 从日志审计到安全风控搞定请求/响应 body 的记录后网关能做的不只是日志。我在后续扩展里顺手接了一个参数合规校验从cachedRequestBody里解析 JSON对关键字段做白名单校验命中敏感规则直接返回 400不再转发下游。这个改造的好处是校验逻辑收拢在网关后端服务不用每个接口重复写一遍。需要注意的是别在 GlobalFilter 里做太多阻塞性质的处理。解析 JSON 还好如果是要做机器学习风控模型推理、外部接口查询一定得走响应式方式用Mono.zip组合异步结果或者把事件投递到消息队列异步处理。否则网关很快会成为链路上的瓶颈。5.2 基于 body 内容实现灰度路由请求体缓存有了之后还可以做基于内容的路由。比如判断订单号的区域码决定流量打到哪个机房的后端实例判断请求里的用户级别决定是否命中某个灰度发布策略。实现思路也不复杂在过滤器拿到 body 字节数组之后先解析出一个路由标签放到 exchange 的 attribute 里然后在自定义路由过滤器里根据标签重新选择服务实例即可。这个能力在 2.2.0.RELEASE 里完全是可行的不用额外引第三方组件。5.3 后续改造建议如果你们的网关流量较大我建议把 body 缓存逻辑抽成一个独立的请求/响应过滤器而不是散落在各个 GlobalFilter 里。比如定义CacheBodyGlobalFilterorder 放最前它只负责缓存和装饰下游其他过滤器签名校验、日志、路由直接从 attribute 里取缓存结果。这样职责单一排查问题也清晰。另一个建议是给缓存 body 加上字节上限超过上限的请求不再缓存完整 body只记录长度或截断信息。这不是偷懒而是真实生产环境里防止一个 200MB 的 POST 直接把网关内存打爆的必要手段。我一般设定为 1MB 以内完整记录超过 1MB 只记录摘要。最后验证整个流程是否正常我习惯在本地起一个简单的回显服务来模拟后端。回显服务把收到的 body 原封不动返回网关这边看日志请求体记录和后端回显内容一致说明请求插件和响应插件都工作正常。如果你没有现成的回显服务直接用某个已有的 POST 接口也行关键是选那种“传什么返回什么”的接口否则很难判断是请求记录丢了还是响应记录丢了。说实话这套东西第一次搭的时候确实痛苦主要是不适应响应式思维。但踩完坑会你会发现核心就两句话请求要缓存后重发流响应要截获后回写流。以后再换别的 WebFlux 版本套路基本一模一样。希望这篇记录能让你少掉几根头发。