Gateway微服务网关

一、创建项目

我们创建一个新项目,利用二阶段代码对项目进行改造,创建微服务网关模块,并通过Gateway实现动态路由功能

附上项目源码地址

二、Gateway网关

创建新的modules命名gateway-serv,搭建一个 Gateway 网关,并完成三个任务:设置跨域规则、添加路由和实现网关层限流。

2.1 设置跨域规则

导入Gateway依赖,Nacos服务发现,以及限流相关jar

    <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>

添加配置文件bootstrap.yml

spring:
  # 必须把name属性从application.yml迁移过来,否则无法动态刷新
  application:
    name: gateway-serv

添加配置文件application.yml

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

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

如果前后端是分离部署的,大部分情况下,前端系统和后端 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 是最重要的,你需要将受信任的域名添加到这个列表当中。从安全性角度考虑,非特殊情况下我并不建议你使用 * 通配符,因为这意味着后台服务可以接收任何跨域发来的请求。

2.2 添加路由

定义路由规则

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

@Configuration
public class RoutesConfiguration {

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

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

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

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

2.3 实现网关层限流

Gateway 过滤器在一个 Request 生命周期中的作用阶段。其实 Filter 的一大功能无非就是对 Request 和 Response 动手动脚,为什么这么说呢?比如你想对 Request Header 和 Parameter 进行删改,又或者从 Response 里面删除某个 Header,那么你就可以使用下面这种方式,通过链式 Builder 风格构造过滤器链。

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

接下来,我们通过一个轻量级的网关层限流方案来进一步熟悉 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()
        );
    }

    //producer服务限流规则
    @Bean("producerRateLimiter")
    public RedisRateLimiter producerRateLimiter() {
        return new RedisRateLimiter(10, 20);
    }

    // customer服务限流规则
    @Bean("customerRateLimiter")
    public RedisRateLimiter customerRateLimiter() {
        return new RedisRateLimiter(1, 1);
    }

    @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”) 指定想要加载的限流参数就可以了,并调整RoutesConfiguration类中的路由规则

@Configuration
public class RoutesConfiguration {

    @Autowired
    private KeyResolver hostAddrKeyResolver;

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

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

    @Bean
    public RouteLocator declare(RouteLocatorBuilder builder) {
        return builder.routes()
                .route(route -> route
                        .path("/gateway/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://customer-serv")
                )
                .route(route -> route
                        .path("/gateway/producer/**")
                        .filters(f -> f.stripPrefix(1))
                        .uri("lb://producer-serv")
                ).build();
    }
}

启动测试,多次访问超过限流规则时,显示返回http状态code为509

三、Nacos实现动态路由规则

首先,我们需要定义一个底层的网关路由规则编辑类,它的作用是将变化后的路由信息添加到网关上下文中。我把这个类命名为 GatewayService,放置在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 动态参数了,我另辟蹊径使用了一种更为灵活的监听机制,通过注册一个“监听器”来获取 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。

spring:
  # 必须把name属性从application.yml迁移过来,否则无法动态刷新
  application:
    name: gateway-serv
  cloud:
    nacos:
      config:
        # nacos config服务器的地址
        server-addr: 127.0.0.1:8848
        file-extension: yml
        # prefix: 文件名前缀,默认是spring.application.name
        # 如果没有指定命令空间,则默认命令空间为PUBLIC
        namespace: dev
        # 如果没有配置Group,则默认值为DEFAULT_GROUP
        group: DEFAULT_GROUP
        # 从Nacos读取配置项的超时时间
        timeout: 5000
        # 长轮询超时时间
        config-long-poll-timeout: 10000
        # 轮询的重试时间
        config-retry-time: 2000
        # 长轮询最大重试次数
        max-retry: 3
        # 开启监听和自动刷新
        refresh-enabled: true

添加 Nacos 配置文件

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

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

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

如果需要删除路由,可以通过在配置文件中的metadata属性添加一个删除标志,DynamicRoutesListener当监听到存在删除标志时,执行删除逻辑即可。