Gateway微服务界的Nginx

一、Gateway和nginx区别

Spring Cloud Gateway(简称 Gateway)。它在微服务架构中扮演的角色是“微服务网关”。

Nginx 和 Gateway 在微服务体系中的分工是不一样的。Gateway 作为更底层的微服务网关,通常是作为外部 Nginx 网关和内部微服务系统之间的桥梁,起了这么一个承上启下的作用。

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

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

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

二、Gateway 路由规则

Gateway 的路由规则主要有三个部分,分别是路由、谓词和过滤器。我这里画了一张图来表示 Gateway 的路由结构。

2.1 路由

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

2.2 谓词

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

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

2.3 过滤器

Gateway 在把请求转发给目标地址的过程中,把这个任务全权委托给了 Filter(过滤器)来处理。我用一幅图为你比划一下 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 的扩展接口实现一个自定义过滤器并应用到路由规则中。

三、声明路由的几种方式

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

Gateway 提供了三种方式来加载路由规则,分别是 Java 代码、yaml 文件和动态路由。

3.1 代码声明路由

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

	@Bean
    public RouteLocator declare(RouteLocatorBuilder builder) {
        return builder.routes()
                .route(route -> route
                        .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();
    }

3.2 配置文件来声明路由

第二种方式是通过配置文件来声明路由,你可以在 application.yml 文件中组装路由规则。我把前面定义的 Java 路由规则改写成了 yml 版,你可以参考一下。

spring:
  cloud:
    gateway:
      routes:
        - id: id001
          uri: lb://customer-serv
          predicates:
            - Path=/gateway/customer/**
          filters:
            - StripPrefix=2

        - id: id002
          uri: lb://producer-serv
          predicates:
            - Path=/gateway/producer/**
          filters:
            - StripPrefix=1

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

3.3 动态路由

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

动态路由还有另一种实现方式,是我比较推荐的,那就是借助 Nacos 配置中心来存储路由规则。Gateway 通过监听 Nacos Config 中的文件变动,就可以动态获取 Nacos 中配置的规则,并在本地生效了。我将在后面的课程中带你落地一套 Nacos+Gateway 的动态路由。

四、Gateway 的内置谓词

谓词大致分为三个类型:寻址谓词、请求参数谓词和时间谓词。我将使用基于 Java 代码的声明方式,带你挨个来看下如何在路由中配置谓词。

4.1 寻址谓词

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

.route(route -> route
	.path("/gateway/producer/**")
	.and().method(HttpMethod.GET, HttpMethod.POST)
	.filters(f -> f.stripPrefix(1))
	.uri("lb://producer-serv")
).build();

在上面这段代码中,我添加了不止一个谓词。在谓词与谓词之间,你可以使用 and、or、negate 这类“与或非”逻辑连词进行组合,构造一个复杂判断条件。

4.2 请求谓词

接下来是请求参数谓词,这类谓词主要对服务请求所附带的参数进行判断。这里的参数不单单是 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 谓词进行判断。

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

4.3 时间谓词

最后一组是时间谓词。你可以借助 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("结束时间"))

拿一项秒杀活动来说,如果开发团队做了一个新的秒杀下单入口,我要限定该入口的生效时间在秒杀时间点之后,那么我就可以使用 after 谓词。对于固定时间窗口的秒杀活动来说,你还可以使用 between 来限定生效时间窗口。再结合前面我们讲到的请求参数谓词,你还可以实现更加复杂的路由判断逻辑,比如通过 query 谓词针对特定商品开放不同的秒杀时段。

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

4.4 自定义谓词

我把实现一个自定义谓词的代码框架放到了这里,你可以参考一下。


// 继承自通用扩展抽象类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 方法编写谓词判断逻辑。