2020-03-30

Spring Cloud Gateway-获取body踩坑实践

问题1:无法获取body内容

问题原因分析

在使用过程中碰到过滤器中获取的内容一直都是空的,尝试了网上的各种解析body内容的方法,但是得到结果都是一样,死活获取不到body数据,一度很崩溃。后来进行了各种尝试,最终发现使用不同的spring boot版本和spring cloud版本,对结果影响很大

最佳实践

方案1:降低版本

springboot版本:2.0.5-RELEASE
springcloud版本:Finchley.RELEASE​
使用以上的版本会报以下的错误:

java.lang.IllegalStateException: Only one connection receive subscriber allowed.​

原因在于spring boot在2.0.5版本如果使用了WebFlux就自动配置HiddenHttpMethodFilter过滤器。查看源码发现,这个过滤器的作用是,针对当前的浏览器一般只支持GET和POST表单提交方法,如果想使用其他HTTP方法(如:PUT、DELETE、PATCH),就只能通过一个隐藏的属性如(_method=PUT)来表示,那么HiddenHttpMethodFilter的作用是将POST请求的_method参数里面的value替换掉http请求的方法。但是这就导致已经读取了一次body,导致后面的过滤器无法读取body。解决方案就是可以自己重写HiddenHttpMethodFilter来覆盖原来的实现,实际上gateway本身就不应该做这种事情,原始请求是怎样的,转发给下游的请求就应该是怎样的。

@Bean
public HiddenHttpMethodFilter hiddenHttpMethodFilter() {
    return new HiddenHttpMethodFilter() {
        @Override
        public Mono<Void> filter(ServerWebExchange exchange, WebFilterChain chain) {
            return chain.filter(exchange);
        }
    };
}

这个方案也是gateway官方开发者目前所提出的解决方案。

方案2:不降低版本,缓存body内容

springboot版本:2.1.5-RELEASE
springcloud版本:Greenwich.SR1​
在较高版本中,上面的方法已经行不通了,可以自定义一个高优先级的过滤器先获取body内容并缓存起来,解决body只能读取一次的问题。具体解决方案见问题2。

问题2:body只能读取一次

这个问题网上主要的解决思路就是获取body之后,重新封装request,然后把封装后的request传递下去。思路很清晰,但是实现的方式却千奇百怪。在使用的过程中碰到了各种千奇百怪的问题,比如说第一次请求正常,第二次请求报400错误,这样交替出现。最终定位原因就是我自定义的全局过滤器把request重新包装导致的,去掉就好了。鉴于踩得坑比较多,下面给出在实现过程中笔者认为的最佳实践。

核心代码

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.server.reactive.ServerHttpRequest;
import org.springframework.http.server.reactive.ServerHttpRequestDecorator;
import org.springframework.stereotype.Component;
import org.springframework.web.server.ServerWebExchange;
import reactor.core.publisher.Flux;
import reactor.core.publisher.Mono;

/**
 * 这个过滤器解决body不能重复读的问题
 * 实际上这里没必要把body的内容放到attribute中去,因为从attribute取出body内容还是需要强转成
 * Flux<DataBuffer>,然后转换成String,和直接读取body没有什么区别
 */
@Component
public class CacheBodyGlobalFilter implements Ordered, GlobalFilter {

//  public static final String CACHE_REQUEST_BODY_OBJECT_KEY = "cachedRequestBodyObject";

  @Override
  public Mono<Void> filter(ServerWebExchange exchange, GatewayFilterChain chain) {
    if (exchange.getRequest().getHeaders().getContentType() == null) {
      return chain.filter(exchange);
    } else {
      return DataBufferUtils.join(exchange.getRequest().getBody())
          .flatMap(dataBuffer -> {
            DataBufferUtils.retain(dataBuffer);
            Flux<DataBuffer> cachedFlux = Flux
                .defer(() -> Flux.just(dataBuffer.slice(0, dataBuffer.readableByteCount())));
            ServerHttpRequest mutatedRequest = new ServerHttpRequestDecorator(
                exchange.getRequest()) {
              @Override
              public Flux<DataBuffer> getBody() {
                return cachedFlux;
              }
            };
//            exchange.getAttributes().put(CACHE_REQUEST_BODY_OBJECT_KEY, cachedFlux);

            return chain.filter(exchange.mutate().request(mutatedRequest).build());
          });
    }
  }

  @Override
  public int getOrder() {
    return Ordered.HIGHEST_PRECEDENCE;
  }
}

CacheBodyGlobalFilter这个全局过滤器的目的就是把原有的request请求中的body内容读出来,并且使用ServerHttpRequestDecorator这个请求装饰器对request进行包装,重写getBody方法,并把包装后的请求放到过滤器链中传递下去。这样后面的过滤器中再使用exchange.getRequest().getBody()来获取body时,实际上就是调用的重载后的getBody方法,获取的最先已经缓存了的body数据。这样就能够实现body的多次读取了。

值得一提的是,这个过滤器的order设置的是Ordered.HIGHEST_PRECEDENCE,即最高优先级的过滤器。优先级设置这么高的原因是某些系统内置的过滤器可能也会去读body,这样就会导致我们自定义过滤器中获取body的时候报body只能读取一次这样的错误如下:

java.lang.IllegalStateException: Only one connection receive subscriber allowed.
    at reactor.ipc.netty.channel.FluxReceive.startReceiver(FluxReceive.java:279)
    at reactor.ipc.netty.channel.FluxReceive.lambda$subscribe$2(FluxReceive.java:129)
    at 

所以需要先解决body只能读取一次的问题,把CacheBodyGlobalFilter的优先级设到最高。

import io.netty.buffer.ByteBufAllocator;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
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.NettyDataBufferFactory;
import org.springframework.http.HttpMethod;
import org.springframework.http.HttpStatus;
import org.springframework.http.MediaType;
import org.springframework.http.server.reactive.ServerHttpRequest;
import org.springframework.http.server.reactive.ServerHttpRequestDecorator;
import org.springframework.stereotype.Component;
import org.springframework.util.MultiValueMap;
import org.springframework.web.server.ServerWebExchange;
import org.springframework.web.util.UriComponentsBuilder;
import reactor.core.publisher.Flux;
import reactor.core.publisher.Mono;

import java.net.URI;
import java.nio.charset.StandardCharsets;
import java.util.Collections;
import java.util.List;
import java.util.stream.Collectors;

/**
 * @author mjw
 * @date 2020/3/24
 */
@Component
@Slf4j
public class AuthGlobalFilter implements GlobalFilter, Ordered
{
    @Override
    public Mono<Void> filter(ServerWebExchange exchange, GatewayFilterChain chain)
    {
        String bodyContent = RequestUtil.resolveBodyFromRequest(exchange.getRequest());
        
        // TODO 身份认证相关逻辑

        return chain.filter(exchange.mutate().build());
    }

    @Override
    public int getOrder()
    {
        return -100;
    }
}

这个类是自定义的身份认证的全局过滤器,这里需要说一下的就是读取body之后如何解析。由于spring cloud gateway使用的是webFlux,因此获取的body内容是Flux<DataBuffer>结构的,读取的方式如下:

import org.springframework.core.io.buffer.DataBuffer;
import org.springframework.core.io.buffer.DataBufferUtils;
import org.springframework.http.server.reactive.ServerHttpRequest;
import reactor.core.publisher.Flux;

import java.nio.charset.StandardCharsets;
import java.util.regex.Matcher;
import java.util.regex.Pattern;

/**
 * @author mjw
 * @date 2020/3/30
 */
public class RequestUtil
{
    /**
     * 读取body内容
     * @param serverHttpRequest
     * @return
     */
    public static String resolveBodyFromRequest(ServerHttpRequest serverHttpRequest){
        //获取请求体
        Flux<DataBuffer> body = serverHttpRequest.getBody();
        StringBuilder sb = new StringBuilder();

        body.subscribe(buffer -> {
            byte[] bytes = new byte[buffer.readableByteCount()];
            buffer.read(bytes);
//            DataBufferUtils.release(buffer);
            String bodyString = new String(bytes, StandardCharsets.UTF_8);
            sb.append(bodyString);
        });
        return formatStr(sb.toString());
    }

    /**
     * 去掉空格,换行和制表符
     * @param str
     * @return
     */
    private static String formatStr(String str){
        if (str != null && str.length() > 0) {
            Pattern p = Pattern.compile("\\s*|\t|\r|\n");
            Matcher m = p.matcher(str);
            return m.replaceAll("");
        }
        return str;
    }
}

实际上在网上查找资料的过程中发现,解析body内容网上普遍提到两种方式,一种就是上文中的方式,读取字节方式拼接字符串,另一种方式如下:

private String getBodyContent(ServerWebExchange exchange){
        Flux<DataBuffer> body = exchange.getRequest().getBody();
        AtomicReference<String> bodyRef = new AtomicReference<>();
        // 缓存读取的request body信息
        body.subscribe(dataBuffer -> {
            CharBuffer charBuffer = StandardCharsets.UTF_8.decode(dataBuffer.asByteBuffer());
            DataBufferUtils.release(dataBuffer);
            bodyRef.set(charBuffer.toString());
        });
        //获取request body
        return bodyRef.get();
    }

但是网上有网友说这种方式最多能获取1024字节的数据,数据过长会被截断,导致数据丢失。这里笔者没有亲自验证过,只是把这种方式提供在这里供大家参考。

另外需要注意的是在我们创建ByteBuf对象后,它的引用计数是1,当你每次调用DataBufferUtils.release之后会释放引用计数对象时,它的引用计数减1,如果引用计数为0,这个引用计数对象会被释放(deallocate),并返回对象池。当尝试访问引用计数为0的引用计数对象会抛出IllegalReferenceCountException异常如下:

io.netty.util.IllegalReferenceCountException: refCnt: 0
    at io.netty.buffer.AbstractByteBuf.ensureAccessible(AbstractByteBuf.java:1423) ~[netty-all-4.1.0.Final.jar:4.1.0.Final]
    at io.netty.buffer.UnpooledHeapByteBuf.capacity(UnpooledHeapByteBuf.java:102) ~[netty-all-4.1.0.Final.jar:4.1.0.Final]
    at io.netty.buffer.ReadOnlyByteBuf.capacity(ReadOnlyByteBuf.java:408) ~[netty-all-4.1.0.Final.jar:4.1.0.Final]
    at io.netty.buffer.AbstractByteBuf.setIndex(AbstractByteBuf.java:126) ~[netty-all-4.1.0.Final.jar:4.1.0.Final]
    at io.netty.buffer.ReadOnlyByteBuf.<init>(ReadOnlyByteBuf.java:50) ~[netty-all-4.1.0.Final.jar:4.1.0.Final]
    at io.netty.buffer.ReadOnlyByteBuf.duplicate(ReadOnlyByteBuf.java:278) ~[netty-all-4.1.0.Final.jar:4.1.0.Final]

因此这里为了能够在多个自定义过滤器中使用相同的方法来获取body数据,就不进行release了。

参考文章

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 219,490评论 6 508
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 93,581评论 3 395
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 165,830评论 0 356
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 58,957评论 1 295
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 67,974评论 6 393
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 51,754评论 1 307
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 40,464评论 3 420
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 39,357评论 0 276
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 45,847评论 1 317
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 37,995评论 3 338
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 40,137评论 1 351
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 35,819评论 5 346
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 41,482评论 3 331
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 32,023评论 0 22
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 33,149评论 1 272
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 48,409评论 3 373
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 45,086评论 2 355

推荐阅读更多精彩内容

  • 四、Filter的生命周期 1、Filter的生命周期描述 Filter的创建和销毁是由web服务器负责。We...
    请重置阅读 123评论 0 0
  • 前言 Hi~,我是 2020 届物联网专业毕业生,现就读于杭州。谨以此文来记录我的秋招以及入门前端以来的学习历程,...
    半眼鱼阅读 564评论 0 0
  • 转自JsonChao的GitHubhttps://github.com/JsonChao/Awesome-Andr...
    瀚海来客阅读 335评论 0 0
  • gc判断对象是否需要回收的方式 1.引用计数法 给对象添加一个引用计数器,每有当对象被引用,计数器加一,引用失效,...
    stayFAndH阅读 79评论 0 0
  • (一)文件上传的概述 一、什么是文件上传 文件上传:将本地的文件通过流写入到服务器的过程 二、为什么要学习文件...
    友人Atx阅读 211评论 0 0