SpringCloud Gateway缓存body参数引发的问题

最近在使用SpringCloud的过程中,因需要在网关中获取请求的Body参数,但是在 Spring Cloud Gateway 中,请求体(Body)参数不能多次获取,这并非其本身的设计缺陷,而是由其底层技术架构所决定的一个核心特性。理解其背后的原因,有助于我们更正确地使用网关。

根源:数据流的一次性消费

Spring Cloud Gateway 基于 Spring WebFlux​ 和 Project Reactor,构建在非阻塞(NIO)的 Netty 服务器之上。在这种响应式编程模型中,HTTP 请求的 Body 部分被表示为一个数据流(Flux),其特性与从硬盘读取文件或从网络下载数据类似。
核心限制 :这个数据流遵循 "只能被订阅(消费)一次"​ 的原则。一旦流中的数据被读取,它就不会被重置或重新播放。这是响应式编程流的通用标准。
实际表现:在网关的过滤器链中,如果第一个过滤器读取了请求 Body 以进行身份验证或日志记录,那么当请求流转到第二个过滤器时,Body 流已经到达末尾。此时再次尝试读取,自然会得到空(null)或触发异常。

解决方案

这里采用自定义过滤器来缓存并重用请求体的方法,做法是在过滤器链的最开始,将 Body 数据从流中读取出来并缓存,后续的读取操作都使用这份缓存副本。

方法一

bash 复制代码
import lombok.extern.slf4j.Slf4j;
import org.springframework.cloud.gateway.filter.GatewayFilterChain;
import org.springframework.cloud.gateway.filter.GlobalFilter;
import org.springframework.core.Ordered;
import org.springframework.core.io.buffer.DataBuffer;
import org.springframework.core.io.buffer.DataBufferUtils;
import org.springframework.http.HttpHeaders;
import org.springframework.http.MediaType;
import org.springframework.http.codec.HttpMessageReader;
import org.springframework.http.server.reactive.ServerHttpRequest;
import org.springframework.http.server.reactive.ServerHttpRequestDecorator;
import org.springframework.stereotype.Component;
import org.springframework.web.reactive.function.server.HandlerStrategies;
import org.springframework.web.server.ServerWebExchange;
import reactor.core.publisher.Flux;
import reactor.core.publisher.Mono;

import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.util.List;

/**
 * 缓存请求体的全局过滤器
 * 
 * 主要功能:解决Spring Cloud Gateway中请求体只能被读取一次的问题
 * 通过将请求体内容缓存到内存中,使得后续的过滤器可以多次读取请求体数据
 * 
 * 实现原理:
 * 1. 读取原始请求的body数据并累积到字节数组中
 * 2. 使用ServerHttpRequestDecorator重写getBody()方法
 * 3. 返回包含缓存数据的新请求对象
 * 
 * 适用场景:需要对请求体进行验证、日志记录、签名校验等操作的场景[1,4](@ref)
 * 
 * @author admin
 * @version 1.0
 */
@Slf4j
@Component
public class CacheBodyGlobalFilter implements Ordered, GlobalFilter {

    /**
     * 默认的HTTP消息读取器列表
     * 用于解析请求和响应消息,基于Spring WebFlux的HandlerStrategies配置
     */
    private static final List<HttpMessageReader<?>> messageReaders = HandlerStrategies.withDefaults().messageReaders();

    /**
     * 过滤器核心方法
     * 
     * 执行流程:
     * 1. 检查请求内容和类型是否符合处理条件
     * 2. 对于JSON类型的请求,读取并缓存请求体
     * 3. 创建可重复读取body的新请求对象
     * 4. 将新请求放入过滤器链继续执行
     * 
     * @param exchange 服务器Web交换对象,包含请求、响应等信息
     * @param chain 网关过滤器链,用于继续执行后续过滤器
     * @return Mono<Void> 响应式编程的返回类型
     */
    @Override
    public Mono<Void> filter(ServerWebExchange exchange, GatewayFilterChain chain) {
        // 获取当前请求对象
        ServerHttpRequest request = exchange.getRequest();
        // 获取请求头信息
        HttpHeaders headers = request.getHeaders();

        // 获取内容类型和内容长度
        MediaType contentType = headers.getContentType();
        long contentLength = headers.getContentLength();
        
        // 只有当内容长度大于0且为JSON类型时,才进行body缓存处理
        if (contentLength > 0) {
            if (MediaType.APPLICATION_JSON.equals(contentType) || 
                MediaType.APPLICATION_JSON_UTF8.equals(contentType)) {
                // 执行body读取和缓存逻辑
                return readBody(exchange, chain);
            }
        }
        // 不符合条件的请求直接放行
        return chain.filter(exchange);
    }

    /**
     * 读取并缓存请求体数据
     * 
     * 该方法通过以下步骤实现body的缓存:
     * 1. 使用ByteArrayOutputStream累积所有数据缓冲区的字节数据
     * 2. 在每个数据缓冲区处理完成后及时释放资源
     * 3. 将所有累积的数据转换为字节数组和字符串
     * 4. 创建装饰器请求对象,重写getBody()方法返回缓存的数据
     * 
     * @param exchange 服务器Web交换对象
     * @param chain 网关过滤器链
     * @return Mono<Void> 响应式编程的返回类型
     */
    private Mono<Void> readBody(ServerWebExchange exchange, GatewayFilterChain chain) {
        // 创建字节数组输出流,用于累积请求体数据
        ByteArrayOutputStream baos = new ByteArrayOutputStream();

        // 获取请求体数据流并进行处理
        return exchange.getRequest().getBody()
                // 处理每个数据缓冲区
                .doOnNext(dataBuffer -> {
                    try {
                        // 创建与数据缓冲区可读字节数相同的字节数组
                        byte[] bytes = new byte[dataBuffer.readableByteCount()];
                        // 从数据缓冲区读取字节到数组
                        dataBuffer.read(bytes);
                        // 将字节写入字节数组输出流,累积所有数据
                        baos.write(bytes);
                    } catch (IOException e) {
                        // 读取过程中发生IO异常,抛出运行时异常
                        throw new RuntimeException("Failed to accumulate body", e);
                    } finally {
                        // 确保释放数据缓冲区,防止内存泄漏[1](@ref)
                        DataBufferUtils.release(dataBuffer);
                    }
                })
                // 所有数据处理完成后执行
                .then(Mono.defer(() -> {
                    // 将累积的数据转换为字节数组
                    byte[] fullBytes = baos.toByteArray();
                    // 将字节数组转换为UTF-8字符串,用于日志记录或处理
                    String bodyString = new String(fullBytes, StandardCharsets.UTF_8);

                    // 调试模式下记录读取的JSON body内容
                    if (log.isDebugEnabled()) {
                        log.debug("Read JsonBody: {}", bodyString);
                    }

                    /**
                     * 创建请求装饰器,重写getBody()方法
                     * 
                     * 此装饰器的作用是提供可重复读取的body数据
                     * 原始请求的body只能被读取一次,而装饰后的请求可以多次返回缓存的数据[1,5](@ref)
                     */
                    ServerHttpRequestDecorator mutatedRequest = new ServerHttpRequestDecorator(exchange.getRequest()) {
                        /**
                         * 重写getBody方法,返回包含缓存数据的Flux<DataBuffer>
                         * 
                         * @return Flux<DataBuffer> 包含缓存数据的响应式流
                         */
                        @Override
                        public Flux<DataBuffer> getBody() {
                            // 使用响应对象的缓冲区工厂包装字节数组,创建新的DataBuffer
                            return Flux.just(exchange.getResponse().bufferFactory().wrap(fullBytes));
                        }
                    };

                    // 使用装饰后的请求创建新的WebExchange对象
                    ServerWebExchange mutatedExchange = exchange.mutate().request(mutatedRequest).build();
                    // 继续执行过滤器链,后续过滤器将使用可重复读取body的请求对象
                    return chain.filter(mutatedExchange);
                }));
    }

    /**
     * 获取过滤器执行顺序
     * 
     * 设置为最高优先级,确保在其它可能读取body的过滤器之前执行
     * 这是解决"body只能读取一次"问题的关键
     * 
     * @return int 过滤器执行顺序(最高优先级)
     */
    @Override
    public int getOrder() {
        return Ordered.HIGHEST_PRECEDENCE;
    }
}

方法二

bash 复制代码
/**
 * 读取并缓存请求体数据的方法
 * 这是解决Spring Cloud Gateway中请求体只能读取一次问题的核心方法
 * 
 * 方法执行流程:
 * 1. 使用DataBufferUtils.join将请求体数据流合并为单个DataBuffer
 * 2. 读取DataBuffer中的字节数据并释放原始缓冲区
 * 3. 创建可重复读取的缓存数据流
 * 4. 使用装饰器模式包装原始请求,重写getBody方法
 * 5. 创建新的ServerWebExchange并继续过滤器链执行
 * 
 * @param exchange 服务器Web交换对象,包含请求和响应信息
 * @param chain 网关过滤器链,用于继续执行后续过滤器
 * @return Mono<Void> 响应式编程的返回类型
 */
private Mono<Void> readBody(ServerWebExchange exchange, GatewayFilterChain chain) {
    // 使用DataBufferUtils.join将请求体的Flux<DataBuffer>合并为单个DataBuffer
    // 这样可以一次性读取整个请求体内容
    return DataBufferUtils.join(exchange.getRequest().getBody())
        // 对合并后的DataBuffer进行扁平映射处理
        .flatMap(dataBuffer -> {
            // 创建与DataBuffer可读字节数相同的字节数组
            byte[] bytes = new byte[dataBuffer.readableByteCount()];
            // 从DataBuffer读取数据到字节数组
            dataBuffer.read(bytes);
            // 重要:释放DataBuffer资源,防止内存泄漏
            DataBufferUtils.release(dataBuffer);
            
            /**
             * 创建可重复使用的缓存数据流
             * 使用Flux.defer延迟创建,确保每次订阅时都返回新的DataBuffer
             * 这样后续的过滤器可以多次读取相同的body内容
             */
            Flux<DataBuffer> cachedFlux = Flux.defer(() -> {
                // 使用响应对象的缓冲区工厂包装字节数组,创建新的DataBuffer
                DataBuffer buffer = exchange.getResponse().bufferFactory().wrap(bytes);
                // 增加缓冲区引用计数,防止被意外释放
                DataBufferUtils.retain(buffer);
                return Mono.just(buffer);
            });

            /**
             * 使用装饰器模式创建请求包装器
             * ServerHttpRequestDecorator允许我们重写getBody方法
             * 使其返回我们缓存的数据流而不是原始的一次性流
             */
            ServerHttpRequest mutatedRequest = new ServerHttpRequestDecorator(exchange.getRequest()) {
                @Override
                public Flux<DataBuffer> getBody() {
                    // 返回缓存的数据流,支持多次读取
                    return cachedFlux;
                }
            };
            
            /**
             * 使用装饰后的请求对象创建新的ServerWebExchange
             * mutate()方法创建交换对象的构建器,request()设置新请求
             * build()方法构建最终的ServerWebExchange对象
             */
            ServerWebExchange mutatedExchange = exchange.mutate().request(mutatedRequest).build();
            
            /**
             * 使用默认的消息读取器将请求体解析为String
             * 这里主要用于日志记录或后续处理,同时确保body被正确缓存
             */
            return ServerRequest.create(mutatedExchange, messageReaders).bodyToMono(String.class)
                // 当body被成功解析为String时执行的回调,用于日志记录
                .doOnNext(objectValue -> {
                    // 记录调试日志,显示读取的JSON body内容
                    log.debug("[GatewayContext]Read JsonBody:{}", objectValue);
                })
                // 忽略body解析结果,继续执行过滤器链
                // then()确保在doOnNext完成后继续执行
                .then(chain.filter(mutatedExchange));
        });
}

方法三

bash 复制代码
private Mono<Void> readBody(ServerWebExchange exchange, GatewayFilterChain chain) {
        // 1. 合并所有 DataBuffer 为一个 byte[]
        return DataBufferUtils.join(exchange.getRequest().getBody())
                .map(dataBuffer -> {
                    try {
                        // 2. 一次性读取全部字节
                        byte[] bytes = new byte[dataBuffer.readableByteCount()];
                        dataBuffer.read(bytes);
                        // 3. 统一 UTF-8 解码(避免分片解码乱码)
                        String bodyString = new String(bytes, StandardCharsets.UTF_8);
                        log.debug("[GatewayContext] Read JsonBody: {}", bodyString);

                        // 4. 构造新的请求
                        ServerHttpRequestDecorator mutatedRequest = new ServerHttpRequestDecorator(exchange.getRequest()) {
                            @Override
                            public Flux<DataBuffer> getBody() {
                                return Flux.just(exchange.getResponse().bufferFactory().wrap(bytes));
                            }
                        };

                        ServerWebExchange mutatedExchange = exchange.mutate().request(mutatedRequest).build();
                        return mutatedExchange;
                    } finally {
                        // 5. 必须释放原始 buffer
                        DataBufferUtils.release(dataBuffer);
                    }
                })
                .switchIfEmpty(Mono.defer(() -> {
                    // 处理空 body 情况
                    ServerWebExchange mutatedExchange = exchange.mutate().build();
                    return Mono.just(mutatedExchange);
                }))
                .flatMap(chain::filter);
    }

存在问题的实现

发送大数据量Body时存在数据被截取的异常问题。

bash 复制代码
    private Mono<Void> readBody(ServerWebExchange exchange, GatewayFilterChain chain) {
        Flux<DataBuffer> body = exchange.getRequest().getBody();
        AtomicReference<String> bodyRef = new AtomicReference<>();

        return body.map(dataBuffer -> {
            byte[] bytes = new byte[dataBuffer.readableByteCount()];
            dataBuffer.read(bytes);
            DataBufferUtils.release(dataBuffer);
            return new String(bytes, StandardCharsets.UTF_8);
        }).collectList().flatMap(strings -> {
            String bodyString = String.join("", strings);
            bodyRef.set(bodyString);
            log.debug("[GatewayContext]Read JsonBody:{}", bodyString);

            ServerHttpRequestDecorator mutatedRequest = new ServerHttpRequestDecorator(exchange.getRequest()) {
                @Override
                public Flux<DataBuffer> getBody() {
                    byte[] bytes = bodyString.getBytes(StandardCharsets.UTF_8);
                    return Flux.just(exchange.getResponse().bufferFactory().wrap(bytes));
                }
            };
            ServerWebExchange mutatedExchange = exchange.mutate().request(mutatedRequest).build();
            return chain.filter(mutatedExchange);
        });
    }

这里使用大模型对代码进行分析,结论如下:

结论

正确解决方案:先合并字节,再统一解码,推荐使用 DataBufferUtils.join() 合并为单个 DataBuffer,一次性解码。

相关推荐
昂子的博客5 小时前
Redis缓存 更新策略 双写一致 缓存穿透 击穿 雪崩 解决方案... 一篇文章带你学透
java·数据库·redis·后端·spring·缓存
苦学编程的谢6 小时前
Redis_12_持久化(1)
数据库·redis·缓存
百***12226 小时前
Redis开启远程访问
数据库·redis·缓存
Chan169 小时前
【 Java八股文面试 | Redis篇 缓存问题、持久化、分布式锁 】
java·数据库·redis·后端·spring·缓存·面试
P***253912 小时前
前端构建工具缓存清理,npm cache与yarn cache
前端·缓存·npm
张小瑜14 小时前
Vue3 + Vite 项目部署后浏览器缓存导致标题错误
缓存
stevenzqzq14 小时前
android recyclerview缓存_缓存问题解决办法
android·java·缓存
hoiii18716 小时前
设置Redis在CentOS7上的自启动配置
数据库·redis·缓存
t***D26416 小时前
前端构建工具缓存策略,contenthash与chunkhash
前端·缓存