八、Spring Cloud Gateway 微服务网关
一、网关的介绍
1. 网关的整体概述
在单体架构中,随着数据量和并发量的提升,会将之拆分为多个微服务,而每个微服务需要的诸如用户权限校验、熔断、限流、日志监控等功能,如果在每个微服务内实现,肯定会有一部分的代码与逻辑的冗余。此时可以在客户端与微服务之间,建立网关,统一做这些事情并在验证通过后路由到指定微服务。
在微服务架构中,每个服务都是一个可以独立开发和运行的组件,而一个完整的微服务架构由一系列独立运行的微服务组成。其中每个服务都只会完成特定领域的功能,比如订单服务提供与订单业务场景有关的功能、商品服务提供商品展示功能等。各个微服务之间通过轻量级通信机制 REST API 或者 RPC 完成通信。 微服务之后在某些层面会带来一定的影响,比如,一个用户查看一个商品的详情,对于客户端来说,可能需要调用商品服务、评论服务、库存服务、营销服务等多个服务来完成数据的渲染。在这个场景中,客户端虽然能通过调用多个服务实现数据的获取,但是会存在一 些问题,比如:
- 客户端需要发起多次请求,增加了网络通信的成本及客户端处理的复杂性。
- 服务的鉴权会分布在每个微服务中处理,客户端对于每个服务的调用都需要重复鉴权。
- 在后端的微服务架构中,可能不同的服务采用的协议不同,比如有 HTTP、RPC 等。客户端如果需要调用多个服务,需要对不同协议进行适配。
![](https://img2020.cnblogs.com/blog/1660657/202008/1660657-20200803210153718-548373611.png)
2. 微服务网关的作用
所以,我们可以在微服务之前增加一个前置节点,这个节点就是网关。
![](https://img2020.cnblogs.com/blog/1660657/202008/1660657-20200803210210232-1095684395.png)
对于商品详情展示的场景来说,增加了 API 网关之后,在 API 网关层可以把后端的多个服务进行整合,然后提供一个唯一的业务接口,客户端只需要调用这个接口即可完成数据的获取及展示。在网关中会再去消费后端的多个微服务进行统一的整合,给客户端返回一个唯一的响应。当然,网关不仅只是做一个请求转发以及服务整合,有了网关这个统一的入口之后,它还能提供:
- 针对所有请求进行统一鉴权、限流、熔断、日志
- 协议转化。针对后端多种不同的协议,在网关层统一处理后以 HTTP 协议对外提供服务
- 统一错误码处理
- 请求转发,并且可以基于网关实现内外网隔离
网关的作用:
性能:API高可用,负载均衡,容错机制。
安全:权限身份认证、脱敏,流量清洗,后端签名(保证全链路可信调用),黑名单(非法调用的限制)。
日志:日志记录(spainid,traceid)一旦涉及分布式,全链路跟踪必不可少。
缓存:数据缓存。
监控:记录请求响应数据,api耗时分析,性能监控。
限流:流量控制,错峰流控,可以定义多种限流规则。
灰度:线上灰度部署,可以减小风险。
路由:动态路由规则。
3. 服务网关的要求
从上面的这个架构图来看,网关成了所有流量的入口,那么对于这样一个角色来说,它需要在某些方面有很高的要求。
- 稳定性,
- 安全性,防止恶意请求,以及保障数据传输的安全性
- 高性能、可用性,
网关作为所有流量的入口,那么对于性能这块的要求就非常高了,因为一旦网关的性能出现瓶颈,即便后端的服务性能再高,意义也不大
网关必须要支持集群部署,这个是分布式架构的基本要求。否则网关服务挂掉就会导致整个系统不可用 - 扩展性,可维护性,对于定制化需求方面,如何实现可扩展;
4. 常见的网关方案
- OpenResty(Nginx+lua)
- Kong,是基于openresty之上的一个封装,提供了更简单的配置方式。 它还提供了付费的商业插件
- Tyk(开源、轻量级),Tyk 是一个开源的、轻量级的、快速可伸缩的 API 网关,支持配额和速度限制,支持认证和数据分析,支持多用户多组织,提供全 RESTful API。它是基于go语言开发的组件。
- Zuul,是spring cloud生态下提供的一个网关服务,性能相对来说不是很高
- Spring Cloud Gateway,是Spring团队开发的高性能网关
5. 网关选型
对于网关选型,主要关注几个方面
- 部署和维护成本
- 开源还是闭源
- 是否私有化部署
- 功能是否满足当前需求
- 社区资料的完善以及版本迭代和功能维护
整体架构:
二、Gateway使用
1. Spring Cloud Gateway的核心概念
Route 路由 (id、predicate、filter、uri)
Route是网关的基础元素,包含ID、目标URI、断言、过滤器组成,当前请求到达网关时,会通过Gateway Handler Mapping,基于断言进行路由匹配,当断言为true时,匹配到路由进行转发
- Predicate,断言
学过java8的同学应该知道这个函数,它可以允许开发人员去匹配HTTP请求中的元素,一旦匹配为true,则表示匹配到合适的路由进行转发 - Filter,过滤器
可以在请求发出的前后进行一些业务上的处理,比如授权、埋点、限流等。 可分为前置处理器、后置处理器 PreFilter/PostFilter
过滤器内可以进行:授权认证、限流、更改请求报文
它的整体工作原理如下。
其中,predicate就是我们的匹配条件;而filter,就可以理解为一个无所不能的拦截器。有了这两个元素,再加上目标uri,就可以实现一个具体的路由了。
客户端向 Spring Cloud Gateway 发出请求,如果请求与网关程序定义的路由匹配,则该请求就会被发送到网关 Web 处理程序,此时处理程序运行特定的请求过滤器链。
过滤器之间用虚线分开的原因是过滤器可能会在发送代理请求的前后执行逻辑。所有 pre 过滤器逻辑先执行,然后执行代理请求;代理请求完成后,执行 post 过滤器逻辑
![](https://img2020.cnblogs.com/blog/1660657/202008/1660657-20200804210641505-1543891971.png)
![](https://img2020.cnblogs.com/blog/1660657/202008/1660657-20200804211041124-700473054.png)
Predicate 断言
官方提供了11种不同的断言,包括
Before : 请求在某个时间之前
After : 请求在某个时间之后
Between : 再某个时间段内
Cookie : cookie内是否包含某个键值对
Header: 请求头格式
Host :地址
等,具体可见 Route Predicate Factories
2. 案例使用
a. 匹配url规则进行路由
- 首先创建SpringBoot项目,端口号为8080,添加Gateway依赖:
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-starter-gateway</artifactId>
</dependency>
- 配置文件内进行路由配置
目的:
现在已有本地订单服务在启动,端口号8082,其中查询订单的接口为: http://localhost:8082/order;
需要通过网关Gateway项目路由到订单服务接口进行正常请求:
配置如下:
application.yml
spring:
application:
name: spring-cloud-gateway-8080
cloud:
gateway:
routes:
- predicates: # 断言,进行拦截判断; - 表示集合,可以配置多个
- Path=/gateway/** # 请求url规则: /gateway/**
filters: # 过滤
- StripPrefix=1 # 去掉第一个前缀, 以 订单服务的:http://localhost:8082/order 为例, 经过网关时的请求为:http://localhost:8080/gateway/order
uri: http://localhost:8082 # 匹配成功之后,跳转的路径
server:
port: 8080
我们定义了一个路由route,断言匹配所有/gateway/**
的请求: 如 http://localhost:8080/gateway/order
;
匹配成功之后通过过滤器filters将第一个前缀去除: /gateway/order
的请求就会转变为 /order
;
最后,指定跳转uri:最终路径即为 http://localhost:8082/order
即为订单服务的正常请求。
通过结果也可看出,我们访问的虽是网关服务8080的地址,返回的数据确实是订单服务8082的接口提供的:
b. 根据cookie内容来路由
- application.yml内定义cookie路由
spring:
application:
name: spring-cloud-gateway-8080
cloud:
gateway:
routes:
- id : test_route
predicates: # 断言,进行拦截判断; - 表示集合,可以配置多个
- Path=/gateway/** # 请求url规则: /gateway/**
filters: # 过滤
- StripPrefix=1 # 去掉第一个前缀, 以 订单服务的:http://localhost:8082/order 为例, 经过网关时的请求为:http://localhost:8080/gateway/order
uri: http://localhost:8082 # 匹配成功之后,跳转的路径
- id : cookie_route
predicates:
- Cookie=myName,shen
uri: https://www.baidu.com/
当请求的cookie内包含 key为myName,value为shen的内容时,跳转至 百度
2. Postman添加cookie模拟
重启服务发起请求,结果也是成功路由至 baidu:
c. 负载均衡来进行路由
- 当需要通过网关路由到集群节点时,肯定需要从注册中心将服务提供者列表获取到本地,进行负载均衡。此时我们需要引入服务注册中心
Eureka-client
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-netflix-eureka-client</artifactId>
</dependency>
- application.yml内进行负载均衡route的配置
...
...
- id: lb_route # lb: load balance 负载均衡,从eureka服务注册中心获取服务提供者地址列表
predicates:
- Path=/lb/**
filters:
- StripPrefix=1
uri: lb://shen-order-service # 集群服务名,eureka中服务的key
discovery:
locator:
enabled: true
lower-case-service-id: true # 小写方式进行匹配
server:
port: 8080
eureka:
client:
service-url:
defaultZone: http://localhost:9090/eureka # eurkea注册中心
此时访问gateway服务,http://localhost:8080/lb/order 接口时,会匹配到id为lb_route
的路由route,从eureka获取到服务名称为shen-order-service
的服务列表,负载均衡获取其中一个服务提供者地址,再拼接/order
进行调用。
d. 对请求进行限流的过滤器
requestratelimiter-gatewayfilter-factory
官方提供了RequestRateLimiterGatewayFilterFactory
类型的过滤器,可以针对请求限流,默认使用Redis进行限流,限流的实现使用令牌桶算法(和semaphore类似,每秒桶内生成一定数量的令牌,当令牌消耗完毕则限流,之前的消耗不完会堆积一部分令牌)
本次针对同一host的请求进行限流
- 首先引入redis依赖
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>
- 自定义限流规则,根据host限流
需要实现KeyResolver
接口
import org.springframework.cloud.gateway.filter.ratelimit.KeyResolver;
import org.springframework.stereotype.Component;
import org.springframework.web.server.ServerWebExchange;
import reactor.core.publisher.Mono;
@Component
public class IpAddressKeyResolver implements KeyResolver {
@Override
public Mono<String> resolve(ServerWebExchange exchange) {
// 根据id作为限流的Key
return Mono.just(exchange.getRequest().getRemoteAddress().getAddress().getHostAddress());
}
}
- application.yml内配置
...
...
- id: ratelimiter_route
predicates:
- Path=/ratelimiter/**
filters:
- StripPrefix=1
- name: RequestRateLimiter
args:
deny-empty-key: true
keyResolver: '#{@ipAddressKeyResolver}'
redis-rate-limiter.replenishRate: 1
redis-rate-limiter.burstCapacity: 2
uri: lb://shen-order-service
redis:
host: 192.168.197.1
port: 6379
重启服务,当我们对同一个请求联系进行多次请求时,就会出现限流的标识符:HTTP ERROR 429
。
e. 动态路由
查询路由信息
官方提供了actuator
可以让我们通过/actuator/gateway/routes
请求来查询路由的配置; http://localhost:8080/actuator/gateway/routes
在/actuator/gateway/routes
路径后添加某一个路由id可以查看具体某一路由的详细信息,如之前配置的限流路由: http://localhost:8080/actuator/gateway/routes/ratelimiter_route
新增、删除路由信息
creating-and-deleting-a-particular-route
发送post请求新增一个路由:
返回1表示新增成功,从上一步骤的查询接口可以根据路由Id来查看是否新增了该路由。
引入actuator
进行路由查看:
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-actuator</artifactId>
</dependency>
f. 路由持久化
使用Redis进行路由持久化:
import com.alibaba.fastjson.JSON;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.cloud.gateway.route.RouteDefinition;
import org.springframework.cloud.gateway.route.RouteDefinitionRepository;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.stereotype.Component;
import reactor.core.publisher.Flux;
import reactor.core.publisher.Mono;
import java.util.ArrayList;
import java.util.List;
/**
* 扩展路由数据的持久化
* 根据Redis实现
*/
@Component
public class RedisRouteDefinitionRepository implements RouteDefinitionRepository {
private static final String GATEWAY_ROUTE_KEY = "gateway_dynamic_route";
@Autowired
RedisTemplate<String, String> redisTemplate;
/**
* 获取路由信息
* @return
*/
@Override
public Flux<RouteDefinition> getRouteDefinitions() {
List<RouteDefinition> routeDefinitionList = new ArrayList<>();
redisTemplate.opsForHash().values(GATEWAY_ROUTE_KEY).stream().forEach(route -> {
routeDefinitionList.add(JSON.parseObject(route.toString(), RouteDefinition.class));
});
return Flux.fromIterable(routeDefinitionList);
}
/**
* 保存路由信息
* @param route
* @return
*/
@Override
public Mono<Void> save(Mono<RouteDefinition> route) {
return route.flatMap(routeDefinition -> {
redisTemplate.opsForHash().put(GATEWAY_ROUTE_KEY, routeDefinition.getId(), JSON.toJSONString(routeDefinition));
return Mono.empty();
});
}
/**
* 删除路由信息
* @param routeId
* @return
*/
@Override
public Mono<Void> delete(Mono<String> routeId) {
return routeId.flatMap(id -> {
if (redisTemplate.opsForHash().hasKey(GATEWAY_ROUTE_KEY, id)) {
redisTemplate.opsForHash().delete(GATEWAY_ROUTE_KEY, id);
return Mono.empty();
}
return Mono.defer(() -> Mono.error(new Exception("RouteDefinition not found: " + routeId)));
});
}
}
这样做的好处显而易见,之前动态添加的路由,服务重启后仍然存在。
三、源码及自定义
1. 源码逻辑
断言类的匹配逻辑
以 cookie为例:
断言类CookieRoutePredicateFactory
内有GatewayPredicate.apply()
方法的实现,当内部回调test
方法返回true
表示匹配。
@Override
public Predicate<ServerWebExchange> apply(Config config) {
return new GatewayPredicate() {
@Override
public boolean test(ServerWebExchange exchange) {
List<HttpCookie> cookies = exchange.getRequest().getCookies()
.get(config.name);
if (cookies == null) {
return false;
}
for (HttpCookie cookie : cookies) {
if (cookie.getValue().matches(config.regexp)) {
return true;
}
}
return false;
}
@Override
public String toString() {
return String.format("Cookie: name=%s regexp=%s", config.name,
config.regexp);
}
};
}
加入断点看着就更为清晰了
断言类的定义规则
其中之前提到的11种断言规则,在gateway源码内都对应具体的工厂类,如这里用到的cookie断言,对应org.springframework.cloud.gateway.handler.predicate.CookieRoutePredicateFactory
,在该类所在目录下定义了一系列不同的断言工厂。
这些类的命名其实是有一定的规则的,如我们之前使用到了 Path 和 Cookie 两种断言,在yml内配置时key分别为 - Path=/gateway/**
和 - Cookie=myName,shen
,则对应类名为 Path
+ RoutePredicateFactory
和 Cookie
+ RoutePredicateFactory
;
反言之当我们自定义断言工厂时,如ShenRoutePredicateFactory
,在yml内使用时,key即为Shen
。
过滤器类
之前我们用过了- StripPrefix=2
类型的过滤器,可以从请求的url总截取掉一部分,在进行跳转,这是官方提供的。官方提供的StripPrefixGatewayFilterFactory
类型的过滤器提供的,官方也提供了很多其余的过滤器:
主要有:Gateway Filters
(包含31中过滤器)和Global Filters
(包含10中常用过滤器)两种类型。
这些过滤器对应的实现类,定义的规则是: key + GatewayFilterFactory
;
如:用到的filters: - StripPrefix=1
,key为StripPrefix
,实现类为StripPrefixGatewayFilterFactory
。可以根据这种规则查找官方提供的几十种过滤器的实现原理,当自定义过滤器时也根据此种规则来即可。
2. 自定义断言、过滤器
自定义断言
- 自定义一个断言: 匹配当请求头header里包含
key=myHeader
,value=test123
时,路由至 博客首页
模仿cookie等断言,继承抽象类AbstractRoutePredicateFactory
,apply
方法内对请求头进行匹配
import org.springframework.cloud.gateway.handler.predicate.AbstractRoutePredicateFactory;
import org.springframework.cloud.gateway.handler.predicate.GatewayPredicate;
import org.springframework.http.HttpHeaders;
import org.springframework.stereotype.Component;
import org.springframework.util.CollectionUtils;
import org.springframework.web.server.ServerWebExchange;
import java.util.Arrays;
import java.util.List;
import java.util.function.Predicate;
@Component
public class ShenRoutePredicateFactory extends AbstractRoutePredicateFactory<ShenRoutePredicateFactory.Config> {
public ShenRoutePredicateFactory() {
super(Config.class);
}
private static final String NAME_KEY="name";
private static final String VALUE_KEY="value";
// 定义 key、value顺序
@Override
public List<String> shortcutFieldOrder() {
return Arrays.asList(NAME_KEY,VALUE_KEY);
}
@Override
public Predicate<ServerWebExchange> apply(Config config) {
return new GatewayPredicate() {
@Override
public boolean test(ServerWebExchange exchange) {
// 匹配请求头 header 里是否包含对应key、value
HttpHeaders httpHeaders = exchange.getRequest().getHeaders();
List<String> value = httpHeaders.get(config.getName());
return !CollectionUtils.isEmpty(value);
}
@Override
public String toString() {
return String.format("Cookie: name=%s value=%s", config.name,config.value);
}
};
}
public static class Config {
private String name;
private String value;
public String getName() {
return name;
}
public void setName(String name) {
this.name = name;
}
public String getValue() {
return value;
}
public void setValue(String value) {
this.value = value;
}
}
}
- 配置文件内指定匹配规则
定义路由id: shenHeader_route
匹配规则,两个断言: 请求url为/shenGateway/**
格式;自定义断言Shen匹配请求头内有:myHeader:test123
的请求;
过滤器: 截取url的首个单词
跳转url: 博客首页 https://www.cnblogs.com/Qkxh320
application.yml
...
...
- id : shenHeader_route
predicates:
- Path=/shenGateway/**
- Shen=myHeader,test123
filters:
- StripPrefix=1
uri: https://www.cnblogs.com/Qkxh320
路由结果:
自定义过滤器
package com.bigshen.springcloud.demo.springcloudgateway8080;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.cloud.gateway.filter.GatewayFilter;
import org.springframework.cloud.gateway.filter.factory.AbstractGatewayFilterFactory;
import org.springframework.stereotype.Component;
import reactor.core.publisher.Mono;
import java.util.Arrays;
import java.util.List;
@Component
public class ShenDefineGatewayFilterFactory extends AbstractGatewayFilterFactory<ShenDefineGatewayFilterFactory.ShenConfig> {
Logger logger = LoggerFactory.getLogger(ShenDefineGatewayFilterFactory.class);
private static final String NAME_KEY = "name";
public ShenDefineGatewayFilterFactory() {
super(ShenConfig.class);
}
@Override
public List<String> shortcutFieldOrder() {
return Arrays.asList(NAME_KEY);
}
@Override
public GatewayFilter apply(ShenConfig config) {
// Filter pre/post
return (((exchange, chain) -> {
// 过滤器 pre
logger.info("[Pre] Filter Request, name: " + config.getName());
// TODO before
return chain.filter(exchange) // 处理
.then(Mono.fromRunnable(()-> { // then,响应式编程,当前一步骤 chain.filter 处理结束,进入then逻辑进行处理
// 内部回调
// 过滤器 post
// TODO after
logger.info("[post]: Response Filter");
}));
}));
}
public static class ShenConfig {
private String name;
public String getName() {
return name;
}
public void setName(String name) {
this.name = name;
}
}
}
application.yml:
...
...
- id : shenHeader_route
predicates:
- Path=/shenGateway/**
- Shen=myHeader,test123
filters:
- StripPrefix=1
- ShenDefine=Shen World
uri: https://www.cnblogs.com/Qkxh320