Spring Cloud Gateway 多种思路实现动态路由

关于动态路由,是各类业务场景中的基础功能,通过动态化配置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 和核心加载机制,如图所示。

大体上来讲,我们有两种修改思路:

  1. 从 RouteDefinitonLocator阶段下手
  2. 从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> 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<Route> 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<RouteDefinition> 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<Void> save(Mono<RouteDefinition> route) {
        return Mono.defer(() -> Mono.error(new NotFoundException("Unsupported operation")));
    }

    @Override
    public Mono<Void> delete(Mono<String> routeId) {
        return Mono.defer(() -> Mono.error(new NotFoundException("Unsupported operation")));
    }

    @Override
    public Flux<RouteDefinition> getRouteDefinitions() {
        return Flux.fromIterable(routeDefinitionList);
    }
}

这个例子是将JSON文件反序列化为RouteDefinition对象的,至于如何新建RouteDefinition对象,可参考如下代码:

 public class GatewayFilterDefinition {
    //Filter Name
    private String name;
    //对应的路由规则
    private Map<String, String> args = new LinkedHashMap<>();
    //此处省略Get和Set方法
}

 
 //把传递进来的参数转换成路由对象
    private RouteDefinition assembleRouteDefinition(GatewayRouteDefinition gwdefinition) {
        RouteDefinition definition = new RouteDefinition();
        definition.setId(gwdefinition.getId());
        definition.setOrder(gwdefinition.getOrder());

        //设置断言
        List<PredicateDefinition> pdList=new ArrayList<>();
        List<GatewayPredicateDefinition> 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<FilterDefinition> filters = new ArrayList();
        List<GatewayFilterDefinition> 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<Void>
     */
    Mono<Void> save(Mono<RouteDefinition> route);

    /**
     * 删除路由配置
     *
     * @param routeId 路由编号
     * @return Mono<Void>
     */
    Mono<Void> delete(Mono<String> 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<ResponseEntity<Object>> 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<Void> 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的优秀特性

参考文章

  1. Spring-Cloud-Gateway 源码解析 —— 网关初始化
  2. Spring Cloud Gateway 源码剖析之Route数据模型
  3. 聊聊spring cloud gateway的RouteLocator
  4. Spring Cloud Gateway 官方文档
  5. Spring Cloud Gateway 动态路由
最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 203,271评论 5 476
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 85,275评论 2 380
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 150,151评论 0 336
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 54,550评论 1 273
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 63,553评论 5 365
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 48,559评论 1 281
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 37,924评论 3 395
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 36,580评论 0 257
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 40,826评论 1 297
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 35,578评论 2 320
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 37,661评论 1 329
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 33,363评论 4 318
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 38,940评论 3 307
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 29,926评论 0 19
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 31,156评论 1 259
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 42,872评论 2 349
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 42,391评论 2 342