在 Spring Cloud Gateway 中修改响应体

1、简介

本文将带你了解如何在 Spring Cloud Gateway 中读取、修改响应体,然后再响应给客户端。

2、Spring Cloud Gateway 快速回顾

Spring Cloud Gateway(简称 SCG)是 Spring Cloud 系列中的一个子项目,它提供了一个构建在响应式 Web 栈之上的 API 网关。关于它的更多详细信息和用法,你可以参考 官方文档

设计 API Gateway 解决方案时经常出现的一种特殊使用场景:如何在将后端响应的 Body 发送回客户端之前对其进行处理?

下面列出了一些可能会用到这种功能的场景:

  • 保持与现有客户端的兼容性,同时允许后台不断迭代
  • 需要屏蔽响应中的某些敏感字段(脱敏)

实现这个需求,只需要实现一个 Filter 来处理后台响应即可。Filter 是 SCG 的核心概念。

Filter 组件创建后就可以将其应用于任何已声明的路由(Route)。

3、实现数据过滤 Filter

创建一个简单的 Filter 来屏蔽 JSON 响应中的某些值。

例如,给定的 JSON 有一个名为 “ssn” 的字段:

{
  "name" : "John Doe",
  "ssn" : "123-45-9999",
  "account" : "9999888877770000"
}

我们希望用一个固定的值进行替换,从而防止数据泄漏:

{
  "name" : "John Doe",
  "ssn" : "****",
  "account" : "9999888877770000"
}

3.1、实现 GatewayFilterFactory

顾名思义,GatewayFilterFactory 是一个 Filter 的工厂,它用于创建给定类型的 Filter。在启动时,Spring 会查找所有实现该接口的带有 @Component 注解的类。然后它会构建一个可用 Filter 的注册表,声明路由时可以使用这些 Filter。

spring:
  cloud:
    gateway:
      routes:
      - id: rewrite_with_scrub
        uri: ${rewrite.backend.uri:http://example.com}
        predicates:
        - Path=/v1/customer/**
        filters:
        - RewritePath=/v1/customer/(?<segment>.*),/api/$\{segment}
        - ScrubResponse=ssn,***

注意,在使用这种基于配置的方法定义路由时,必须根据 SCG 的命名约定来命名工厂: FilterNameGatewayFilterFactory

因此,把工厂命名为 ScrubResponseGatewayFilterFactory

SCG 已经有几个工具类,可用来实现工厂。在这里,使用一个开箱即用的 Filter 常用的类: AbstractGatewayFilterFactory<T> 是一个模板化的基类,其中泛型 T 代表与我们的 Filter 实例相关的配置类。在本例中,只需要两个配置属性:

  • fields:用于匹配字段名的正则表达式
  • replacement:字符串,用于替换原始值

必须实现的关键方法是 apply()。Spring Cloud Gateway 在每个使用该 Filter 的路由定义中调用此方法。例如,在上面的配置中,由于只有一个路由定义,apply() 方法将只被调用一次。

在本案例中,实现起来非常简单:

@Override
public GatewayFilter apply(Config config) {
    return modifyResponseBodyFilterFactory
       .apply(c -> c.setRewriteFunction(JsonNode.class, JsonNode.class, new Scrubber(config)));
}

在本例中之所以如此简单,是因为我们使用了另一个内置 Filter - ModifyResponseBodyGatewayFilterFactory,我们将所有与 body 解析和类型转换相关的工作都委托给了它。

使用构造器注入来获取该工厂的实例,并在 apply() 中委托它创建 GatewayFilter 实例。

这里的关键是使用 apply() 方法的变体,它不是接受一个配置对象,而是一个用于配置的 Consumer。同样重要的是,这个配置对象是 ModifyResponseBodyGatewayFilterFactory 的一个实例。这个配置对象提供了在代码中调用的 setRewriteFunction() 方法。

3.2、使用 setRewriteFunction()

现在,让我们深入了解一下 setRewriteFunction()

该方法需要三个参数:两个类(输入和输出)和一个可以将输入类型转换为输出类型的函数。在本例中,没有转换类型,所以输入和输出都使用同一个类: JsonNode。该类来自 Jackson 库,是一个抽象的 JSON 顶层类,用于表示 JSON 中不同节点类型(如对象节点、数组节点等)。使用 JsonNode 作为输入/输出类型,可以处理任何有效的 JSON 数据。

对于转换器类,我们传递一个 Scrubber 的实例,它在 apply() 方法中实现了所需的 RewriteFunction 接口:

public static class Scrubber implements RewriteFunction<JsonNode,JsonNode> {
    // /... 构造函数和字段
    @Override
    public Publisher<JsonNode> apply(ServerWebExchange t, JsonNode u) {
        return Mono.just(scrubRecursively(u));
    }
    // ... 订阅实现
}

传递给 apply() 的第一个参数是当前的 ServerWebExchange,它使我们能够访问到目前为止的请求处理上下文(Request Context)。在这里我们不会使用它。下一个参数是已经转换为指定输入类的接收到的请求体。

期望的返回值是一个发布者(Publisher),其实例是指定输出类的实例。因此,只要我们不进行任何阻塞的 I/O 操作,我们可以在重写函数内部执行一些复杂的工作。

3.3、Scrubber 实现

现在,来实现最终的清理逻辑。假设 Payload 相对比较小,因为不必担心存储接收对象所需的内存。

其实现方式只是在所有节点上进行递归,寻找与配置模式(pattern)匹配的属性,并替换相应的屏蔽值:

public static class Scrubber implements RewriteFunction<JsonNode,JsonNode> {
    // ...字段和构造函数

    private JsonNode scrubRecursively(JsonNode u) {
        if ( !u.isContainerNode()) {
            return u;
        }
        
        if (u.isObject()) {
            ObjectNode node = (ObjectNode)u;
            node.fields().forEachRemaining((f) -> {
                if ( fields.matcher(f.getKey()).matches() && f.getValue().isTextual()) {
                    f.setValue(TextNode.valueOf(replacement));
                }
                else {
                    f.setValue(scrubRecursively(f.getValue()));
                }
            });
        }
        else if (u.isArray()) {
            ArrayNode array = (ArrayNode)u;
            for ( int i = 0 ; i < array.size() ; i++ ) {
                array.set(i, scrubRecursively(array.get(i)));
            }
        }
        
        return u;
    }
}

4、测试

示例代码中包含了两个测试:一个简单的单元测试和一个集成测试。第一个测试只是一个普通的 JUnit 测试,用于检查 Scrubber 是否正常。集成测试则展示了在 SCG 开发环境中使用的有用技术。

首先,我们需要提供一个实际的后端,可以发送消息到该后端。一种可能的方式是使用像 Postman 或类似的外部工具,但这在典型的 CI/CD 场景中会存在一些问题。

相反,我们使用 JDK 中鲜为人知的 HttpServer 类,它实现了一个简单的 HTTP 服务器。

@Bean
public HttpServer mockServer() throws IOException {
    HttpServer server = HttpServer.create(new InetSocketAddress(0),0);
    server.createContext("/customer", (exchange) -> {
        exchange.getResponseHeaders().set("Content-Type", "application/json");
        
        byte[] response = JSON_WITH_FIELDS_TO_SCRUB.getBytes("UTF-8");
        exchange.sendResponseHeaders(200,response.length);
        exchange.getResponseBody().write(response);
    });
    
    server.setExecutor(null);
    server.start();
    return server;
}

该服务器将处理 /customer 的请求,并返回测试中使用的固定 JSON 响应。

注意,方法返回的服务器已经启动,并将通过随机端口监听传入请求。这里还指示服务器创建一个新的默认 Executor,以管理用于处理请求的线程。

接着,以编程方式创建了一个包含 Filter 的路由 @Bean。这相当于使用配置属性构建路由,但允许我们完全控制测试路由的所有方面。

@Bean
public RouteLocator scrubSsnRoute(
  RouteLocatorBuilder builder, 
  ScrubResponseGatewayFilterFactory scrubFilterFactory, 
  SetPathGatewayFilterFactory pathFilterFactory, 
  HttpServer server) {
    int mockServerPort = server.getAddress().getPort();
    ScrubResponseGatewayFilterFactory.Config config = new ScrubResponseGatewayFilterFactory.Config();
    config.setFields("ssn");
    config.setReplacement("*");
    
    SetPathGatewayFilterFactory.Config pathConfig = new SetPathGatewayFilterFactory.Config();
    pathConfig.setTemplate("/customer");
    
    return builder.routes()
      .route("scrub_ssn",
         r -> r.path("/scrub")
           .filters( 
              f -> f
                .filter(scrubFilterFactory.apply(config))
                .filter(pathFilterFactory.apply(pathConfig)))
           .uri("http://localhost:" + mockServerPort ))
      .build();
}

最后,现在这些 Bean 是 @TestConfiguration 的一部分,可以将它们与 WebTestClient 一起注入到实际的测试中。实际的测试使用这个 WebTestClient 驱动 SCG 和后端:

@Test
public void givenRequestToScrubRoute_thenResponseScrubbed() {
    client.get()
      .uri("/scrub")
      .accept(MediaType.APPLICATION_JSON)
      .exchange()
      .expectStatus()
        .is2xxSuccessful()
      .expectHeader()
        .contentType(MediaType.APPLICATION_JSON)
      .expectBody()
        .json(JSON_WITH_SCRUBBED_FIELDS);
}

5、总结

本文介绍了如何在 Spring Cloud Gateway 中读取后端服务的响应体并对其进行修改。


参考:https://www.baeldung.com/spring-cloud-gateway-response-body