你好,我是姚秋辰。

在上节课中,我们了解了如何在Spring Cloud Gateway中加载一个路由,以及常用的内置谓词都有哪些。今天我们就来动手实践一把,在实战项目中搭建一个Gateway网关,并完成三个任务:设置跨域规则、添加路由和实现网关层限流。这三个任务将以怎样的方式展开呢?

首先是跨域规则,它是一段添加在配置文件中的逻辑。我将在编写网关配置文件的同时,顺便为你讲解下跨域的基本原理,以及如何设置同源访问策略。

然后,我将使用基于Java代码的方式来定义静态路由规则。当然了,你也可以使用配置文件来编写路由,用代码还是用配置全凭个人喜好。不过呢,如果你的路由规则比较复杂,比如,它包含了大量谓词和过滤器,那么我还是推荐你使用代码方式,可读性高,维护起来也容易一些。

最后就是网关层限流,我们将使用内置的Lua脚本,并借助Redis组件来完成网关层限流。

闲话少叙,我们先去搭建一个微服务网关应用吧。你可以在Gitee代码仓库中找到下面所有源码。

创建微服务网关

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

添加依赖项

我们要在这个模块的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>    
    <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组件中,但是网关通常是一次服务调用的起点,我们在搭建线上应用的时候,应当把Gateway纳入到链路追踪体系当中。所以呢我们需要将Sleuth、Zipkin还有ELK集成进来,我把这个任务留给了你来实现,你可以从依赖项的添加开始,完整回顾一下前面学过的链路追踪知识点,温故而又知新。

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

添加配置文件

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

spring:
  application:
    name: coupon-gateway

接下来,我们创建一个application.yml。这个配置文件里的内容主要就两部分,一部分是Nacos服务发现的配置项,这段是老生常谈了咱就不再展开讲了。另一部分是Gateway特有的配置项,我们来看一下。

我会通过Java代码来落地各种路由规则,所以你看到的配置文件并不包含任何路由规则,非常干净清爽。如果你比较喜欢用配置项来定义路由规则,那你可以在spring.cloud.gateway.routes节点下尽情发挥,定义各种路由、谓词断言和过滤器规则。我在上节课开头写了几个在yml中定义路由规则的例子,你可以参考一下。

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下添加了一段跨域规则的相关配置,这里我们就来展开说道说道。

什么是跨域规则

在了解如何配置跨域规则之前,我需要先为你讲一讲什么是浏览器的“同源保护策略”。

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

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

图片

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

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

图片

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

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

定义路由规则

我推荐你使用一个独立的配置类来管理路由规则,这样代码看起来比较清晰。比如我这里就在com.geekbang.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的网关层限流。

Filter和网关限流

第23课中,我们了解了Gateway过滤器在一个Request生命周期中的作用阶段。其实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组件本身提供了丰富的内置谓词和过滤器,但在实际项目中我们大多用不到它们,因为网关层的核心用途只是简单的路由转发,为了保证组件之间的职责隔离,我并不建议通过谓词和过滤器实现带有业务属性的逻辑

那什么样的逻辑可以在网关层实现呢?比如一些通用的身份鉴权、登录检测和签名验签之类的服务,你可以将这类安全检测的逻辑前置到网关层来实现,这样可以对不合法请求做快速失败处理。

思考题

结合这节课的内容,请你尝试说一说,内置Filter是如何实现的,它继承了哪些通用类和接口。再请你在本地用类似的方式实现一个自定义的过滤器,并配置到路由表中。你可以使用这个过滤器完成一些简单的业务,比如打印所有发到网关服务的请求和响应参数。

好啦,这节课就结束啦。欢迎你把这节课分享给更多对Spring Cloud感兴趣的朋友。我是姚秋辰,我们下节课再见!

评论