Skip to content

25. Temas Avanzados y Expertos

Spring WebFlux es el framework reactivo de Spring para construir aplicaciones no bloqueantes y escalables.

CaracterísticaSpring MVCSpring WebFlux
ModeloSíncrono/BloqueanteAsíncrono/No bloqueante
ServidorTomcat, JettyNetty, Undertow
ThreadsThread por requestEvent loop
EscalabilidadVerticalHorizontal
Caso de usoCRUD tradicionalStreaming, alta concurrencia
Dependencia WebFlux
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-webflux</artifactId>
</dependency>
Mono y Flux
// Mono: 0 o 1 elemento
Mono<Usuario> usuario = usuarioRepository.findById(id);
// Flux: 0 a N elementos
Flux<Producto> productos = productoRepository.findAll();
// Operadores comunes
Mono<String> resultado = Mono.just("Hola")
.map(String::toUpperCase)
.flatMap(s -> Mono.just(s + " Mundo"))
.filter(s -> s.length() > 5)
.defaultIfEmpty("Vacío");
Flux<Integer> numeros = Flux.range(1, 10)
.filter(n -> n % 2 == 0)
.map(n -> n * 2)
.take(3);
Controller reactivo
@RestController
@RequestMapping("/api/productos")
@RequiredArgsConstructor
public class ProductoController {
private final ProductoService productoService;
@GetMapping
public Flux<ProductoDTO> listar() {
return productoService.listar();
}
@GetMapping("/{id}")
public Mono<ResponseEntity<ProductoDTO>> obtener(@PathVariable String id) {
return productoService.obtenerPorId(id)
.map(ResponseEntity::ok)
.defaultIfEmpty(ResponseEntity.notFound().build());
}
@PostMapping
@ResponseStatus(HttpStatus.CREATED)
public Mono<ProductoDTO> crear(@Valid @RequestBody Mono<ProductoCreateDTO> dto) {
return dto.flatMap(productoService::crear);
}
@DeleteMapping("/{id}")
public Mono<Void> eliminar(@PathVariable String id) {
return productoService.eliminar(id);
}
// Streaming (Server-Sent Events)
@GetMapping(value = "/stream", produces = MediaType.TEXT_EVENT_STREAM_VALUE)
public Flux<ProductoDTO> stream() {
return productoService.listar()
.delayElements(Duration.ofSeconds(1));
}
}
Dependencia R2DBC
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-r2dbc</artifactId>
</dependency>
<dependency>
<groupId>io.r2dbc</groupId>
<artifactId>r2dbc-postgresql</artifactId>
</dependency>
Repository R2DBC
public interface ProductoRepository extends ReactiveCrudRepository<Producto, Long> {
Flux<Producto> findByCategoria(String categoria);
Mono<Producto> findByNombre(String nombre);
@Query("SELECT * FROM productos WHERE precio > :precio")
Flux<Producto> findByPrecioMayorQue(BigDecimal precio);
}
@Service
@RequiredArgsConstructor
public class ProductoService {
private final ProductoRepository repository;
public Flux<ProductoDTO> listar() {
return repository.findAll()
.map(this::toDTO);
}
public Mono<ProductoDTO> crear(ProductoCreateDTO dto) {
return Mono.just(dto)
.map(this::toEntity)
.flatMap(repository::save)
.map(this::toDTO);
}
}
WebClient
@Service
public class ExternalApiService {
private final WebClient webClient;
public ExternalApiService(WebClient.Builder builder) {
this.webClient = builder
.baseUrl("https://api.externa.com")
.defaultHeader(HttpHeaders.CONTENT_TYPE, MediaType.APPLICATION_JSON_VALUE)
.build();
}
public Mono<DatosDTO> obtenerDatos(String id) {
return webClient.get()
.uri("/datos/{id}", id)
.retrieve()
.onStatus(HttpStatusCode::is4xxClientError,
response -> Mono.error(new NotFoundException("No encontrado")))
.bodyToMono(DatosDTO.class)
.timeout(Duration.ofSeconds(5))
.retryWhen(Retry.backoff(3, Duration.ofSeconds(1)));
}
public Flux<EventoDTO> streamEventos() {
return webClient.get()
.uri("/eventos/stream")
.accept(MediaType.TEXT_EVENT_STREAM)
.retrieve()
.bodyToFlux(EventoDTO.class);
}
}

Compila aplicaciones Spring a ejecutables nativos usando GraalVM, logrando:

  • Arranque instantáneo (~50ms vs ~2s)
  • Menor consumo de memoria (~50MB vs ~200MB)
  • Ideal para serverless y contenedores
Configuración Native
<plugin>
<groupId>org.graalvm.buildtools</groupId>
<artifactId>native-maven-plugin</artifactId>
</plugin>
<!-- En spring-boot-maven-plugin -->
<configuration>
<image>
<builder>paketobuildpacks/builder:tiny</builder>
<env>
<BP_NATIVE_IMAGE>true</BP_NATIVE_IMAGE>
</env>
</image>
</configuration>
Compilar nativo
# Con Maven
mvn -Pnative native:compile
# Con Buildpacks (Docker)
mvn spring-boot:build-image -Pnative
# Ejecutar
./target/mi-app
Hints de reflexión
// Registrar clases para reflexión
@RegisterReflectionForBinding({
ProductoDTO.class,
UsuarioDTO.class
})
@SpringBootApplication
public class Application {
public static void main(String[] args) {
SpringApplication.run(Application.class, args);
}
}
// O con archivo de configuración
// src/main/resources/META-INF/native-image/reflect-config.json
[
{
"name": "com.miapp.dto.ProductoDTO",
"allDeclaredConstructors": true,
"allDeclaredMethods": true,
"allDeclaredFields": true
}
]

Configuración WebSocket
@Configuration
@EnableWebSocketMessageBroker
public class WebSocketConfig implements WebSocketMessageBrokerConfigurer {
@Override
public void configureMessageBroker(MessageBrokerRegistry config) {
config.enableSimpleBroker("/topic", "/queue");
config.setApplicationDestinationPrefixes("/app");
config.setUserDestinationPrefix("/user");
}
@Override
public void registerStompEndpoints(StompEndpointRegistry registry) {
registry.addEndpoint("/ws")
.setAllowedOrigins("*")
.withSockJS();
}
}
Controller WebSocket
@Controller
@RequiredArgsConstructor
public class ChatController {
private final SimpMessagingTemplate messagingTemplate;
// Recibir mensaje y broadcast a todos
@MessageMapping("/chat.send")
@SendTo("/topic/mensajes")
public MensajeDTO enviarMensaje(MensajeDTO mensaje) {
mensaje.setTimestamp(LocalDateTime.now());
return mensaje;
}
// Mensaje privado a usuario específico
@MessageMapping("/chat.privado")
public void mensajePrivado(MensajePrivadoDTO mensaje) {
messagingTemplate.convertAndSendToUser(
mensaje.getDestinatario(),
"/queue/privado",
mensaje
);
}
// Notificación desde servicio
public void notificarNuevoPedido(PedidoDTO pedido) {
messagingTemplate.convertAndSend("/topic/pedidos", pedido);
}
}
Cliente WebSocket
import SockJS from 'sockjs-client';
import { Stomp } from '@stomp/stompjs';
const socket = new SockJS('http://localhost:8080/ws');
const stompClient = Stomp.over(socket);
stompClient.connect({}, () => {
// Suscribirse a topic público
stompClient.subscribe('/topic/mensajes', (message) => {
const data = JSON.parse(message.body);
console.log('Mensaje recibido:', data);
});
// Suscribirse a cola privada
stompClient.subscribe('/user/queue/privado', (message) => {
const data = JSON.parse(message.body);
console.log('Mensaje privado:', data);
});
// Enviar mensaje
stompClient.send('/app/chat.send', {}, JSON.stringify({
contenido: 'Hola!',
usuario: 'Juan'
}));
});

Habilitar Scheduling
@SpringBootApplication
@EnableScheduling
@EnableAsync
public class Application {
public static void main(String[] args) {
SpringApplication.run(Application.class, args);
}
}
Tareas programadas
@Service
@Slf4j
public class TareasService {
// Cada 5 minutos
@Scheduled(fixedRate = 300000)
public void limpiarCache() {
log.info("Limpiando caché...");
}
// 5 segundos después de terminar la anterior
@Scheduled(fixedDelay = 5000)
public void procesarCola() {
log.info("Procesando cola...");
}
// Expresión cron: todos los días a las 2:00 AM
@Scheduled(cron = "0 0 2 * * *")
public void backupDiario() {
log.info("Ejecutando backup...");
}
// Cada hora en horario laboral (L-V, 9-18)
@Scheduled(cron = "0 0 9-18 * * MON-FRI")
public void reporteHorario() {
log.info("Generando reporte...");
}
// Con zona horaria
@Scheduled(cron = "0 0 8 * * *", zone = "America/Mexico_City")
public void tareaConZona() {
log.info("Tarea con zona horaria...");
}
}
Configuración de pool
@Configuration
public class SchedulingConfig implements SchedulingConfigurer {
@Override
public void configureTasks(ScheduledTaskRegistrar taskRegistrar) {
taskRegistrar.setScheduler(taskScheduler());
}
@Bean
public TaskScheduler taskScheduler() {
ThreadPoolTaskScheduler scheduler = new ThreadPoolTaskScheduler();
scheduler.setPoolSize(5);
scheduler.setThreadNamePrefix("scheduled-");
scheduler.setErrorHandler(t -> log.error("Error en tarea", t));
return scheduler;
}
}

Dependencia GraphQL
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-graphql</artifactId>
</dependency>
Schema GraphQL
# src/main/resources/graphql/schema.graphqls
type Query {
productos: [Producto!]!
producto(id: ID!): Producto
productosPorCategoria(categoria: String!): [Producto!]!
}
type Mutation {
crearProducto(input: ProductoInput!): Producto!
actualizarProducto(id: ID!, input: ProductoInput!): Producto
eliminarProducto(id: ID!): Boolean!
}
type Producto {
id: ID!
nombre: String!
precio: Float!
categoria: Categoria!
createdAt: String
}
type Categoria {
id: ID!
nombre: String!
productos: [Producto!]!
}
input ProductoInput {
nombre: String!
precio: Float!
categoriaId: ID!
}
Controller GraphQL
@Controller
@RequiredArgsConstructor
public class ProductoGraphQLController {
private final ProductoService productoService;
private final CategoriaService categoriaService;
@QueryMapping
public List<Producto> productos() {
return productoService.listar();
}
@QueryMapping
public Producto producto(@Argument Long id) {
return productoService.obtenerPorId(id);
}
@MutationMapping
public Producto crearProducto(@Argument ProductoInput input) {
return productoService.crear(input);
}
// Resolver para campo anidado
@SchemaMapping(typeName = "Producto", field = "categoria")
public Categoria categoria(Producto producto) {
return categoriaService.obtenerPorId(producto.getCategoriaId());
}
// Batch loading para evitar N+1
@BatchMapping
public Map<Producto, Categoria> categoria(List<Producto> productos) {
Set<Long> categoriaIds = productos.stream()
.map(Producto::getCategoriaId)
.collect(Collectors.toSet());
Map<Long, Categoria> categorias = categoriaService.obtenerPorIds(categoriaIds)
.stream()
.collect(Collectors.toMap(Categoria::getId, c -> c));
return productos.stream()
.collect(Collectors.toMap(p -> p, p -> categorias.get(p.getCategoriaId())));
}
}

Dependencia Prometheus
<dependency>
<groupId>io.micrometer</groupId>
<artifactId>micrometer-registry-prometheus</artifactId>
</dependency>
Configuración métricas
# application.properties
management.endpoints.web.exposure.include=health,info,prometheus,metrics
management.metrics.tags.application=mi-app
management.metrics.tags.environment=prod
Dependencia Tracing
<dependency>
<groupId>io.micrometer</groupId>
<artifactId>micrometer-tracing-bridge-brave</artifactId>
</dependency>
<dependency>
<groupId>io.zipkin.reporter2</groupId>
<artifactId>zipkin-reporter-brave</artifactId>
</dependency>
Configuración Zipkin
# application.properties
management.tracing.sampling.probability=1.0
management.zipkin.tracing.endpoint=http://localhost:9411/api/v2/spans
Métricas personalizadas
@Service
@RequiredArgsConstructor
public class PedidoService {
private final MeterRegistry meterRegistry;
@Timed(value = "pedidos.crear", description = "Tiempo de creación de pedidos")
@Counted(value = "pedidos.total", description = "Total de pedidos")
public Pedido crear(PedidoDTO dto) {
// Lógica
}
// Métricas manuales
public void procesarPago(Pedido pedido) {
Timer.Sample sample = Timer.start(meterRegistry);
try {
// Procesar pago
meterRegistry.counter("pagos.exitosos").increment();
} catch (Exception e) {
meterRegistry.counter("pagos.fallidos").increment();
throw e;
} finally {
sample.stop(meterRegistry.timer("pagos.tiempo"));
}
}
}
🐝