Contactez-nous

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 des Mono ou des Flux.
  • 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 RouterFunction et HandlerFunction.

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 un ServerRequest en argument et retourne un Mono. 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 un ServerRequest et retourne un Mono>. 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 HandlerFunction approprié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 un Mono. Les contrôleurs annotés nécessitent souvent des outils comme WebTestClient pour des tests d'intégration (similaires à MockMvc pour 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.