Gateway微服务网关
一、创建项目
我们创建一个新项目,利用二阶段代码对项目进行改造,创建微服务网关模块,并通过Gateway实现动态路由功能
附上项目源码地址
二、Gateway网关
创建新的modules命名gateway-serv,搭建一个 Gateway 网关,并完成三个任务:设置跨域规则、添加路由和实现网关层限流。
2.1 设置跨域规则
导入Gateway依赖,Nacos服务发现,以及限流相关jar
<dependencies>
<!-- Gateway依赖 -->
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-starter-gateway</artifactId>
</dependency>
<!-- Nacos服务发现 -->
<dependency>
<groupId>com.alibaba.cloud</groupId>
<artifactId>spring-cloud-starter-alibaba-nacos-discovery</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-starter-loadbalancer</artifactId>
</dependency>
<!-- Redis+Lua限流 -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-redis-reactive</artifactId>
</dependency>
</dependencies>
添加配置文件bootstrap.yml
spring:
# 必须把name属性从application.yml迁移过来,否则无法动态刷新
application:
name: gateway-serv
添加配置文件application.yml
server:
port: 8083
spring:
# 分布式限流的Redis连接
redis:
database: 2
host: ip地址
password: redis密码
port: redis端口
jedis:
pool:
max-active: 20
cloud:
nacos:
# Nacos配置项
discovery:
server-addr: 127.0.0.1:8848
heart-beat-interval: 5000
heart-beat-timeout: 15000
cluster-name: Cluster-A
namespace: dev
group: myGroup
register-enabled: true
watch:
enabled: true
watch-delay: 30000
gateway:
discovery:
locator:
# 创建默认路由,以"/服务名称/接口地址"的格式规则进行转发
# Nacos服务名称本来就是小写,但Eureka默认大写
enabled: true
lower-case-service-id: true
# 跨域配置
globalcors:
cors-configurations:
'[/**]':
# 授信地址列表
allowed-origins:
- "http://192.168.2.26:8081"
- "http://192.168.2.26:8082"
- "http://192.168.2.26:8083"
# cookie, authorization认证信息
expose-headers: "*"
allowed-methods: "*"
allow-credentials: true
allowed-headers: "*"
# 浏览器缓存时间
max-age: 1000
在了解如何配置跨域规则之前,我需要先为你讲一讲什么是浏览器的“同源保护策略”。
如果前后端是分离部署的,大部分情况下,前端系统和后端 API 都在同一个根域名下,但也有少数情况下,前后端位于不同域名。比如前端页面的地址是 geekbang.com,后端 API 的访问地址是 infoq.com。如果一个应用请求发生了跨域名访问,比如位于 geekbang.com 的页面通过 Node.js 访问了 infoq.com 的后端 API,这种情况就叫“跨域请求”。
我们的浏览器对跨域访问的请求设置了一道保护措施,在跨域调用发起之前,浏览器会尝试发起一个 OPTIONS 类型的请求到目标地址,探测一下你的后台服务是否支持跨域调用。如果你的后端 Say NO,那么前端浏览器就会阻止这次非同源访问。通过这种方式,一些美女聊天类的钓鱼网站就无法对你实施跨站脚本攻击了,这就是浏览器端的同源保护策略。
我们接下来就来了解一下,如何通过跨域配置的参数来控制跨域访问。这些参数都定义在的 spring.cloud.gateway.globalcors.cors-configurations 节点的[/]路径下,[/]这串通配符可以匹配所有请求路径。当然了,你也可以为某个特定的路径设置跨域规则(比如[/order/])。
在这上面的几个配置项中,allowed-origins 是最重要的,你需要将受信任的域名添加到这个列表当中。从安全性角度考虑,非特殊情况下我并不建议你使用 * 通配符,因为这意味着后台服务可以接收任何跨域发来的请求。
2.2 添加路由
定义路由规则
我推荐你使用一个独立的配置类来管理路由规则,这样代码看起来比较清晰。创建了RoutesConfiguration 类,为两个微服务分别定义了简单明了的规则。
@Configuration
public class RoutesConfiguration {
@Bean
public RouteLocator declare(RouteLocatorBuilder builder) {
return builder.routes()
.route(route -> route
.order(1)
.path("/gateway/customer/**")
.filters(f -> f.stripPrefix(1))
.uri("lb://customer-serv")
)
.route(route -> route
.path("/gateway/producer/**")
.filters(f -> f.stripPrefix(1))
.uri("lb://producer-serv")
).build();
}
}
首先,我使用 path 谓词约定了路由的匹配规则为 path=“/gateway/customer/**”。这里你要注意的是,如果某一个请求匹配上了多个路由,但你又想让各个路由之间有个先后匹配顺序,这时你就可以使用 order(n) 方法设定路由优先级,n 数字越小则优先级越高。
接下来,我使用了一个 stripPrefix 过滤器,将 path 访问路径中的第一个前置子路径删除掉。这样一来,/gateway/customer/xxx的访问请求经由过滤器处理后就变成了 /customer/xxx。同理,如果你想去除 path 中打头的前两个路径,那就使用 stripPrefix(2),参数里传入几它就能吞掉几个 prefix path。
最后,我使用 uri 方法指定了当前路由的目标转发地址,这里的“lb://customer-serv”表示使用本地负载均衡将请求转发到名为“customer-serv”的服务。测试一下
2.3 实现网关层限流
Gateway 过滤器在一个 Request 生命周期中的作用阶段。其实 Filter 的一大功能无非就是对 Request 和 Response 动手动脚,为什么这么说呢?比如你想对 Request Header 和 Parameter 进行删改,又或者从 Response 里面删除某个 Header,那么你就可以使用下面这种方式,通过链式 Builder 风格构造过滤器链。
.route(route -> route
.order(1)
.path("/gateway/customer/**")
.filters(f -> f.stripPrefix(1)
// 修改Request参数
.removeRequestHeader("mylove")
.addRequestHeader("myLove", "u")
.removeRequestParameter("urLove")
.addRequestParameter("urLove", "me")
// response系列参数 不一一列举了
.removeResponseHeader("responseHeader")
)
.uri("lb://customer-serv")
接下来,我们通过一个轻量级的网关层限流方案来进一步熟悉 Filter 的使用,这个限流方案所采用的底层技术是 Redis + Lua。
Redis 你一定很熟悉了,而 Lua 这个名词你可能是第一次听说,但提到愤怒的小鸟这个游戏,你一定不陌生,这个游戏就是用 Lua 语言写的。Lua 是一类很小巧的脚本语言,它和 Redis 可以无缝集成,你可以在 Lua 脚本中执行 Redis 的 CRUD 操作。在这个限流方案中,Redis 用来保存限流计数,而限流规则则是定义在 Lua 脚本中,默认使用令牌桶限流算法。如果你对 Lua 脚本的内容感兴趣,可以在 IDE 中全局搜索 request_rate_limiter.lua 这个文件。
前面我们已经添加了 Redis 的依赖和连接配置,现在你可以直接来定义限流参数了。我在 Gateway 模块里新建了一个 RedisLimitationConfig 类,专门用来定义限流参数。我们用到的主要参数有两个,一个是限流的维度,另一个是限流规则,你可以参考下面的代码。
@Configuration
public class RedisLimitationConfig {
// 限流的维度
@Bean
@Primary
public KeyResolver remoteHostLimitationKey() {
return exchange -> Mono.just(
exchange.getRequest()
.getRemoteAddress()
.getAddress()
.getHostAddress()
);
}
//producer服务限流规则
@Bean("producerRateLimiter")
public RedisRateLimiter producerRateLimiter() {
return new RedisRateLimiter(10, 20);
}
// customer服务限流规则
@Bean("customerRateLimiter")
public RedisRateLimiter customerRateLimiter() {
return new RedisRateLimiter(1, 1);
}
@Bean("defaultRateLimiter")
@Primary
public RedisRateLimiter defaultRateLimiter() {
return new RedisRateLimiter(50, 100);
}
}
我在 remoteHostLimitationKey 这个方法中定义了一个以 Remote Host Address 为维度的限流规则,当然了你也可以自由发挥,改用某个请求参数或者用户 ID 为限流规则的统计维度。其它的三个方法定义了基于令牌桶算法的限流速率,RedisRateLimiter 类接收两个 int 类型的参数,第一个参数表示每秒发放的令牌数量,第二个参数表示令牌桶的容量。通常来说一个请求会消耗一张令牌,如果一段时间内令牌产生量大于令牌消耗量,那么积累的令牌数量最多不会超过令牌桶的容量。
定义好了限流参数之后,我们来看一下如何将限流规则应用到路由表中。因为 Gateway 路由规则都定义在 RoutesConfiguration 类中,所以你需要把刚才我们定义的限流参数类注入到 RoutesConfiguration 类中。考虑到不同的路由表可能会使用不同的限流参数,所以你在定义多个限流参数的时候,可以使用 @Bean(“customerRateLimiter”)
这种方式来做区分,然后在 Autowired 注入对象的时候,使用 @Qualifier(“customerRateLimiter”)
指定想要加载的限流参数就可以了,并调整RoutesConfiguration类中的路由规则
@Configuration
public class RoutesConfiguration {
@Autowired
private KeyResolver hostAddrKeyResolver;
@Autowired
@Qualifier("customerRateLimiter")
private RateLimiter customerRateLimiter;
@Autowired
@Qualifier("producerRateLimiter")
private RateLimiter producerRateLimiter;
@Bean
public RouteLocator declare(RouteLocatorBuilder builder) {
return builder.routes()
.route(route -> route
.path("/gateway/customer/**")
.filters(f -> f.stripPrefix(1)
.requestRateLimiter(limiter-> {
limiter.setKeyResolver(hostAddrKeyResolver);
limiter.setRateLimiter(customerRateLimiter);
// 限流失败后返回的HTTP status code
limiter.setStatusCode(HttpStatus.BANDWIDTH_LIMIT_EXCEEDED);
}
)
)
.uri("lb://customer-serv")
)
.route(route -> route
.path("/gateway/producer/**")
.filters(f -> f.stripPrefix(1))
.uri("lb://producer-serv")
).build();
}
}
启动测试,多次访问超过限流规则时,显示返回http状态code为509
三、Nacos实现动态路由规则
首先,我们需要定义一个底层的网关路由规则编辑类,它的作用是将变化后的路由信息添加到网关上下文中。我把这个类命名为 GatewayService,放置在dynamic包下
@Slf4j
@Service
public class GatewayService {
@Autowired
private RouteDefinitionWriter routeDefinitionWriter;
@Autowired
private ApplicationEventPublisher publisher;
public void updateRoutes(List<RouteDefinition> routes) {
if (CollectionUtils.isEmpty(routes)) {
log.info("No routes found");
return;
}
routes.forEach(r -> {
try {
routeDefinitionWriter.save(Mono.just(r)).subscribe();
publisher.publishEvent(new RefreshRoutesEvent(this));
} catch (Exception e) {
log.error("cannot update route, id={}", r.getId());
}
});
}
}
这段代码接收了一个 RouteDefinition List 对象作为入参,它是 Gateway 网关组件用来封装路由规则的标准类,在里面包含了谓词、过滤器和 metadata 等一系列构造路由规则所需要的元素。在主体逻辑部分,我调用了 Gateway 内置的路由编辑类 RouteDefinitionWriter,将路由规则写入上下文,再调用 ApplicationEventPublisher 类发布一个路由刷新事件。
接下来,我们要去做一个中间层转换层来对接 Nacos 和 GatewayService,这个中间层主要完成两个任务,一是动态接收 Nacos Config 的参数,二是将配置文件的内容转换为 GatewayService 的入参。
这里我不打算使用 @RefreshScope 来获取 Nacos 动态参数了,我另辟蹊径使用了一种更为灵活的监听机制,通过注册一个“监听器”来获取 Nacos Config 的配置变化通知。我把这段逻辑封装在了 DynamicRoutesListener 类中,它位于 GatewayService 同级目录下,你可以参考下面的代码实现。
@Slf4j
@Component
public class DynamicRoutesListener implements Listener {
@Autowired
private GatewayService gatewayService;
@Override
public Executor getExecutor() {
log.info("getExecutor");
return null;
}
// 使用JSON转换,将plain text变为RouteDefinition
@Override
public void receiveConfigInfo(String configInfo) {
log.info("received routes changes {}", configInfo);
List<RouteDefinition> definitionList = JSON.parseArray(configInfo, RouteDefinition.class);
gatewayService.updateRoutes(definitionList);
}
}
DynamicRoutesListener 实现了 Listener 接口,后者是 Nacos Config 提供的标准监听器接口,当被监听的 Nacos 配置文件发生变化的时候,框架会自动调用 receiveConfigInfo 方法执行自定义逻辑。在这段方法里,我将接收到的文本对象 configInfo 转换成了 List类,并调用 GatewayService 完成路由表的更新。
这里需要你注意的一点是,你需要按照 RouteDefinition 的 JSON 格式来编写 Nacos Config 中的配置项,如果两者格式不匹配,那么这一步格式转换就会抛出异常。
定义好了监听器之后,接下来你就要考虑如何来加载 Nacos 路由配置项了。我们需要在两个场景下加载配置文件,一个是项目首次启动的时候,从 Nacos 读取文件用来初始化路由表;另一个场景是当 Nacos 的配置项发生变化的时候,动态获取配置项。
为了能够一石二鸟简化开发,我决定使用一个类来搞定这两个场景。我定义了一个叫做 DynamicRoutesLoader 的类,它实现了 InitializingBean 接口,后者是 Spring 框架提供的标准接口。它的作用是在当前类所有的属性加载完成后,执行一段定义在 afterPropertiesSet 方法中的自定义逻辑。
在 afterPropertiesSet 方法中我执行了两项任务,第一项任务是调用 Nacos 提供的 NacosConfigManager 类加载指定的路由配置文件,配置文件名是 routes-config.json;第二项任务是将前面我们定义的 DynamicRoutesListener 注册到 routes-config.json 文件的监听列表中,这样一来,每次这个文件发生变动,监听器都能够获取到通知。
@Slf4j
@Configuration
public class DynamicRoutesLoader implements InitializingBean {
@Autowired
private NacosConfigManager configService;
@Autowired
private NacosConfigProperties configProps;
@Autowired
private DynamicRoutesListener dynamicRoutesListener;
private static final String ROUTES_CONFIG = "routes-config.json";
@Override
public void afterPropertiesSet() throws Exception {
// 首次加载配置
String routes = configService.getConfigService().getConfig(
ROUTES_CONFIG, configProps.getGroup(), 10000);
dynamicRoutesListener.receiveConfigInfo(routes);
// 注册监听器
configService.getConfigService().addListener(ROUTES_CONFIG,
configProps.getGroup(),
dynamicRoutesListener);
}
}
到这里,我们的代码任务就完成了,你只需要往项目的 bootstrap.yml 文件中添加 Nacos Config 的配置项就可以了。按照惯例,我仍然使用 dev 作为存放配置文件的 namespace。
spring:
# 必须把name属性从application.yml迁移过来,否则无法动态刷新
application:
name: gateway-serv
cloud:
nacos:
config:
# nacos config服务器的地址
server-addr: 127.0.0.1:8848
file-extension: yml
# prefix: 文件名前缀,默认是spring.application.name
# 如果没有指定命令空间,则默认命令空间为PUBLIC
namespace: dev
# 如果没有配置Group,则默认值为DEFAULT_GROUP
group: DEFAULT_GROUP
# 从Nacos读取配置项的超时时间
timeout: 5000
# 长轮询超时时间
config-long-poll-timeout: 10000
# 轮询的重试时间
config-retry-time: 2000
# 长轮询最大重试次数
max-retry: 3
# 开启监听和自动刷新
refresh-enabled: true
添加 Nacos 配置文件
在 Nacos 配置列表页中,你需要在“开发环境”的命名空间下创建一个 JSON 格式的文件,文件名要和 Gateway 代码中的名称一致,叫做“routes-config.json”,它的 Group 是默认分组,也就是 DEFAULT_GROUP。
[{
"id": "customer-dynamic-router",
"order": 0,
"predicates": [{
"args": {
"pattern": "/test/customer/**"
},
"name": "Path"
}],
"filters": [{
"name": "StripPrefix",
"args": {
"parts": 1
}
}
],
"uri": "lb://customer-serv"
}]
在这段配置文件中,我指定当前路由的 ID 是 customer-dynamic-router,并且优先级为 0。除此之外,我还定义了一段 Path 谓词作为路径匹配规则,还通过 StripPrefix 过滤器将 Path 中第一个前置路径删除。
如果需要删除路由,可以通过在配置文件中的metadata属性添加一个删除标志,DynamicRoutesListener当监听到存在删除标志时,执行删除逻辑即可。