【Java开发】Spring Cloud 09 :微服务网关 Gateway

  • Post author:
  • Post category:java


Spring Cloud Gateway(简称 Gateway),它在微服务架构中扮演的角色是“微服务网关”,Nginx 和 Gateway 在微服务体系中的分工是不一样的。Gateway 作为更底层的微服务网关,通常是作为外部 Nginx 网关和内部微服务系统之间的桥梁。

项目源码:

尹煜 / coupon-yinyu · GitCode

1 Gateway 介绍

Gateway 既然叫“微服务网关”,就说明它自己就是一个微服务。换句话说,它也是 Nacos 服务注册中心的一员。既然 Gateway 能连接到 Nacos,那么就意味着它可以轻松获取到 Nacos 中所有服务的注册表。这样一来,Gateway 就可以根据本地的路由规则,将请求精准无误地送达到每个微服务组件中。

使用 Gateway 有一个显而易见的好处,那就是

高可扩展性

。当你对后台的微服务集群做扩容或缩容的时候,Gateway 可以从 Nacos 注册中心轻松获取所有服务节点的变动,不需要任何额外的配置,一切都在无感知的情况下自然而然地发生。如果使用其他技术方案,你可能还需要花些力气修改 VIP Pool 中的节点列表,将新增的机器手动添加到列表中,还要把移除的机器从列表中删除。

Gateway 的另一个优点就是高度可定制化。它提供了一种对开发人员非常友好的方式,可以让你通过 Java 代码去定制各种复杂的路由逻辑,还可以使用 Filter 对请求进行加工。

接下来就是 Gateway 的几个核心功能模块,看一看它是如何组装路由规则的。

2 Gateway 组成

2.1 路由规则

Gateway 的路由规则主要有三个部分,分别是路由、谓词和过滤器:


路由是 Gateway 的一个基本单元

,每个路由都有一个目标地址,这个目标地址就是当前路由规则要调用的目标服务。那么一条路由规则在什么情况下会去调用目标服务呢?这就要看路由的谓词设置了。


所谓谓词,实际上是路由的判断规则

,一个路由中可以添加多个谓词的组合。如果一个服务请求满足某个路由里设置的所有的谓词规则,那么就说明这个请求是当前路由的心动女神,这时候 Gateway 就会把请求转发到路由中设置的目标地址。

打个比方,你可以为某个路由设置一条谓词规则,约定访问路径的匹配规则为 Path=/bingo/*,在这种情况下只有以 /bingo 打头的请求才会被当前路由选中。

Gateway 为我们提供了非常丰富的内置谓词,你可以通过内置谓词构建复杂的路由条件,甚至连“整点秒杀”这个场景都能在网关层做控制。

现在你已经了解了谓词和路由是怎么配合工作的,其实 Gateway 里通常会配置多个路由单元。因为在真实项目里,每个微服务都有不同的路由规则,但每个请求只能被一个路由规则选中。那如果某个请求同时匹配上了多个路由,该选择哪个路由呢?Gateway 提供了一种“优先级”设置,你可以通过设置路由的优先级参数来调整生效的先后顺序。


过滤器

和路由、目标地址之间是什么关系呢?其实 Gateway 在把请求转发给目标地址的过程中,把这个任务全权委托给了 Filter(过滤器)来处理:

Gateway 组件使用了一种 FilterChain 的模式对请求进行处理,每一个服务请求(Request)在发送到目标服务之前都要被一串 FilterChain 处理。同理,在 Gateway 接收服务响应(Response)的过程中也会被 FilterChain 处理一把。

Gateway 的过滤器主要分为两种,一种是 GlobalFilter,也就是“

全局过滤器

”;另一种是 GatewayFilter,也就是对指定路由生效的“

局部过滤器

”。

  • 全局过滤器继承自 GlobalFilter 接口,它的作用大多是“例行公事”,也就是一些底层能力的支持。比如,RouteToRequestUrlFilter 这个全局过滤器就是用来解析“目标服务地址”的。

除此之外,Gateway 还有一系列用来做路径转发、请求跨域、WebSocket、WebClient 和 Loadbalancer 功能支持的全局过滤器。如果你想深入了解,可以参考 GatewayAutoConfiguration 的源码,这个类是 Gateway 的自动装配器,里面包含了大量 GlobalFilter 的声明。就算你不做任何配置,项目在初始化的时候也会把一大家子全局过滤器添加到上下文中。

  • GatewayFilter 也就是局部过滤器,Gateway 提供了一系列的内置过滤器,可以实现对 Request/Response 的修改、请求路径修改、调用重试、限流等等功能。当然了,你也可以通过 Gateway 的扩展接口实现一个自定义过滤器并应用到路由规则中。

2.2 声明路由

路由是 Gateway 中的一条基本转发规则,网关在启动的时候,必须将这些路由规则加载到上下文中,它才能正确处理服务转发请求。那么网关可以从哪些地方加载路由呢?

Gateway 提供了三种方式来加载路由规则,分别是

Java 代码、yaml 文件和动态路由

第一种加载方式是 Java 代码声明路由,它是可读性和可维护性最好的方式,你可以使用一种链式编程的 Builder 风格来构造一个 route 对象,比如在下面的例子里。它声明了两个路由,根据 path 的匹配规则将请求转发到不同的地址。

@Bean
public RouteLocator declare(RouteLocatorBuilder builder) {
    return builder.routes()
            .route("id-001", route -> route
                    .path("/geekbang/**")
                    .uri("http://time.geekbang.org")
            ).route(route -> route
                    .path("/test/**")
                    .uri("http://www.test.com")
            ).build();
}

第二种方式是通过配置文件来声明路由,你可以在 application.yml 文件中组装路由规则:

spring:
  cloud:
    gateway:
      routes:
        - id: id-001
          uri: http://time.geekbang.org
          predicates:
            - Path=/geekbang2/**
        - uri: http://www.test.com
          predicates:
            - Path=/test2/**

不管是 Java 版还是 yml 版,它们都是通过“hardcode”的方式声明的静态路由规则,这些 Route 只会在项目启动后被加载一次。如果你想要在 Gateway 运行期更改路由逻辑,那么就要使用第三种方式:动态路由加载。

动态路由也有不同的实现方式。如果你在项目中集成了 actuator 服务,那么就可以通过 Gateway 对外开放的 actuator 端点在运行期对路由规则做增删改查。但这种修改只是临时性的,项目重新启动后就会被打回原形,因为这些动态规则并没有持久化到任何地方。

动态路由还有另一种实现方式,那就是借助

Nacos 配置中心来存储路由规则

。Gateway 通过监听 Nacos Config 中的文件变动,就可以动态获取 Nacos 中配置的规则,并在本地生效了,后边将落地一套 Nacos+Gateway 的动态路由。

2.3 内置谓词

Gateway 的内置谓词不少,这些谓词大致分为三个类型:寻址谓词、请求参数谓词和时间谓词。


寻址谓词

,顾名思义,就是针对请求地址和类型做判断的谓词条件。比如这里我们用到的 path,其实就是一个路径匹配条件,当请求的 URL 和 Path 谓词中指定的模式相匹配的时候,这个谓词就会返回一个 True 的判断。而 method 谓词则是根据请求的 Http Method 做为判断条件,比如这里就限定了只有 GET 和 POST 请求才能访问当前 Route。

.route("id-001", route -> route
      .path("/geekbang/**")
      .and().method(HttpMethod.GET, HttpMethod.POST)
      .uri("http://time.geekbang.org")

在谓词与谓词之间,你可以使用 and、or、negate 这类“与或非”逻辑连词进行组合,构造一个复杂判断条件。

接下来是

请求参数谓词

,这类谓词主要对服务请求所附带的参数进行判断。这里的参数不单单是 Query 参数,还可以是 Cookie 和 Header 中包含的参数。比如下面这段代码,如果请求中没有包含指定参数,或者指定参数的值和我指定的 regex 表达式不匹配,那么请求就无法满足当前路由的谓词判断条件。

.route("id-001", route -> route
    // 验证cookie
    .cookie("myCookie", "regex")
    // 验证header
    .and().header("myHeaderA")
    .and().header("myHeaderB", "regex")
    // 验证param
    .and().query("paramA")
    .and().query("paramB", "regex")
    .and().remoteAddr("远程服务地址")
    .and().host("pattern1", "pattern2")

如果你要对原始服务请求的远程地址或 Header 中的 Host 参数做些文章,那么你也可以通过 remoteAddr 和 host 谓词进行判断。

在实际项目中,非必要情况下,不推荐把过多的参数谓词条件定义在网关层,因为这些参数往往携带了业务层的逻辑。如果这些业务参数被大量引入到网关层,从职责分离的角度来讲,并不合适。网关层的逻辑一般来说比较“轻薄”,主要只是一个请求转发,最多再夹带一些简单的鉴权和登录态检查就够了。

最后一组是

时间谓词

。你可以借助 before、after、between 这三个时间谓词来控制当前路由的生效时间段。

.route("id-001", route -> route
   // 在指定时间之前
   .before(ZonedDateTime.parse("2022-12-25T14:33:47.789+08:00"))
   // 在指定时间之后
   .or().after(ZonedDateTime.parse("2022-12-25T14:33:47.789+08:00"))
   // 或者在某个时间段以内
   .or().between(
        ZonedDateTime.parse("起始时间"),
        ZonedDateTime.parse("结束时间"))

如果 Gateway 的内置谓词还差那么点意思,你想要实现自定义的谓词逻辑,那么你可以通过 Gateway 的可扩展谓词工厂来实现自定义谓词。Gateway 组件提供了一个统一的抽象类 AbstractRoutePredicateFactory 作为谓词工厂,你可以通过继承这个类来添加新的谓词逻辑:

// 继承自通用扩展抽象类AbstractRoutePredicateFactory
public class MyPredicateFactory extends 
    AbstractRoutePredicateFactory<MyPredicateFactory.Config> {

   public MyPredicateFactory() {
      super(Config.class);
   }
   
   // 定义当前谓词所需要用到的参数
   @Validated
   public static class Config {
       private String myField;
   }
   
   @Override
   public List<String> shortcutFieldOrder() {
      // 声明当前谓词参数的传入顺序
      // 参数名要和Config中的参数名称一致
      return Arrays.asList("myField");
   }
   
   // 实现谓词判断的核心方法
   // Gateway会将外部传入的参数封装为Config对象
   @Override
   public Predicate<ServerWebExchange> apply(Config config) {
      return new GatewayPredicate() {
      
         // 在这个方法里编写自定义谓词逻辑
         @Override
         public boolean test(ServerWebExchange exchange) {
            return true;
         }
         
         @Override
         public String toString() {
            return String.format("myField: %s", config.myField);
         }
      };
   }
}

这个实现的过程非常简单,相信看了上面的源码就能明白。这里面的关键步骤就两步,一是定义 Config 结构来接收外部传入的谓词参数,二是实现 apply 方法编写谓词判断逻辑。

在实际工作中,最常用的谓词当属 path,其它大部分内置谓词都用不太上,如果你想要使用这些谓词在网关层

判断登录状态或者做权限验证

,那么更推荐你使用 Gateway 的 Filter 机制,也就是过滤器。

3 Gateway 集成–设置请求转发、跨域和限流规则

3.1 创建微服务网关

微服务网关是一个独立部署的平台化组件,先在 middleware 目录下创建一个名为 gateway 的子模块。接下来的工作就是按部就班地搞定依赖项、配置项和路由规则。

3.2 添加依赖项

我们需要在这个模块的 pom.xml 文件中添加几个关键依赖项,分别是 Gateway、Nacos 和 Loadbalancer。

<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> 
   
    <!-- I版中移除了Ribbon,gateway玩不转了,需要手动加入loadbalancer注解 -->
    <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>

辅助依赖组件没有一一列出,你可以参考源码,查看完整的依赖项列表。

在这几个核心依赖项中,spring-cloud-starter-gateway 是最重要的一个,它是实现了网关功能模块的基础组件。而 Nacos 和 Loadbalancer 则扮演了“导航”的作用,让 Gateway 在请求转发的过程中可以通过“服务发现 + 负载均衡”定位到对应的服务节点。最后一个是 Redis 依赖项,用来实现网关层限流。

网关通常是一次服务调用的起点,我们在搭建线上应用的时候,也需要把 Gateway 纳入到链路追踪体系当中。所以呢我们需要将 Sleuth、Zipkin 还有 ELK 集成进来,可以参考之前的文章进行配置。

依赖项添加完成后,接下来就是编写 bootstrap.yml 和 application.yml 配置文件。

3.3 添加配置文件

首先,我们创建一个 bootstrap.yml,将“coupon-gateway”定义为当前项目的名称。使用 bootstrap.yml 的目的之一是优先加载 Nacos Config 配置项,我们要借助 Nacos 来完成动态路由表的加载。

spring:
  application:
    name: coupon-gateway

接下来,我们创建一个 application.yml。这个配置文件里的内容主要就两部分,一部分是 Nacos 服务发现的配置项,另一部分是 Gateway 特有的配置项,我们来看一下。项目主要通过 Java 代码来落地各种路由规则,如果你比较喜欢用配置项来定义路由规则,那你可以在 spring.cloud.gateway.routes 节点下尽情发挥,定义各种路由、谓词断言和过滤器规则。

server:
  port: 30000
spring:
  # 分布式限流的Redis连接
  redis:
    host: localhost
    port: 6379
  cloud:
    nacos:
      # Nacos配置项
      discovery:
        server-addr: localhost:8848
        heart-beat-interval: 5000
        heart-beat-timeout: 15000
        cluster-name: Cluster-A
        namespace: dev
        group: myGroup
        register-enabled: true
    gateway:
      discovery:
        locator:
          # 创建默认路由,以"/服务名称/接口地址"的格式规则进行转发
          # Nacos服务名称本来就是小写,但Eureka默认大写
          enabled: true
          lower-case-service-id: true
      # 跨域配置
      globalcors:
        cors-configurations:
          '[/**]':
            # 授信地址列表
            allowed-origins:
              - "http://localhost:10000"
              - "https://www.geekbang.com"
            # cookie, authorization认证信息
            expose-headers: "*"
            allowed-methods: "*"
            allow-credentials: true
            allowed-headers: "*"
            # 浏览器缓存时间
            max-age: 1000          

上面这段配置代码的重点是

全局跨域规则

,我在 spring.cloud.gateway.globalcors 下添加了一段跨域规则的相关配置。

3.4 跨域规则介绍

在了解如何配置跨域规则之前,首先了解下什么是浏览器的“

同源保护策略

”。

如果前后端是分离部署的,大部分情况下,前端系统和后端 API 都在同一个根域名下,但也有少数情况下,前后端位于不同域名。比如前端页面的地址是 yinyu.com,后端 API 的访问地址是 infoq.com。如果一个应用请求发生了跨域名访问,比如位于 yinyu.com 的页面通过 Node.js 访问了 infoq.com 的后端 API,这种情况就叫“跨域请求”。

我们的浏览器对跨域访问的请求设置了一道保护措施,在跨域调用发起之前,浏览器会尝试发起一个 OPTIONS 类型的请求到目标地址,探测一下你的后台服务是否支持跨域调用。如果你的后端 Say NO,那么前端浏览器就会阻止这次非同源访问。通过这种方式,一些美女聊天类的钓鱼网站就无法对你实施跨站脚本攻击了,这就是浏览器端的同源保护策略。

不过也有一种例外,比如你的前端网站和后端接口确实部署在了两个域名,而这两个域名背后都是正经应用,这时候为了让浏览器可以通过同源保护策略的检查,你就必须在后台应用里设置跨域规则,告诉浏览器哪些跨域请求是可以被接受的。

我们接下来就来了解一下,如何通过跨域配置的参数来控制跨域访问。这些参数都定义在的 spring.cloud.gateway.globalcors.cors-configurations 节点的[/**]路径下,[/**]这串通配符可以匹配所有请求路径。当然了,你也可以为某个特定的路径设置跨域规则(比如[/order/])。

在这上面的几个配置项中,allowed-origins 是最重要的,你需要将受信任的域名添加到这个列表当中。从安全性角度考虑,非特殊情况下并不建议你使用 * 通配符,因为这意味着后台服务可以接收任何跨域发来的请求。

到这里,所有配置都已经 Ready 了,我们可以去代码中定义路由规则了。定义路由规则

3.5 定义路由规则

推荐使用一个独立的配置类来管理路由规则,这样代码看起来比较清晰。比如 com.yinyu.gateway 下面创建了 RoutesConfiguration 类,为三个微服务分别定义了简单明了的规则。

@Configuration
public class RoutesConfiguration {

    @Bean
    public RouteLocator declare(RouteLocatorBuilder builder) {
        return builder.routes()
                .route(route -> route
                        .path("/gateway/coupon-customer/**")
                        .filters(f -> f.stripPrefix(1))
                        .uri("lb://coupon-customer-serv")
                ).route(route -> route
                        .order(1)
                        .path("/gateway/template/**")
                        .filters(f -> f.stripPrefix(1))
                        .uri("lb://coupon-template-serv")
                ).route(route -> route
                        .path("/gateway/calculator/**")
                        .filters(f -> f.stripPrefix(1))
                        .uri("lb://coupon-calculation-serv")
            ).build();
    }
}

这三个路由规则都是大同小异的。我们就以第二个路由规则为例,你可以看出,路由设置遵循了一套三连的风格。

首先, path 谓词约定了路由的匹配规则为 path=“/template/**”。这里你要注意的是,如果某一个请求匹配上了多个路由,但你又想让各个路由之间有个先后匹配顺序,这时你就可以使用 order(n) 方法设定路由优先级,n 数字越小则优先级越高。

接下来,stripPrefix 过滤器将 path 访问路径中的第一个前置子路径删除掉。这样一来,/gateway/template/xxx 的访问请求经由过滤器处理后就变成了 /template/xxx。同理,如果你想去除 path 中打头的前两个路径,那就使用 stripPrefix(2),参数里传入几它就能吞掉几个 prefix path。

最后,uri 方法指定了当前路由的目标转发地址,这里的“lb://coupon-template-serv”表示使用本地负载均衡将请求转发到名为“coupon-template-serv”的服务。

谓词和 uri 你是再熟悉不过了,但这个 filter 想必还是第一次见到。接下来简单了解一下 Gateway Filter 的使用方式,下边的小案例借助过滤器来实现基于 Lua + Redis 的网关层限流。

3.6 Filter 和网关限流

其实 Filter 的一大功能无非就是对 Request 和 Response 进行处理,比如你想对 Request Header 和 Parameter 进行删改,又或者从 Response 里面删除某个 Header,那么你就可以使用下面这种方式,通过链式 Builder 风格构造过滤器链。

.route(route -> route
        .order(1)
        .path("/gateway/template/**")
        .filters(f -> f.stripPrefix(1)
                // 修改Request参数
                .removeRequestHeader("mylove")
                .addRequestHeader("myLove", "u")
                .removeRequestParameter("urLove")
                .addRequestParameter("urLove", "me")
                // response系列参数 不一一列举了
                .removeResponseHeader("responseHeader")
        )
        .uri("lb://coupon-template-serv")

当然了,Gateway 的内置过滤器还包括了 redirect 转发、retry 重试、修改 requestBody 等等内置 Filter。如果你对这些内容感兴趣,你可以根据 IDE 中自动弹出的代码提示来了解它们,再配几个到路由规则里。

接下来,我们通过一个轻量级的网关层限流方案来进一步熟悉 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()
        );
    }
    
    //template服务限流规则
    @Bean("tempalteRateLimiter")
    public RedisRateLimiter templateRateLimiter() {
        return new RedisRateLimiter(10, 20);
    }
    
    // customer服务限流规则
    @Bean("customerRateLimiter")
    public RedisRateLimiter customerRateLimiter() {
        return new RedisRateLimiter(20, 40);
    }

    @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”) 指定想要加载的限流参数就可以了。

@Autowired
private KeyResolver hostAddrKeyResolver;

@Autowired
@Qualifier("customerRateLimiter")
private RateLimiter customerRateLimiter;

@Autowired
@Qualifier("tempalteRateLimiter")
private RateLimiter templateRateLimiter;

限流参数注入完成之后,接下来我们只需要添加一个内置的限流过滤器,分别指定限流的维度、限流速率就可以了,你可以参考下面这段 rquestRateLimiter 过滤器配置代码。除了限流参数之外,还额外定义了一个 Status Code,当服务请求被限流的时候,后端服务便会返回指定的这个 Status Code。

.route(route -> route.path("/gateway/coupon-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://coupon-customer-serv")

到这里,我们就完整搭建了 Gateway 组件的路由和限流规则,最后你只需要写一个普通的启动类就可以在本地测试了。

最后,尽管 Gateway 组件本身提供了丰富的内置谓词和过滤器,但在实际项目中我们大多用不到它们,因为网关层的核心用途只是简单的路由转发,

为了保证组件之间的职责隔离,并不建议通过谓词和过滤器实现带有业务属性的逻辑

4

Nacos 实现动态路由规则

我们通过一系列谓词和过滤器的组合为三个微服务模块配置了路由规则,这套方案足以应对大部分线上业务的需求,但在可扩展性方面还不够完美。

因为这些路由规则是以 yml 文件或者 Java 代码配置在项目中的静态规则。随着项目启动,这些路由规则会被加载到应用上下文并生效。但在程序运行期,如果我们想要改变这些预定义的路由规则,或者创建新的路由规则,似乎只有提交改动到 Gateway 组件 -> 编译项目 -> 重新部署这一条路子。

下面,我们就来了解一下,如何借助 Nacos Config 实现动态路由规则的持久化。

4.1 使用 Nacos Config 添加动态路由表

前提是将 Nacos Config 的依赖项添加到了 Gateway 模块,然后我们需要定义一个底层的网关路由规则编辑类,它的作用是将变化后的路由信息添加到网关上下文中。这个类命名为 GatewayService,放置在 com.yinyu.gateway.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 动态参数(也可以用@RefreshScope)

,使用了一种更为灵活的监听机制,通过注册一个“监听器”来获取 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。

完成了以上步骤之后,Gateway 组件的改造任务就算搞定了,接下来去 Nacos 里创建一个路由规则配置文件。

4.2 添加 Nacos 配置文件

在 Nacos 配置列表页中,你需要在“开发环境”的命名空间下创建一个 JSON 格式的文件,文件名要和 Gateway 代码中的名称一致,叫做“routes-config.json”,它的 Group 是默认分组,也就是 DEFAULT_GROUP。

创建好之后,你需要根据 RoutesDefinition 这个类的格式定义配置文件的内容。以 coupon-customer-serv 为例,编写了下面的路由规则。

[{
    "id": "customer-dynamic-router",
    "order": 0,
    "predicates": [{
        "args": {
            "pattern": "/dynamic-routes/**"
        },
        "name": "Path"
    }],
    "filters": [{
        "name": "StripPrefix",
        "args": {
            "parts": 1
        }
    }  
    ],
    "uri": "lb://coupon-customer-serv"
}]

在这段配置文件中,指定当前路由的 ID 是 customer-dynamic-router,并且优先级为 0。除此之外,还定义了一段 Path 谓词作为路径匹配规则,还通过 StripPrefix 过滤器将 Path 中第一个前置路径删除。

创建完成后,你可以在本地启动项目,并尝试访问 localhost:30000/dynamic-routes/coupon-customer/requestCoupon,发起一个用户领券请求到 Gateway 组件来领取优惠券。在配置正确无误的情况下,这个请求就会被转发到 Customer 服务了。