SpringCloudGateway 动态转发后端服务

文章来源声明: 原文作者:海岳云舟; 来源站点:掘金; 原文链接:https://juejin.cn/post/7689096640691568655; 本文基于上述来源整理/加工,觅优补充点评,仅供技术学习交流。版权归原作者所有。
觅优短评

把路由配置从文件搬到数据库,按header动态选后端,无需重启即可切换,适合多环境、灰度分流与多租户场景,代码可直接复用。

API网关的核心功能是统一流量入口,实现路由转发,SpringCloudGateway是API网关开发的技术之一,此外比较流行的还有Kong和ApiSix,这2个都是基于OpenResty技术栈。 简单的路由转发可以通过SpringCloudGateway的配置文件实现,在一些业务场景种,会需要动态替换路由配置中的后端服务地址,单纯靠配置文件无法满足这种需求。 本文介绍一种将路由配置保存到数据库中,可以根据接口请求的特定条件,从数据库中动态读取后端服务地址,实现灵活转发。

具体的代码参照 示例项目 https://github.com/qihaiyan/springcamp/tree/main/spring-cloud-gateway

一、概述

通过把SpringCloudGateway的相关路由配置规则保存到数据库中,可以动态的灵活调整路由。在本文的实现中,我们通过请求header中的特定值,动态选择对应的后端服务地址。

二、项目中加入依赖

在项目的gradle中增加依赖关系。

build.gradle:

plugins {
    <span>id</span> <span>'org.springframework.boot'</span> version <span>'3.0.2'</span>
    <span>id</span> <span>'io.spring.dependency-management'</span> version <span>'1.1.0'</span>
    <span>id</span> <span>'java'</span>
}

group = <span>'cn.springcamp'</span>
version = <span>'0.0.1-SNAPSHOT'</span>
sourceCompatibility = <span>'17'</span>

configurations {
    compileOnly {
        extendsFrom annotationProcessor
    }
    testCompileOnly {
        extendsFrom testAnnotationProcessor
    }
}

repositories {
    mavenCentral()
}

dependencies {
    implementation <span>"org.springframework.boot:spring-boot-starter-json"</span>
    implementation <span>'org.springframework.boot:spring-boot-starter-validation'</span>
    implementation <span>'org.springframework.boot:spring-boot-starter-data-r2dbc'</span>
    implementation <span>'org.springframework.cloud:spring-cloud-starter-gateway'</span>
    runtimeOnly <span>'com.h2database:h2'</span>
    runtimeOnly <span>'io.r2dbc:r2dbc-h2'</span>
    annotationProcessor <span>'org.projectlombok:lombok'</span>
    testAnnotationProcessor <span>'org.projectlombok:lombok'</span>
    testImplementation <span>"org.springframework.boot:spring-boot-starter-test"</span>
    testImplementation <span>'org.junit.vintage:junit-vintage-engine'</span>
    testImplementation <span>'io.projectreactor:reactor-test'</span>
    testImplementation <span>'com.h2database:h2'</span>
    testImplementation <span>'io.r2dbc:r2dbc-h2'</span>
    testImplementation <span>'org.junit.vintage:junit-vintage-engine'</span>
}

dependencyManagement {
    imports {
        mavenBom <span>"org.springframework.cloud:spring-cloud-dependencies:2022.0.1"</span>
    }
}

<span>test</span> {
    useJUnitPlatform()
}

由于SpringCloudGateway基于SpringWebFlux技术构建,所以依赖中的数据库配置需要使用r2dbc 。

三、配置文件

示例程序首选通过配置文件对路由进行基本配置,配置文件代码:

<span>spring:</span>
  <span>r2dbc:</span>
    <span>url:</span> <span>r2dbc:h2:mem:///testdb?options=DB_CLOSE_DELAY=-1;DB_CLOSE_ON_EXIT=FALSE</span>
    <span>username:</span> <span>sa</span>
    <span>password:</span>
  <span>cloud:</span>
    <span>gateway:</span>
      <span>routes:</span>
        <span>-</span> <span>id:</span> <span>routeOne</span>
          <span>predicates:</span>
            <span>-</span> <span>Path=/route1/**</span>
          <span>uri:</span> <span>no</span><span>://op</span>
          <span>filters:</span>
            <span>-</span> <span>UriHostPlaceholderFilter=10001</span>
        <span>-</span> <span>id:</span> <span>routeTwo</span>
          <span>predicates:</span>
            <span>-</span> <span>Path=/route2/**</span>
          <span>uri:</span> <span>no</span><span>://op</span>
          <span>filters:</span>
            <span>-</span> <span>UriHostPlaceholderFilter=10001</span>

配置文件中配置了2个路由,对应的接口地址路径分别是 /route1/** 和 Path=/route2/** ,路径中的 *** 表示模糊匹配,只要是以 /route1/ 为前缀的路径都可以被访问到。

后端服务地址配置了一个无意的地址: uri: no://op ,因为我们的处理逻辑会通过从数据库中读取配置来动态替换后端服务地址。

四、动态路由数据存储格式

我们通过 ROUTE_FILTER_ENTITY 这个数据库表来存储接口后端服务配置数据。表结构为:

<span>CREATE</span> <span>TABLE</span> "ROUTE_FILTER_ENTITY"
(
   id <span>VARCHAR</span>(<span>255</span>) <span>PRIMARY</span> KEY,
   route_id <span>VARCHAR</span>(<span>255</span>),  <span>-- 路由ID,对应配置文件中的 ```id``` 配置项</span>
   code <span>VARCHAR</span>(<span>255</span>), <span>-- 接口请求header中的code参数的值</span>
   url <span>VARCHAR</span>(<span>255</span>) <span>-- 后端服务地址</span>
);

当客户端访问 /route1/test 接口时,根据配置文件的路由配置,SpringCloudGateway 会命中 id: routeOne 这个路由规则,这个规则对应的后端服务地址是 uri: no://op ,并不是我们期望的真实后端服务地址。

因此,我们需要读取到真实的后端服务地址,并将请求转发到这个地址。跟据 routeId 和 接口请求header中的code参数的值,就可以从 ROUTE_FILTER_ENTITY 表中查到对应的后端服务地址 url 这个字段的值。

我们已经读取到了后端服务地址,还需要将请求转发到这个地址,下面介绍转发的方法。

五、后端服务动态转发

动态转发通过自定义 filter 的方式实现,自定义 filter 代码如下:

<span>@Component</span>
public class UriHostPlaceholderFilter extends AbstractGatewayFilterFactory<UriHostPlaceholderFilter.Config> {
    <span>@Autowired</span>
    private RouteFilterRepository routeFilterRepository;

    public <span>UriHostPlaceholderFilter</span>() {
        super(Config.class);
    }

    <span>@Override</span>
    public List<String> shortcutFieldOrder() {
        return Collections<span>.singletonList</span>("order");
    }

    <span>@Override</span>
    public GatewayFilter apply(Config config) {
        return new <span>OrderedGatewayFilter</span>((exchange, chain) -> {
            String <span>code</span> = exchange<span>.getRequest</span>()<span>.getHeaders</span>()<span>.getOrDefault</span>("code", new ArrayList<>())<span>.stream</span>()<span>.findFirst</span>()<span>.orElse</span>("");
            String routeId = exchange<span>.getAttribute</span>(GATEWAY_PREDICATE_MATCHED_PATH_ROUTE_ID_ATTR);
            if (StringUtils.hasText(code)) {
                String newurl;
                try {
                    newurl = routeFilterRepository<span>.findByRouteIdAndCode</span>(routeId, code)<span>.toFuture</span>()<span>.get</span>()<span>.getUrl</span>();
                } catch (InterruptedException | ExecutionException e) {
                    throw new <span>RuntimeException</span>(e);
                }
                if (StringUtils.hasText(exchange.getRequest()<span>.getURI</span>()<span>.getQuery</span>())) {
                    newurl = newurl + "?" + exchange<span>.getRequest</span>()<span>.getURI</span>()<span>.getQuery</span>();
                }
                URI newUri = null;
                try {
                    newUri = new <span>URI</span>(newurl);
                } catch (URISyntaxException e) {
                    log<span>.error</span>("uri error", e);
                }

                exchange<span>.getAttributes</span>()<span>.put</span>(GATEWAY_REQUEST_URL_ATTR, newUri);
            }
            return chain<span>.filter</span>(exchange);
        }, config<span>.getOrder</span>());
    }

    <span>@Data</span>
    <span>@NoArgsConstructor</span>
    public static class Config {
        private int <span>order</span>;

        public <span>Config</span>(int order) {
            this<span>.order</span> = <span>order</span>;
        }
    }
}

通过扩展 AbstractGatewayFilterFactory 类,我们自定义了 UriHostPlaceholderFilter 这个 filter 。

代码的核心逻辑在 apply 方法中。

首先通过 String code = exchange.getRequest().getHeaders().getOrDefault("code", new ArrayList<>()).stream().findFirst().orElse("") 可以获取到接口请求 header 中 code 这个参数的值。

再通过 String routeId = exchange.getAttribute(GATEWAY_PREDICATE_MATCHED_PATH_ROUTE_ID_ATTR) 可以获取到 routeId 。

最后通过 newurl = routeFilterRepository.findByRouteIdAndCode(routeId, code).toFuture().get().getUrl() 就可以从数据库中读取到配置好的后端服务地址。

拿到后端服务地址后, 通过调用 exchange.getAttributes().put(GATEWAY_REQUEST_URL_ATTR, newUri); 将请求转发到对应的地址。

六、单元测试

在单元测试代码中,我们预置了一条后端服务动态配置数据:

<span>insert</span> <span>into</span> ROUTE_FILTER_ENTITY <span>values</span>(<span>'1'</span>,<span>'routeOne'</span>,<span>'alpha'</span>,<span>'http://httpbin.org/anything'</span>)

然后模拟请求 /route1/test?a=test 这个接口,根据我们的配置,请求会被转发到 http://httpbin.org/anything 。

执行单元测试后,可以从日志中发现,接口返回的数据是 httpbin.org/anything 这个后端服务返回的数据。

当我们希望调整后端服务地址时,只需要把 ROUTE_FILTER_ENTITY 表中的这条配置数据中的 url 字段改成其它的任何服务地址即可,大大增加了程序的灵活度。