1. 引言
在本教程中,我們將探討如何使用 Spring Cloud Gateway 來在將響應體發送回客户端之前,對其進行檢查和/或修改。
2. Spring Cloud Gateway 快速回顧
Spring Cloud Gateway(簡稱SCG)是 Spring Cloud 家族的一個子項目,它基於響應式 Web 堆棧構建的 API 網關。我們之前在早期教程中已經涵蓋了它的基本用法,這裏將不會深入探討這些方面。
相反,這次我們將重點關注在圍繞 API 網關設計解決方案時經常出現的特定使用場景:如何處理後端響應報文,然後再將其發送回客户端?
以下是一些可能使用此功能的情況:
- 保持與現有客户端的兼容性,同時允許後端演進。
- 為了符合 PCI 或 GDPR 等法規要求,屏蔽某些字段。
在更實際的術語中,滿足這些要求意味着我們需要實現一個過濾器來處理後端響應。由於過濾器是 SCG 的核心概念,支持響應處理只需實現一個自定義過濾器,該過濾器應用所需的轉換。
此外,一旦我們創建了我們的過濾器組件,就可以將其應用於任何已聲明的路由。
3. 實現數據清洗過濾器
為了更好地説明響應體操作的工作原理,我們創建一個簡單的過濾器,該過濾器會掩蓋 JSON 響應中的值。例如,給定一個包含名為“ssn”的字段的 JSON:
{
"name" : "John Doe",
"ssn" : "123-45-9999",
"account" : "9999888877770000"
}我們希望用固定的值替換它們的值,從而防止數據泄露。
{
"name" : "John Doe",
"ssn" : "****",
"account" : "9999888877770000"
}3.1. 實現 GatewayFilterFactory
GatewayFilterFactory,顧名思義,是為特定時間創建過濾器的工廠。在啓動時,Spring會查找任何標註了@Component的實現該接口的類。然後它會構建一個過濾器的可用註冊表,我們可以用它來聲明路由:
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()` 方法。
此方法接受三個參數:兩個類(in 和 out)和一個可以從傳入類型轉換為傳出類型的函數。 在我們的情況下,我們不進行類型轉換,因此輸入和輸出都使用相同的類: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,這使我們能夠訪問請求處理的上下文。我們這裏不會使用它,但知道我們擁有這種能力是有益的。下一個參數是接收到的 body,已經轉換為 informed 類。
預期的返回值是一個 Publisher,包含 informed 類實例。只要我們不進行任何阻塞 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 測試,用作清洗器(scrubber)的初步驗證。後者則更具價值,因為它展示了在 SCG 開發中使用的實用技術。
首先,需要提供一個實際的後端,以便可以發送消息。一種可能的方法是使用像 Postman 這樣的外部工具,但這可能會對典型的 CI/CD 場景產生問題。相反,我們將使用 JDK 中不太為人所知 <em >HttpServer</em> 類,該類實現了簡單的 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 庫對其進行修改。