Spring Cloud Gateway 从数据库动态获取路由信息
我们使用 spring cloud gateway 时,绝大部分情况下都是使用 application.yml 配置文件或者 nacos 、apollo 等配置中心存储路由信息,每当对路由进行增删改操作后,需要重启 gateway 服务才能生效。
在工作中也很可能会遇到这样的场景:用户想自己根据实际情况配置路由转发地址等信息。这就需要我们把用户配置的内容,存储到数据库中,并且在不重启 gateway 服务的前提下,更新 gateway 服务内存中的路由信息。要想实现将 spring cloud gateway 的路由和过滤器等信息存储到其它地方,最简单的方案就是实现 ApplicationEventPublisherAware 接口,并对其进行数据存取方案的扩展即可。
本篇博客仅仅针对最常用的路由转发和 StripPrefix 过滤器进行 mysql 数据库存取,实现动态路由配置功能,在博客最后提供源代码下载。
有关 spring cloud gateway 的路由条件设置和过滤器设置,可以参考如下官网地址:
https://docs.spring.io/spring-cloud-gateway/docs/3.1.4-SNAPSHOT/reference/html/#gateway-request-predicates-factories
https://docs.spring.io/spring-cloud-gateway/docs/3.1.4-SNAPSHOT/reference/html/#gatewayfilter-factories
一、项目搭建
很久之前写过一篇 spring cloud gateway 的博客:https://www.cnblogs.com/studyjobs/p/16913715.html
本篇博客仍然采用之前编写的博客 demo,工程结构完全一模一样,仅仅改造 gateway 模块。为了方便,仍然采用了 Eureka 作为注册中心。大家在实际工作中可以更改为使用 nacos 或 consul 作为注册中心。
先看一下 gateway 服务结构,如下图所示,在原来 demo 的基础上,新增加了 entity、mapper、service、controller 内容,主要实现对 mysql 中配置的路由信息进行增删改,并同时将最新的路由信息更新到 getway 服务的内存中。
gateway 服务的 pom 文件的内容如下:
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0
http://maven.apache.org/xsd/maven-4.0.0.xsd">
<parent>
<artifactId>springcloud_gateway</artifactId>
<groupId>com.jobs</groupId>
<version>1.0-SNAPSHOT</version>
</parent>
<modelVersion>4.0.0</modelVersion>
<artifactId>gateway_app</artifactId>
<dependencies>
<!--引入 gateway 网关-->
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-starter-gateway</artifactId>
</dependency>
<!-- eureka-client -->
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-starter-netflix-eureka-client</artifactId>
</dependency>
<!--引入 mysql 相关依赖-->
<dependency>
<groupId>mysql</groupId>
<artifactId>mysql-connector-java</artifactId>
<version>8.0.33</version>
</dependency>
<dependency>
<groupId>com.baomidou</groupId>
<artifactId>mybatis-plus-boot-starter</artifactId>
<version>3.5.0</version>
</dependency>
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
<version>1.18.26</version>
</dependency>
</dependencies>
</project>
为了能够更简单的进行演示,mysql 数据库表设计的非常简单,具体的 sql 语句如下:
DROP TABLE IF EXISTS `routes_config`;
CREATE TABLE `routes_config` (
`id` varchar(50) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NOT NULL COMMENT '路由id名称',
`uri` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NOT NULL COMMENT '转发的目标地址',
`predicates` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NOT NULL COMMENT '访问路由地址',
`filters` int(255) NULL DEFAULT 0 COMMENT '过滤掉左侧的路径数量',
`remarks` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NOT NULL DEFAULT '' COMMENT '备注信息',
PRIMARY KEY (`id`) USING BTREE
) ENGINE = InnoDB CHARACTER SET = utf8mb4 COLLATE = utf8mb4_general_ci COMMENT = '路由配置表' ROW_FORMAT = Dynamic;
INSERT INTO `routes_config` VALUES ('baidu', 'https://www.baidu.com', '/baidu/**', 1, '百度');
INSERT INTO `routes_config` VALUES ('providerA', 'lb://PROVIDER-A', '/testa/**', 0, '服务A');
INSERT INTO `routes_config` VALUES ('providerB', 'lb://PROVIDER-B', '/testb/**', 0, '服务B');
INSERT INTO `routes_config` VALUES ('qq', 'https://www.qq.com', '/qq/**', 1, '腾讯');
为了实现从数据库中动态读取配置的路由信息,因此需要将 gateway 的配置文件中配置的路由信息注释掉:
server:
port: 8089
eureka:
instance:
# 配置主机名
hostname: gateway-app
# 显示 ip 地址,代替显示主机名
prefer-ip-address: true
# 所注册服务实例名称的显示形式
instance-id: ${eureka.instance.hostname}:${server.port}
# 每隔 3 秒发一次心跳包
lease-renewal-interval-in-seconds: 3
# 如果 15 秒没有发送心跳包,就让 eureka 把自己从服务列表中移除
lease-expiration-duration-in-seconds: 15
client:
service-url:
# 将当前 springboot 服务注册到 eureka 中
defaultZone: http://localhost:8761/eureka
# 是否将自己的路径注册到 eureka 上
register-with-eureka: true
# 是否需要从 eureka 中抓取路径
fetch-registry: true
# provider 集群需要使用相同的 application 名称
spring:
application:
name: gateway-app
datasource:
driver-class-name: com.mysql.jdbc.Driver
url: jdbc:mysql://192.168.139.128:3306/gatewayDb?characterEncoding=utf8&allowMultiQueries=true&useSSL=false
username: root
password: root
cloud:
# 网关配置
gateway:
# 允许跨域请求(仅配置这里可能不行,还得代码中进行配置)
globalcors:
add-to-simple-url-handler-mapping: true
corsConfigurations:
'[/**]':
allowedHeaders: "*"
allowedOrigins: "*"
allowedMethods:
- GET
- POST
- DELETE
- PUT
- OPTION
# 路由配置:转发规则
#routes:
# - id: aaa
# # 静态路由
# # uri: http://localhost:8100/
# # 动态路由
# uri: lb://PROVIDER-A
# predicates:
# - Path=/testa/**
# - id: gateway-consumer
# uri: lb://PROVIDER-B
# predicates:
# - Path=/testb/**
# 微服务名称配置
discovery:
locator:
# 设置为true 请求路径前可以添加微服务名称
enabled: true
# 允许为小写
lower-case-service-id: true
二、代码细节
RouteEntity 的内容如下:
package com.jobs.gateway.entity;
import com.baomidou.mybatisplus.annotation.TableField;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import lombok.Data;
@Data
@TableName("routes_config")
public class RouteEntity {
@TableId("id")
private String id;
@TableField("uri")
private String uri;
@TableField("predicates")
private String predicates;
@TableField("filters")
private Integer filters;
@TableField("remarks")
private String remarks;
}
由于采用了 mybatis plus 框架,因此 RouteMapper 就比较简单,具体内容如下:
package com.jobs.gateway.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.jobs.gateway.entity.RouteEntity;
import org.apache.ibatis.annotations.Mapper;
@Mapper
public interface RouteMapper extends BaseMapper<RouteEntity> {
}
核心功能主要在 RouteService 中,需要实现 ApplicationEventPublisherAware 接口。另外之所以实现 CommandLineRunner 接口,主要目的是让 gateway 服务启动成功后,自动去 mysql 中读取路由配置信息,加载到网关内存中。
package com.jobs.gateway.service;
import com.baomidou.mybatisplus.core.toolkit.CollectionUtils;
import com.jobs.gateway.entity.RouteEntity;
import com.jobs.gateway.mapper.RouteMapper;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.CommandLineRunner;
import org.springframework.cloud.gateway.event.RefreshRoutesEvent;
import org.springframework.cloud.gateway.filter.FilterDefinition;
import org.springframework.cloud.gateway.handler.predicate.PredicateDefinition;
import org.springframework.cloud.gateway.route.RouteDefinition;
import org.springframework.cloud.gateway.route.RouteDefinitionWriter;
import org.springframework.context.ApplicationEventPublisher;
import org.springframework.context.ApplicationEventPublisherAware;
import org.springframework.stereotype.Component;
import org.springframework.web.util.UriComponentsBuilder;
import reactor.core.publisher.Mono;
import java.net.URI;
import java.util.Arrays;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
@Component
@Slf4j
public class RouteService implements ApplicationEventPublisherAware, CommandLineRunner {
private ApplicationEventPublisher publisher;
@Autowired
private RouteDefinitionWriter routeDefinitionWriter;
@Autowired
private RouteMapper routeMapper;
@Override
public void setApplicationEventPublisher(ApplicationEventPublisher applicationEventPublisher) {
this.publisher = applicationEventPublisher;
}
public void refresh() {
loadRouteConfig();
}
@Override
public void run(String... args) throws Exception {
//CommandLineRunner 项目启动成功后执行,只执行一次
//加载数据库中的路由信息
loadRouteConfig();
}
private void loadRouteConfig() {
List<RouteEntity> gatewayList = routeMapper.selectList(null);
if (!CollectionUtils.isEmpty(gatewayList)) {
for (RouteEntity gateway : gatewayList) {
RouteDefinition definition = handleData(gateway);
routeDefinitionWriter.save(Mono.just(definition)).subscribe();
}
publisher.publishEvent(new RefreshRoutesEvent(this));
}
}
private RouteDefinition handleData(RouteEntity gateway) {
RouteDefinition definition = new RouteDefinition();
PredicateDefinition predicate = new PredicateDefinition();
Map<String, String> predicateParams = new HashMap<>(8);
FilterDefinition filterDefinition = new FilterDefinition();
Map<String, String> filterParams = new HashMap<>(8);
//设置id
definition.setId(gateway.getId());
URI url;
if (gateway.getUri().startsWith("http") || gateway.getUri().startsWith("https")) {
//直接转发到具体的网址
url = UriComponentsBuilder.fromHttpUrl(gateway.getUri()).build().toUri();
} else {
//负载均衡地址
url = UriComponentsBuilder.fromUriString(gateway.getUri()).build().toUri();
}
//设置转发的 url
definition.setUri(url);
//这里只设置请求的路由条件
predicate.setName("Path");
predicateParams.put("pattern", gateway.getPredicates());
predicate.setArgs(predicateParams);
//设置predicate路由条件
definition.setPredicates(Arrays.asList(predicate));
if (gateway.getFilters() != 0) {
filterDefinition.setName("StripPrefix");
filterParams.put("_genkey_0", gateway.getFilters().toString());
filterDefinition.setArgs(filterParams);
//设置过滤器
definition.setFilters(Arrays.asList(filterDefinition));
}
//设置过滤器的优先级
definition.setOrder(0);
return definition;
}
//新增路由并更新网关
public void addRoute(RouteEntity gateway) {
RouteDefinition definition = handleData(gateway);
routeMapper.insert(gateway);
routeDefinitionWriter.save(Mono.just(definition)).subscribe();
publisher.publishEvent(new RefreshRoutesEvent(this));
}
//更新路由并更新网关
public void updateRoute(RouteEntity gateway) {
RouteDefinition definition = handleData(gateway);
routeMapper.updateById(gateway);
routeDefinitionWriter.delete(Mono.just(definition.getId()));
routeDefinitionWriter.save(Mono.just(definition)).subscribe();
publisher.publishEvent(new RefreshRoutesEvent(this));
}
//删除路由并更新网关
public void deleteRoute(String routeId) {
routeMapper.deleteById(routeId);
routeDefinitionWriter.delete(Mono.just(routeId)).subscribe();
publisher.publishEvent(new RefreshRoutesEvent(this));
}
}
最后编写一个 RouteController 用于对外提供接口,对 mysql 中的路由信息表配置进行增删改操作
package com.jobs.gateway.controller;
import com.jobs.gateway.entity.RouteEntity;
import com.jobs.gateway.service.RouteService;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.web.bind.annotation.*;
@RequestMapping("/route")
@RestController
public class RouteController {
@Autowired
private RouteService routeService;
@PostMapping("/add")
public String addRoute(@RequestBody RouteEntity routeEntity) {
try {
routeService.addRoute(routeEntity);
return "ok";
} catch (Exception ex) {
return ex.getMessage();
}
}
@PutMapping("/update")
public String updateRoute(@RequestBody RouteEntity routeEntity) {
try {
routeService.updateRoute(routeEntity);
return "ok";
} catch (Exception ex) {
return ex.getMessage();
}
}
@DeleteMapping("/delete")
public String deleteRoute(String routeId) {
try {
routeService.deleteRoute(routeId);
return "ok";
} catch (Exception ex) {
return ex.getMessage();
}
}
}
三、验证成果
启动 eureka_app、gateway_app、provider_a1、provider_b1 等服务后,由于 gateway_app 的 application.yml 配置文件中,已经注释掉了所配置的路由信息,因此通过网关无法访问 provider 所提供的接口,此时可以通过 Apipost 工具调用 gateway_app 服务所提供的接口,添加路由信息:
添加完成之后,此时访问 http://localhost:8089/testa/getdata/1
就可以将请求转发到 PROVIDER-A 服务,获取结果了。也就是说当访问网关域名时,如果访问的路径以 /testa 开头的话,就会访问 PROVIDER-A 服务的 /testa 对应的地址。
转发的目标路径【lb://服务名】表示负载均衡转发。同样你可以把 PROVIDER-B 服务的路由信息,添加到数据库中。
如果想直接转发到具体的网址时,比如转发到百度网站,可以添加如下路由:
添加完成后,就可以访问 http://localhost:8089/baidu
就可以访问百度网站了。
需要注意的是:这里的 filters 必须配置为 1,由于我们代码中添加的过滤器是 StripPrefix,当其值为 1 时,表示去掉路径中的第一段。
例如:如果 StripPrefix = 0 的话,访问 http://localhost:8089/baidu
,转发后的地址是:https://www.baidu.com/baidu
,访问的结果是 404
如果 StripPrefix = 1 的话,访问 http://localhost:8089/baidu
,转发后的地址是:https://www.baidu.com
,去掉了访问路由 /baidu/**
中的第一段,因此就可以正常访问百度网站。
当然你想 http://localhost:8089/baidu
查询百度的内容时,访问的地址也必须是以 http://localhost:8089/baidu
开头的地址。
例如通过网关在百度查询 gateway 关键字:http://localhost:8089/baidu/s?wd=gateway
本篇博客的源代码下载地址为:https://files.cnblogs.com/files/blogs/699532/springcloud_gateway2.zip