使用Spring Cloud Gateway填过的坑

前言

Spring Cloud Gateway 是 Spring Cloud 新推出的网关框架,之前是 Netflix Zuul。网关通常在项目中为了简化前端的调用逻辑,同时也简化内部服务之间互相调用的复杂度;具体作用就是转发服务,接收并转发所有内外部的客户端调用;其他常见的功能还有权限认证,限流控制等等。
我们都知道,由于Spring Cloud Gateway是基于Spring5开发的,在Web框架上,Spring Cloud Gateway采用了自家新推出的Web框架WebFlux。由于WebFlux底层是采用Reactive Netty的NIO框架,所以无论在网络编程方面与传统的WebMvc都有所不同,虽然WebFlux可以完全兼容旧的WebMvc写法,但这并不代表我们的代码可以完全迁移没有任何问题。
最近在项目中,在使用Spring Cloud Gateway的过程中,发现有几个坑需要我们去注意且进行改造修复,在此分享一下,我在项目中使用Gateway遇到的问题及解决方案。

1. 千万别依赖Undertow

我们在开发SpringBoot应用中都会把spring-web-starter默认依赖的Web容器Tomcat排除掉,并添加上undertow的依赖,使用undertow作为我们的Web运行容器。由于undertow与tomcat相比性能会更优一些,具体原因不再此赘述,感兴趣的同学可以看下这篇文章:https://www.jianshu.com/p/f7cb40a8ce22
上面提到Gateway是基于WebFlux开发,WebFlux是基于NIO的Web框架,所以要注意在添加了spring-cloud-starter-gateway依赖的项目中,不可再添加undertow依赖。之所以说这是个坑,是因为添加了undertow依赖后,gateway仍可以正常启动,不会有任何报错,我们并不容易察觉。当我们启动后,发送请求进入Gateway你就会发现会出现一个DataBuffey类型转换的错误,代码出问题的地方出现在NettyRoutingFilter 139行,如下代码:

return nettyOutbound.options(NettyPipeline.SendOptions::flushOnEach)
                            .send(request.getBody()
                                    .map(dataBuffer -> ((NettyDataBuffer) dataBuffer)
                                            .getNativeBuffer()));

这里我们可以看到dataBuffer是直接强转成NettyDataBuffer类型,而当我们依赖中加入了undertow此处便为报类型转换异常,原因是因为Gateway基于NIO,而Undertow是基于BIO,而这里由于是undertow处理的请求,所以dataBuffer并不能强轩成NIO的NettyDataBuffer类型。所以注意,在Gateway项目中千万要记住不可再添加undertow依赖,否则你会发现,会有很多让你觉得不可思议的错误出现。

2. Form表单数据重复读取

我们都知道InputStream只能允许我们读取一次,在我使用Gateway的过程中,由于需要在网关中执行一些用户鉴权的逻缉,而在一个获取账户明细的接口中,我们可以从请求头、Url请求参数、Form Body这三个地方去获取用户的Token来进行鉴权校验。在Gateway中,我们通过实现WebFlux的WebFilter接口来实现一个过滤,以校验用户Token,以下是我写的Filter,校验逻缉做了删减:

@Slf4j
@Component
@Order(value = Ordered.HIGHEST_PRECEDENCE+2)
public class AccountContextFilter implements WebFilter {

    private final static String TOKEN_PARAM_KEY = "access_token";

    @Override
    public Mono filter(ServerWebExchange exchange, WebFilterChain chain) {
        
        ServerHttpRequest request = exchange.getRequest();
        HttpHeaders header = request.getHeaders();
        String token = header.getFirst(AuthConstants.HEADER_AUTH);
        if (StringUtils.isBlank(token)) {
            token = request.getQueryParams().getFirst(TOKEN_PARAM_KEY);
            //若为空,尝试从FormData取参
        }
    }
}

从上面的代码,我们可以很轻易的从exchange获取到的request对象中获取到请求头和Url请求参数,而当我想从FormData中取参时,却发现并不能轻易的调用取到参数。于是,通过查阅WebFlux官方API文档,找到了获取表单Body的方法,如下图:


image.png

于是,我便按照上述的方法取出表单数据,并从Map中取到了token,代码如下:

exchange.getFormData().flatMap(formData->{
      String token  = formData.getFirst(TOKEN_PARAM_KEY);
      ...
      return chain.filter(exchange);
});

以上方式看上去虽然麻烦了点,但还算是拿到了token,实现了鉴权的逻缉,但当过滤器执行完成后,进入到获取账户明细的Controller中时,我发现Form表单传的参数不见了,在Controller并不能接收到前端传过来的参数。此时,我便想到在Filter中取过一次FormData,应该问题出在此处。那么,该如何解决这个问题呢?我们想在Filter取到表单参数,又想在Controller中能够正常接收参数。于是,我便想到是否能够让这个FormData支持多次读取,而FormData是从exchange中取出来的,于是只要解决好Exchange这个对象就可以实现,决定对exchange再做一次封装。通过网上搜索,找到了网友对exchange二次封装的编码实现,下面直接出代码:
Request装饰类
创建包装类PartnerServerHttpRequestDecorator继承ServerHttpRequestDecorator,在含参构造放中打印请求url,query,headers和报文信息。

@Slf4j
public class PartnerServerHttpRequestDecorator extends ServerHttpRequestDecorator {

    private Flux body;

    PartnerServerHttpRequestDecorator(ServerHttpRequest delegate) {
        super(delegate);
        final String path = delegate.getURI().getPath();
        final String query = delegate.getURI().getQuery();
        final String method = Optional.ofNullable(delegate.getMethod()).orElse(HttpMethod.GET).name();
        final String headers = delegate.getHeaders().entrySet()
                .stream()
                .map(entry -> "            " + entry.getKey() + ": [" + String.join(";", entry.getValue()) + "]")
                .collect(Collectors.joining("\n"));
        final MediaType contentType = delegate.getHeaders().getContentType();
        if (log.isDebugEnabled()) {
            log.debug("\n" +
                    "HttpMethod : {}\n" +
                    "Uri        : {}\n" +
                    "Headers    : \n" +
                    "{}", method, path + (StrUtil.isEmpty(query) ? "" : "?" + query), headers);
        }
        Flux flux = super.getBody();
        body = flux;
    }

    @Override
    public Flux getBody() {
        return body;
    }

}

Response装饰类
创建响应装饰类PartnerServerHttpResponseDecorator继承ServerHttpResponseDecorator

public class PartnerServerHttpResponseDecorator extends ServerHttpResponseDecorator {

    PartnerServerHttpResponseDecorator(ServerHttpResponse delegate) {
        super(delegate);
    }

    @Override
    public Mono writeAndFlushWith(Publisher> body) {
        return super.writeAndFlushWith(body);
    }

    @Override
    public Mono writeWith(Publisher body) {
        return super.writeWith(body);
    }
}

WebExchange装饰类
创建PayloadServerWebExchangeDecorator类继承ServerWebExchangeDecorator

public class PayloadServerWebExchangeDecorator extends ServerWebExchangeDecorator {

    private PartnerServerHttpRequestDecorator requestDecorator;

    private PartnerServerHttpResponseDecorator responseDecorator;

    public PayloadServerWebExchangeDecorator(ServerWebExchange delegate) {
        super(delegate);
        requestDecorator = new PartnerServerHttpRequestDecorator(delegate.getRequest());
        responseDecorator = new PartnerServerHttpResponseDecorator(delegate.getResponse());
    }

    @Override
    public ServerHttpRequest getRequest() {
        return requestDecorator;
    }

    @Override
    public ServerHttpResponse getResponse() {
        return responseDecorator;
    }
}

实现思路,其实很简单,通过封装ServerHttpRequestDecorator,将body作为成员变量缓存起来,方便后面随时获取调用。最后使用方式很简单,我采用的方式是直接新创建一个Filter在所有自定义过滤器之前执行,用来读取FormData,以便后面的Filter使用,代码如下:

@Component
@Order(value = Ordered.HIGHEST_PRECEDENCE)
public class RequestBodyReadMoreFilter implements WebFilter {

    public final static String FORM_DATA_ATTR = "fromData";

    @Override
    public Mono filter(ServerWebExchange exchange, WebFilterChain chain) {
        PayloadServerWebExchangeDecorator payloadServerWebExchangeDecorator = new PayloadServerWebExchangeDecorator(exchange);
        // mediaType
        MediaType mediaType = exchange.getRequest().getHeaders().getContentType();

        if (MediaType.MULTIPART_FORM_DATA.isCompatibleWith(mediaType) || MediaType.APPLICATION_FORM_URLENCODED.isCompatibleWith(mediaType)) {
            return payloadServerWebExchangeDecorator.getFormData().flatMap(formData->{
                payloadServerWebExchangeDecorator.getAttributes().put(FORM_DATA_ATTR,formData);
                return chain.filter(payloadServerWebExchangeDecorator);
            });
        }
        return chain.filter(payloadServerWebExchangeDecorator);
    }
}

通过在exchange中setAttribute的方式,在后面的Filter中直接getAttribute()的方式,方便的取到表单数据完成校验逻缉。

3. @RequestParam无法接收post的FormData数据

这个问题也是很坑,通过查看WebFlux文档

https://docs.spring.io/spring/docs/current/spring-framework-reference/web-reactive.html#webflux-ann-requestparam

The Servlet API “request parameter” concept conflates query parameters, form data, and multiparts into one. However, in WebFlux, each is accessed individually through ServerWebExchange. While @RequestParam binds to query parameters only, you can use data binding to apply query parameters, form data, and multiparts to a command object.

文档中已经明确说明了webflux中,该注解仅支持url传参方式
解决方案其实比较直接,既然WebFlux不帮我们赋值,我们便自己实现,为此通过阅读文档,可以采用自定义实现一个RequestParamMethodArgumentResolver的方式,去定义我们的表单参数映射。实现代码如下:

@Configuration
public class WebArgumentResolversConfig implements WebFluxConfigurer {

    @Autowired
    ConfigurableApplicationContext applicationContext;

    @Override
    public void configureArgumentResolvers(ArgumentResolverConfigurer configurer) {
        configurer.addCustomResolver(new FormDataMethodArgumentResolver(applicationContext.getBeanFactory(), ReactiveAdapterRegistry.getSharedInstance(), true));
    }

    class FormDataMethodArgumentResolver extends RequestParamMethodArgumentResolver {

        public FormDataMethodArgumentResolver(ConfigurableBeanFactory factory, ReactiveAdapterRegistry registry, boolean useDefaultResolution) {
            super(factory, registry, useDefaultResolution);
        }

        @Override
        protected Object resolveNamedValue(String name, MethodParameter parameter, ServerWebExchange exchange) {
            MultiValueMap requestParams = exchange.getRequest().getQueryParams();
            Object value = resolveParameterByParam(name, parameter, requestParams);
            MultiValueMap formMap = (MultiValueMap)exchange.getAttributes().get(RequestBodyReadMoreFilter.FORM_DATA_ATTR);
            if(value == null && formMap != null) {
                value = resolveParameterByForm(name, parameter, formMap);
            }
            return value;
        }

        private Object resolveParameterByParam(String name,MethodParameter parameter,MultiValueMap data){
            List values = data.get(name);
            if (values != null && values.size() > 0 && parameter.getParameterType() == List.class) {
                return values;
            } else if (values != null && values.size() > 0) {
                return data.getFirst(name);
            }
            return null;
        }

        private Object resolveParameterByForm(String name,MethodParameter parameter,MultiValueMap data){
            List values = data.get(name);
            if (values != null && values.size() > 0 && parameter.getParameterType() == List.class) {
                return values;
            } else if (values != null && values.size() > 0) {
                return data.getFirst(name);
            }
            return null;
        }

    }

}

通过实现WebFluxConfigurer接口,将我们自定义的ArgumentResolver注册到配置中去,在获取FormData的方式上,也是延用了上述的方式,从exchange的attribute中去获取,然后剩下的就是表单K/V对的赋值逻缉实现了,这个比较简单,就不在此赘述了。
以上是我在使用Spring Cloud Gateway中遇到的3个比较大的问题,且都一一完成了填坑,希望给够给予开发者们一些指导与帮助。

你可能感兴趣的:(使用Spring Cloud Gateway填过的坑)