在 Spring Cloud Gateway 中處理響應體
一、簡介
在本教程中,我們將了解如何使用 Spring Cloud Gateway 在將響應主體發送回客戶端之前檢查和/或修改響應主體。
2. Spring Cloud Gateway 快速回顧
Spring Cloud Gateway,簡稱 SCG,是 Spring Cloud 系列的一個子項目,它提供了一個構建在響應式 Web 堆棧之上的 API 網關。我們已經在之前的教程中介紹了它的基本用法,所以我們不會在這裡討論這些方面。
相反,這次我們將關注在圍繞 API 網關設計解決方案時不時出現的特定使用場景:如何在將後端響應有效負載發送回客戶端之前對其進行處理?
以下是我們可能會使用此功能的一些情況的列表:
- 保持與現有客戶端的兼容性,同時允許後端發展
- 從響應中屏蔽某些字段以遵守 PCI 或 GDPR 等法規
在更實際的情況下,滿足這些要求意味著我們需要實現一個過濾器來處理後端響應。由於過濾器是 SCG 中的核心概念,我們需要做的就是支持響應處理,實現一個應用所需轉換的自定義過濾器。
此外,一旦我們創建了過濾器組件,我們就可以將它應用於任何聲明的路由。
3. 實現數據清理過濾器
為了更好地說明響應正文操作的工作原理,讓我們創建一個簡單的過濾器來屏蔽基於 JSON 的響應中的值。例如,給定一個 JSON 具有一個名為“ssn”的字段:
{
"name" : "John Doe",
"ssn" : "123-45-9999",
"account" : "9999888877770000"
}
我們想用固定值替換它們的值,從而防止數據洩漏:
{
"name" : "John Doe",
"ssn" : "****",
"account" : "9999888877770000"
}
3.1。實現GatewayFilterFactory
顧名思義, GatewayFilterFactory
是給定時間過濾器的工廠。在啟動時,Spring 會查找任何實現此接口的@Component
-annotated 類。然後它會構建一個可用過濾器的註冊表,我們可以在聲明路由時使用它:
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 已經有幾個實用程序類,我們可以使用它們來實現這個工廠。在這裡,我們將使用開箱即用的過濾器常用的一個: AbstractGatewayFilterFactory<T>
,一個模板化的基類,其中 T 代表與我們的過濾器實例關聯的配置類。在我們的例子中,我們只需要兩個配置屬性:
-
fields
:用於匹配字段名稱的正則表達式 -
replacement
:將替換原始值的字符串
我們必須實現的關鍵方法是apply()
。 SCG 為使用我們過濾器的每個路由定義調用此方法。例如,在上面的配置中, apply()
只會被調用一次,因為只有一個路由定義。
在我們的例子中,實現很簡單:
@Override
public GatewayFilter apply(Config config) {
return modifyResponseBodyFilterFactory
.apply(c -> c.setRewriteFunction(JsonNode.class, JsonNode.class, new Scrubber(config)));
}
在這種情況下非常簡單,因為我們使用了另一個內置過濾器ModifyResponseBodyGatewayFilterFactory
,我們將所有與正文解析和類型轉換相關的繁重工作委託給它。我們使用構造函數注入來獲取這個工廠的一個實例,並且在apply(),
我們將創建GatewayFilter
實例的任務委託給它。
這裡的關鍵點是使用apply()
方法變體,該變體不是獲取配置對象,而是期望Consumer
用於配置。同樣重要的是,此配置是ModifyResponseBodyGatewayFilterFactory
配置。這個配置對象提供了我們在代碼中調用的setRewriteFunction()
方法。
3.2.使用setRewriteFunction()
現在,讓我們更深入地了解setRewriteFunction().
此方法接受三個參數:兩個類(輸入和輸出)和一個可以從傳入類型轉換為傳出類型的函數。在我們的例子中,我們沒有轉換類型,所以輸入和輸出都使用同一個類: JsonNode
。此類來自 Jackson 庫,位於用於表示 JSON 中不同節點類型(例如對象節點、數組節點等)的類層次結構的最頂端。使用JsonNode
作為輸入/輸出類型允許我們處理任何有效的 JSON 有效負載,這在本例中是我們想要的。
對於轉換器類,我們傳遞了一個Scrubber
的實例,它在其apply()
方法中實現了所需的RewriteFunction
接口:
public static class Scrubber implements RewriteFunction<JsonNode,JsonNode> {
// ... fields and constructor omitted
@Override
public Publisher<JsonNode> apply(ServerWebExchange t, JsonNode u) {
return Mono.just(scrubRecursively(u));
}
// ... scrub implementation omitted
}
傳遞給apply()
的第一個參數是當前的ServerWebExchange
,它使我們能夠訪問到目前為止的請求處理上下文。我們不會在這裡使用它,但很高興知道我們有這種能力。下一個參數是接收到的正文,已經轉換為課堂內通知。
預期的回報是通知類的實例的Publisher
者。所以,只要我們不做任何阻塞 I/O 操作,我們就可以在 rewrite 函數內部做一些複雜的工作。
3.3. Scrubber
實施
所以,既然我們知道了重寫函數的合約,讓我們最終實現我們的清理器邏輯。在這裡,我們假設有效負載相對較小,因此我們不必擔心存儲接收到的對象的內存需求。
它的實現只是遞歸遍歷所有節點,尋找與配置模式匹配的屬性並替換掩碼的相應值:
public static class Scrubber implements RewriteFunction<JsonNode,JsonNode> {
// ... fields and constructor omitted
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 測試,用作洗滌器的健全性檢查。集成測試更有趣,因為它說明了 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
來管理用於處理請求的線程
其次,我們以編程方式創建一個包含我們的過濾器的路由@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 庫對其進行修改。像往常一樣,所有代碼都可以在 GitHub 上找到。