建立自訂 Spring Cloud Gateway 篩選器

工程 | Fredrich Ombico | 2022 年 8 月 27 日 | ...

在本文中,我們將探討如何為 Spring Cloud Gateway 撰寫自訂擴充功能。在開始之前,讓我們先了解 Spring Cloud Gateway 的運作方式

Spring Cloud Gateway diagram

  1. 首先,用戶端向閘道發出網路請求
  2. 閘道定義了許多路由,每個路由都有謂詞來將請求與路由匹配。例如,您可以比對 URL 的路徑區段或請求的 HTTP 方法。
  3. 一旦匹配,閘道會在應用於路由的每個篩選器上執行請求前邏輯。例如,您可能想要將查詢參數新增至您的請求
  4. Proxy 篩選器將請求路由到代理服務
  5. 服務執行並傳回回應
  6. 閘道接收回應,並在傳回回應之前,對每個篩選器執行請求後邏輯。例如,您可以在傳回用戶端之前移除不需要的回應標頭。

我們的擴充功能將會雜湊請求 body,並將該值新增為名為 X-Hash 的請求標頭。這對應於上圖中的步驟 3。注意:由於我們正在讀取請求 body,閘道的記憶體將會受到限制。

首先,我們在 start.spring.io 建立一個專案,並包含 Gateway 相依性。在本範例中,我們將使用 Java 中的 Gradle 專案,JDK 17 和 Spring Boot 2.7.3。下載、解壓縮並在您最愛的 IDE 中開啟專案,然後執行它以確保您已設定好本機開發環境。

接下來,讓我們建立 GatewayFilter Factory,它是一個限定於特定路由的篩選器,允許我們以某種方式修改傳入的 HTTP 請求或傳出的 HTTP 回應。在我們的案例中,我們將修改傳入的 HTTP 請求,新增一個額外的標頭

package com.example.demo;

import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
import java.util.Collections;
import java.util.List;

import org.bouncycastle.util.encoders.Hex;
import reactor.core.publisher.Mono;

import org.springframework.cloud.gateway.filter.GatewayFilter;
import org.springframework.cloud.gateway.filter.factory.AbstractGatewayFilterFactory;
import org.springframework.cloud.gateway.support.ServerWebExchangeUtils;
import org.springframework.http.codec.HttpMessageReader;
import org.springframework.http.server.reactive.ServerHttpRequest;
import org.springframework.stereotype.Component;
import org.springframework.util.Assert;
import org.springframework.web.reactive.function.server.HandlerStrategies;
import org.springframework.web.reactive.function.server.ServerRequest;

import static org.springframework.cloud.gateway.support.ServerWebExchangeUtils.CACHED_SERVER_HTTP_REQUEST_DECORATOR_ATTR;

/**
 * This filter hashes the request body, placing the value in the X-Hash header.
 * Note: This causes the gateway to be memory constrained.
 * Sample usage: RequestHashing=SHA-256
 */
@Component
public class RequestHashingGatewayFilterFactory extends
        AbstractGatewayFilterFactory<RequestHashingGatewayFilterFactory.Config> {

    private static final String HASH_ATTR = "hash";
    private static final String HASH_HEADER = "X-Hash";
    private final List<HttpMessageReader<?>> messageReaders =
            HandlerStrategies.withDefaults().messageReaders();

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

    @Override
    public GatewayFilter apply(Config config) {
        MessageDigest digest = config.getMessageDigest();
        return (exchange, chain) -> ServerWebExchangeUtils
                .cacheRequestBodyAndRequest(exchange, (httpRequest) -> ServerRequest
                    .create(exchange.mutate().request(httpRequest).build(),
                            messageReaders)
                    .bodyToMono(String.class)
                    .doOnNext(requestPayload -> exchange
                            .getAttributes()
                            .put(HASH_ATTR, computeHash(digest, requestPayload)))
                    .then(Mono.defer(() -> {
                        ServerHttpRequest cachedRequest = exchange.getAttribute(
                                CACHED_SERVER_HTTP_REQUEST_DECORATOR_ATTR);
                        Assert.notNull(cachedRequest, 
                                "cache request shouldn't be null");
                        exchange.getAttributes()
                                .remove(CACHED_SERVER_HTTP_REQUEST_DECORATOR_ATTR);

                        String hash = exchange.getAttribute(HASH_ATTR);
                        cachedRequest = cachedRequest.mutate()
                                .header(HASH_HEADER, hash)
                                .build();
                        return chain.filter(exchange.mutate()
                                .request(cachedRequest)
                                .build());
                    })));
    }

    @Override
    public List<String> shortcutFieldOrder() {
        return Collections.singletonList("algorithm");
    }

    private String computeHash(MessageDigest messageDigest, String requestPayload) {
        return Hex.toHexString(messageDigest.digest(requestPayload.getBytes()));
    }

    static class Config {

        private MessageDigest messageDigest;

        public MessageDigest getMessageDigest() {
            return messageDigest;
        }

        public void setAlgorithm(String algorithm) throws NoSuchAlgorithmException {
            messageDigest = MessageDigest.getInstance(algorithm);
        }
    }
}

讓我們更詳細地看看程式碼

  • 我們將 @Component 註解新增至類別。Spring Cloud Gateway 需要能夠偵測到這個類別才能使用它。或者,我們可以使用 @Bean 定義一個實例
  • 在我們的類別名稱中,我們使用 GatewayFilterFactory 作為後綴。在 application.yaml 中新增此篩選器時,我們不包含後綴,僅包含 RequestHashing。這是 Spring Cloud Gateway 篩選器命名慣例。
  • 我們的類別也延伸了 AbstractGatewayFilterFactory,類似於所有其他 Spring Cloud Gateway 篩選器。我們也指定一個類別來設定我們的篩選器,一個名為 Config 的巢狀靜態類別有助於保持簡潔。組態類別允許我們設定要使用的雜湊演算法。
  • 覆寫的 apply 方法是所有工作發生的位置。在參數中,我們獲得組態類別的實例,我們可以在其中存取 MessageDigest 實例以進行雜湊。接下來,我們看到 (exchange, chain),這是一個傳回的 GatewayFilter 介面類別的 lambda。exchange 是 ServerWebExchange 的實例,它為 Gateway 篩選器提供對 HTTP 請求和回應的存取權。在我們的案例中,我們想要修改 HTTP 請求,這需要我們變更 exchange。
  • 我們需要讀取請求 body 以產生雜湊,但是,由於 body 儲存在位元組緩衝區中,因此在篩選器中只能讀取一次。透過使用 ServerWebExchangeUtils,我們將請求快取為 exchange 中的屬性。屬性提供了一種跨篩選器鏈為特定請求共用資料的方式。我們還將儲存請求 body 的計算雜湊值。
  • 我們使用 exchange 屬性來取得快取的請求和計算的雜湊值。然後,我們透過新增雜湊標頭來變更 exchange,然後最終將其傳送至鏈中的下一個篩選器。
  • shortcutFieldOrder 方法有助於將引數的數量和順序對應到篩選器。algorithm 字串與 Config 類別中的 setter 相符。

為了測試程式碼,我們將使用 WireMock。將相依性新增至您的 build.gradle 檔案

testImplementation 'com.github.tomakehurst:wiremock:2.27.2'

這裡我們有一個測試檢查標頭的存在和值,另一個測試檢查如果沒有請求 body,則標頭不存在

package com.example.demo;

import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;

import com.github.tomakehurst.wiremock.WireMockServer;
import com.github.tomakehurst.wiremock.client.WireMock;
import com.github.tomakehurst.wiremock.core.WireMockConfiguration;
import org.bouncycastle.jcajce.provider.digest.SHA512;
import org.bouncycastle.util.encoders.Hex;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.Test;

import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.web.reactive.AutoConfigureWebTestClient;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.boot.test.context.TestConfiguration;
import org.springframework.cloud.gateway.filter.GatewayFilter;
import org.springframework.cloud.gateway.route.RouteLocator;
import org.springframework.cloud.gateway.route.builder.RouteLocatorBuilder;
import org.springframework.context.annotation.Bean;
import org.springframework.http.HttpStatus;
import org.springframework.test.web.reactive.server.WebTestClient;

import static com.example.demo.RequestHashingGatewayFilterFactory.*;
import static com.example.demo.RequestHashingGatewayFilterFactoryTest.*;
import static com.github.tomakehurst.wiremock.client.WireMock.equalTo;
import static com.github.tomakehurst.wiremock.client.WireMock.postRequestedFor;
import static com.github.tomakehurst.wiremock.client.WireMock.urlEqualTo;
import static com.github.tomakehurst.wiremock.core.WireMockConfiguration.wireMockConfig;
import static org.springframework.boot.test.context.SpringBootTest.WebEnvironment.RANDOM_PORT;

@SpringBootTest(
        webEnvironment = RANDOM_PORT,
        classes = RequestHashingFilterTestConfig.class)
@AutoConfigureWebTestClient
class RequestHashingGatewayFilterFactoryTest {

    @TestConfiguration
    static class RequestHashingFilterTestConfig {

        @Autowired
        RequestHashingGatewayFilterFactory requestHashingGatewayFilter;

        @Bean(destroyMethod = "stop")
        WireMockServer wireMockServer() {
            WireMockConfiguration options = wireMockConfig().dynamicPort();
            WireMockServer wireMock = new WireMockServer(options);
            wireMock.start();
            return wireMock;
        }

        @Bean
        RouteLocator testRoutes(RouteLocatorBuilder builder, WireMockServer wireMock)
                throws NoSuchAlgorithmException {
            Config config = new Config();
            config.setAlgorithm("SHA-512");

            GatewayFilter gatewayFilter = requestHashingGatewayFilter.apply(config);
            return builder
                    .routes()
                    .route(predicateSpec -> predicateSpec
                            .path("/post")
                            .filters(spec -> spec.filter(gatewayFilter))
                            .uri(wireMock.baseUrl()))
                    .build();
        }
    }

    @Autowired
    WebTestClient webTestClient;

    @Autowired
    WireMockServer wireMockServer;

    @AfterEach
    void afterEach() {
        wireMockServer.resetAll();
    }

    @Test
    void shouldAddHeaderWithComputedHash() {
        MessageDigest messageDigest = new SHA512.Digest();
        String body = "hello world";
        String expectedHash = Hex.toHexString(messageDigest.digest(body.getBytes()));

        wireMockServer.stubFor(WireMock.post("/post").willReturn(WireMock.ok()));

        webTestClient.post().uri("/post")
                .bodyValue(body)
                .exchange()
                .expectStatus()
                .isEqualTo(HttpStatus.OK);

        wireMockServer.verify(postRequestedFor(urlEqualTo("/post"))
                .withHeader("X-Hash", equalTo(expectedHash)));
    }

    @Test
    void shouldNotAddHeaderIfNoBody() {
        wireMockServer.stubFor(WireMock.post("/post").willReturn(WireMock.ok()));

        webTestClient.post().uri("/post")
                .exchange()
                .expectStatus()
                .isEqualTo(HttpStatus.OK);

        wireMockServer.verify(postRequestedFor(urlEqualTo("/post"))
                .withoutHeader("X-Hash"));
    }
}

為了在我們的閘道中使用篩選器,我們將 RequestHashing 篩選器新增到 application.yaml 中的路由,使用 SHA-256 作為演算法

spring:
  cloud:
    gateway:
      routes:
        - id: demo
          uri: https://httpbin.org
          predicates:
            - Path=/post/**
          filters:
            - RequestHashing=SHA-256

我們使用 https://httpbin.org,因為它在其傳回的回應中顯示我們的請求標頭。執行應用程式並發出 curl 請求以查看結果

$> curl --request POST 'https://127.0.0.1:8080/post' \
--header 'Content-Type: application/json' \
--data-raw '{
    "data": {
        "hello": "world"
    }
}'

{
  ...
  "data": "{\n    \"data\": {\n        \"hello\": \"world\"\n    }\n}",
  "headers": {
        "Accept": "*/*",
        "Accept-Encoding": "gzip, deflate, br",
        "Content-Length": "48",
        "Content-Type": "application/json",
        "Forwarded": "proto=http;host=\"localhost:8080\";for=\"[0:0:0:0:0:0:0:1]:55647\"",
        "Host": "httpbin.org",
        "User-Agent": "PostmanRuntime/7.29.0",
        "X-Forwarded-Host": "localhost:8080",
        "X-Hash": "1bd93d38735501b5aec7a822f8bc8136d9f1f71a30c2020511bdd5df379772b8"
    },
  ...
}

總之,我們看到了如何為 Spring Cloud Gateway 撰寫自訂擴充功能。我們的篩選器讀取請求的 body 以產生雜湊值,我們將其新增為請求標頭。我們還使用 WireMock 為篩選器編寫了測試,以檢查標頭值。最後,我們執行了一個包含篩選器的閘道,以驗證結果。

如果您計劃在 Kubernetes 叢集上部署 Spring Cloud Gateway,請務必查看 VMware Spring Cloud Gateway for Kubernetes。除了支援開放原始碼 Spring Cloud Gateway 篩選器和自訂篩選器(例如我們上面撰寫的篩選器)之外,它還附帶 更多內建篩選器 來操作您的請求和回應。Spring Cloud Gateway for Kubernetes 代表 API 開發團隊處理跨領域問題,例如:單一登入 (SSO)、存取控制、速率限制、彈性、安全性等等。

取得 Spring 電子報

隨時掌握 Spring 電子報的最新資訊

訂閱

領先一步

VMware 提供培訓和認證,以加速您的進展。

了解更多

取得支援

Tanzu Spring 在一個簡單的訂閱中提供 OpenJDK™、Spring 和 Apache Tomcat® 的支援和二進位檔案。

了解更多

即將舉辦的活動

查看 Spring 社群中所有即將舉辦的活動。

檢視全部