Écriture de filtres Spring Cloud Gateway personnalisés

1. Vue d'ensemble

Dans ce didacticiel, nous allons apprendre à écrire des filtres Spring Cloud Gateway personnalisés.

Nous avons présenté ce cadre dans notre article précédent, Explorer la nouvelle passerelle Spring Cloud, où nous avons examiné de nombreux filtres intégrés.

À cette occasion, nous irons plus loin, nous écrirons des filtres personnalisés pour tirer le meilleur parti de notre passerelle API.

Tout d'abord, nous verrons comment nous pouvons créer des filtres globaux qui affecteront chaque requête unique traitée par la passerelle. Ensuite, nous écrirons des usines de filtres de passerelle, qui peuvent être appliquées de manière granulaire à des routes et des demandes particulières.

Enfin, nous travaillerons sur des scénarios plus avancés, en apprenant à modifier la requête ou la réponse, et même à enchaîner la requête avec des appels à d'autres services, de manière réactive.

2. Configuration du projet

Nous commencerons par configurer une application de base que nous utiliserons comme passerelle API.

2.1. Configuration Maven

Lorsque vous travaillez avec des bibliothèques Spring Cloud, c'est toujours un bon choix de configurer une configuration de gestion des dépendances pour gérer les dépendances pour nous:

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

Nous pouvons maintenant ajouter nos bibliothèques Spring Cloud sans spécifier la version réelle que nous utilisons:

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

La dernière version de Spring Cloud Release Train peut être trouvée à l'aide du moteur de recherche Maven Central. Bien sûr, nous devons toujours vérifier que la version est compatible avec la version Spring Boot que nous utilisons dans la documentation Spring Cloud.

2.2. Configuration de la passerelle API

Nous supposerons qu'il existe une deuxième application s'exécutant localement sur le port 8081 , qui expose une ressource (pour simplifier, juste une simple chaîne ) lorsqu'elle frappe / resource .

Dans cet esprit, nous allons configurer notre passerelle pour les requêtes proxy vers ce service. En un mot, lorsque nous envoyons une demande à la passerelle avec un préfixe / service dans le chemin de l'URI, nous transférerons l'appel vers ce service.

Ainsi, lorsque nous appelons / service / resource dans notre passerelle, nous devrions recevoir la réponse String .

Pour ce faire, nous allons configurer cette route à l'aide des propriétés de l'application :

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

De plus, pour pouvoir suivre correctement le processus de passerelle, nous activerons également certains journaux:

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

3. Création de filtres globaux

Une fois que le gestionnaire de passerelle a déterminé qu'une demande correspond à une route, l'infrastructure transmet la demande à travers une chaîne de filtres. Ces filtres peuvent exécuter une logique avant l'envoi de la demande ou après.

Dans cette section, nous commencerons par écrire des filtres globaux simples. Cela signifie que cela affectera chaque demande.

Tout d'abord, nous verrons comment nous pouvons exécuter la logique avant l'envoi de la demande de proxy (également appelé filtre «pré»)

3.1. Ecriture de la logique globale du «pré» filtre

Comme nous l'avons dit, nous allons créer des filtres simples à ce stade, puisque l'objectif principal ici est uniquement de voir que le filtre est réellement exécuté au bon moment; simplement enregistrer un message simple fera l'affaire.

Tout ce que nous avons à faire pour créer un filtre global personnalisé est d'implémenter l' interface Spring Cloud Gateway GlobalFilter et de l'ajouter au contexte en tant que 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); } }

Nous pouvons facilement voir ce qui se passe ici; une fois ce filtre appelé, nous enregistrerons un message et continuerons l'exécution de la chaîne de filtres.

Définissons maintenant un filtre «post», qui peut être un peu plus délicat si nous ne sommes pas familiarisés avec le modèle de programmation réactif et l'API Spring Webflux.

3.2. Écriture de la logique de filtrage globale «post»

Une autre chose à noter à propos du filtre global que nous venons de définir est que l' interface GlobalFilter ne définit qu'une seule méthode. Ainsi, il peut être exprimé comme une expression lambda, ce qui nous permet de définir des filtres de manière pratique.

Par exemple, nous pouvons définir notre filtre «post» dans une classe de configuration:

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

En termes simples, nous exécutons ici une nouvelle instance Mono une fois que la chaîne a terminé son exécution.

Essayons-le maintenant en appelant l' URL / service / resource dans notre service de passerelle et en consultant la console de journal:

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

Comme nous pouvons le voir, les filtres sont effectivement exécutés avant et après que la passerelle transmet la demande au service.

Naturellement, nous pouvons combiner les logiques «pré» et «post» dans un seul filtre:

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

Notez que nous pouvons également implémenter l' interface Ordered si nous nous soucions du placement du filtre dans la chaîne.

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

Maintenant que nous avons appris à écrire des filtres Spring Cloud Gateway personnalisés et vu comment manipuler les entités de requête et de réponse, nous sommes prêts à tirer le meilleur parti de ce framework.

Comme toujours, tous les exemples complets peuvent être trouvés dans over sur GitHub. N'oubliez pas que pour le tester, nous devons exécuter des tests d'intégration et en direct via Maven.