写点什么

微服务网关:SpringCloud Gateway 保姆级入门教程

用户头像
Java架构
关注
发布于: 3 小时前
微服务网关:SpringCloud Gateway保姆级入门教程

什么是微服务网关 SpringCloud Gateway 是 Spring 全家桶中一个比较新的项目,Spring 社区是这么介绍它的:该项目借助 Spring WebFlux 的能力,打造了一个 API 网关。旨在提供一种简单而有效的方法来作为 API 服务的路由,并为它们提供各种增强功能,例如:安全性,监控和可伸缩性。而在真实的业务领域,我们经常用 SpringCloud Gateway 来做微服务网关,如果你不理解微服务网关和传统网关的区别,可以阅读此篇文章 Service Mesh 和 API Gateway 关系深度探讨 来了解两者的定位区别。以我粗浅的理解,传统的 API 网关,往往是独立于各个后端服务,请求先打到独立的网关层,再打到服务集群。而微服务网关,将流量从南北走向改为东西走向(见下图), 微服务网关和后端服务是在同一个容器中的 ,所以也有个别名,叫做 Gateway Sidecar。


为啥叫 Sidecar,这个词应该怎么理解呢,吃鸡里的三蹦子见过没:


此外,请检查一下你的依赖中是否含有 spring-boot-starter-web,如果有, 请干掉它 。因为我们的 SpringCloud Gateway 是一个 netty+webflux 实现的 web 服务器,和 Springboot Web 本身就是冲突的。<dependency><groupId>org.springframework.boot</groupId><artifactId>spring-boot-starter-web</artifactId></dependency>做到这里,实际上你的项目就已经可以启动了,运行 SpringcloudGatewayApplication,得到结果如图:


编写 yml 文件 SpringBoot 的核心概念是 约定优先于配置 ,在以前初学 Spring 时,一直不理解这句话的意思,在使用 SpringCloud Gateway 时,更加深入的理解了这句话。在默认情况下,你不需要任何的配置,就能够运行起来最基本的网关。针对你之后特定的需求,再去追加配置。而 SpringCloud Gateway 更强大的一点就是内置了非常多的默认功能实现, 你需要的大部分功能,比如在请求中添加一个 header,添加一个参数,都只需要在 yml 中引入相应的内置过滤器即可。可以说,yml 是整个 SpringCloud Gateway 的灵魂。一个网关最基本的功能,就是配置路由,在这方面,SpringCloud Gateway 支持非常多方式。比如:通过时间匹配通过 Cookie 匹配通过 Header 属性匹配通过 Host 匹配通过请求方式匹配通过请求路径匹配通过请求参数匹配通过请求 ip 地址进行匹配这些在官网教程中,都有详细的介绍,就算你百度下,也会有很多民间翻译的入门教程,我就不再赘述了,我只用一个请求路径做一个简单的例子。在公司的项目中,由于有新老两套后台服务,我们使用不同的 uri 路径进行区分。老服务路径为:url/api/xxxxxx,服务端口号为 8001 新服务路径为:url/api/v2/xxxxx,服务端口号为 8002 那么可以直接在 yml 里面配置:logging:level:org.springframework.cloud.gateway: DEBUGreactor.netty.http.client: DEBUG


spring:cloud:gateway:default-filters:- AddRequestHeader=gateway-env, springcloud-gatewayroutes:- id: "server_v2"uri: "http://127.0.0.1:8002"predicates:- Path=/api/v2/**- id: "server_v1"uri: "http://127.0.0.1:8001"predicates:- Path=/api/**上面的代码解释如下:logging:由于文章需要,我们打开 gateway 和 netty 的 Debug 模式,可以看清楚请求进来后执行的流程,方便后续说明。default-filters:我们可以方便的使用 default-filters,在请求中加入一个自定义的 header,我们加入一个 KV 为 gateway-env:springcloud-gateway,来注明我们这个请求经过了此网关。这样做的好处是后续服务端也能够看到。routes:路由是网关的重点,相信读者们看代码也能理解,我配置了两个路由,一个是 server_v1 的老服务,一个是 server_v2 的新服务。 请注意,一个请求满足多个路由的谓词条件时,请求只会被首个成功匹配的路由转发。 由于我们老服务的路由是/xx,所以需要将老服务放在后面,优先匹配词缀/v2 的新服务,不满足的再匹配到/xx。来看一下 http://localhost:8080/api/xxxxx 的结果:


来看一下 http://localhost:8080/api/v2/xxxxx 的结果:


可以看到两个请求被正确的路由了。由于我们真正并没有开启后端服务,所以最后一句 error 请忽略。接口转义问题在公司实际的项目中,我在搭建好网关后,遇到了一个接口转义问题,相信很多读者可能也会碰到,所以在这里我们最好是防患于未然,优先处理下。问题是这样的,很多老项目在 url 上并没有进行转义,导致会出现如下接口请求,http://xxxxxxxxx/api/b3d56a6fa19975ba520189f3f55de7f6/140x140.jpg?t=1 "这样请求过来,网关会报错:java.lang.IllegalArgumentException: Invalid character '=' for QUERY_PARAM in "http://pic1.ajkimg.com/display/anjuke/b3d56a6fa19975ba520189f3f55de7f6/140x140.jpg?t=1"在不修改服务代码逻辑的前提下,网关其实已经可以解决这件事情,解决办法就是升级到 2.1.1.RELEASE 以上的版本。The issue was fixed in version spring-cloud-gateway 2.1.1.RELEASE.所以我们一开始就是用了高版本 2.2.5.RELEASE,避免了这个问题,如果小伙伴发现之前使用的版本低于 2.1.1.RELEASE,请升级。获取请求体(Request Body)在网关的使用中,有时候会需要拿到请求 body 里面的数据,比如验证签名,body 可能需要参与签名校验。但是 SpringCloud Gateway 由于底层采用了 webflux,其请求是流式响应的,即 Reactor 编程,要读取 Request Body 中的请求参数就没那么容易了。网上谷歌了很久,很多解决方案要么是彻底过时,要么是版本不兼容,好在最后参考了这篇文章,终于有了思路:https://www.jianshu.com/p/db3b15aec646首先我们需要将 body 从请求中拿出来,由于是流式处理,Request 的 Body 是只能读取一次的,如果直接通过在 Filter 中读取,会导致后面的服务无法读取数据。SpringCloud Gateway 内部提供了一个断言工厂类 ReadBodyPredicateFactory,这个类实现了读取 Request 的 Body 内容并放入缓存,我们可以通过从缓存中获取 body 内容来实现我们的目的。首先新建一个 CustomReadBodyRoutePredicateFactory 类,这里只贴出关键代码,完整代码请看可运行的 Github 仓库 :@Componentpublic class CustomReadBodyRoutePredicateFactory extends AbstractRoutePredicateFactory<CustomReadBodyRoutePredicateFactory.Config> {


protected static final Log log = LogFactory.getLog(CustomReadBodyRoutePredicateFactory.class);private List<HttpMessageReader<?>> messageReaders;
@Value("${spring.codec.max-in-memory-size}")private DataSize maxInMemory;
public CustomReadBodyRoutePredicateFactory() { super(Config.class); this.messageReaders = HandlerStrategies.withDefaults().messageReaders();}
public CustomReadBodyRoutePredicateFactory(List<HttpMessageReader<?>> messageReaders) { super(Config.class); this.messageReaders = messageReaders;}
@PostConstructprivate void overrideMsgReaders() { this.messageReaders = HandlerStrategies.builder().codecs((c) -> c.defaultCodecs().maxInMemorySize((int) maxInMemory.toBytes())).build().messageReaders();}
@Overridepublic AsyncPredicate<ServerWebExchange> applyAsync(Config config) { return new AsyncPredicate<ServerWebExchange>() { @Override public Publisher<Boolean> apply(ServerWebExchange exchange) { Class inClass = config.getInClass(); Object cachedBody = exchange.getAttribute("cachedRequestBodyObject"); if (cachedBody != null) { try { boolean test = config.predicate.test(cachedBody); exchange.getAttributes().put("read_body_predicate_test_attribute", test); return Mono.just(test); } catch (ClassCastException var6) { if (CustomReadBodyRoutePredicateFactory.log.isDebugEnabled()) { CustomReadBodyRoutePredicateFactory.log.debug("Predicate test failed because class in predicate does not match the cached body object", var6); } return Mono.just(false); } } else { return ServerWebExchangeUtils.cacheRequestBodyAndRequest(exchange, (serverHttpRequest) -> { return ServerRequest.create(exchange.mutate().request(serverHttpRequest).build(), CustomReadBodyRoutePredicateFactory.this.messageReaders).bodyToMono(inClass).doOnNext((objectValue) -> { exchange.getAttributes().put("cachedRequestBodyObject", objectValue); }).map((objectValue) -> { return config.getPredicate().test(objectValue); }).thenReturn(true); }); } }
@Override public String toString() { return String.format("ReadBody: %s", config.getInClass()); } };}
@Overridepublic Predicate<ServerWebExchange> apply(Config config) { throw new UnsupportedOperationException("ReadBodyPredicateFactory is only async.");}
复制代码


}代码主要作用:在有 body 的请求到来时,将 body 读取出来放到内存缓存中。若没有 body,则不作任何操作。这样我们便可以在拦截器里使用 exchange.getAttribute("cachedRequestBodyObject")得到 body 体。对了,我们还没有演示一个 filter 是如何写的,在这里就先写一个完整的 demofilter。让我们新建类 DemoGatewayFilterFactory:@Componentpublic class DemoGatewayFilterFactory extends AbstractGatewayFilterFactory<DemoGatewayFilterFactory.Config> {


private static final String CACHE_REQUEST_BODY_OBJECT_KEY = "cachedRequestBodyObject";
public DemoGatewayFilterFactory() { super(Config.class); log.info("Loaded GatewayFilterFactory [DemoFilter]");}
@Overridepublic List<String> shortcutFieldOrder() { return Collections.singletonList("enabled");}
@Overridepublic GatewayFilter apply(DemoGatewayFilterFactory.Config config) { return (exchange, chain) -> { if (!config.isEnabled()) { return chain.filter(exchange); } log.info("-----DemoGatewayFilterFactory start-----"); ServerHttpRequest request = exchange.getRequest(); log.info("RemoteAddress: [{}]", request.getRemoteAddress()); log.info("Path: [{}]", request.getURI().getPath()); log.info("Method: [{}]", request.getMethod()); log.info("Body: [{}]", (String) exchange.getAttribute(CACHE_REQUEST_BODY_OBJECT_KEY)); log.info("-----DemoGatewayFilterFactory end-----"); return chain.filter(exchange); };}
public static class Config {
private boolean enabled;
public Config() {}
public boolean isEnabled() { return enabled; }
public void setEnabled(boolean enabled) { this.enabled = enabled; }}
复制代码


}这个 filter 里,我们拿到了新鲜的请求,并且打印出了他的 path,method,body 等。我们发送一个 post 请求,body 就写一个“我是 body”,运行网关,得到结果:


是不是非常清晰明了!你以为这就结束了吗?这里有两个非常大的坑。


  1. body 为空时处理上面贴出的 CustomReadBodyRoutePredicateFactory 类其实已经是我修复过的代码,里面有一行 .thenReturn(true) 是需要加上的。这才能保证当 body 为空时,不会报出异常。至于为啥一开始写的有问题,显然因为我偷懒了,直接 copy 网上的代码了,哈哈哈哈哈。

  2. body 大小超过了 buffer 的最大限制这个情况是在公司项目上线后才发现的,我们的请求里 body 有时候会比较大,但是网关会有默认大小限制。所以上线后发现了频繁的报错:org.springframework.core.io.buffer.DataBufferLimitException: Exceeded limit on max bytes to buffer : 262144


谷歌后,找到了解决方案,需要在配置中增加了如下配置 spring:codec:max-in-memory-size: 5MB 把 buffer 大小改到了 5M。你以为这就又双叕结束了,太天真了,你会发现可能没有生效。问题的根源在这里:我们在 spring 配置了上面的参数,但是我们自定义的拦截器是会初始化 ServerRequest, 这个 DefaultServerRequest 中的 HttpMessageReader 会使用默认的 262144 所以我们在此处需要从 Spring 中取出 CodecConfigurer, 并将里面的 Reader 传给 serverRequest。详细的 debug 过程可以看这篇参考文献:http://theclouds.io/tag/spring-gateway/OK,找到问题后,就可以修改我们的代码,在 CustomReadBodyRoutePredicateFactory 里,增加:@Value("${spring.codec.max-in-memory-size}")private DataSize maxInMemory;


@PostConstructprivate void overrideMsgReaders() {this.messageReaders = HandlerStrategies.builder().codecs((c) -> c.defaultCodecs().maxInMemorySize((int) maxInMemory.toBytes())).build().messageReaders();}这样每次就会使用我们的 5MB 来作为最大缓存限制了。依然提醒一下,完整的代码可以请看可运行的 Github 仓库讲到这里,入门实战就差不多了,你的网关已经可以上线使用了,你要做的就是加上你需要的业务功能,比如日志,延签,统计等。踩坑实战获取客户端真实 IP 很多时候,我们的后端服务会去通过 host 拿到用户的真实 IP,但是通过外层反向代理 nginx 的转发,很可能就需要从 header 里拿 X-Forward-XXX 类似这样的参数,才能拿到真实 IP。在我们加入了微服务网关后,这个复杂的链路中又增加了一环。这不,如果你不做任何设置,由于你的网关和后端服务在同一个容器中,你的后端服务很有可能就会拿到 localhost:8080(你的网关端口)这样的 IP。这时候,你需要在 yml 里配置 PreserveHostHeader,这是 SpringCloud Gateway 自带的实现:filters:


  • PreserveHostHeader # 防止 host 被修改为 localhost 字面意思, 就是将 Host 的 Header 保留起来,透传给后端服务。filter 里面的源码贴出来给大家:public GatewayFilter apply(Object config) {return new GatewayFilter() {public Mono<Void> filter(ServerWebExchange exchange, GatewayFilterChain chain) {exchange.getAttributes().put(ServerWebExchangeUtils.PRESERVE_HOST_HEADER_ATTRIBUTE, true);return chain.filter(exchange);}

  • };}尾缀匹配公司的项目中,老的后端仓库 api 都以.json 结尾 (/api/xxxxxx.json) ,这就催生了一个需求,当我们对老接口进行了重构后,希望其打到我们的新服务,我们就要将.json 这个尾缀切除。可以在 filters 里设置:filters:

  • RewritePath=(?<segment>/?.*).json, ${segment} # 重构接口抹去.json 尾缀这样就可以实现打到后端的接口去除了.json 后缀。总结本文带领读者一步步完成了一个微服务网关的搭建,并且将许多可能隐藏的坑进行了解决。最后的成品项目在笔者公司已经上线运行,并且增加了签名验证,日志记录等业务,每天承担百万级别的请求,是经过实战验证过的项目。最后再发一次项目源码仓库:https://github.com/qqxx6661/springcloud_gateway_demo

用户头像

Java架构

关注

还未添加个人签名 2021.06.21 加入

还未添加个人简介

评论

发布
暂无评论
微服务网关:SpringCloud Gateway保姆级入门教程