1、简介 {#1简介}
本文将带你了解如何在 Spring Cloud Gateway 中读取、修改响应体,然后再响应给客户端。
2、Spring Cloud Gateway 快速回顾 {#2spring-cloud-gateway-快速回顾}
Spring Cloud Gateway(简称 SCG)是 Spring Cloud 系列中的一个子项目,它提供了一个构建在响应式 Web 栈之上的 API 网关。关于它的更多详细信息和用法,你可以参考 官方文档。
设计 API Gateway 解决方案时经常出现的一种特殊使用场景:如何在将后端响应的 Body 发送回客户端之前对其进行处理?
下面列出了一些可能会用到这种功能的场景:
- 保持与现有客户端的兼容性,同时允许后台不断迭代
- 需要屏蔽响应中的某些敏感字段(脱敏)
实现这个需求,只需要实现一个 Filter 来处理后台响应即可。Filter 是 SCG 的核心概念。
Filter 组件创建后就可以将其应用于任何已声明的路由(Route)。
3、实现数据过滤 Filter {#3实现数据过滤-filter}
创建一个简单的 Filter 来屏蔽 JSON 响应中的某些值。
例如,给定的 JSON 有一个名为 "ssn" 的字段:
{
"name" : "John Doe",
"ssn" : "123-45-9999",
"account" : "9999888877770000"
}
我们希望用一个固定的值进行替换,从而防止数据泄漏:
{
"name" : "John Doe",
"ssn" : "****",
"account" : "9999888877770000"
}
3.1、实现 GatewayFilterFactory {#31实现-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()
{#32使用-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
实现 {#33scrubber-实现}
现在,来实现最终的清理逻辑。假设 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、测试 {#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、总结 {#5总结}
本文介绍了如何在 Spring Cloud Gateway 中读取后端服务的响应体并对其进行修改。
参考:https://www.baeldung.com/spring-cloud-gateway-response-body