Schreiben von benutzerdefinierten Spring Cloud Gateway-Filtern

1. Übersicht

In diesem Tutorial erfahren Sie, wie Sie benutzerdefinierte Spring Cloud Gateway-Filter schreiben.

Wir haben dieses Framework in unserem vorherigen Beitrag "Exploring the New Spring Cloud Gateway" vorgestellt, in dem wir uns viele integrierte Filter angesehen haben.

Bei dieser Gelegenheit werden wir tiefer gehen und benutzerdefinierte Filter schreiben, um unser API-Gateway optimal zu nutzen.

Zunächst werden wir sehen, wie wir globale Filter erstellen können, die sich auf jede einzelne Anforderung auswirken, die vom Gateway verarbeitet wird. Anschließend schreiben wir Gateway-Filterfabriken, die detailliert auf bestimmte Routen und Anforderungen angewendet werden können.

Schließlich werden wir an fortgeschritteneren Szenarien arbeiten und lernen, wie Sie die Anforderung oder die Antwort ändern und sogar die Anforderung reaktiv mit Aufrufen an andere Dienste verketten.

2. Projekteinrichtung

Zunächst richten wir eine Basisanwendung ein, die wir als API-Gateway verwenden.

2.1. Maven-Konfiguration

Wenn Sie mit Spring Cloud-Bibliotheken arbeiten, ist es immer eine gute Wahl, eine Abhängigkeitsverwaltungskonfiguration einzurichten, um die Abhängigkeiten für uns zu behandeln:

   org.springframework.cloud spring-cloud-dependencies Hoxton.SR4 pom import   

Jetzt können wir unsere Spring Cloud-Bibliotheken hinzufügen, ohne die tatsächliche Version anzugeben, die wir verwenden:

 org.springframework.cloud spring-cloud-starter-gateway 

Die neueste Version von Spring Cloud Release Train kann über die Suchmaschine von Maven Central gefunden werden. Natürlich sollten wir immer überprüfen, ob die Version mit der Spring Boot-Version kompatibel ist, die wir in der Spring Cloud-Dokumentation verwenden.

2.2. API-Gateway-Konfiguration

Wir gehen davon aus , dass in Port 8081 eine zweite Anwendung lokal ausgeführt wird , die eine Ressource (der Einfachheit halber nur eine einfache Zeichenfolge ) beim Drücken von / resource verfügbar macht .

Vor diesem Hintergrund konfigurieren wir unser Gateway für Proxy-Anforderungen an diesen Dienst. Kurz gesagt, wenn wir eine Anfrage mit einem / service- Präfix im URI-Pfad an das Gateway senden, leiten wir den Anruf an diesen Service weiter.

Wenn wir also / service / resource in unserem Gateway aufrufen , sollten wir die String- Antwort erhalten.

Um dies zu erreichen, konfigurieren wir diese Route mithilfe der Anwendungseigenschaften :

spring: cloud: gateway: routes: - id: service_route uri: //localhost:8081 predicates: - Path=/service/** filters: - RewritePath=/service(?/?.*), $\{segment}

Um den Gateway-Prozess ordnungsgemäß verfolgen zu können, aktivieren wir außerdem einige Protokolle:

logging: level: org.springframework.cloud.gateway: DEBUG reactor.netty.http.client: DEBUG

3. Globale Filter erstellen

Sobald der Gateway-Handler feststellt, dass eine Anforderung mit einer Route übereinstimmt, leitet das Framework die Anforderung durch eine Filterkette. Diese Filter können Logik ausführen, bevor die Anforderung gesendet wird oder danach.

In diesem Abschnitt schreiben wir zunächst einfache globale Filter. Das bedeutet, dass es jede einzelne Anfrage betrifft.

Zuerst werden wir sehen, wie wir die Logik ausführen können, bevor die Proxy-Anfrage gesendet wird (auch als "Pre" -Filter bekannt).

3.1. Schreiben einer globalen "Pre" -Filterlogik

Wie gesagt, wir werden an dieser Stelle einfache Filter erstellen, da das Hauptziel hier nur darin besteht, zu sehen, dass der Filter tatsächlich zum richtigen Zeitpunkt ausgeführt wird. Nur eine einfache Nachricht zu protokollieren reicht aus.

Um einen benutzerdefinierten globalen Filter zu erstellen, müssen Sie lediglich die Spring Cloud Gateway GlobalFilter- Schnittstelle implementieren und als Bean zum Kontext hinzufügen:

@Component public class LoggingGlobalPreFilter implements GlobalFilter { final Logger logger = LoggerFactory.getLogger(LoggingGlobalPreFilter.class); @Override public Mono filter( ServerWebExchange exchange, GatewayFilterChain chain) { logger.info("Global Pre Filter executed"); return chain.filter(exchange); } }

Wir können leicht sehen, was hier los ist; Sobald dieser Filter aufgerufen wird, protokollieren wir eine Nachricht und fahren mit der Ausführung der Filterkette fort.

Definieren wir nun einen "Post" -Filter, der etwas schwieriger sein kann, wenn wir nicht mit dem reaktiven Programmiermodell und der Spring Webflux-API vertraut sind.

3.2. Schreiben einer globalen "Post" -Filterlogik

Eine andere Sache, die Sie bei dem soeben definierten globalen Filter beachten sollten, ist, dass die GlobalFilter- Schnittstelle nur eine Methode definiert. Somit kann es als Lambda-Ausdruck ausgedrückt werden, wodurch wir Filter bequem definieren können.

Zum Beispiel können wir unseren "Post" -Filter in einer Konfigurationsklasse definieren:

@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"); })); }; } }

Einfach ausgedrückt, hier führen wir eine neue Mono- Instanz aus, nachdem die Kette ihre Ausführung abgeschlossen hat.

Probieren Sie es jetzt aus, indem Sie die URL / service / resource in unserem Gateway-Service aufrufen und die Protokollkonsole überprüfen:

DEBUG --- o.s.c.g.h.RoutePredicateHandlerMapping: Route matched: service_route DEBUG --- o.s.c.g.h.RoutePredicateHandlerMapping: Mapping [Exchange: GET //localhost/service/resource] to Route{id='service_route', uri=//localhost:8081, order=0, predicate=Paths: [/service/**], match trailing slash: true, gatewayFilters=[[[RewritePath /service(?/?.*) = '${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=//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

Wie wir sehen können, werden die Filter effektiv ausgeführt, bevor und nachdem das Gateway die Anforderung an den Dienst weiterleitet.

Natürlich können wir die Logik „vor“ und „nach“ in einem einzigen Filter kombinieren:

@Component public class FirstPreLastPostGlobalFilter implements GlobalFilter, Ordered { final Logger logger = LoggerFactory.getLogger(FirstPreLastPostGlobalFilter.class); @Override public Mono 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; } }

Beachten Sie, dass wir die geordnete Schnittstelle auch implementieren können, wenn wir uns um die Platzierung des Filters in der Kette kümmern.

Due to the nature of the filter chain, a filter with lower precedence (a lower order in the chain) will execute its “pre” logic in an earlier stage, but it's “post” implementation will get invoked later:

4. Creating GatewayFilters

Global filters are quite useful, but we often need to execute fine-grained custom Gateway filter operations that apply to only some routes.

4.1. Defining the GatewayFilterFactory

In order to implement a GatewayFilter, we'll have to implement the GatewayFilterFactory interface. Spring Cloud Gateway also provides an abstract class to simplify the process, the AbstractGatewayFilterFactory class:

@Component public class LoggingGatewayFilterFactory extends AbstractGatewayFilterFactory { final Logger logger = LoggerFactory.getLogger(LoggingGatewayFilterFactory.class); public LoggingGatewayFilterFactory() { super(Config.class); } @Override public GatewayFilter apply(Config config) { // ... } public static class Config { // ... } }

Here we've defined the basic structure of our GatewayFilterFactory. We'll use a Config class to customize our filter when we initialize it.

In this case, for example, we can define three basic fields in our configuration:

public static class Config { private String baseMessage; private boolean preLogger; private boolean postLogger; // contructors, getters and setters... }

Simply put, these fields are:

  1. a custom message that will be included in the log entry
  2. a flag indicating if the filter should log before forwarding the request
  3. a flag indicating if the filter should log after receiving the response from the proxied service

And now we can use these configurations to retrieve a GatewayFilter instance, which again, can be represented with a lambda function:

@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. Registering the GatewayFilter with Properties

We can now easily register our filter to the route we defined previously in the application properties:

... filters: - RewritePath=/service(?/?.*), $\{segment} - name: Logging args: baseMessage: My Custom Message preLogger: true postLogger: true

We simply have to indicate the configuration arguments. An important point here is that we need a no-argument constructor and setters configured in our LoggingGatewayFilterFactory.Config class for this approach to work properly.

If we want to configure the filter using the compact notation instead, then we can do:

filters: - RewritePath=/service(?/?.*), $\{segment} - Logging=My Custom Message, true, true

We'll need to tweak our factory a little bit more. In short, we have to override the shortcutFieldOrder method, to indicate the order and how many arguments the shortcut property will use:

@Override public List shortcutFieldOrder() { return Arrays.asList("baseMessage", "preLogger", "postLogger"); }

4.3. Ordering the GatewayFilter

If we want to configure the position of the filter in the filter chain, we can retrieve an OrderedGatewayFilter instance from the AbstractGatewayFilterFactory#apply method instead of a plain lambda expression:

@Override public GatewayFilter apply(Config config) { return new OrderedGatewayFilter((exchange, chain) -> { // ... }, 1); }

4.4. Registering the GatewayFilter Programmatically

Furthermore, we can register our filter programmatically, too. Let's redefine the route we've been using, this time by setting up a 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}") .filter(loggingFactory.apply( new Config("My Custom Message", true, true)))) .uri("//localhost:8081")) .build(); }

5. Advanced Scenarios

So far, all we've been doing is logging a message at different stages of the gateway process.

Usually, we need our filters to provide more advanced functionality. For instance, we may need to check or manipulate the request we received, modify the response we're retrieving, or even chain the reactive stream with calls to other different services.

Next, we'll see examples of these different scenarios.

5.1. Checking and Modifying the Request

Let's imagine a hypothetical scenario. Our service used to serve its content based on a locale query parameter. Then, we changed the API to use the Accept-Language header instead, but some clients are still using the query parameter.

Thus, we want to configure the gateway to normalize following this logic:

  1. if we receive the Accept-Language header, we want to keep that
  2. otherwise, use the locale query parameter value
  3. if that's not present either, use a default locale
  4. finally, we want to remove the locale query param

Note: To keep things simple here, we'll focus only on the filter logic; to have a look at the whole implementation we'll find a link to the codebase at the end of the tutorial.

Let's configure our gateway filter as a “pre” filter then:

(exchange, chain) -> { if (exchange.getRequest() .getHeaders() .getAcceptLanguage() .isEmpty()) { // populate the Accept-Language header... } // remove the query param... return chain.filter(exchange); };

Here we're taking care of the first aspect of the logic. We can see that inspecting the ServerHttpRequest object is really simple. At this point, we accessed only its headers, but as we'll see next, we can obtain other attributes just as easily:

String queryParamLocale = exchange.getRequest() .getQueryParams() .getFirst("locale"); Locale requestLocale = Optional.ofNullable(queryParamLocale) .map(l -> Locale.forLanguageTag(l)) .orElse(config.getDefaultLocale());

Now we've covered the next two points of the behavior. But we haven't modified the request, yet. For this, we'll have to make use of the mutate capability.

With this, the framework will be creating a Decorator of the entity, maintaining the original object unchanged.

Modifying the headers is simple because we can obtain a reference to the HttpHeaders map object:

exchange.getRequest() .mutate() .headers(h -> h.setAcceptLanguageAsLocales( Collections.singletonList(requestLocale)))

But, on the other hand, modifying the URI is not a trivial task.

We'll have to obtain a new ServerWebExchange instance from the original exchange object, modifying the original ServerHttpRequest instance:

ServerWebExchange modifiedExchange = exchange.mutate() // Here we'll modify the original request: .request(originalRequest -> originalRequest) .build(); return chain.filter(modifiedExchange);

Now it's time to update the original request URI by removing the query params:

originalRequest -> originalRequest.uri( UriComponentsBuilder.fromUri(exchange.getRequest() .getURI()) .replaceQueryParams(new LinkedMultiValueMap()) .build() .toUri())

There we go, we can try it out now. In the codebase, we added log entries before calling the next chain filter to see exactly what is getting sent in the request.

5.2. Modifying the Response

Proceeding with the same case scenario, we'll define a “post” filter now. Our imaginary service used to retrieve a custom header to indicate the language it finally chose instead of using the conventional Content-Language header.

Hence, we want our new filter to add this response header, but only if the request contains the locale header we introduced in the previous section.

(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); }); })); }

We can obtain a reference to the response object easily, and we don't need to create a copy of it to modify it, as with the request.

This is a good example of the importance of the order of the filters in the chain; if we configure the execution of this filter after the one we created in the previous section, then the exchange object here will contain a reference to a ServerHttpRequest that will never have any query param.

It doesn't even matter that this is effectively triggered after the execution of all the “pre” filters because we still have a reference to the original request, thanks to the mutate logic.

5.3. Chaining Requests to Other Services

The next step in our hypothetical scenario is relying on a third service to indicate which Accept-Language header we should use.

Thus, we'll create a new filter which makes a call to this service, and uses its response body as the request header for the proxied service API.

In a reactive environment, this means chaining requests to avoid blocking the async execution.

In our filter, we'll start by making the request to the language service:

(exchange, chain) -> { return WebClient.create().get() .uri(config.getLanguageEndpoint()) .exchange() // ... }

Notice we're returning this fluent operation, because, as we said, we'll chain the output of the call with our proxied request.

The next step will be to extract the language – either from the response body or from the configuration if the response was not successful – and parse it:

// ... .flatMap(response -> { return (response.statusCode() .is2xxSuccessful()) ? response.bodyToMono(String.class) : Mono.just(config.getDefaultLanguage()); }).map(LanguageRange::parse) // ...

Finally, we'll set the LanguageRange value as the request header as we did before, and continue the filter chain:

.map(range -> { exchange.getRequest() .mutate() .headers(h -> h.setAcceptLanguage(range)) .build(); return exchange; }).flatMap(chain::filter);

That's it, now the interaction will be carried out in a non-blocking manner.

6. Conclusion

Nachdem wir gelernt haben, benutzerdefinierte Spring Cloud Gateway-Filter zu schreiben und die Anforderungs- und Antwortentitäten zu bearbeiten, können wir dieses Framework optimal nutzen.

Wie immer finden Sie alle vollständigen Beispiele auf GitHub. Bitte denken Sie daran, dass wir zum Testen Integrations- und Live-Tests über Maven ausführen müssen.