自定义 Spring Cloud Gateway 过滤器(Filter)

1、概览

上一篇文章《Spring Cloud Gateway 教程》中介绍了 Spring Cloud Gateway 网关框架。本文将带你了解如何在 Spring Cloud Gateway 中自定义 Filter。以及如何在 Filter 中修改请求和响应数据。

2、项目设置

创建一个基本应用,并将其用作 API 网关。

2.1、Maven 配置

在使用 Spring Cloud 时,往往通过 <dependencyManagement> 来管理组件的版本:

<dependencyManagement>
    <dependencies>
        <dependency>
            <groupId>org.springframework.cloud</groupId>
            <artifactId>spring-cloud-dependencies</artifactId>
            <version>Hoxton.SR4</version>
            <type>pom</type>
            <scope>import</scope>
        </dependency>
    </dependencies>
</dependencyManagement>

添加 Spring Cloud Gateway,无需指定使用的实际版本:

<dependency>
    <groupId>org.springframework.cloud</groupId>
    <artifactId>spring-cloud-starter-gateway</artifactId>
</dependency>

最新的 Spring Cloud 版本可通过 Maven Central 中找到。当然,需要注意使用的 Spring Cloud 版本需要与 Spring Boot 版本兼容。

2.2、API 网关配置

假设 API 服务在本地 8081 端口运行,在 /resource 端点暴露了一个简单的字符串资源。

接下来,配置网关,把请求代理到该服务。简而言之,当请求网关的 URI 路径中带有 /service 前缀的请求时,网关将把请求转发给该服务。

也就是说,当在网关中调用 /service/resource 时,应该会收到字符串响应。

application.yaml 中配置此路由:

spring:
  cloud:
    gateway:
      routes:
      - id: service_route
        uri: http://localhost:8081
        predicates:
        - Path=/service/**
        filters:
        - RewritePath=/service(?<segment>/?.*), $\{segment}

此外,为了追踪网关的处理过程,还要启用一些日志:

logging:
  level:
    org.springframework.cloud.gateway: DEBUG
    reactor.netty.http.client: DEBUG

3、创建全局 Filter

一旦网关 Handler 确定请求与路由相匹配,网关就会将请求传递给过滤器链(Filter Chain)。这些过滤器可在请求发送前或发送后执行逻辑。

先从简单的全局过滤器开始,全局,意味着它将影响每一个请求。

首先,来看看如何在发送代理请求之前执行逻辑(也称为 “pre” Filter)

3.1、“Pre” Filter

要创建自定义全局过滤器,只需实现 Spring Cloud Gateway GlobalFilter 接口,并将其作为 Bean 添加到上下文中:

出于演示目的,只是简单地在 Filter 中输出一条日志信息。

@Component
public class LoggingGlobalPreFilter implements GlobalFilter {

    final Logger logger =
      LoggerFactory.getLogger(LoggingGlobalPreFilter.class);

    @Override
    public Mono<Void> filter(
      ServerWebExchange exchange,
      GatewayFilterChain chain) {
        logger.info("Global Pre Filter executed");
        return chain.filter(exchange);
    }
}

如上,当这个过滤器被执行的时候就会输出日志记录,然后继续执行过滤器链。

如果你不熟悉响应式编程模型和 Spring Webflux API,这可能会有点不好理解。

3.2、“Post” Filter

还有一点需要注意,那就是 GlobalFilter 接口只定义了一个方法。因此,它可以用 lambda 来表示。

例如,可以在配置类中定义 “Post” 过滤器:

@Configuration
public class LoggingGlobalFiltersConfigurations {

    final Logger logger =
      LoggerFactory.getLogger(
        LoggingGlobalFiltersConfigurations.class);

    @Bean
    public GlobalFilter postGlobalFilter() {
        return (exchange, chain) -> {
            return chain.filter(exchange)
              .then(Mono.fromRunnable(() -> {
                  logger.info("Global Post Filter executed");
              }));
        };
    }
}

简单来说,上述 Filter 在过滤器链完成执行后运行了一个新的 Mono 实例。

现在,尝试在网关服务中调用 /service/resource URL 并查看控制台输出的日志:

DEBUG --- o.s.c.g.h.RoutePredicateHandlerMapping:
  Route matched: service_route
DEBUG --- o.s.c.g.h.RoutePredicateHandlerMapping:
  Mapping [Exchange: GET http://localhost/service/resource]
  to Route{id='service_route', uri=http://localhost:8081, order=0, predicate=Paths: [/service/**],
  match trailing slash: true, gatewayFilters=[[[RewritePath /service(?<segment>/?.*) = '${segment}'], order = 1]]}
INFO  --- c.b.s.c.f.global.LoggingGlobalPreFilter:
  Global Pre Filter executed
DEBUG --- r.netty.http.client.HttpClientConnect:
  [id: 0x58f7e075, L:/127.0.0.1:57215 - R:localhost/127.0.0.1:8081]
  Handler is being applied: {uri=http://localhost:8081/resource, method=GET}
DEBUG --- r.n.http.client.HttpClientOperations:
  [id: 0x58f7e075, L:/127.0.0.1:57215 - R:localhost/127.0.0.1:8081]
  Received response (auto-read:false) : [Content-Type=text/html;charset=UTF-8, Content-Length=16]
INFO  --- c.f.g.LoggingGlobalFiltersConfigurations:
  Global Post Filter executed
DEBUG --- r.n.http.client.HttpClientOperations:
  [id: 0x58f7e075, L:/127.0.0.1:57215 - R:localhost/127.0.0.1:8081] Received last HTTP packet

如你所见,在网关将请求转发给服务之前和之后,对应的过滤器都执行了。

可以将 “Pre” 和 “Post” 逻辑结合到一个过滤器中:

@Component
public class FirstPreLastPostGlobalFilter
  implements GlobalFilter, Ordered {

    final Logger logger =
      LoggerFactory.getLogger(FirstPreLastPostGlobalFilter.class);

    @Override
    public Mono<Void> filter(ServerWebExchange exchange,
      GatewayFilterChain chain) {
        logger.info("First Pre Global Filter");
        return chain.filter(exchange)
          .then(Mono.fromRunnable(() -> {
              logger.info("Last Post Global Filter");
            }));
    }

    @Override
    public int getOrder() {
        return -1;
    }
}

可以通过实现 Ordered 接口,来控制 Filter 在 Filter Chain 中的位置。

由于过滤器链(Filter Chain)的性质,优先级较低(在链中的顺序较低)的过滤器将在较早阶段执行其 “pre” 逻辑,但其 “post” 执行将在较后阶段调用:

Spring Cloud Gateway Filter执行顺序

4、创建 GatewayFilter

全局过滤器非常有用,但我们经常需要执行仅适用于某些路由的细粒度自定义网关 Filter 操作。

4.1、定义 GatewayFilterFactory

要实现 GatewayFilter,必须实现 GatewayFilterFactory 接口。Spring Cloud Gateway 还提供了一个抽象类来简化这一过程,即 AbstractGatewayFilterFactory 类:

@Component
public class LoggingGatewayFilterFactory extends 
  AbstractGatewayFilterFactory<LoggingGatewayFilterFactory.Config> {

    final Logger logger =
      LoggerFactory.getLogger(LoggingGatewayFilterFactory.class);

    public LoggingGatewayFilterFactory() {
        super(Config.class);
    }

    @Override
    public GatewayFilter apply(Config config) {
        // ...
    }

    public static class Config {
        // ...
    }
}

如上,定义了 GatewayFilterFactory 的基本结构。在初始化过滤器时,使用 Config 类来自定义过滤器。

例如,可以在配置中定义三个基本字段:

public static class Config {
    private String baseMessage;
    private boolean preLogger;
    private boolean postLogger;

    //构造函数、Get、Set
}

简单地说,这些字段是:

  1. baseMessage:日志条目中包含的自定义信息
  2. preLogger:表示过滤器是否应在转发请求前记录日志
  3. postLogger:表示过滤器是否应在收到代理服务的响应后记录日志

现在,可以使用这些配置来获取一个 GatewayFilter 实例,这同样可以用 lambda 函数来表示:

@Override
public GatewayFilter apply(Config config) {
    return (exchange, chain) -> {
        // Pre-processing
        if (config.isPreLogger()) {
            logger.info("Pre GatewayFilter logging: "
              + config.getBaseMessage());
        }
        return chain.filter(exchange)
          .then(Mono.fromRunnable(() -> {
              // Post-processing
              if (config.isPostLogger()) {
                  logger.info("Post GatewayFilter logging: "
                    + config.getBaseMessage());
              }
          }));
    };
}

4.2、通过 Properties 注册 GatewayFilter

将 Filter 注册到之前在 application.yaml 中定义的路由上:

...
filters:
- RewritePath=/service(?<segment>/?.*), $\{segment}
- name: Logging
  args:
    baseMessage: My Custom Message
    preLogger: true
    postLogger: true

只需指定配置参数即可。这里有一点很重要,必须需要在 LoggingGatewayFilterFactory.Config 类中配置无参数构造函数和 Setter 方法,这样这种方法才能正常工作。

也可以使用更紧凑的方式来配置 Filter:

filters:
- RewritePath=/service(?<segment>/?.*), $\{segment}
- Logging=My Custom Message, true, true

这需要对 Factory 再做一些调整。简而言之,必须重写 shortcutFieldOrder 方法,以指定紧凑方式属性的顺序和参数数量:

@Override
public List<String> shortcutFieldOrder() {
    return Arrays.asList("baseMessage",
      "preLogger",
      "postLogger");
}

4.3、OrderedGatewayFilter

如果要配置 Filter 在 Filter Chain 中的位置,可以从 AbstractGatewayFilterFactory#apply 方法中返回一个 OrderedGatewayFilter 实例,而不是一个普通的 lambda 表达式:

@Override
public GatewayFilter apply(Config config) {
    return new OrderedGatewayFilter((exchange, chain) -> {
        // ...
    }, 1);
}

4.4、编程式注册 GatewayFilter

还可以通过编程式注册 Filter。

重新定义前面的路由,这次设置一个 RouteLocator Bean:

@Bean
public RouteLocator routes(
  RouteLocatorBuilder builder,
  LoggingGatewayFilterFactory loggingFactory) {
    return builder.routes()
      .route("service_route_java_config", r -> r.path("/service/**")
        .filters(f -> 
            f.rewritePath("/service(?<segment>/?.*)", "$\\{segment}")
              .filter(loggingFactory.apply(
              new Config("My Custom Message", true, true))))
            .uri("http://localhost:8081"))
      .build();
}

5、高级场景

到目前为止,我们所做的只是在网关流程的不同阶段输出日志。

一般来说,我们会通过 Filter 实现更高级的功能。例如:检查或者操作接收到的请求,修改响应,甚至在响应式流(Reactive Stream)中与其他不同的服务调用进行链式操作。

5.1、检查和修改请求

假设一个场景。服务过去是根据 locale 查询参数来提供内容的。后来,更改了 API,改用 Accept-Language Header,但有些客户端仍在使用查询参数。

因此,我们希望在网关屏蔽这个差异:

  1. 如果接收到 Accept-Language 标头,则保留它
  2. 否则,使用 locale 查询参数值
  3. 如果查询参数也不存在,则使用默认的 locale
  4. 最后,删除 locale 查询参数

这里只关注 Filter 中的实现逻辑,其他的内容你可以在文末的 Github 仓库中找到。

把网关 Filter 配置为 “pre” Filter:

(exchange, chain) -> {
    if (exchange.getRequest()
      .getHeaders()
      .getAcceptLanguage()
      .isEmpty()) {
        // 填充 Accept-Language header 。。。
    }

    // 删除查询参数
    return chain.filter(exchange);
};

如上,通过 ServerHttpRequest 对象访问 Header。

还可以用同样的方式访问其他的属性。

String queryParamLocale = exchange.getRequest()
  .getQueryParams()
  .getFirst("locale");

Locale requestLocale = Optional.ofNullable(queryParamLocale)
  .map(l -> Locale.forLanguageTag(l))
  .orElse(config.getDefaultLocale());

现在,使用 mutate() 方法来修改请求,框架会为实体创建一个 Decorator(装饰器),同时保持原始对象不变。

修改 Header 很简单,因为可以获取 HttpHeaders Map 对象的引用:

exchange.getRequest()
  .mutate()
  .headers(h -> h.setAcceptLanguageAsLocales(
    Collections.singletonList(requestLocale)))

修改 URI 也一样,必须从原始 exchange 对象中获取一个新的 ServerWebExchange 实例,并修改原始 ServerHttpRequest 实例:

ServerWebExchange modifiedExchange = exchange.mutate()
  // 在这里修改原始请求:
  .request(originalRequest -> originalRequest)
  .build();

return chain.filter(modifiedExchange);

现在,删除查询参数更新原始请求 URI:

originalRequest -> originalRequest.uri(
  UriComponentsBuilder.fromUri(exchange.getRequest()
    .getURI())
  .replaceQueryParams(new LinkedMultiValueMap<String, String>())
  .build()
  .toUri())

5.2、修改响应

继续相同的案例场景,现在定义一个 “Post” Filter。假设服务会检索一个自定义 Header,以指示它最终选择的语言,而不是使用传统的 Content-Language Header。

因此,我们希望 Filter 添加这个响应 Header,但前提是请求包含上一节中介绍的 locale Header。

(exchange, chain) -> {
    return chain.filter(exchange)
      .then(Mono.fromRunnable(() -> {
          ServerHttpResponse response = exchange.getResponse();

          Optional.ofNullable(exchange.getRequest()
            .getQueryParams()
            .getFirst("locale"))
            .ifPresent(qp -> {
                String responseContentLanguage = response.getHeaders()
                  .getContentLanguage()
                  .getLanguage();

                response.getHeaders()
                  .add("Bael-Custom-Language-Header", responseContentLanguage);
                });
        }));
}

如上,这可以轻松获取 response 对象的引用,而且不需要像 request 那样创建一个副本来修改它。

这是一个很好的例子,说明了链中 Filter 顺序的重要性;如果在上一节创建的 Filter 之后配置执行该 Filter,那么此处的 exchange 对象将包含对 ServerHttpRequest 的引用,而该引用将永远不会有任何查询参数。

在执行了所有 “Pre” Filter 后,再触发这个 Filter 也没有关系,因为仍然可以引用原始请求,这要归功于 mutate() 逻辑。

5.3、将请求与其他服务链接

在我们假设的场景中,下一步是依靠第三方服务来指示我们应该使用哪种 Accept-Language Header。

因此,创建一个新的 Filter 来调用该服务,并将其响应体用作代理服务 API 的请求 Header。

在响应式环境中,这意味着通过链式请求来避免阻塞异步执行。

在 Filter 中,首先向 Language Service 发起请求:

(exchange, chain) -> {
    return WebClient.create().get()
      .uri(config.getLanguageEndpoint())
      .exchange()
      // ...
}

注意,这返回了 Fluent 风格的操作,用于链式地将调用的输出与代理请求连接在一起。

下一步是提取 Language(从响应体中提取,如果响应不成功,则从配置中提取)并进行解析:

// ...
.flatMap(response -> {
    return (response.statusCode()
      .is2xxSuccessful()) ? response.bodyToMono(String.class) : Mono.just(config.getDefaultLanguage());
}).map(LanguageRange::parse)
// ...

最后,像之前一样将 LanguageRange 值设置为请求 Header,然后继续过滤器链(Filter Chain):

.map(range -> {
    exchange.getRequest()
      .mutate()
      .headers(h -> h.setAcceptLanguage(range))
      .build();

    return exchange;
}).flatMap(chain::filter);

交互将以非阻塞方式进行。

6、总结

本文介绍了如何在 Spring Cloud Gateway 中自定义 Filter,以及如何通过 Filter 修改请求、响应,甚至是链接到其他的服务调用。


Ref:https://www.baeldung.com/spring-cloud-custom-gateway-filters