「微服务网关实战五」做网关系统, 99% 会被问到这个功能

3,181 阅读11分钟

本文为稀土掘金技术社区首发签约文章,14天内禁止转载,14天后未获授权禁止转载,侵权必究!

又来给大家更文了。

微服务网关实战系列不知不觉已经第五篇了,该讲的基础知识已经讲过了,今天要来点进阶内容了:网关路由动态刷新

我们知道,在 SCG 中,路由的配置有两种方式:代码配置和配置文件配置。

代码配置的是通过一个 bean 写入自定义的路由配置,就像这样:

@Bean
public RouteLocator routeLocator(RouteLocatorBuilder builder) {
    return builder.routes()
            .route((r) -> r.path("/user/**").uri("lb://user-api"))
            .route((r) -> r.path("/order/**").uri("lb://order-api"))
            .build();
}

配置文件配置则是直接通过配置文件加载的方式读取配置内容,就像这样:

spring:
  cloud:
    gateway:
      routes:
      - id: requestratelimiter_route
        uri: https://example.org
        filters:
        - name: RequestRateLimiter
          args:
            redis-rate-limiter.replenishRate: 10
            redis-rate-limiter.burstCapacity: 20
            redis-rate-limiter.requestedTokens: 1

当然,如果你引入了 spring-boot-starter-actuator 包你还可以拥有通过 OpenApi 方式修改路由的能力:

image-20221023153906039

虽然以上三种方式都可以修改路由,但是在实际的工作中,前两种方式往往都是极不方便的,因为当我们想要修改某个路由的时候需要经过修改代码、重新打包、测试、部署、上线这一系列流程,随随便便可能半天的时间就去了,效率极低。

而第三种方式需要打开可观测点,这在线上环境中大概率不会一直打开,所以可以直接忽略。

所以对于以上这种需求,我们必然要提出一个解决方案进行路由的动态处理,这就是今天的正文内容了,我们开始吧!

1. 怎么修改路由?

我们在开头提到了三种修改路由的方式,前两种都是启动时加载,只有第三种是真正的动态加载,所以我们为了完成路由动态刷新的功能,可以很容易的想到是参考第三种的方式去进行路由动态修改,这个时候就需要我们阅读源码了。

我们先看查看修改路由接口的源码,在 IDEA 中你只需要在接口右键点击编辑就可以直接触达到代码位置:

image-20221023154325526

此接口源码如下:

    @PostMapping("/routes/{id}")
    @SuppressWarnings("unchecked")
    public Mono<ResponseEntity<Object>> save(@PathVariable String id, @RequestBody RouteDefinition route) {
​
        return Mono.just(route).doOnNext(this::validateRouteDefinition)
                .flatMap(routeDefinition -> this.routeDefinitionWriter.save(Mono.just(routeDefinition).map(r -> {
                    r.setId(id);
                    log.debug("Saving route: " + route);
                    return r;
                })).then(Mono.defer(() -> Mono.just(ResponseEntity.created(URI.create("/routes/" + id)).build()))))
                .switchIfEmpty(Mono.defer(() -> Mono.just(ResponseEntity.badRequest().build())));
    }

通过这段源码可以看到一个有一个专门的 Bean 去处理路由的修改:routeDefinitionWriter,而我们只需要建造对应的 RouteDefinition 进行插入操作就可以了。

此时通过查看 RouteDefinition 的类定义可以看到它是这样的数据结构:

public class RouteDefinition {
​
    private String id;
​
    @NotEmpty
    @Valid
    private List<PredicateDefinition> predicates = new ArrayList<>();
​
    @Valid
    private List<FilterDefinition> filters = new ArrayList<>();
​
    @NotNull
    private URI uri;
​
    private Map<String, Object> metadata = new HashMap<>();
​
    private int order = 0;
}

这个数据结构在网关系列讲解路由的那篇文章中我也提到过,很明显这是一个路由的数据结构,每个路由都有一个唯一的路由标识,可以很简单的联想到整个路由数据在内存中都是以一个 Map 的形式存在的。

所以无论是新增路由还是修改路由,我们只需要调用 routeDefinitionWriter.save 方法即可,由于这也是一个接口,所以我们直接来看它的接口定义:

public interface RouteDefinitionWriter {
​
    Mono<Void> save(Mono<RouteDefinition> route);
​
    Mono<Void> delete(Mono<String> routeId);
​
}

可以看到它只有两个方法,并且没有区分插入和更新方法,所以我们的判断是正确的,我们只需要通过这两个方法就可以完成路由的增加、修改和删除了。

那么我们目前知道了怎样去修改路由,我们就要解决接下来的一个问题,路由数据从哪来?

2. Nacos 存放路由数据

由于我们达到一个动态修改路由的能力,所以路由数据必须存放在一个存储中心中,这个存储中心可以是配置中心也可以类似 Redis 之类的缓存中间件,或者数据库都行。

SCG 在动态路由的扩展中还真给我们做了一个用 Redis 修改路由的扩展类,但是我们必然不会用它,很简单的一个原因是:Redis 中的数据所有人都没权力修改。

如果有人能随意修改线上 Redis 中的数据,那肯定一不小心就是一个大事故,当然无论是 Redis 还是数据库,线上数据一般都是不能通过工具来修改的,大家都只有读权限而没有写权限。

所以我们要把路由数据存储在配置中心中,配置中心用什么其实都可以,因为我的文章主题缘故,我们这里的例子是用 Nacos 当路由数据的配置中心。

既然是存储路由数据,那么我们就要在 Nacos 的配置中心单独建立一个路由数据的文件,防止和其他网关配置产生混乱,也更易于管理,毕竟路由数据一般都是很多行。

这里有一个 Nacos 监听配置文件的知识点需要掌握,默认并不是直接监听某个命名空间下的所有文件,而是通过命名空间和服务名的方式默认监听几个文件,这一点我们可以通过启动网关之后观察它的日志发现:

2022-10-23 20:13:55.807  INFO 1040 --- [main] c.a.c.n.refresh.NacosContextRefresher    : listening config: dataId=gateway-api.properties, group=DEFAULT_GROUP
2022-10-23 20:13:55.808  INFO 1040 --- [main] c.a.c.n.refresh.NacosContextRefresher    : listening config: dataId=gateway-api-test.properties, group=DEFAULT_GROUP
2022-10-23 20:13:55.808  INFO 1040 --- [main] c.a.c.n.refresh.NacosContextRefresher    : listening config: dataId=gateway-api, group=DEFAULT_GROUP

我们的项目名字叫做:gateway-api,它就自动监听了gateway-api.properties、gateway-api-test.properties 和 gateway-api 这三个配置文件,而当我们需要去监听其他文件时就需要在配置文件中手动设置一下所监听文件的 dataId 和 groupId:

spring:
  cloud:
    nacos:
      config:
        extension-configs:
          - data-id: gateway-routes
            group: router
            refresh: true

这里我把动态路由的配置起名为 gateway-routes ,然后在 Nacos 控制台新建一个名字为 gateway-routes 且 group 为 router 的配置文件,如下图所示:

image-20221023201917949

这样项目启动之后就会自动监听此文件了,从日志中也可以看到多了这样一条日志:

2022-10-23 20:13:55.809  INFO 1040 --- [main] c.a.c.n.refresh.NacosContextRefresher    : listening config: dataId=gateway-routes, group=router

3. 监听 Nacos 动态刷新路由

ok,我们现在已经做足了准备工作,现在就可以正式开始动态刷新路由的操作了。

首先,我们要确定一下路由数据要以什么方式存储在 Nacos 中,一般情况下我们用 Nacos 做配置中心的时候存储的数据都是 yaml 格式,但是在动态刷新路由这个需求下,我要存储的路由格式数据是 JSON,这是因为当我们监听到数据更新之后,JSON 数据更容易使用 JSON 工具类进行序列化操作。

所以,我们要在控制台中将配置文件的数据类型改为 JSON,并插入一条路由测试数据,如图所示: image-20221023204617100

插入完这条测试数据后,我们就可以开始编写监听Nacos配置的主逻辑了。

首先,我们需要先通过 NacosConfigManager 的相关 Bean 去拿到配置中心的实例,然后通过这个实例去拿到我们存储在配置中心中的路由数据,它的代码大概是这样的:

    @Autowired
    private NacosConfigManager nacosConfigManager;
​
    public void example() throws NacosException {
        
        ConfigService configService = nacosConfigManager.getConfigService();
​
        if(configService == null){
            throw new RuntimeException("Spring Gateway Nacos 动态路由启动失败");
        }
​
        String configInfo = configService.getConfig(DATA_ID, GROUP_ID, 100000);
    }

最后一行的两个常量就是我们路由配置文件的 dataId 和 groupId,获取到这个文件内容之后返回值是一个字符串类型,接着我们就可以将其转化为路由对象然后塞进路由中了:

        List<RouteDefinition> definitionList = JacksonUtils.toObj(configInfo, new TypeReference<List<RouteDefinition>>() {});
​
        for(RouteDefinition definition : definitionList){
            log.info("Spring Gateway Nacos 路由配置 : {}", definition.toString());
            routeDefinitionWriter.save(Mono.just(definition)).block();
        }

我们将拿到的字符串通过反序列化转换一遍,得到一个 List 对象,然后将其中的每一个路由都通过 routeDefinitionWriter 塞进内存中。

这就完成了系统启动时的一个配置载入,接下来我们需要监听这个配置文件,在文件更新时做一些对应的操作:

        configService.addListener(DATA_ID, GROUP_ID, new Listener() {
            @Override
            public Executor getExecutor() {
                return null;
            }
​
            @Override
            public void receiveConfigInfo(String configInfo) {
                
            }
        });

我们可以通过给 configService 对象增加监听器的方式去监听配置文件变化,当配置文件发生变化时其 receiveConfigInfo 方法就会得到调用,这个方法中的参数就是最新版本的配置文件,这里要注意这是全量数据而非更新的部分,哪怕只更新了一行,通过这个方法拿到的数据也是整个配置文件的所有数据。

监听文件之后,我们就要处理动态刷新中的三种操作了:新增路由、修改路由和删除路由,像新增路由和修改路由我们前文已经说过可以直接调用 save 方法,而删除路由则必须要取之前的数据和当前的数据做一份路由比较才能判断出哪些路由被删除了,所以我们要先准备一下数据:

            public void receiveConfigInfo(String configInfo) {
                // 序列化新路由
                List<RouteDefinition> updateDefinitionList = JacksonUtils.toObj(configInfo, new TypeReference<List<RouteDefinition>>() {});
​
                // 拿到新路由的所有id
                List<String> ids = updateDefinitionList.stream().map(RouteDefinition::getId).collect(Collectors.toList());
                
                // 拿到旧路由数据
                Flux<RouteDefinition> routeDefinitions = routeDefinitionLocator.getRouteDefinitions();
            }

我们首先依旧采用序列化的方式将新版路由数据更新成一个 List 对象,接着去拿当前内存中的路由数据,然后对它们做一个比较:

            public void receiveConfigInfo(String configInfo) {
                routeDefinitions.doOnNext(r -> {
                    String id = r.getId();
                    if (!ids.contains(id)) {
                        routeDefinitionWriter.delete(Mono.just(id)).subscribeOn(Schedulers.parallel()).subscribe();
                        log.info("Spring Gateway 删除 Nacos 路由配置 : {}", id);
                    }
                }).doAfterTerminate(() -> {
                    for(RouteDefinition definition : updateDefinitionList){
                        log.info("Spring Gateway merge Nacos 路由配置 : {}", definition.toString());
                        routeDefinitionWriter.save(Mono.just(definition)).subscribeOn(Schedulers.parallel()).subscribe();
                    }
                }).subscribe();
                
               applicationEventPublisher.publishEvent(new RefreshRoutesEvent(new Object()));
            }

上面这段代码可能稍微有一点烧脑,因为 SCG 中使用 reactor 模式,这些对象都会被 Flux 或者 Mono 包裹起来然后再进行操作。

首先调用的是 routeDefinitions.doOnNext 方法,你可以把它看作是一个 for 循环,挨个对 List 中的数据做处理,当新版本路由的 ids 中不存在当前的路由 id 时,就代表当前这个路由已经被删除了,我们就通过 routeDefinitionWriter.delete 做一个删除操作。

然后当整个循环处理完成之后就会调用 doAfterTerminate 方法中的内容,我们直接循环整个 List,将其中的路由对象更新一遍。

最后发出一个刷新路由的事件,用于通知整个网关,路由刷新了。

完成以上步骤后,我们整个网关动态刷新系统就做好了。

4. 动态刷新路由演示

接下来我们来演示一下动态路由的效果,首先在 user 服务准备如下两个接口:

@RestController
public class UserController {
​
    @GetMapping("/user/test1")
    public String test1() {
        return "user-api-test1";
    }
​
    @GetMapping("/user/test2")
    public String test2() {
        return "user-api-test2";
    }
}

接着在 Nacos 中配置如下路由:

[    {        "id": "first_route",        "predicates":        [            {                "name": "Path",                "args":                {                    "_genkey_0": "/user/test1",                    "_genkey_1": "/user/test2"                }            }        ],
        "filters": [],
        "uri": "lb://user-api",
        "order": 100
    }
]

接着请求这两个接口可以看到时通的:

image-20221023220056013

image-20221023220109361

这个时候我们把路由中的 test2 去掉看看改为如下路由:

[    {        "id": "first_route",        "predicates":        [            {                "name": "Path",                "args":                {                    "_genkey_0": "/user/test1"                }            }        ],
        "filters": [],
        "uri": "lb://user-api",
        "order": 100
    }
]

紧接着再去请求 test2 就会变成 404:

image-20221023220603906

这时如果我们新建一个 test2 的路由再去请求 test2 就又可以访问了:

[    {        "id": "first_route",        "predicates":        [            {                "name": "Path",                "args":                {                    "_genkey_0": "/user/test1"                }            }        ],
        "filters": [],
        "uri": "lb://user-api",
        "order": 100
    },
    {
        "id": "two_route",
        "predicates":
        [
            {
                "name": "Path",
                "args":
                {
                    "_genkey_0": "/user/test2"
                }
            }
        ],
        "filters": [],
        "uri": "lb://user-api",
        "order": 100
    }
]

最后,我们再测试一下删除路由的功能,把我们新建的路由2给删除掉,看看test2是否还能访问,果然其然,又是 404 了:

image-20221023220603906

同时还可以在控制台日志中看到如下日志:

2022-10-23 22:08:11.763  INFO 9292 --- [ternal.notifier] org.juejin.scg.listener.NacosListener    : Spring Gateway 删除 Nacos 路由配置 : two_route
​
2022-10-23 22:08:11.763  INFO 9292 --- [ternal.notifier] org.juejin.scg.listener.NacosListener    : Spring Gateway merge Nacos 路由配置 : RouteDefinition{id='first_route', predicates=[PredicateDefinition{name='Path', args={_genkey_0=/user/test1}}], filters=[], uri=lb://user-api, order=100, metadata={}}

经过以上的几次例子验证,我们可以发现动态刷新路由的所有功能我们都已经实现了,而且代码非常的简洁明了。

5. 最后

好了,今天的文章就是以上这些内容了,相关源代码在这里,本篇主要的重点是路由动态刷新的讲解,干货满满,相信大家不会失望。

其实通过 Nacos 配置路由还有一点点不够好的地方,就是如果你的路由有几十个甚至上百个的话,在 Nacos 控制台操作起来非常不易,因为文件行数太长了,这个时候如果能通过前端做一个控制台,直接通过可视化页面进行各种交互就更好了,这个功能正好我还真做了,在后面的文章中将于大家分享。

最后,如果大家觉得本文还不错的话就可以点赞以示支持,对内容有什么疑问也可以在评论区留言,我会积极对线的,下篇见。

本系列其他文章:

「微服务网关实战一」SCG 和 APISIX 该怎么选?

「微服务网关实战二」SCG + Nacos 动态感知上下线

「微服务网关实战三」详细理解 SCG 路由、断言与过滤器

「微服务网关实战四」随意扩展定制的分布式限流,看看我怎么做