前言 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行,如下代码:
1 2 3 4 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依赖,否则你会发现,会有很多让你觉得不可思议的错误出现。
我们都知道InputStream只能允许我们读取一次,在我使用Gateway的过程中,由于需要在网关中执行一些用户鉴权的逻缉,而在一个获取账户明细的接口中,我们可以从请求头、Url请求参数、Form Body这三个地方去获取用户的Token来进行鉴权校验。在Gateway中,我们通过实现WebFlux的WebFilter接口来实现一个过滤,以校验用户Token,以下是我写的Filter,校验逻缉做了删减:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 @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<Void> 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); } } }
从上面的代码,我们可以很轻易的从exchange获取到的request对象中获取到请求头和Url请求参数,而当我想从FormData中取参时,却发现并不能轻易的调用取到参数。于是,通过查阅WebFlux官方API文档,找到了获取表单Body的方法,如下图: 于是,我便按照上述的方法取出表单数据,并从Map中取到了token,代码如下:
1 2 3 4 5 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和报文信息。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 @Slf4j public class PartnerServerHttpRequestDecorator extends ServerHttpRequestDecorator { private Flux<DataBuffer> 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<DataBuffer> flux = super .getBody(); body = flux; } @Override public Flux<DataBuffer> getBody () { return body; } }
Response装饰类 创建响应装饰类PartnerServerHttpResponseDecorator继承ServerHttpResponseDecorator
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 public class PartnerServerHttpResponseDecorator extends ServerHttpResponseDecorator { PartnerServerHttpResponseDecorator(ServerHttpResponse delegate) { super (delegate); } @Override public Mono<Void> writeAndFlushWith (Publisher<? extends Publisher<? extends DataBuffer>> body) { return super .writeAndFlushWith(body); } @Override public Mono<Void> writeWith (Publisher<? extends DataBuffer> body) { return super .writeWith(body); } }
WebExchange装饰类 创建PayloadServerWebExchangeDecorator类继承ServerWebExchangeDecorator
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 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使用,代码如下:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 @Component @Order(value = Ordered.HIGHEST_PRECEDENCE) public class RequestBodyReadMoreFilter implements WebFilter { public final static String FORM_DATA_ATTR = "fromData" ; @Override public Mono<Void> filter (ServerWebExchange exchange, WebFilterChain chain) { PayloadServerWebExchangeDecorator payloadServerWebExchangeDecorator = new PayloadServerWebExchangeDecorator (exchange); 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 throughServerWebExchange
. While@RequestParam
binds to query parameters only, you can use data binding to apply query parameters, form data, and multiparts to acommand object .
文档中已经明确说明了webflux中,该注解仅支持url传参方式 解决方案其实比较直接,既然WebFlux不帮我们赋值,我们便自己实现,为此通过阅读文档,可以采用自定义实现一个RequestParamMethodArgumentResolver的方式,去定义我们的表单参数映射。实现代码如下:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 @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<String, String> requestParams = exchange.getRequest().getQueryParams(); Object value = resolveParameterByParam(name, parameter, requestParams); MultiValueMap<String,Object> formMap = (MultiValueMap<String,Object>)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<String,String> data) { List<String> 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<String,Object> data) { List<Object> 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个比较大的问题,且都一一完成了填坑,希望给够给予开发者们一些指导与帮助。