Spring WebFlux 1: Fundamentos Reactivos y el Corazón de Reactor
- Mauricio ECR
- Arquitectura
- 08 May, 2025
¡Hola, entusiasta del desarrollo moderno! 👋
En el vertiginoso mundo de las aplicaciones web, donde la escalabilidad y la eficiencia son reyes, ha surgido un paradigma que desafía el modelo tradicional de solicitud-respuesta síncrono: la Programación Reactiva. Y si trabajas con Spring, inevitablemente te encontrarás con Spring WebFlux, la respuesta de este popular framework a este emocionante cambio.
Prepararte para una entrevista sobre WebFlux implica comprender no solo cómo usarlo, sino por qué existe y cómo funciona por dentro. En esta primera entrega de nuestra serie, sentaremos las bases, explorando los principios reactivos y conociendo a Project Reactor, la biblioteca que impulsa WebFlux.
1. Fundamentos de Programación Reactiva y el “Por Qué” de WebFlux
Imagínate un restaurante. En el modelo tradicional (síncrono), un camarero toma una orden (petición), va a la cocina y espera a que el plato esté listo para llevarlo a la mesa. Mientras espera, no puede atender a nadie más. Si el restaurante se llena, necesitas más camareros (hilos) esperando. Esto escala, pero llega un punto en que tener demasiados camareros se vuelve ineficiente (consumo de memoria, sobrecarga del planificador de hilos).
Ahora, imagina un modelo diferente. El camarero toma la orden, la lleva a la cocina y, en lugar de esperar, vuelve a tomar más órdenes. Cuando un plato está listo, el cocinero avisa, y el camarero que esté libre lo recoge y lo lleva. Este es el modelo reactivo/asíncrono/no bloqueante. Los camareros (hilos) no se quedan inactivos esperando; están constantemente haciendo algo útil.
Teoría: ¿Qué es la Programación Reactiva?
La Programación Reactiva es un paradigma de programación que se centra en trabajar con flujos de datos asíncronos que reaccionan a cambios. No es solo sobre asincronía; es sobre gestionar la propagación de cambios y el manejo de “eventos” de manera eficiente y no bloqueante.
Aunque existe un “Reactive Manifesto” que define los principios de sistemas reactivos (responsivos, resilientes, elásticos y basados en mensajes), en el contexto de la programación reactiva a nivel de código, nos enfocamos más en cómo manejamos esos flujos de datos asíncronos.
Programación Síncrona vs. Asíncrona vs. No Bloqueante vs. Reactiva
Es crucial entender estas diferencias:
- Síncrona: Las operaciones se ejecutan secuencialmente. Una operación debe completarse antes de que la siguiente pueda comenzar. Un hilo realiza una tarea de principio a fin.
- Asíncrona: Una operación se inicia y el programa continúa ejecutando otras tareas sin esperar a que la primera termine. Cuando la operación asíncrona finaliza, a menudo notifica al programa (por ejemplo, a través de un callback o una promesa).
- No Bloqueante: Un subconjunto importante de la programación asíncrona. Una llamada a una función no bloqueante regresa inmediatamente, incluso si la operación solicitada no se ha completado. Si el resultado no está disponible, a menudo devuelve un valor especial (como
nullo un indicador de “pendiente”). No bloquea el hilo llamador. - Reactiva: Un estilo de programación que utiliza flujos de datos asíncronos y no bloqueantes. Se basa en el patrón Observer, donde un “Publisher” emite elementos y un “Subscriber” los consume reaccionando a ellos. Permite componer operaciones complejas sobre estos flujos de manera declarativa.
El Problema del Bloqueo (Thread per Request):
En las arquitecturas web tradicionales (como Spring MVC sobre Servlet API), el modelo común es “un hilo por petición”. Cuando una petición llega, se le asigna un hilo del pool. Si esa petición necesita interactuar con algo lento (una base de datos, un servicio externo, una espera de I/O), el hilo asignado se bloquea esperando. Mientras está bloqueado, no puede atender otras peticiones. En escenarios de alto tráfico o latencia, esto lleva a:
- Agotamiento del pool de hilos.
- Alta demanda de recursos del sistema (memoria, CPU por el cambio de contexto entre muchos hilos).
- Disminución del rendimiento y la capacidad de respuesta.
La programación reactiva y WebFlux resuelven esto utilizando un modelo basado en eventos y no bloqueante. Un pequeño número de hilos (a menudo llamados Event Loop threads) maneja muchas peticiones concurrentemente. Cuando una operación de I/O es necesaria, el hilo no espera; delega la operación al sistema operativo y se libera para manejar otras peticiones. Cuando el resultado de la operación de I/O está listo, el sistema operativo notifica a uno de los hilos del Event Loop, que entonces procesa la respuesta.
Ventajas de Usar WebFlux
- Escalabilidad: Maneja un gran número de conexiones concurrentes con un número reducido de hilos, lo que se traduce en una mejor utilización de recursos y mayor capacidad para escalar horizontalmente.
- Uso Eficiente de Recursos: Menos hilos significan menos consumo de memoria y menos sobrecarga del planificador de hilos.
- Manejo de Latencia: Al no bloquear hilos en operaciones de I/O, la aplicación sigue siendo receptiva incluso cuando depende de servicios lentos o tiene alta latencia.
- Composición de Flujos Asíncronos: El modelo reactivo basado en operadores facilita la construcción de lógica compleja que involucra múltiples operaciones asíncronas.
¿Cuándo NO Usar WebFlux?
WebFlux no es una bala de plata para todos los casos. Hay situaciones donde Spring MVC tradicional puede ser más adecuado:
- Aplicaciones CPU-Bound: Si tu aplicación realiza principalmente cálculos intensivos que consumen mucha CPU, un modelo reactivo no te dará grandes beneficios en términos de escalabilidad, ya que los hilos estarán ocupados computando, no esperando I/O. De hecho, la sobrecarga del modelo reactivo podría ser detrimental.
- Aplicaciones Simples con Bajo Tráfico: Para APIs sencillas o aplicaciones internas con poca carga, la complejidad adicional de la programación reactiva puede no justificarse. El modelo síncrono de Spring MVC es a menudo más rápido de desarrollar en estos casos.
- Ecosistema Bloqueante: Si dependes fuertemente de bibliotecas o tecnologías que son inherentemente bloqueantes y no tienen alternativas reactivas, adoptar WebFlux implicará wrappers o adaptadores que pueden complicar el código.
Casos Típicos/Práctica
-
Hilo Bloqueado vs. Hilo No Bloqueado:
- Hilo Bloqueado: Imagina un hilo pidiendo datos a una base de datos y esperando pasivamente hasta que todos los datos llegan. Durante ese tiempo, el hilo no puede hacer nada más.
- Hilo No Bloqueado: El hilo pide los datos y, en lugar de esperar, le dice a la base de datos “avísame cuando tengas los datos”. Luego, el hilo queda libre para procesar otra petición. Cuando la base de datos termina, notifica a un hilo disponible para que procese los resultados.
-
Escenario donde WebFlux Brilla: Una API Gateway que recibe miles de peticiones por segundo, cada una de las cuales necesita hacer varias llamadas a microservicios internos (con latencia variable) y a bases de datos antes de agregar y devolver la respuesta. En este escenario, un modelo tradicional agotaría rápidamente los hilos, mientras que WebFlux, al no bloquear, puede manejar la concurrencia eficientemente con muchos menos hilos.
-
¿Por qué Spring creó WebFlux si ya existía Spring MVC? Spring MVC se basa en la API de Servlets, que es fundamentalmente síncrona y bloqueante en su diseño original (aunque ha evolucionado). Para ofrecer una solución de programación reactiva y no bloqueante de extremo a extremo que pudiera competir con frameworks como Node.js o Vert.x en escenarios de alta concurrencia y I/O-bound, Spring necesitaba una arquitectura desde cero que no dependiera del modelo Servlet. WebFlux nació para llenar ese vacío, proporcionando una pila web completamente reactiva construida sobre bibliotecas como Reactor y servidores no bloqueantes como Netty.
2. Project Reactor: El Corazón de WebFlux
WebFlux no implementa la programación reactiva desde cero; se apoya en una biblioteca especializada para ello: Project Reactor. Reactor es una biblioteca de programación reactiva para JVM, basada en la especificación Reactive Streams, que define un estándar para el procesamiento de flujos de datos asíncronos con “backpressure”.
Teoría: Conceptos Clave de Reactor
Reactor proporciona dos tipos principales para representar flujos de datos asíncronos:
- Mono: Representa un flujo reactivo que emite 0 o 1 elemento y luego se completa (o emite un error). Ideal para operaciones que devuelven un único resultado o ninguna (como guardar un registro, buscar por ID si existe, o una operación de borrado).
- Flux: Representa un flujo reactivo que emite 0 a N elementos y luego se completa (o emite un error). Ideal para operaciones que pueden devolver múltiples resultados (como buscar todos los usuarios, un stream de eventos, o resultados de una consulta paginada).
Estos tipos implementan la interfaz Publisher de Reactive Streams.
El modelo de Reactor (y Reactive Streams) se basa en cuatro interfaces principales:
- Publisher: Produce elementos (eventos). Es el origen de la secuencia. Solo tiene un método:
subscribe(Subscriber s). - Subscriber: Consume elementos emitidos por el Publisher. Define métodos de callback:
onSubscribe(Subscription s): Se invoca una vez cuando el Subscriber se suscribe exitosamente al Publisher. Recibe un objetoSubscription.onNext(T t): Se invoca para cada elemento emitido por el Publisher.onError(Throwable t): Se invoca si el Publisher encuentra un error. La secuencia termina.onComplete(): Se invoca cuando el Publisher ha terminado de emitir elementos exitosamente. La secuencia termina.
- Subscription: Representa la relación entre un Publisher y un Subscriber. Permite al Subscriber gestionar el flujo de datos (pedir más elementos - backpressure) o cancelar la suscripción. Métodos clave:
request(long n)ycancel(). - Operator: Son funciones puras que transforman, filtran, combinan o manipulan flujos. Reciben un Publisher como entrada y devuelven un nuevo Publisher. Encadenar operadores crea un pipeline reactivo.
El Ciclo de Vida de un Stream Reactivo
El ciclo de vida es fundamental:
- Un Subscriber se suscribe a un Publisher llamando a
publisher.subscribe(subscriber). - El Publisher, si acepta la suscripción, llama a
subscriber.onSubscribe(subscription), pasándole un objetoSubscription. - El Subscriber utiliza el objeto
Subscriptionpara solicitar elementos llamando asubscription.request(n). Esto es backpressure: el consumidor le dice al productor cuántos elementos está listo para manejar. - El Publisher emite elementos llamando a
subscriber.onNext(element)hasta que se alcanzan losnelementos solicitados o se agotan los elementos disponibles. - Este proceso de
request(n)yonNext(element)se repite. - Eventualmente, el Publisher terminará la secuencia llamando a
subscriber.onComplete()osubscriber.onError(error). Una vez queonCompleteoonErrorson llamados, la secuencia termina y no se emitirán más eventos. El Subscriber también puede cancelar la suscripción prematuramente llamando asubscription.cancel().
Importante: La ejecución real del flujo (el pushing de datos a través del pipeline) solo comienza cuando hay un Subscriber. Esto se conoce como lazy execution.
Operadores: ¿Qué son y por qué son importantes?
Los operadores son el poder de Reactor. Permiten construir lógica compleja sobre flujos de datos de manera declarativa y componible. Cada operador toma un Publisher de entrada y devuelve un nuevo Publisher modificado. Puedes encadenar múltiples operadores para construir una secuencia de procesamiento.
Ejemplos de categorías de operadores:
- Transformación:
map,flatMap,concatMap. - Filtrado:
filter,take,skip. - Combinación:
merge,zip,concat. - Manejo de Errores:
onErrorReturn,onErrorResume,doOnError. - Utilidad:
doOnNext,doOnComplete,delayElements.
Casos Típicos/Práctica
-
Diferencia entre Mono y Flux con ejemplos:
// Mono: Representa 0 o 1 elemento Mono<String> greeting = Mono.just("Hola Mundo"); // Emite "Hola Mundo" Mono<String> noValue = Mono.empty(); // Emite 0 elementos // Flux: Representa 0 a N elementos Flux<Integer> numbers = Flux.just(1, 2, 3, 4, 5); // Emite 1, 2, 3, 4, 5 Flux<String> greetings = Flux.fromIterable(Arrays.asList("Hello", "World", "Reactor")); // Emite "Hello", "World", "Reactor" Flux<Long> infinite = Flux.interval(Duration.ofSeconds(1)); // Emite un número cada segundo (infinito)- Ejemplo de Uso: Usarías un
Mono<User>para obtener los detalles de un usuario por su ID, y unFlux<Product>para obtener una lista de productos de una categoría.
- Ejemplo de Uso: Usarías un
-
Demostrar el uso de operadores comunes:
Flux.just(1, 2, 3, 4, 5) .filter(n -> n % 2 == 0) // Filtra solo números pares .map(n -> "Número par: " + n) // Transforma cada número en un String .subscribe(System.out::println); // Suscriptor que imprime cada elemento // Salida: // Número par: 2 // Número par: 4 Mono.just("spring") .map(String::toUpperCase) // Transforma a mayúsculas .subscribe(System.out::println); // Suscriptor // Salida: // SPRING -
Entender bien
flatMapvsmap: ¡Crucial!map: Transforma cada elemento emitido por el origen sincrónicamente en otro elemento. Si la función de mapeo devuelve un tipo reactivo (MonooFlux), el resultado será unFluxdeMonos oFluxs anidados (unFlux<Mono<T>>oFlux<Flux<T>>), lo cual rara vez es lo que quieres.flatMap: Transforma cada elemento emitido por el origen en un nuevo Publisher (MonooFlux) y luego aplana (fusiona) los elementos de estos Publishers resultantes en un únicoFlux. Es ideal para operaciones asíncronas. El orden de los elementos resultantes no está garantizado conflatMapsi las operaciones internas tardan tiempos variables.concatMap: Similar aflatMap, pero garantiza que los Publishers internos se suscriban y emitan sus elementos en el mismo orden en que llegaron los elementos originales. Esto es útil cuando el orden es importante, pero puede ser menos eficiente queflatMapya que espera a que cada Publisher interno termine antes de procesar el siguiente.
// Ejemplo flatMap vs map Flux.just("Alpha", "Beta") .flatMap(word -> Mono.just(word.length()) // Crea un Mono con la longitud de la palabra (asíncrono o síncrono envuelto en Mono) .delayElement(Duration.ofMillis(word.length() * 100))) // Simula una operación asíncrona con retraso .subscribe(length -> System.out.println("flatMap - Longitud: " + length)); // Posible salida (el orden puede variar debido a delayElement y flatMap): // flatMap - Longitud: 5 // flatMap - Longitud: 4 Flux.just("Alpha", "Beta") .map(word -> Mono.just(word.length()) // Crea un Mono con la longitud .delayElement(Duration.ofMillis(word.length() * 100))) .subscribe(monoLength -> monoLength.subscribe(length -> System.out.println("map - Longitud: " + length))); // Necesitas suscribirte al Mono interno! // Salida (después de 500ms y 400ms): // map - Longitud: 5 // map - Longitud: 4 // ¡Fíjate que map devolvió un Flux<Mono<Integer>>! Tuvimos que suscribirnos a cada Mono. flatMap lo hizo automáticamente y aplanó el resultado. Flux.just("Alpha", "Beta") .concatMap(word -> Mono.just(word.length()) // Crea un Mono con la longitud .delayElement(Duration.ofMillis(word.length() * 100))) // Simula operación asíncrona con retraso .subscribe(length -> System.out.println("concatMap - Longitud: " + length)); // Salida (el orden está garantizado por concatMap): // concatMap - Longitud: 5 (espera 500ms) // concatMap - Longitud: 4 (luego espera 400ms) -
Secuencia que emita números y luego los transforme:
Flux.range(1, 10) // Emite números del 1 al 10 .map(n -> n * 2) // Multiplica cada número por 2 .filter(n -> n > 10) // Mantiene solo los resultados mayores que 10 .subscribe(result -> System.out.println("Resultado transformado: " + result), // onNext error -> System.err.println("Ocurrió un error: " + error), // onError () -> System.out.println("Secuencia completada.")); // onComplete // Salida: // Resultado transformado: 12 // Resultado transformado: 14 // Resultado transformado: 16 // Resultado transformado: 18 // Resultado transformado: 20 // Secuencia completada. -
¿Qué sucede si un Flux emite un error? ¿Cómo lo manejas? Cuando un Publisher emite un error a través de
onError(Throwable t), la secuencia termina inmediatamente. Ningún elemento posterior será emitido. El Subscriber recibe la notificaciónonError, y el flujo se detiene en ese punto. Para manejar errores de forma elegante, se usan operadores de manejo de errores (los veremos en detalle en un artículo posterior), comoonErrorReturn(devuelve un valor por defecto y completa),onErrorResume(cambia a un Publisher alternativo), oretry(intenta la secuencia de nuevo). -
subscribeOnvspublishOn: ¡Otro concepto fundamental! Controlan la ejecución concurrente.subscribeOn(Scheduler scheduler): Afecta el contexto de ejecución del Publisher original y toda la cadena de operadores subsiguiente hasta que se encuentra otropublishOn. Define en quéScheduler(un ejecutor de tareas, similar a un Thread Pool) se ejecutará el trabajo del Publisher y dónde comenzará el pipeline. Si hay múltiplessubscribeOn, solo el primero (el más cercano al Publisher) tiene efecto.publishOn(Scheduler scheduler): Afecta el contexto de ejecución de los operadores que le siguen en la cadena, no los que están antes o el Publisher original. Es útil para cambiar de contexto de ejecución en medio de un pipeline, por ejemplo, para pasar del hilo rápido de I/O a un pool de hilos de trabajo para una operación intensiva en CPU. Puede haber múltiplespublishOnen una cadena, cada uno afectando a la parte del pipeline que le sigue.
Scheduler ioScheduler = Schedulers.boundedElastic(); // Scheduler adecuado para I/O Scheduler computationScheduler = Schedulers.parallel(); // Scheduler adecuado para CPU-bound Flux.range(1, 5) .map(i -> { System.out.println("Map 1 en hilo: " + Thread.currentThread().getName()); return i * 2; }) .publishOn(computationScheduler) // Los operadores que siguen se ejecutarán aquí .map(i -> { System.out.println("Map 2 en hilo: " + Thread.currentThread().getName()); return i + 1; }) .subscribeOn(ioScheduler) // El Publisher original y todo comienza aquí (si no hay publishOn antes) .subscribe(result -> System.out.println("Subscripción en hilo: " + Thread.currentThread().getName() + " - Resultado: " + result)); // Posible Salida (los nombres de hilos variarán): // Map 1 en hilo: boundedElastic-1 // Map 1 en hilo: boundedElastic-1 // Map 1 en hilo: boundedElastic-1 // Map 1 en hilo: boundedElastic-1 // Map 1 en hilo: boundedElastic-1 // Map 2 en hilo: parallel-1 // Subscripción en hilo: parallel-1 - Resultado: 3 // Map 2 en hilo: parallel-2 // Subscripción en hilo: parallel-2 - Resultado: 5 // Map 2 en hilo: parallel-3 // Subscripción en hilo: parallel-3 - Resultado: 7 // Map 2 en hilo: parallel-4 // Subscripción en hilo: parallel-4 - Resultado: 9 // Map 2 en hilo: parallel-1 // Subscripción en hilo: parallel-1 - Resultado: 11 // Observa cómo el primer map se ejecuta en el scheduler de subscribeOn (boundedElastic), // mientras que el segundo map y la subscripción se ejecutan en el scheduler de publishOn (parallel).- Cuándo usar cada uno:
- Usa
subscribeOncerca del origen de tu stream (elPublisherque quizás interactúa con una API bloqueante envuelta o realiza una operación de I/O inicial) para asegurar que esa parte del trabajo no bloquee tus hilos principales. - Usa
publishOnpara cambiar de contexto de ejecución en medio del pipeline, por ejemplo, si después de una operación de I/O (que se ejecuta en un scheduler de I/O), necesitas realizar cálculos intensivos en CPU y quieres usar un pool de hilos diferente dedicado a la computación para no saturar los hilos de I/O.
- Usa
Conclusión
En esta primera parte, hemos desempacado los conceptos fundamentales que motivaron la creación de Spring WebFlux: los desafíos del bloqueo en arquitecturas tradicionales y cómo la programación reactiva, basada en flujos de datos asíncronos y no bloqueantes, ofrece una solución elegante y escalable. Hemos introducido Project Reactor como la biblioteca clave detrás de WebFlux, explorando sus tipos principales (Mono y Flux), el modelo Publisher/Subscriber/Subscription y la importancia de los operadores. Conceptos como flatMap vs map y subscribeOn vs publishOn son esenciales para dominar la programación reactiva con Reactor.
Comprender estas bases es el primer paso crucial. En la próxima entrega de esta serie, nos adentraremos en la arquitectura específica de Spring WebFlux y cómo se construyen las aplicaciones sobre este modelo reactivo, explorando el EventLoop y las diferencias arquitectónicas con Spring MVC.
¡Mantente reactivo!