Scrittura di filtri Spring Cloud Gateway personalizzati

1. Panoramica

In questo tutorial impareremo come scrivere filtri Spring Cloud Gateway personalizzati.

Abbiamo introdotto questo framework nel nostro post precedente, Exploring the New Spring Cloud Gateway, dove abbiamo dato uno sguardo a molti filtri integrati.

In questa occasione andremo più in profondità, scriveremo filtri personalizzati per ottenere il massimo dal nostro gateway API.

Per prima cosa, vedremo come creare filtri globali che influenzeranno ogni singola richiesta gestita dal gateway. Quindi, scriveremo factory di filtri gateway, che possono essere applicati in modo granulare a particolari route e richieste.

Infine, lavoreremo su scenari più avanzati, imparando come modificare la richiesta o la risposta e persino come concatenare la richiesta con le chiamate ad altri servizi, in modo reattivo.

2. Configurazione del progetto

Inizieremo configurando un'applicazione di base che utilizzeremo come nostro gateway API.

2.1. Configurazione Maven

Quando si lavora con le librerie Spring Cloud, è sempre una buona scelta impostare una configurazione di gestione delle dipendenze per gestire le dipendenze per noi:

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

Ora possiamo aggiungere le nostre librerie Spring Cloud senza specificare la versione effettiva che stiamo utilizzando:

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

L'ultima versione di Spring Cloud Release Train può essere trovata utilizzando il motore di ricerca Maven Central. Ovviamente, dovremmo sempre verificare che la versione sia compatibile con la versione Spring Boot che stiamo utilizzando nella documentazione di Spring Cloud.

2.2. Configurazione del gateway API

Supponiamo che ci sia una seconda applicazione in esecuzione localmente nella porta 8081 , che espone una risorsa (per semplicità, solo una semplice stringa ) quando si preme / resource .

Con questo in mente, configureremo il nostro gateway per le richieste proxy a questo servizio. In poche parole, quando inviamo una richiesta al gateway con un prefisso / service nel percorso URI, inoltreremo la chiamata a questo servizio.

Quindi, quando chiamiamo / service / resource nel nostro gateway, dovremmo ricevere la risposta String .

Per ottenere ciò, configureremo questo percorso utilizzando le proprietà dell'applicazione :

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

Inoltre, per poter tracciare correttamente il processo del gateway, abiliteremo anche alcuni log:

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

3. Creazione di filtri globali

Una volta che il gestore del gateway determina che una richiesta corrisponde a una route, il framework passa la richiesta attraverso una catena di filtri. Questi filtri possono eseguire la logica prima che la richiesta venga inviata o successivamente.

In questa sezione inizieremo scrivendo semplici filtri globali. Ciò significa che influenzerà ogni singola richiesta.

Innanzitutto, vedremo come possiamo eseguire la logica prima che venga inviata la richiesta proxy (noto anche come filtro "pre")

3.1. Scrittura della logica di "pre" filtro globale

Come abbiamo detto, creeremo filtri semplici a questo punto, poiché l'obiettivo principale qui è solo vedere che il filtro viene effettivamente eseguito al momento giusto; solo registrare un semplice messaggio farà il trucco.

Tutto ciò che dobbiamo fare per creare un filtro globale personalizzato è implementare l' interfaccia Spring Cloud Gateway GlobalFilter e aggiungerla al contesto come bean:

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

Possiamo facilmente vedere cosa sta succedendo qui; una volta richiamato questo filtro, registreremo un messaggio e continueremo con l'esecuzione della catena di filtri.

Definiamo ora un filtro "post", che può essere un po 'più complicato se non abbiamo familiarità con il modello di programmazione Reactive e l'API Spring Webflux.

3.2. Scrittura della logica del filtro globale "post"

Un'altra cosa da notare sul filtro globale che abbiamo appena definito è che l' interfaccia GlobalFilter definisce un solo metodo. Pertanto, può essere espresso come un'espressione lambda, permettendoci di definire i filtri in modo conveniente.

Ad esempio, possiamo definire il nostro filtro "post" in una classe di configurazione:

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

In poche parole, qui stiamo eseguendo una nuova istanza Mono dopo che la catena ha completato la sua esecuzione.

Proviamolo ora chiamando l' URL / service / resource nel nostro servizio gateway e controllando la console di log:

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

Come possiamo vedere, i filtri vengono effettivamente eseguiti prima e dopo che il gateway inoltra la richiesta al servizio.

Naturalmente, possiamo combinare la logica "pre" e "post" in un unico filtro:

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

Nota che possiamo anche implementare l' interfaccia Ordered se ci interessa il posizionamento del filtro nella catena.

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

Ora che abbiamo imparato a scrivere filtri Spring Cloud Gateway personalizzati e visto come manipolare le entità di richiesta e risposta, siamo pronti per sfruttare al meglio questo framework.

Come sempre, tutti gli esempi completi possono essere trovati in oltre su GitHub. Ricorda che per testarlo, dobbiamo eseguire l'integrazione e i test dal vivo tramite Maven.