1. 概述
在本教程中,我們將學習如何編寫自定義 Spring Cloud Gateway 過濾器。
我們在之前的文章《探索新的 Spring Cloud Gateway》中介紹了這個框架,並對許多內置過濾器進行了瞭解。
在本場合,我們將深入探討,編寫自定義過濾器以充分發揮我們的 API Gateway 的作用。
首先,我們將瞭解如何創建全局過濾器,這些過濾器將影響 Gateway 處理的每個請求。然後,我們將編寫 Gateway 過濾器工廠,這些工廠可以針對特定路由和請求進行精細應用。
最後,我們將處理更高級的場景,學習如何修改請求或響應,甚至如何以反應式方式將請求與調用其他服務鏈接起來。
2. 項目設置
我們將首先設置一個基本的應用程序,該應用程序將用作我們的 API 網關。
2.1. Maven 配置
在使用 Spring Cloud 庫時,設置依賴管理配置以管理依賴項通常是一個不錯的選擇:
<dependencyManagement>
<dependencies>
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-dependencies</artifactId>
<version>2023.0.0</version>
<type>pom</type>
<scope>import</scope>
</dependency>
</dependencies>
</dependencyManagement>現在我們可以添加我們的 Spring Cloud 庫,而無需指定我們實際使用的版本。
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-starter-gateway</artifactId>
</dependency>最新 Spring Cloud Release Train 版本 可以通過 Maven Central 搜索引擎找到。當然,我們應該始終檢查該版本是否與我們正在使用的 Spring Boot 版本兼容,請參考 Spring Cloud 文檔。
2.2 API 網關配置
假設本地運行着一個應用程序,該應用程序在端口 8081 上提供一個資源(為了簡化起見,只是一個簡單的 String),當訪問 /resource 時。
考慮到這一點,我們將配置網關以將請求轉發到該服務。 簡單來説,當我們通過帶有 /service 前綴的 URI 路徑向網關發送請求時,我們將將調用轉發到該服務。
因此,當我們通過網關調用 /service/resource 時,我們應該收到 String 響應。
要實現這一點,我們將使用 應用程序屬性 配置此路由:
spring:
cloud:
gateway:
routes:
- id: service_route
uri: http://localhost:8081
predicates:
- Path=/service/**
filters:
- RewritePath=/service(?<segment>/?.*), $\{segment}為了能夠正確追蹤網關進程,我們還將啓用一些日誌:
logging:
level:
org.springframework.cloud.gateway: DEBUG
reactor.netty.http.client: DEBUG3. 創建全局過濾器
一旦網關處理程序確定請求與路由匹配,框架會將請求傳遞給過濾器鏈。這些過濾器可以執行邏輯,在請求發送之前或之後。
在本節中,我們將開始編寫簡單的全局過濾器。這意味着它們將影響每個單獨的請求。
首先,我們將看到如何執行在發送代理請求之前執行的邏輯(也稱為“預”過濾器)。
3.1. 編寫全局“預”過濾邏輯
正如我們所説,目前我們將創建簡單的過濾器,因為主要目標只是確保過濾器在正確的時間被執行;僅僅記錄一條簡單的消息就能達到目的。
要創建自定義全局過濾器,只需實現 Spring Cloud Gateway 的 GlobalFilter 接口,並將它作為 Bean 添加到上下文中即可。
@Component
public class LoggingGlobalPreFilter implements GlobalFilter {
final Logger logger =
LoggerFactory.getLogger(LoggingGlobalPreFilter.class);
@Override
public Mono<Void> filter(
ServerWebExchange exchange,
GatewayFilterChain chain) {
logger.info("Global Pre Filter executed");
return chain.filter(exchange);
}
}我們可以很容易地看到這裏發生了什麼;一旦這個過濾器被調用,我們將記錄一條消息,並繼續執行過濾器鏈。
現在,讓我們定義一個“後置”過濾器,如果對 Reactive 編程模型和 Spring Webflux API 不熟悉,可能會比較複雜。
3.2. 全局“Post”過濾器邏輯
需要注意的是,我們剛才定義的全局過濾器定義了一個 GlobalFilter 接口,其中只定義了一個方法。因此,它可以被表示為 lambda 表達式,從而方便我們定義過濾器。
例如,我們可以將“post”過濾器定義在配置類中:
@Configuration
public class LoggingGlobalFiltersConfigurations {
final Logger logger =
LoggerFactory.getLogger(
LoggingGlobalFiltersConfigurations.class);
@Bean
public GlobalFilter postGlobalFilter() {
return (exchange, chain) -> {
return chain.filter(exchange)
.then(Mono.fromRunnable(() -> {
logger.info("Global Post Filter executed");
}));
};
}
}簡單來説,我們在這裏在鏈式執行完成後運行一個新的 Mono 實例。
現在讓我們通過在我們的網關服務中調用 /service/resource URL,並查看日誌控制枱來嘗試一下。
DEBUG --- o.s.c.g.h.RoutePredicateHandlerMapping:
Route matched: service_route
DEBUG --- o.s.c.g.h.RoutePredicateHandlerMapping:
Mapping [Exchange: GET http://localhost/service/resource]
to Route{id='service_route', uri=http://localhost:8081, order=0, predicate=Paths: [/service/**],
match trailing slash: true, gatewayFilters=[[[RewritePath /service(?<segment>/?.*) = '${segment}'], order = 1]]}
INFO --- c.b.s.c.f.global.LoggingGlobalPreFilter:
Global Pre Filter executed
DEBUG --- r.netty.http.client.HttpClientConnect:
[id: 0x58f7e075, L:/127.0.0.1:57215 - R:localhost/127.0.0.1:8081]
Handler is being applied: {uri=http://localhost:8081/resource, method=GET}
DEBUG --- r.n.http.client.HttpClientOperations:
[id: 0x58f7e075, L:/127.0.0.1:57215 - R:localhost/127.0.0.1:8081]
Received response (auto-read:false) : [Content-Type=text/html;charset=UTF-8, Content-Length=16]
INFO --- c.f.g.LoggingGlobalFiltersConfigurations:
Global Post Filter executed
DEBUG --- r.n.http.client.HttpClientOperations:
[id: 0x58f7e075, L:/127.0.0.1:57215 - R:localhost/127.0.0.1:8081] Received last HTTP packet如我們所見,過濾器在請求轉發到服務之前和之後都有效地執行。
當然,可以將“前”和“後”邏輯合併到一個過濾器中:
@Component
public class FirstPreLastPostGlobalFilter
implements GlobalFilter, Ordered {
final Logger logger =
LoggerFactory.getLogger(FirstPreLastPostGlobalFilter.class);
@Override
public Mono<Void> filter(ServerWebExchange exchange,
GatewayFilterChain chain) {
logger.info("First Pre Global Filter");
return chain.filter(exchange)
.then(Mono.fromRunnable(() -> {
logger.info("Last Post Global Filter");
}));
}
@Override
public int getOrder() {
return -1;
}
}請注意,我們還可以實現Ordered接口,如果需要對過濾器在鏈中的位置進行管理。
由於過濾器鏈的特性,優先級較低的過濾器(在鏈中順序較低)將在更早的階段執行其“pre”邏輯,但其“post”實現將會在稍後的階段被調用。
4. 創建 GatewayFilter 實例
全局過濾器非常有用,但我們經常需要執行精細的自定義 Gateway 過濾器操作,這些操作僅應用於某些路由。
4.1. 定義 GatewayFilterFactory
為了實現 GatewayFilter,我們需要實現 GatewayFilterFactory 接口。Spring Cloud Gateway 還提供了一個抽象類來簡化流程,即 AbstractGatewayFilterFactory 類。
@Component
public class LoggingGatewayFilterFactory extends
AbstractGatewayFilterFactory<LoggingGatewayFilterFactory.Config> {
final Logger logger =
LoggerFactory.getLogger(LoggingGatewayFilterFactory.class);
public LoggingGatewayFilterFactory() {
super(Config.class);
}
@Override
public GatewayFilter apply(Config config) {
// ...
}
public static class Config {
// ...
}
}我們已經定義了 GatewayFilterFactory 的基本結構。 我們將使用 Config 類來定製我們的過濾器,在初始化時。
例如,在這種情況下,我們可以定義配置中的三個基本字段:
public static class Config {
private String baseMessage;
private boolean preLogger;
private boolean postLogger;
// contructors, getters and setters...
}簡單來説,這些字段是:
- 一個自定義消息,將在日誌條目中包含
- 一個標誌,指示過濾器是否在轉發請求之前進行日誌記錄
- 一個標誌,指示過濾器是否在接收到代理服務響應後進行日誌記錄
現在我們可以使用這些配置來檢索一個 GatewayFilter 實例,再次説明,它可以表示為一個 lambda 函數:
@Override
public GatewayFilter apply(Config config) {
return (exchange, chain) -> {
// Pre-processing
if (config.isPreLogger()) {
logger.info("Pre GatewayFilter logging: "
+ config.getBaseMessage());
}
return chain.filter(exchange)
.then(Mono.fromRunnable(() -> {
// Post-processing
if (config.isPostLogger()) {
logger.info("Post GatewayFilter logging: "
+ config.getBaseMessage());
}
}));
};
}4.2. 註冊 GatewayFilter 的屬性
現在,我們可以輕鬆地將我們的過濾器註冊到我們之前在應用程序屬性中定義的路由中:
...
filters:
- RewritePath=/service(?<segment>/?.*), $\{segment}
- name: Logging
args:
baseMessage: My Custom Message
preLogger: true
postLogger: true我們只需指示配置參數。這裏的一個重要點是,為了使這種方法正常工作,我們需要在 LoggingGatewayFilterFactory.Config 類中配置無參數構造函數和設置器。
如果我們想使用緊湊的語法來配置過濾器,則可以這樣做:
filters:
- RewritePath=/service(?<segment>/?.*), $\{segment}
- Logging=My Custom Message, true, true我們需要對工廠進行一些微調。簡而言之,我們需要覆蓋 shortcutFieldOrder 方法,以指示快捷屬性將使用哪些順序和參數數量:
@Override
public List<String> shortcutFieldOrder() {
return Arrays.asList("baseMessage",
"preLogger",
"postLogger");
}4.3. 指定 GatewayFilter</h3
如果我們希望配置過濾器在過濾器鏈中的位置,可以從 AbstractGatewayFilterFactory#apply方法中檢索一個 OrderedGatewayFilter實例,而不是簡單的 lambda 表達式:
@Override
public GatewayFilter apply(Config config) {
return new OrderedGatewayFilter((exchange, chain) -> {
// ...
}, 1);
}4.4. 手動註冊 GatewayFilter 過濾器程序化
此外,我們也可以通過程序化方式註冊我們的過濾器。 讓我們重新定義我們之前使用的路由,這次通過設置一個 RouteLocator Bean:
@Bean
public RouteLocator routes(
RouteLocatorBuilder builder,
LoggingGatewayFilterFactory loggingFactory) {
return builder.routes()
.route("service_route_java_config", r -> r.path("/service/**")
.filters(f ->
f.rewritePath("/service(?<segment>/?.*)", "$\\{segment}")
.filter(loggingFactory.apply(
new Config("My Custom Message", true, true))))
.uri("http://localhost:8081"))
.build();
}5. 高級場景
我們之前一直只是在網關流程的不同階段記錄一條消息。
通常,我們需要過濾器提供更高級的功能。例如,我們可能需要檢查或修改接收到的請求,修改獲取到的響應,甚至將反應式流與調用其他不同服務的調用鏈接起來。
接下來,我們將看到這些不同場景的示例。
5.1. 檢查和修改請求
設想一個假設場景。我們的服務以前基於 locale查詢參數來提供內容。後來,我們更改了 API 以使用 Accept-Language標頭,但仍有一些客户端使用查詢參數。
因此,我們希望配置網關以遵循以下邏輯:
- 如果收到 Accept-Language標頭,則保持該標頭
- 否則,使用 locale查詢參數的值
- 如果該值不存在,則使用默認 locale
- 最後,移除 locale查詢參數
注意:為了簡化此處的情況,我們將僅關注過濾邏輯;要查看整個實現,請在教程末尾找到代碼庫鏈接。
(exchange, chain) -> {
if (exchange.getRequest()
.getHeaders()
.getAcceptLanguage()
.isEmpty()) {
// populate the Accept-Language header...
}
// remove the query param...
return chain.filter(exchange);
};我們正在處理邏輯的第一方面。我們可以看到,檢查 <em >ServerHttpRequest</em > 對象非常簡單。 在這一步,我們僅訪問了它的頭部,但正如我們稍後看到的,我們可以同樣輕鬆地獲取其他屬性:
String queryParamLocale = exchange.getRequest()
.getQueryParams()
.getFirst("locale");
Locale requestLocale = Optional.ofNullable(queryParamLocale)
.map(l -> Locale.forLanguageTag(l))
.orElse(config.getDefaultLocale());現在我們已經討論了行為的這兩個關鍵點。但我們尚未修改請求本身。為此,我們需要利用<em>mutate</em>功能。
通過這樣做,框架將為實體創建一個<em>裝飾器</em>,保持原始對象不變。
修改頭部信息很簡單,因為我們可以獲取對<em>HttpHeaders</em>映射對象的引用:
exchange.getRequest()
.mutate()
.headers(h -> h.setAcceptLanguageAsLocales(
Collections.singletonList(requestLocale)))但是,另一方面,修改URI並非易事。 我們需要從原始的 exchange 對象中獲取一個新的 ServerWebExchange 實例,並修改原始的 ServerHttpRequest 實例:
ServerWebExchange modifiedExchange = exchange.mutate()
// Here we'll modify the original request:
.request(originalRequest -> originalRequest)
.build();
return chain.filter(modifiedExchange);現在是時候通過刪除查詢參數來更新原始請求 URI:
originalRequest -> originalRequest.uri(
UriComponentsBuilder.fromUri(exchange.getRequest()
.getURI())
.replaceQueryParams(new LinkedMultiValueMap<String, String>())
.build()
.toUri())我們已經完成了,現在可以嘗試一下。在代碼庫中,我們在調用下一個過濾器鏈之前添加了日誌條目,以便查看請求中實際發送的內容。
5.2. 修改響應
繼續採用相同的案例場景,現在我們將定義一個“post”過濾器。 我們的虛擬服務原本會檢索一個自定義標題,用於指示它最終選擇的語言,而不是使用標準的 Content-Language 標題。
因此,我們希望新的過濾器添加此響應標題,但前提是請求包含我們在上一部分中引入的 locale 標題。
(exchange, chain) -> {
return chain.filter(exchange)
.then(Mono.fromRunnable(() -> {
ServerHttpResponse response = exchange.getResponse();
Optional.ofNullable(exchange.getRequest()
.getQueryParams()
.getFirst("locale"))
.ifPresent(qp -> {
String responseContentLanguage = response.getHeaders()
.getContentLanguage()
.getLanguage();
response.getHeaders()
.add("Bael-Custom-Language-Header", responseContentLanguage);
});
}));
}我們可以輕鬆獲取響應對象的引用,並且無需複製它以進行修改,就像處理請求一樣。
這是一個很好的例子,説明了過濾器鏈中順序的重要性。如果我們將此過濾器的執行配置為在我們在上一部分創建的過濾器之後,那麼此處出現的 exchange 對象將包含指向沒有查詢參數的 ServerHttpRequest 的引用。
即使這種過濾器的執行實際上在所有“預”過濾器執行之後,我們仍然擁有原始請求的引用,這要歸功於 mutate 邏輯。
5.3. 將請求鏈接到其他服務
下一步是在我們假設的場景中,依賴第三方服務來指示應使用哪個 Accept-Language 標頭。
因此,我們將創建一個新的過濾器,該過濾器將調用該服務,並將其響應正文作為代理服務 API 的請求標頭。
在反應式環境中,這意味着鏈接請求以避免阻塞異步執行。
在我們的過濾器中,我們首先將向語言服務發出請求:
(exchange, chain) -> {
return WebClient.create().get()
.uri(config.getLanguageEndpoint())
.exchange()
// ...
}請注意,我們返回了此流暢操作,因為正如我們所説,我們將調用結果與我們的代理請求鏈接起來。
下一步是提取語言——無論是從響應體還是從未成功的配置中提取,然後解析它:
// ...
.flatMap(response -> {
return (response.statusCode()
.is2xxSuccessful()) ? response.bodyToMono(String.class) : Mono.just(config.getDefaultLanguage());
}).map(LanguageRange::parse)
// ...最後,我們將設置 LanguageRange 的值作為請求頭,就像之前一樣,並繼續過濾鏈:
.map(range -> {
exchange.getRequest()
.mutate()
.headers(h -> h.setAcceptLanguage(range))
.build();
return exchange;
}).flatMap(chain::filter);現在就結束了,後續交互將採用非阻塞方式進行。
6. 結論
現在我們已經學習瞭如何編寫自定義 Spring Cloud Gateway 過濾器,並瞭解瞭如何操作請求和響應實體,我們已經準備好充分利用這個框架。