Spring WebFlux est une fonctionnalité majeure de Spring Framework 5. Disposant de son propre module Maven (spring-weblux), ce nouveau framework web se positionne comme une alternative à Spring Web MVC. Ce dernier a été conçu par-dessus l’API Servlet. Spring WebFlux l’a été pour les applications réactives, avec I/O non bloquantes, asynchrones, à faible latence, basées sur des serveurs comme Netty, Undertow ou compatibles Servlets 3.1 et +.
Spring WebFlux s’éloigne du modèle d’un thread par requête HTTP et se base désormais sur le projet Reactor pour orchestrer le traitement des requêtes.
Conçu avant tout pour exposer des API REST attaquant des bases NoSQL non bloquantes dans des architecture micro-services, Spring WebFlux peut être utilisé sur des applications web dont les IHM sont rendues côté serveur (ex : avec Thymeleaf ou Freemarker).
J’ai récemment migré vers Spring WebFlux la version Kotlin et Spring Boot de l’application démo Spring Petclinic. Dans ce court billet, je voulais vous lister les adaptations mises en œuvre dans le commit 279b2e7.
Changement de dépendances
Le build Gradle a été modifié en 2 points :
- Le starter Spring Boot spring-boot-starter-web est remplacé par spring-boot-starter-webflux
- La dépendance vers Expression Language (org.glassfish:javax.el) a été ajoutée pour les tests qui requièrent le support Bean Validation offert par Spring (classe LocalValidatorFactoryBean).
Après résolution des dépendances, le changement le plus notable est que le JAR spring-webmvc a été remplacé par spring-weblux.
Spring Web MVC s’appuie sur l’API Servlet. Pour preuve, toutes les classes de ce module appartiennent au package org.springframework.web.servlet. On y retrouvait par exemples les classes DispatcherServlet et ModelAndView. Spring WebFlux ne les utilise plus.
Une migration quasi-transparente
Spring WebFlux réutilisent les classes et annotations bien connues des développeurs Spring MVC : @Controller, @RequestMapping, @ModelAttribute, Model ou bien encore @InitBinder.
La migration vers Spring WebFlux du code de production est donc relativement simple.
Les contrôleurs doivent être ajustés afin de ne plus utiliser les classes du module spring-webmvc. Ces changements sont identifiés dès la phase de compilation.
Dans l’exemple ci-dessous, la classe ModelAndView a été remplacée par la classe Model :
Utilisation de ModelAndView avec Spring Web MVC :
@GetMapping("/owners/{ownerId}") fun showOwner(@PathVariable("ownerId") ownerId: Int): ModelAndView { val mav = ModelAndView("owners/ownerDetails") mav.addObject(this.owners.findById(ownerId)) return mav }
Code migré vers Spring WebFlux en utilisant la classe Model :
@GetMapping("/owners/{ownerId}") fun showOwner(@PathVariable("ownerId") ownerId: Int, model: Model): String { model.addAttribute(this.owners.findById(ownerId)) return "owners/ownerDetails" }
Au cours de la migration, des incompatibilités ont été détectées au runtime et lors de l’exécution des tests unitaires.
Ce fut notamment le cas de la classe ModelMap qui provoquait une erreur lors de la résolution des paramètres :
java.lang.IllegalStateException: Failed to invoke handler method with resolved arguments: [0][type=java.lang.Integer][value=1],[1][type=org.springframework.validation.support.BindingAwareConcurrentModel][value={owner=org.springframework.samples.petclinic.owner.Owner@373c0f9b, types=[bird, cat, dog, hamster, lizard, snake]}] on public java.lang.String org.springframework.samples.petclinic.owner.PetController.initUpdateForm(int,org.springframework.ui.ModelMap) at org.springframework.web.reactive.result.method.InvocableHandlerMethod.lambda$invoke$0(InvocableHandlerMethod.java:160) ~[spring-webflux-5.0.1.RELEASE.jar:5.0.1.RELEASE] at reactor.core.publisher.MonoFlatMap$FlatMapMain.onNext(MonoFlatMap.java:118) [reactor-core-3.1.1.RELEASE.jar:3.1.1.RELEASE]
Pour corriger ce problème, la classe ModelMap a été remplacée par Model dans les handlers de requêtes.
Utilisation de ModelMap avec Spring Web MVC :
@GetMapping(value = "/pets/{petId}/edit") fun initUpdateForm(@PathVariable petId: Int, model: ModelMap): String { val pet = pets.findById(petId) model.put("pet", pet) return VIEWS_PETS_CREATE_OR_UPDATE_FORM }
Remplacée par la classe Model avec Spring WebFlux :
@GetMapping(value = "/pets/{petId}/edit") fun initUpdateForm(@PathVariable petId: Int, model: Model): String { val pet = pets.findById(petId) model.addAttribute("pet", pet) return VIEWS_PETS_CREATE_OR_UPDATE_FORM }
Refactoring des tests unitaires
La migration a demandé davantage d’effort pour les tests unitaires de la couche web. Il a en effet été nécessaire de complètement les refactorer.
Spring WebFlux ne permet plus de tester en boîte blanche les contrôleurs. La classe MockMvc n’existe plus. Et il est désormais impossible de vérifier l’état du model ou le nom de la vue rendue par le contrôleur.
Pour tester les contrôleurs, Spring WebFlux propose d’utiliser la classe WebTestClient, le pendant de la classe WebClient pour les tests. WebTestClient a été pensé avant tout pour tester les retours au format JSON. Tester du HTML est moins simple. Il est nécessaire d’évaluer les templates Thymeleaf, ce qui présente néanmoins l’avantage de les tester. Lorsque l’on souhaite effectuer des assertions XPath, il est nécessaire de normaliser le HTML au format XHTML (fermer les balises).
Si l’on prend exemple sur la classe de test OwnerControllerTest, son en-tête a dû être modifiée en 3 points :
- L’annotation @WebMvcTest est remplacée par @WebFluxTest
- La classe de configuration ThymeleafAutoConfiguration a été ajoutée
- Injecté, le bean WebTestClient remplace MockMvc
En-tête d’une classe de test d’un contrôleur Spring Web MVC :
@RunWith(SpringRunner::class) @WebMvcTest(OwnerController::class) class OwnerControllerTest { @Autowired lateinit private var mockMvc: MockMvc
En-tête d’une classe de test migrée à Spring WebFlux :
@RunWith(SpringRunner::class) @WebFluxTest(OwnerController::class) @Import(ThymeleafAutoConfiguration::class) class OwnerControllerTest { @Autowired lateinit private var client: WebTestClient;
Attardons-nous à présent sur l’une des méthodes de test. Par exemple, celle qui teste la soumission d’un formulaire invalide.
Avec Spring Web MVC, les assertions s’appuient sur les méthodes attributeHasErrors et attributeHasFieldErrors de l’objet renvoyait par la méthode model() :
@Test fun testProcessCreationFormHasErrors() { mockMvc.perform(post("/owners/new") .param("firstName", "Joe") .param("lastName", "Bloggs") .param("city", "London") ) .andExpect(status().isOk) .andExpect(model().attributeHasErrors("owner")) .andExpect(model().attributeHasFieldErrors("owner", "address")) .andExpect(model().attributeHasFieldErrors("owner", "telephone")) .andExpect(view().name("owners/createOrUpdateOwnerForm")) }
Avec Spring WebFlux, on analyse le contenu du HTML généré. Les assertions sont ici moins précises car les messages d’erreur ne sont pas reliés au champs de saisie :
@Test fun testProcessCreationFormHasErrors() { val formData = LinkedMultiValueMap<String, String>(3) formData.put("firstName", Arrays.asList("Joe")) formData.put("lastName", Arrays.asList("Bloggs")) formData.put("city", Arrays.asList("London")) val res = client.post().uri("/owners/new") .header("Accept-Language", "en-US") .body(BodyInserters.fromFormData(formData)) .exchange() .expectStatus().isOk .expectBody(String::class.java).returnResult() Assertions.assertThat(res.responseBody).contains("numeric value out of bounds (<10 digits>.<0 digits> expected") Assertions.assertThat(res.responseBody).contains("must not be empty") }
Lors de la migration du test CrashControllerTest chargé de vérifier que la levée d’une exception technique renvoie sur une page d’erreur générique, après avoir importé la classe de configuration ErrorWebFluxAutoConfiguration, le template error.html n’était pas retrouvé. Un palliatif (temporaire ?) a été de le renommer en 5xx.html.
Démarrage
Une fois que le code compile et que les TU sont au vert, il reste à démarrer l’application.
Dans les logs, on note un changement d’importance :
Netty started on port(s): 8080
Ce n’est plus Jetty, mais Netty qui apparaît dans les logs de démarrage. Netty étant le serveur par défaut choisi par Pivotal dans Spring Boot pour faire exécuter les applications Spring WebFlux.
Pour aller plus loin
Lorsqu’une application reactive utilise Spring WebFlux, 2 modèles de programmation sont proposés pour configurer la couche web :
- Utiliser les annotations de Spring MVC. Choix qui a été fait pour spring-petclinic-kotlin car le plus transparent lors d’une migration.
- Utiliser la programmation fonctionnelle via les Functional Endpoints pour déclarer les routes et les handlers de requêtes HTTP. Spring Framework 5 vient avec une nouvelle fonctionnalité : le Kotlin routing DSL. L’utilisation de ce DSL pourrait avoir du sens sur spring-petclinic-kotlin, au moins pour la partie REST. Peut-être la prochaine évolution ?
Post-scriptum
Comme précisé par Sébastien Deleuze dans la Pull Request #9, utiliser Spring WebFlux avec JPA peut entrainer des problèmes de scalabilité. En effet, JPA est une API bloquante. Un rollback vers Spring MVC a été réalisé.
Une migration complète vers WebFlux passerait donc par une migration vers une base NoSQL supportant les appels non bloquants. L’UI devrait également être retravaillée pour profiter du streaming des données. Fonctionnellement, Petclinic n’en a pas réellement besoin et ne se prête donc pas bien au use case d’utilisation de WebFlux.
Références :
- Spring WebFlux (manuel de référence de Spring Framework)
- Projet Reactor (site web oficiel)
- Commit GitHub montrant les différences entre l’utilisation de Spring MVC et Spring WebFlux
- Spring 5 WebClient (Baeldung blog)
- WebFlux Fonctionnal DSL (manuel de référence de Spring Framework)