关于动态路由,是各类业务场景中的基础功能,通过动态化配置API网关的路由参数,可以实现在不重启服务的情况下,API路由规则的动态配置、实时生效。本文以Spring Cloud Gateway高性能网关3.0.3版本为例,例举了五种实现动态路由的基本思路及示例代码,并对比了优缺点。
看了网上的很多文章,又看了官方文档,感觉大多文章有点零散,本文决定做一个全面的总结,重点探讨如何通过代码的方式实现动态路由,一篇文章带你走天下。
一、Spring Cloud Gateway
Spring Cloud Gateway 是Spring Cloud家族中的一款API网关。因为之前 Zuul 2.x 的不断跳票,Spring Cloud 才釜底抽薪推出了自己的服务网关:Spring Cloud Gateway。Gateway 建立在 Spring Webflux上,目标是提供一个简洁、高效的API网关,同时也可以快速的拼装上Spring Cloud全家桶的API网关,包含如下特性:
- 基于Spring Framework 5, Project Reactor, Spring Boot 2.0构建
- 能够自由设置任何请求属性的路由
- 路由可以自由设置断言(Predicates)和过滤器(Filter)
- 可集成熔断器
- Spring Cloud DiscoveryClient原生支持
- 流量限速
-
路径重写(rewrite)
二、静态路由
所谓静态路由,就是指API网关启动前,通过配置文件或者代码的方式,静态的配置好API之间的路由关系,此后不需要二次维护,大多数的内部API网关适用于这种方式。
2.1 方法一 配置文件方式
本质上是修改application.yml
文件,相关修改方法,官网已经有详尽的描述了,如需帮助可参考官方文档。本文仅举例其中一种,一看便知。
spring:
cloud:
gateway:
routes:
- id: ingredients
uri: lb://ingredients
predicates:
- Path=//ingredients/**
filters:
- name: CircuitBreaker
args:
name: fetchIngredients
fallbackUri: forward:/fallback
- id: ingredients-fallback
uri: http://localhost:9994
predicates:
- Path=/fallback
filters:
- name: FallbackHeaders
args:
executionExceptionTypeHeaderName: Test-Header
2.2 方法二 代码构建路由
// static imports from GatewayFilters and RoutePredicates
@Bean
public RouteLocator customRouteLocator(RouteLocatorBuilder builder, ThrottleGatewayFilterFactory throttle) {
return builder.routes()
.route(r -> r.host("**.abc.org").and().path("/image/png")
.filters(f ->
f.addResponseHeader("X-TestHeader", "foobar"))
.uri("http://httpbin.org:80")
)
.route(r -> r.path("/image/webp")
.filters(f ->
f.addResponseHeader("X-AnotherHeader", "baz"))
.uri("http://httpbin.org:80")
.metadata("key", "value")
)
.route(r -> r.order(-1)
.host("**.throttle.org").and().path("/get")
.filters(f -> f.filter(throttle.apply(1,
1,
10,
TimeUnit.SECONDS)))
.uri("http://httpbin.org:80")
.metadata("key", "value")
)
.build();
}
如上述例子,通过代码即可完成路由的创建,会在Spring Cloud Gateway启动时自动加载到运行态,本质上配置文件和代码方式,仅仅是两种加载形态,底层没有太大的区别。然而,静态路由往往满足不了我们的需求。
三、原生动态路由
动态路由,就是在API服务网关启动之后,路由关系可取决于外部环境的变化而变化,比如通过注册中心的不同的微服务、数据库中的映射路由关系等,动态的改变路由关系。Spring Cloud Gateway也提供了两种动态路由的方式,一种是Spring Cloud DiscoveryClient原生支持、一种是基于Actuator API。
3.1 Spring Cloud DiscoveryClient
Spring Cloud原生支持服务自动发现并且注册到路由之中,通过在application.properties
中设置spring.cloud.gateway.discovery.locator.enabled=true
,同时确保 DiscoveryClient
的实体 (Netflix Eureka, Consul, 或 Zookeeper) 已经生效,即可完成服务的自动发现及注册。
3.2 Actuator API
3.2.1 创建路由关系
创建一个路由关系,需要使用 POST
请求到/gateway/routes/{id_route_to_create}
。请求内容为JSON请求体,请求内容参考如下。
{
"id": "first_route",
"predicates": [{
"name": "Path",
"args": {"_genkey_0":"/first"}
}],
"filters": [],
"uri": "https://www.uri-destination.org",
"order": 0
}]
3.2.2 删除路由关系
删除一个路由关系,需要使用 DELETE
请求到 /gateway/routes/{id_route_to_delete}
即可完成删除路由。
四、自由扩展动态路由
但上述,似乎感觉自由度还是有限。
基于服务注册发现的Spring Cloud DiscoveryClient,需要全部服务在Spring Cloud家族体系下,一旦有外部路由关系,会将思维负载化。
Actuator API是一种外部API调用,虽然能够解决90%以上的问题,但是对于高度定制化的需求,频繁定制增删改查路由的API,难免会有bug,甚至修改时会造成服务的瞬时不可用。
基于上述问题,为何不尝试使用代码的方式解决问题?Spring Cloud Gateway的源码非常优秀,可以有多种方式让我们实现接口,完成一切我们想要的,于是想出了如下两种思路:
思路一:底层修改,扩展Spring Cloud Gateway底层路由加载机制
思路二:动态修改,请求进来时,以GlobalFilter的方式动态修改路由地址
4.1 思路一 底层修改
底层修改,就是通过一定机制,将Spring Cloud Gateway运行态时保存的路由关系,通过实现、继承加载自定义类的方式,对其进行动态路由修改,每当路由有变化时,再触发一次动态的修改。
因此,这种思路需要两种保障: 1. 监听机制 2. 实现自定义路由的核心类
为此,我特意从网上淘来了Spring Cloud Gateway 和核心加载机制,如图所示。
大体上来讲,我们有两种修改思路:
- 从 RouteDefinitonLocator阶段下手
- 从RouteLoacator阶段下手
4.1.1 方法一 RouteLocator 全量更新
首先,先需实现ApplicationEventPublisherAware
接口,实现路由的动态监听。
@Component
public class GatewayRoutesRefresher implements ApplicationEventPublisherAware {
private ApplicationEventPublisher publisher;
@Override
public void setApplicationEventPublisher(ApplicationEventPublisher applicationEventPublisher) {
this.publisher = applicationEventPublisher;
}
public void refreshRoutes() {
publisher.publishEvent(new RefreshRoutesEvent(this));
}
}
然后实现RouteLocator
,一次性刷新全量的API信息,实现动态加载。
apiRepository是自定义的API Repository,可来源于Redis、数据库、Zookeeper等,保存着API的信息及PATH(getRequestPath)、目标地址(getRoutePath)。
@Component
public class RefreshRouteLocator implements RouteLocator {
private static Logger log = LoggerFactory.getLogger(RefreshRouteLocator.class);
private Flux route;
private RouteLocatorBuilder builder;
private RouteLocatorBuilder.Builder routesBuilder;
/**
* 自定义的API Repository,可来源于Redis、数据库、Zookeeper等,保存着API的信息及PATH、目标地址
*/
@Autowired
private APIRepository apiRepository;
@Autowired
GatewayRoutesRefresher gatewayRoutesRefresher;
public RefreshRouteLocator(RouteLocatorBuilder builder) {
this.builder = builder;
clearRoutes();
}
public void clearRoutes() {
routesBuilder = builder.routes();
}
/**
* 配置完成后,调用本方法构建路由和刷新路由表
*/
public void buildRoutes() {
clearRoutes();
if (routesBuilder != null) {
apiRepository.getAll().parallelStream().forEach(service ->{
String serviceId = service.getServiceId();
APIInfo serviceDefinition = apiRepository.get(serviceId);
if (serviceDefinition == null) {
log.error("无此服务配置信息:" + serviceId);
}
URI uri = UriComponentsBuilder.fromHttpUrl(serviceDefinition.getRoutePath()).build().toUri();
routesBuilder.route(serviceId, r -> r.path(serviceDefinition.getRequestPath()).uri(uri));
});
this.route = routesBuilder.build().getRoutes();
}
gatewayRoutesRefresher.refreshRoutes();
}
@Override
public Flux getRoutes() {
return route;
}
}
最后,在需要刷新时,可调用buildRoutes()
,重新构建全量路由,完成大业。
@Autowired
private RefreshRouteLocator refreshableRoutesLocator;
// ......
public void 需要刷新路由时的方法() {
refreshableRoutesLocator.buildRoutes();
}
4.1.2 方法二 RouteDefinitionRepository 全量更新
RouteDefinitionRepository
方法与方法一类似,RouteDefinitionLocator 是路由定义定位器的顶级接口,它的主要作用就是读取路由的配置信息。相关实现方法,我没有采用,但在网上淘来一段代码,仅供参考。
@Component
public class FileRouteDefinitionRepository implements RouteDefinitionRepository, ApplicationEventPublisherAware {
private static final Logger LOGGER = LoggerFactory.getLogger(FileRouteDefinitionRepository.class);
private ApplicationEventPublisher publisher;
private List routeDefinitionList = new ArrayList<>();
@Value("${gateway.route.config.file}")
private String file;
@Override
public void setApplicationEventPublisher(ApplicationEventPublisher publisher) {
this.publisher = publisher;
}
@PostConstruct
public void init() {
load();
}
/**
* 监听事件刷新配置
*/
@EventListener
public void listenEvent(RouteConfigRefreshEvent event) {
load();
this.publisher.publishEvent(new RefreshRoutesEvent(this));
}
/**
* 加载
*/
private void load() {
try {
String jsonStr = Files.lines(Paths.get(file)).collect(Collectors.joining());
routeDefinitionList = JSON.parseArray(jsonStr, RouteDefinition.class);
LOGGER.info("路由配置已加载,加载条数:{}", routeDefinitionList.size());
} catch (Exception e) {
LOGGER.error("从文件加载路由配置异常", e);
}
}
@Override
public Mono save(Mono route) {
return Mono.defer(() -> Mono.error(new NotFoundException("Unsupported operation")));
}
@Override
public Mono delete(Mono routeId) {
return Mono.defer(() -> Mono.error(new NotFoundException("Unsupported operation")));
}
@Override
public Flux getRouteDefinitions() {
return Flux.fromIterable(routeDefinitionList);
}
}
这个例子是将JSON文件反序列化为RouteDefinition对象的,至于如何新建RouteDefinition对象,可参考如下代码:
public class GatewayFilterDefinition {
//Filter Name
private String name;
//对应的路由规则
private Map args = new LinkedHashMap<>();
//此处省略Get和Set方法
}
//把传递进来的参数转换成路由对象
private RouteDefinition assembleRouteDefinition(GatewayRouteDefinition gwdefinition) {
RouteDefinition definition = new RouteDefinition();
definition.setId(gwdefinition.getId());
definition.setOrder(gwdefinition.getOrder());
//设置断言
List pdList=new ArrayList<>();
List gatewayPredicateDefinitionList=gwdefinition.getPredicates();
for (GatewayPredicateDefinition gpDefinition : gatewayPredicateDefinitionList) {
PredicateDefinition predicate = new PredicateDefinition();
predicate.setArgs(gpDefinition.getArgs());
predicate.setName(gpDefinition.getName());
pdList.add(predicate);
}
definition.setPredicates(pdList);
//设置过滤器
List filters = new ArrayList();
List gatewayFilters = gwdefinition.getFilters();
for(GatewayFilterDefinition filterDefinition : gatewayFilters){
FilterDefinition filter = new FilterDefinition();
filter.setName(filterDefinition.getName());
filter.setArgs(filterDefinition.getArgs());
filters.add(filter);
}
definition.setFilters(filters);
URI uri = null;
if (gwdefinition.getUri().startsWith("http")) {
uri = UriComponentsBuilder.fromHttpUrl(gwdefinition.getUri()).build().toUri();
} else {
// uri为 lb://consumer-service 时使用下面的方法
uri = URI.create(gwdefinition.getUri());
}
definition.setUri(uri);
return definition;
}
4.1.3 方法三 RouteDefinitionWriter 增量更新
上述的方式,都是通过构建全量API,更新API达到路由关系的全量更新,但似乎操作风险大了点,如果想一条一条的增量更新,除了Actuator API,还有没有其它方式呢?
public interface RouteDefinitionWriter {
/**
* 保存路由配置
*
* @param route 路由配置
* @return Mono
*/
Mono save(Mono route);
/**
* 删除路由配置
*
* @param routeId 路由编号
* @return Mono
*/
Mono delete(Mono routeId);
}
RouteDefinitionWriter
接口定义了保存save
与删除delete
两个路由方法。可以通过Autowired
后调用这两个方法,调用修改路由关系,例子如下。
/**
* 动态路由服务
*/
@Service
public class DynamicRouteServiceImpl implements ApplicationEventPublisherAware{
@Autowired
private RouteDefinitionWriter routeDefinitionWriter;
private ApplicationEventPublisher publisher;
@Override
public void setApplicationEventPublisher(ApplicationEventPublisher applicationEventPublisher) {
this.publisher = applicationEventPublisher;
}
//增加路由
public String add(RouteDefinition definition) {
routeDefinitionWriter.save(Mono.just(definition)).subscribe();
this.publisher.publishEvent(new RefreshRoutesEvent(this));
return "success";
}
//更新路由
public String update(RouteDefinition definition) {
try {
delete(definition.getId());
} catch (Exception e) {
return "update fail, not find route routeId: " + definition.getId();
}
try {
routeDefinitionWriter.save(Mono.just(definition)).subscribe();
this.publisher.publishEvent(new RefreshRoutesEvent(this));
return "success";
} catch (Exception e) {
return "update route fail";
}
}
//删除路由
public Mono> delete(String id) {
return this.routeDefinitionWriter.delete(Mono.just(id)).then(Mono.defer(() -> {
return Mono.just(ResponseEntity.ok().build());
})).onErrorResume((t) -> {
return t instanceof NotFoundException;
}, (t) -> {
return Mono.just(ResponseEntity.notFound().build());
});
}
}
随后,在任何需要更新的时候,调用上述Service具体的增删改方法即可。
4.2 思路二 动态更新
通过修改底层的方式,应该是比较优选的方案,但也有其弊端,就是灵活度不够。
如果相同的API,但需根据不同的业务逻辑,如租户ID等标识路由到不同的位置,那种方案似乎就无法解决了。
这个时候,我们可以自己实现一个GlobalFilter
,来实现在请求进来后,动态的修改路由目标地址。
这种方式,可能损失一定的效率,但可以拥有更高的灵活度。
@Component
public class DynamicEverythingFilter implements GlobalFilter, Ordered {
private static Logger log = LoggerFactory.getLogger(DynamicEverythingFilter.class);
@Autowired
private APIRepository apiRepository;
public DynamicEverythingFilter(APIRepository apiRepository) {
this.apiRepository = apiRepository;
}
@Override
public Mono filter(ServerWebExchange exchange, GatewayFilterChain chain) {
// 定义API路径最后一位,为服务ID,只判断最后一位,实际上也可以自由添加任何逻辑
String serviceID = requestPathSets[requestPathSets.length -1];
APIInfo apiInfo = this.apiRepository.get(serviceID);
String newPath = apiInfo.getRoutePath();
exchange.getAttributes().put(ContextConstants.TARGET_URL, newPath);
log.info("服务ID {}, 路由到后端[{}]", serviceID, newPath);
// 动态修改路由开始
ServerHttpRequest request = exchange.getRequest();
URI uri = UriComponentsBuilder.fromHttpUrl(newPath).build().toUri();
ServerHttpRequest newRequest = request.mutate().uri(uri).build();
Route route =exchange.getAttribute(GATEWAY_ROUTE_ATTR);
if (route ==null){
log.error(ErrorCodeEnum.NO_PATH_ROUTE.getDesc());
return ExceptionHandler.genErrResponse(exchange, ErrorCodeEnum.NO_PATH_ROUTE);
}
Route newRoute = Route.async()
.asyncPredicate(route.getPredicate())
.filters(route.getFilters())
.id(route.getId())
.order(route.getOrder())
.uri(uri)
.build();
exchange.getAttributes().put(GATEWAY_ROUTE_ATTR, newRoute);
return chain.filter(exchange.mutate().request(newRequest).build());
}
@Override
public int getOrder() {
return -80;
}
}
五、思路对比
最后,将对本文披露的修改思路做一个全面的总结,以便读者更好的选择实现方式,希望本文能帮助到大家。
方法 | 优点 | 缺点 |
---|---|---|
Spring Cloud DiscoveryClient | 完全兼容DiscoveryClient,零编码,配置文件一句话 | 场景局限、自由度低 |
Actuator API | OpenAPI、Spring Cloud Gateway内部源码改变影响程度较小,不需要关注内部细节 | 没有修改、有操作风险、加载全量需外部请求大量次数API |
底层更新 - 全量 (RouteDefinitonLocator、RouteLoacator) | 只需考虑整体API路由关系、实现思路简单 | 全量修改万一存在BUG影响整体、效率浪费 |
底层更新 - 增量 (RouteDefinitionWriter) | 效率较优 | 适用于单独修改新增的频繁的场景,有重复新增、删除的风险 |
动态更新 (GlobalFilter) | 自由度超高 | 效率低下、没有在底层或路由关系中修改、Acuator API无法查看实际路由关系、摒弃了Spring Cloud Gateway的优秀特性 |
参考文章
- Spring-Cloud-Gateway 源码解析 —— 网关初始化
- Spring Cloud Gateway 源码剖析之Route数据模型
- 聊聊spring cloud gateway的RouteLocator
- Spring Cloud Gateway 官方文档
- Spring Cloud Gateway 动态路由