
Création de contrôleurs réactifs (annotation-based et functional endpoints)
Apprenez à développer des endpoints web réactifs dans Spring WebFlux en utilisant le style basé sur les annotations (@RestController) et l'approche fonctionnelle (RouterFunction).
Introduction aux endpoints réactifs WebFlux
Spring WebFlux est le framework web réactif de l'écosystème Spring, conçu pour construire des applications non-bloquantes et capables de gérer un grand nombre de connexions concurrentes avec une utilisation efficace des ressources. Au coeur de WebFlux se trouve Project Reactor et ses types réactifs : Mono (pour 0 ou 1 élément) et Flux (pour 0 à N éléments).
Pour exposer des fonctionnalités via le web dans une application WebFlux, vous devez créer des "endpoints" qui gèrent les requêtes HTTP entrantes et produisent des réponses. WebFlux propose deux modèles de programmation principaux pour définir ces endpoints :
- Le modèle basé sur les annotations : Très similaire à celui de Spring MVC traditionnel, utilisant des annotations comme
@RestController,@RequestMapping,@GetMapping, etc. La principale différence réside dans les types de retour, qui sont désormais desMonoou desFlux. - Le modèle fonctionnel (Functional Endpoints) : Une approche plus programmatique et fonctionnelle où les routes et la logique de traitement (handlers) sont définies explicitement à l'aide d'interfaces comme
RouterFunctionetHandlerFunction.
Ces deux modèles peuvent coexister au sein d'une même application, vous permettant de choisir l'approche la mieux adaptée à chaque cas.
Modèle basé sur les annotations : la familiarité réactive
Pour les développeurs venant de Spring MVC, le modèle basé sur les annotations est très familier. Vous utilisez les mêmes annotations pour définir les contrôleurs et mapper les requêtes HTTP aux méthodes de traitement.
La différence fondamentale est que les méthodes de vos contrôleurs ne retournent plus directement les objets ou les vues, mais des types réactifs Mono ou Flux (où T peut être votre objet de réponse, une ResponseEntity, etc.). Spring WebFlux s'occupe de s'abonner à ces flux réactifs et d'écrire la réponse HTTP de manière asynchrone lorsque les données deviennent disponibles.
Les annotations pour récupérer les paramètres de requête (@RequestParam), les variables de chemin (@PathVariable), les en-têtes (@RequestHeader) ou le corps de la requête (@RequestBody) fonctionnent de manière similaire, mais le corps de la requête est typiquement consommé comme un Mono ou Flux.
Voici un exemple de contrôleur réactif basé sur les annotations :
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
import reactor.core.publisher.Flux;
import reactor.core.publisher.Mono;
// Modèle simple
record Item(String id, String name) {}
@RestController
@RequestMapping("/annotated/items")
public class AnnotatedItemController {
// Simule une source de données réactive (ex: un repository réactif)
private final ReactiveItemRepository repository = new ReactiveItemRepository();
@GetMapping
public Flux- getAllItems() {
// Retourne un Flux d'items
return repository.findAll();
}
@GetMapping("/{id}")
public Mono
> getItemById(@PathVariable String id) {
// Retourne un Mono contenant l'item ou une réponse 404
return repository.findById(id)
.map(item -> ResponseEntity.ok(item)) // Si trouvé, encapsule dans ResponseEntity OK
.defaultIfEmpty(ResponseEntity.notFound().build()); // Si Mono vide, retourne 404
}
@PostMapping
@ResponseStatus(HttpStatus.CREATED)
public Mono- createItem(@RequestBody Mono
- itemMono) {
// Le corps de la requête est fourni comme un Mono
// On transforme le Mono entrant en appelant le repository une fois l'item reçu
return itemMono.flatMap(repository::save);
}
@PutMapping("/{id}")
public Mono
> updateItem(@PathVariable String id,
@RequestBody Mono- itemMono) {
return repository.findById(id)
.flatMap(existingItem ->
itemMono.flatMap(newItem -> {
// Simule la mise à jour et la sauvegarde
Item updatedItem = new Item(existingItem.id(), newItem.name());
return repository.save(updatedItem);
})
)
.map(ResponseEntity::ok)
.defaultIfEmpty(ResponseEntity.notFound().build());
}
@DeleteMapping("/{id}")
public Mono
> deleteItem(@PathVariable String id) {
return repository.deleteById(id)
.then(Mono.just(ResponseEntity.noContent().build())) // Si delete réussit, retourne 204
.defaultIfEmpty(ResponseEntity.notFound().build()); // Si l'élément n'existait pas
}
}
// Classe factice simulant un repository réactif
class ReactiveItemRepository {
private final java.util.Map db = new java.util.concurrent.ConcurrentHashMap<>();
Flux- findAll() { return Flux.fromIterable(db.values()); }
Mono
- findById(String id) { return Mono.justOrEmpty(db.get(id)); }
Mono
- save(Item item) {
String id = item.id() != null ? item.id() : java.util.UUID.randomUUID().toString();
Item saved = new Item(id, item.name());
db.put(id, saved);
return Mono.just(saved);
}
Mono
deleteById(String id) { return Mono.fromRunnable(() -> db.remove(id)); }
}
Ce style est souvent apprécié pour sa concision et sa familiarité, surtout pour des API REST relativement simples.
Modèle fonctionnel : contrôle explicite avec RouterFunction
Le modèle fonctionnel offre une alternative où vous définissez le routage et la gestion des requêtes de manière programmatique, généralement au sein d'une classe de configuration Spring (annotée @Configuration).
Il repose sur deux concepts principaux :
HandlerFunction: Une fonction qui prend unServerRequesten argument et retourne unMono. C'est l'équivalent de la méthode de traitement d'un contrôleur annoté. Elle contient la logique métier pour gérer la requête.RouterFunction: Une fonction qui prend unServerRequestet retourne unMono. Son rôle est de déterminer si une requête correspond à une certaine route (basé sur le chemin, la méthode HTTP, les en-têtes, etc.) et, si oui, de renvoyer la> HandlerFunctionappropriée pour la traiter. Ces fonctions sont généralement définies comme des beans Spring.
On utilise des prédicats (RequestPredicate) pour définir les conditions de matching des routes et des builders pour construire les réponses (ServerResponse).
Voici comment l'exemple précédent pourrait être implémenté avec le modèle fonctionnel :
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.http.HttpStatus;
import org.springframework.http.MediaType;
import org.springframework.web.reactive.function.server.*;
import reactor.core.publisher.Mono;
import static org.springframework.web.reactive.function.server.RequestPredicates.*;
import static org.springframework.web.reactive.function.server.RouterFunctions.route;
// Handler contenant la logique métier
@Component // Déclaré comme bean pour pouvoir injecter des dépendances (ex: repository)
class ItemHandler {
private final ReactiveItemRepository repository = new ReactiveItemRepository(); // Injecter via constructeur idéalement
public Mono getAllItems(ServerRequest request) {
return ServerResponse.ok()
.contentType(MediaType.APPLICATION_JSON)
.body(repository.findAll(), Item.class);
}
public Mono getItemById(ServerRequest request) {
String id = request.pathVariable("id");
return repository.findById(id)
.flatMap(item -> ServerResponse.ok()
.contentType(MediaType.APPLICATION_JSON)
.bodyValue(item))
.switchIfEmpty(ServerResponse.notFound().build());
}
public Mono createItem(ServerRequest request) {
Mono- itemMono = request.bodyToMono(Item.class);
return itemMono.flatMap(repository::save)
.flatMap(savedItem -> ServerResponse.status(HttpStatus.CREATED)
.contentType(MediaType.APPLICATION_JSON)
.bodyValue(savedItem));
}
public Mono
updateItem(ServerRequest request) {
String id = request.pathVariable("id");
Mono- itemMono = request.bodyToMono(Item.class);
return repository.findById(id)
.flatMap(existingItem ->
itemMono.flatMap(newItem -> {
Item updatedItem = new Item(existingItem.id(), newItem.name());
return repository.save(updatedItem);
})
)
.flatMap(updated -> ServerResponse.ok()
.contentType(MediaType.APPLICATION_JSON)
.bodyValue(updated))
.switchIfEmpty(ServerResponse.notFound().build());
}
public Mono
deleteItem(ServerRequest request) {
String id = request.pathVariable("id");
// Note: Logique pour savoir si l'élément existait avant suppression est plus complexe ici
return repository.deleteById(id)
.then(ServerResponse.noContent().build());
// Pour retourner 404 si non trouvé, il faudrait vérifier avant
// ou modifier le repository pour retourner un Mono
}
}
// Configuration définissant les routes
@Configuration
class ItemRouter {
@Bean
public RouterFunction itemRoutes(ItemHandler itemHandler) {
return route()
.path("/functional/items", builder -> builder
.GET("", accept(MediaType.APPLICATION_JSON), itemHandler::getAllItems)
.POST("", accept(MediaType.APPLICATION_JSON), itemHandler::createItem)
.GET("/{id}", accept(MediaType.APPLICATION_JSON), itemHandler::getItemById)
.PUT("/{id}", accept(MediaType.APPLICATION_JSON), itemHandler::updateItem)
.DELETE("/{id}", accept(MediaType.APPLICATION_JSON), itemHandler::deleteItem)
).build();
/* Alternative (moins imbriquée):
return route(GET("/functional/items").and(accept(MediaType.APPLICATION_JSON)), itemHandler::getAllItems)
.andRoute(POST("/functional/items").and(accept(MediaType.APPLICATION_JSON)), itemHandler::createItem)
.andRoute(GET("/functional/items/{id}").and(accept(MediaType.APPLICATION_JSON)), itemHandler::getItemById)
.andRoute(PUT("/functional/items/{id}").and(accept(MediaType.APPLICATION_JSON)), itemHandler::updateItem)
.andRoute(DELETE("/functional/items/{id}").and(accept(MediaType.APPLICATION_JSON)), itemHandler::deleteItem);
*/
}
}
// Item et ReactiveItemRepository définis comme dans l'exemple précédent
Ce style sépare clairement la définition des routes (dans ItemRouter) de la logique de traitement (dans ItemHandler). Il est souvent considéré comme plus testable et plus explicite, particulièrement pour des règles de routage complexes, mais peut être plus verbeux pour des cas simples.
Annotation-based vs Fonctionnel : faire le bon choix
Le choix entre le modèle basé sur les annotations et le modèle fonctionnel dépend de plusieurs facteurs :
- Familiarité et Courbe d'Apprentissage : Le modèle annoté est plus facile à aborder pour les développeurs connaissant Spring MVC. Le modèle fonctionnel peut demander une adaptation à un style plus déclaratif et fonctionnel.
- Simplicité vs Expliciteté : Pour des API REST standard (CRUD), le modèle annoté est souvent plus concis. Pour des logiques de routage complexes, des filtres avancés ou un contrôle très fin du processus requête/réponse, le modèle fonctionnel offre plus d'expliciteté et de contrôle.
- Testabilité : Les `HandlerFunction` du modèle fonctionnel peuvent être plus faciles à tester unitairement car elles sont de simples fonctions prenant un
ServerRequest(qui peut être mocké) et retournant unMono. Les contrôleurs annotés nécessitent souvent des outils commeWebTestClientpour des tests d'intégration (similaires àMockMvcpour MVC). - Boilerplate : Le modèle fonctionnel peut introduire plus de code de configuration (boilerplate) pour définir les routes, surtout si elles sont nombreuses.
- Préférences de l'équipe : Le style de programmation (impératif vs fonctionnel) préféré par l'équipe peut influencer le choix.
Il n'y a pas de "meilleure" approche universelle. Les deux sont des citoyens de première classe dans WebFlux et peuvent même être utilisés conjointement dans la même application. Le choix dépendra du contexte spécifique de votre projet et de vos priorités.