Introdução a Programação Reativa com WebFlux
Hoje em dia, já deve ter notado que o mundo digital não para de crescer e evoluir. Vivemos praticamente 24/7 online, e isso é incrível, mas também é um desafio enorme para os sistemas de software que fazem todo esse universo digital funcionar. Um dos heróis invisíveis desta história é uma tal de programação reativa. Ela é uma resposta super inteligente para lidar com a complexidade dos nossos sistemas interativos e em tempo real.
Você pode estar pensando: "Wow, parece super difícil. Como posso entender isso?" Bem, eu vou te contar um segredo: até as coisas mais complexas podem ser entendidas com as analogias certas. Então, vamos a uma delas. Vamos nos transportar para um lugar comum a todos nós, um lugar onde já estivemos ao menos uma vez na vida: um restaurante lotado.
Então, imagine que você está lá, no meio do jantar, num sábado à noite, o lugar está fervendo de gente. Como um restaurante lida com isso? Como ele pode servir todas as pessoas, garantir que todos estejam felizes, que a comida chegue na hora e que ninguém fique esperando mais do que o necessário? Essas são as perguntas que a programação reativa tenta responder. E ao longo da leitura, vamos desvendar juntos.
Um dia na vida de um restaurante síncrono
Imagine que você acabou de entrar em um aconchegante restaurante tradicional. À medida que a porta se fecha atrás de você, um amigável garçom acena em sua direção. Ele rapidamente conduz você e seus amigos para uma mesa com uma linda toalha xadrez e menus já posicionados de forma convidativa.
Vocês se acomodam, apreciam a decoração e a música suave que está tocando, folheando o menu enquanto decidem o que pedir. Depois de alguns minutos, o garçom se aproxima novamente, pronto para anotar seus pedidos.
O primeiro da sua turma, vamos chamá-lo de Bob, decide por um filé mignon. O garçom anota cuidadosamente o pedido e em seguida, ao invés de perguntar ao próximo da mesa o que vai querer, ele se vira e desaparece na direção da cozinha. Vocês ficam um pouco surpresos, mas logo retornam à conversa enquanto aguardam.
O tempo passa. O garçom não está à vista. Vocês notam que os outros clientes também parecem estar esperando. Finalmente, após o que parece uma eternidade, o garçom reaparece, carregando o prato de Bob. Ele serve o filé mignon, e então, com um sorriso, ele se vira para o próximo de vocês e pergunta: "E você, o que vai querer?".
Agora, vocês percebem o que está acontecendo. O garçom está levando cada pedido individualmente à cozinha e esperando que ele seja preparado antes de voltar e pegar o próximo pedido. Isso não é nada eficiente. O pobre garçom está passando mais tempo esperando pela cozinha do que realmente atendendo os clientes.
Isso é muito parecido com como a programação síncrona funciona. Assim como o garçom que espera o chef terminar o prato de Bob antes de pegar o próximo pedido, um processo ou thread em um programa síncrono precisa esperar uma operação ser concluída antes de seguir para a próxima. E assim como o garçom, enquanto ele está esperando, o processo não pode fazer mais nada, mesmo que tenha outras tarefas pendentes a realizar.
Mas, assim como no nosso restaurante, há uma maneira melhor de fazer as coisas...
O Restaurante Reativo
Agora, imagine um restaurante diferente, um restaurante "reativo". Aqui quando um cliente faz seu pedido, o garçom anota, leva-o à cozinha e, ao invés de ficar ali parado, olhando para o relógio enquanto o chef prepara a refeição, ele volta ao salão.
Com um piscar de olhos, o garçom já está atendendo ao próximo cliente, anotando outro pedido e levando-o para a cozinha. Ele repete isso, indo e vindo, sem parar um instante sequer. Não há tempo para o tédio ou a monotonia, cada momento é utilizado com eficiência.
O chef, ao terminar o preparo de um pedido, notifica o garçom, que prontamente recolhe o prato e o entrega ao cliente correto. Neste cenário, o garçom parece estar em todos os lugares ao mesmo tempo, sempre disponível, seja para anotar um novo pedido ou para entregar uma refeição recém-preparada.
Este restaurante reativo é um perfeito representante do paradigma da programação reativa. Cada tarefa que o garçom executa, como levar o pedido à cozinha, é não-bloqueante. Isto significa que enquanto o chef está ocupado preparando o pedido, o garçom não fica parado. Ele pode continuar atendendo outros clientes, maximizando seu tempo e recursos.
Neste modelo, o restaurante opera com uma eficiência incrível. Não há espaço para a ociosidade, as mesas são atendidas de forma mais rápida e a experiência para o cliente é muito mais satisfatória. O restaurante reativo é um lugar onde todos querem estar, porque é onde as coisas acontecem.
Da mesma maneira, a programação reativa traz estes benefícios para o mundo do desenvolvimento de software. Permitindo que uma operação continue enquanto outras estão sendo processadas, um sistema reativo pode lidar com mais solicitações ao mesmo tempo. Isso melhora a capacidade de resposta e o desempenho geral do sistema, criando uma experiência muito mais satisfatória para os usuários.
Nesta analogia, o chef da cozinha pode ser considerado como o servidor ou o sistema que está processando as tarefas ou requisições. No caso de uma aplicação web reativa, por exemplo, o chef seria o sistema que está lidando com as requisições HTTP. Quando um garçom (ou seja, uma thread ou processo de serviço) envia um pedido (ou seja, uma requisição) ao chef (ou seja, o servidor), começa a processar o pedido.
Enquanto o chef está ocupado, o garçom não precisa esperar. Ele pode continuar a atender outros clientes (ou seja, outras requisições), tornando o uso dos recursos mais eficiente. Quando o chef termina de preparar o pedido, ele notifica o garçom, que então pode entregar o pedido ao cliente. Em uma aplicação web reativa, isso seria semelhante ao servidor completando uma tarefa e então enviando a resposta de volta ao cliente.
Espero que tenha ficado claro. Agora vamos falar de WebFlux.
WebFlux, pra que? 🤔
WebFlux é um módulo introduzido no Spring Framework 5.0 (e incluído no Spring Boot 2.0 e versões posteriores, se não estou enganado pessoal 😅) que oferece um ambiente totalmente não-bloqueante para lidar com solicitações HTTP. Em termos simples, o WebFlux é uma alternativa ao tradicional Spring MVC, projetado para aproveitar os benefícios da programação reativa no desenvolvimento de aplicações web.
Se você se lembrar da nossa analogia com o restaurante, você pode pensar no WebFlux como o garçom do restaurante reativo, que é capaz de atender múltiplos clientes (ou seja, solicitações HTTP) simultaneamente, sem ter que esperar que o chef termine de preparar um prato (ou seja, uma operação de I/O) antes de poder atender ao próximo cliente.
A biblioteca nos permite criar controladores que retornam "promessas" de um resultado, em vez do resultado em si. Estas "promessas" são representadas pelos objetos Mono
e Flux
, que são fluxos de dados que podem ser processados de maneira assíncrona e não-bloqueante.
No WebFlux, o servidor não precisa dedicar um thread para cada solicitação, o que permite que ele lide com muitas solicitações simultâneas com um número menor de threads. Isso é especialmente útil para aplicações que lidam com um grande número de solicitações simultâneas e operações de I/O intensivas, como aplicações web de grande escala, pois aumenta a eficiência e a capacidade de resposta da aplicação.
Endpoints Reativos 🤯
Para entender a diferença entre um endpoint reativo com WebFlux e um que não é reativo, vamos começar definindo brevemente o que é um "response" em uma aplicação web.
Em uma aplicação web, quando você faz uma solicitação a um endpoint (como "/users/{id}
"), o servidor processa a solicitação e retorna uma resposta (ou "response"). Esta resposta pode ser qualquer coisa - um objeto JSON, uma página HTML, uma imagem, etc.
Agora, vamos considerar dois cenários diferentes - um usando o Spring MVC (não reativo) e outro usando o Spring WebFlux (reativo).
Spring (Não Reativo)
No Spring, quando você faz uma solicitação, uma thread é atribuída para processar essa solicitação. Se a solicitação envolve uma operação de I/O - por exemplo, buscar dados de um banco de dados ou de um serviço remoto - essa thread fica bloqueada e aguarda até que a operação de I/O seja concluída.1
Durante esse tempo, a thread está inativa e consumindo recursos, mesmo que não esteja realmente fazendo nada. Isso pode levar a problemas de escalabilidade, especialmente em aplicações de alto tráfego, pois o número de threads é um recurso limitado.
Spring WebFlux (Reativo)
Agora, vamos considerar o mesmo cenário com o WebFlux:
No WebFlux a mágica acontece, as operações de I/O são não-bloqueantes. Isso significa que, em vez de aguardar que a operação Input e Output seja concluída, a thread pode ser liberada para fazer outras coisas. Quando a operação de I/O é concluída, os dados são enviados de volta ao cliente como um fluxo de dados. O que resulta em um melhor uso dos recursos e uma melhor escalabilidade, pois a thread pode continuar processando outras solicitações enquanto espera pelos dados.
Resumindo, a principal diferença entre um response reativo e um não reativo é como as operações de I/O são tratadas.
Mono e Flux
Explicando bem resumidamente. A principal diferença entre eles é o número de eventos que eles representam:
Mono: Representa uma sequência de 0 ou 1 item. Em outras palavras, um
Mono
pode emitir no máximo um valor. Isso é útil quando você está trabalhando com operações assíncronas que retornam um único valor (ou nenhum valor), como uma busca de um único registro no banco de dados ou uma chamada para um serviço da web que retorna uma única resposta.Flux: Representa uma sequência de 0 ou mais itens (0 para N). Um
Flux
pode emitir um número arbitrário de valores. Isso é útil quando você está trabalhando com operações assíncronas que retornam vários valores, como uma consulta ao banco de dados que retorna vários registros ou uma chamada a um serviço da web que retorna vários resultados.
Eu vou tentar fornecer o máximo de ilustrações para tentar deixar claro as diferenças, conforme segue abaixo:
Mono: Imagine que temos uma caixa (🟦), e ela pode conter uma fruta (🍎) ou nenhuma (vazia). Ou seja, no máximo um item.
🟦→🍎: Um Mono que contém uma fruta (ou seja, um resultado).
🟦: Um Mono que está vazio (ou seja, nenhum resultado).
Flux: Agora, imagine que temos uma caixa maior (🟥) que pode conter várias frutas ou até mesmo nenhuma. Ou seja, pode ter zero ou múltiplos itens.
🟥→🍎,🍊,🍍: Um Flux que contém várias frutas (ou seja, múltiplos resultados).
🟥: Um Flux que está vazio (ou seja, nenhum resultado).
Mas quando utilizar cada um deles? A decisão de usar Flux
ou Mono
depende de quantos itens são esperados na resposta:
Flux
→ É comumente usado para situações em que você está retornando uma lista de objetos. Por exemplo, você usariaFlux
para um endpoint que retorna todos os usuários em um banco de dados.Mono
→ Utilizado para situações em que você está retornando um único objeto. Por exemplo, você usariaMono
para um endpoint que retorna os detalhes de um usuário específico, identificado por ID.
Ambos, Mono
e Flux
, fornecem uma grande quantidade de operações que você pode usar para transformar, combinar, filtrar, e de outra forma manipular os eventos que eles representam.
filter(): Filtra os elementos do Mono/Flux com base em um predicado (uma função que retorna um valor booleano).
merge() e concat(): Combina vários Flux/Monos em um único Flux/Mono.
zip(): Combina dois Flux/Monos, juntando os elementos em pares.
reduce(): Aplica uma função acumuladora aos elementos do Flux para produzir um único resultado (retornando um Mono).
buffer(): Agrupa elementos do Flux em listas de um tamanho especificado.
take(): Limita o número de elementos tomados do Flux.
skip(): Ignora um número especificado de elementos do Flux.
collectList() e collectMap(): Transforma um Flux em um Mono que emite uma lista ou mapa dos elementos.
then(): Ignora os elementos do Flux e completa quando o Flux completa.
doOnNext(), doOnError(), doOnComplete(): Permitem a execução de ações específicas quando ocorrem determinados eventos no Flux.
Não vamos explorar todos, mas vamos olhar de perto alguns.
doOnNext(), doOnError(), doOnComplete()
São operadores de manipulação de eventos no Flux e Mono. Eles permitem a execução de ações específicas quando ocorrem determinados eventos. Vamos ilustar para facilitar o entendimento inicial.
doOnNext(): Essa função é chamada cada vez que um novo item é emitido no Flux ou Mono. Imagine que cada item é uma carta 🂡 que um mágico 🔮 tira de um baralho. Cada vez que o mágico tira uma carta, ele mostra ao público (a ação realizada por doOnNext()).
🔮→🂡→😮 (o mágico tira uma carta e mostra ao público)
doOnError(): Essa função é chamada se houver um erro durante o processamento do Flux ou Mono. Imagine que o mágico está fazendo um truque que envolve pegar uma 🐇 de uma cartola 🎩. Se algo der errado e a 🐇 não estiver na cartola, o mágico pode mostrar um emoji de erro 😓.
🔮→🎩→❌→😓 (o mágico tenta fazer o truque, falha e mostra o erro)
doOnComplete(): Essa função é chamada quando o Flux ou Mono termina de emitir todos os itens. Imagine que o mágico tem uma caixa de truques 🎁 e pega um truque de cada vez para mostrar ao público. Quando todos os truques acabam, o mágico faz uma reverência 🙇.
🔮→🎁→🙇 (o mágico terminou todos os truques e faz uma reverência)
Agora vamos para algo mais real, que possa fazer sentido dentro da indústria de software para você.
doOnNext():
Podemos utilizar para realizar alguma operação cada vez que um novo item é emitido pela sequência. Por exemplo, imagine que estamos desenvolvendo um serviço de streaming de vídeos e deseja registrar cada vez que um novo vídeo é reproduzido. Podemos fazer algo assim:
videoStreamService.playVideo(videoId)
.doOnNext(video -> log.info("Video " + video.getTitle() + " is being played."))
.subscribe();
doOnError():
Esse é interessante e muito útil, podemos lidar com erros que ocorrem durante o processamento. Por exemplo, imagine que está desenvolvendo um serviço de pagamento e um erro ocorrer durante o processamento de um pagamento, você pode querer registrar esse erro e alertar o usuário. Veja um exemplo:
paymentService.processPayment(payment)
.doOnError(error -> {
log.error("Error processing payment: " + error.getMessage());
alertService.sendPaymentFailureAlert(user, payment);
})
.subscribe();
Aqui, se ocorrer um erro durante o processamento do pagamento, doOnError()
irá registrar uma mensagem de erro e enviar um alerta de falha de pagamento ao usuário.
doOnComplete():
Muito utilizado para realizar alguma operação quando todas as ações terminam. Pense que estamos desenvolvendo um serviço de e-mail em massa e quiser registrar quando todos os e-mails foram enviados, você poderia fazer algo assim:
emailService.sendBulkEmails(emailList)
.doOnComplete(() -> log.info("Finished sending all emails."))
.subscribe();
Para esse cenário, quando todos os e-mails foram enviados (ou seja, quando a sequência de e-mails termina), o o
perador registra uma mensagem informando que todos os e-mails foram enviados.
Map e FlatMap
Esses dois operadores são muito usados, seria legal entender como funcionam e porque precisamos.
Vamos iniciar com o map. É usado quando queremos transformar um objeto em outro objeto de forma não bloqueante. Permite aplicar uma função a um valor dentro de um tipo reativo, retornando um novo valor transformado, sem alterar o tipo reativo em si. Veja o exemplo abaixo:2
No exemplo do endpoint /users/{id}
, temos a necessidade de buscar um usuário pelo ID e retornar suas informações em um objeto UserDto
. A operação de busca pelo ID é feita de forma não bloqueante usando o método findById
do repositório userRepository
, que retorna um Mono<User>
.
Em seguida, utilizamos o operador map
para transformar o User
em um UserDto
. Dentro do map
, criamos uma nova instância de UserDto
com os campos desejados, como name
e email
. O operador map
aplica essa transformação, resultando em um Mono<UserDto>
.
Por fim, utilizamos o último map
para encapsular o UserDto
em um ResponseEntity
. Nesse caso, transformamos o UserDto
em um ResponseEntity<UserDto>
com o status HTTP 200 OK
.
Por outro lado, temos um comportamento diferente com o flatMap. Vamos considerar um cenário hipotético em que queremos buscar as postagens do blog de um usuário. Imagine que temos um método postRepository.findPostsByUserId(id)
que retorna um Flux<Post>
, um objeto que produzirá várias postagens de blog (ou nenhuma) de forma assíncrona.
Se quisermos incluir essas postagens no nosso UserDto
, podemos usar flatMap. Podemos fazer o seguinte:
Neste caso, para cada User
, estamos criando um Flux<Post>
com postRepository.findPostsByUserId(user.getId())
. Queremos transformar esse Flux<Post>
em uma lista, então usamos collectList()
para fazer isso, o que nos dá um Mono<List<Post>>
.
No entanto, queremos "achatar" esse Mono
para que possamos trabalhar diretamente com a List<Post>
. Por isso, usamos flatMap
.
A função que estamos aplicando com flatMap é :
user -> postRepository.findPostsByUserId(user.getId()).collectList().map(posts -> new UserDto(user.getName(), user.getEmail(), posts))
.
Essa função retorna um Mono<UserDto>
, que estamos "achatando" para um UserDto
para poder transformá-lo em um ResponseEntity
na próxima linha.
Como assim estamos achatando? O que quero dizer com isso?
"Achatar", neste contexto, é uma maneira de descrever a transformação que flatMap
realiza em um conjunto de coleções ou em um fluxo de fluxos.
A operação flatMap
é chamada de "flat" porque transforma uma estrutura de dados aninhada (como um Mono<Mono<T>>
ou Flux<Flux<T>>
) em uma estrutura de dados de nível único (como um Mono<T>
ou Flux<T>
). ⚠️
Para entender melhor, vamos olhar um exemplo simplificado:
Vamos supor que você tenha um Flux<Flux<Integer>>
. Em termos leigos, você pode pensar nisso como uma "lista de listas". Aqui está o que poderia parecer:
Flux1: [1, 2, 3]
Flux2: [4, 5, 6]
Flux3: [7, 8, 9]
Se você aplicar flatMap
a este Flux<Flux<Integer>>
, você obterá um Flux<Integer>
:
Flux: [1, 2, 3, 4, 5, 6, 7, 8, 9]
Portanto, a operação flatMap
"achatou" a estrutura de dados de duas dimensões em uma estrutura de dados de uma única dimensão.
No caso do nosso exemplo:
postRepository.findPostsByUserId(user.getId()).collectList();
Temos um Mono<List<Post>>
, que podemos pensar como uma "caixa que vai conter uma lista de postagens". Ao usar flatMap
, estamos dizendo "quando essa caixa estiver preenchida, tire a lista de postagens de dentro e trabalhe com ela diretamente". Isso "achata" o Mono<List<Post>>
para uma List<Post>
, permitindo que você crie o UserDto
com a lista de postagens.
Não ficou claro? Não desista! Vamos para uma breve analogia.
Imagine que você tem uma caixa cheia de caixas menores, e dentro de cada uma dessas caixas menores há uma fruta.
Usando o map
: É como se você pegasse cada caixa menor e trocasse a fruta dentro dela por uma fruta diferente. No final, você ainda teria uma caixa cheia de caixas menores, mas as frutas dentro delas seriam diferentes.
Usando o flatMap
: É como se você abrisse todas as caixas menores e colocasse todas as frutas diretamente na caixa maior, eliminando as caixas menores. No final, você teria uma caixa cheia de frutas, não mais uma caixa cheia de caixas menores.
Vamos deixar isso mais visual:
flatMap: 🟦→🟦→🍉,🍊,🍋,🍓vai para 🟦→🍉,🍊,🍋,🍓.
Nessa linha, você tem uma caixa 🟦 que contém outra caixa 🟦, que por sua vez contém várias frutas 🍉,🍊,🍋,🍓. Quando você aplica
flatMap
, a caixa interna é removida, e você acaba com uma única caixa 🟦 contendo todas as frutas. Alternativamente, você pode pensar nisso como removendo todas as caixas e ficando apenas com as frutas.map: 🟦→🟦→🍉,🍊,🍋,🍓vai para 🟦→🟦→🍎,🍒,🍍,🍇
Aqui, você tem a mesma configuração inicial: uma caixa 🟦 contendo outra caixa 🟦, que contém várias frutas 🍉,🍊,🍋,🍓. Quando você aplica
map
, as caixas permanecem as mesmas, mas as frutas dentro da caixa interna são transformadas em diferentes frutas 🍎,🍒,🍍,🍇.
Nesse sentido, map
transforma os elementos dentro das caixas menores, enquanto flatMap
elimina as caixas menores e deixa apenas os elementos.
IMPORTANTE! 👇
O que cada emoji acima representa:
🟦 representa uma coleção de dados, seja uma lista, um Flux
, um Mono
ou qualquer outra estrutura que possa conter vários itens. Em outras palavras, 🟦 é o contêiner ou a "caixa".
🍉, 🍊, 🍋, 🍓 representam os dados individuais ou elementos contidos na coleção. Estes podem ser objetos simples, objetos complexos com vários atributos ou até mesmo outras coleções. Em outras palavras, 🍉, 🍊, 🍋, 🍓 são as "frutas" dentro da "caixa".
No contexto de Spring WebFlux, isso pode ser representado como:
Mono<User>
: Aqui,Mono
é o contêiner (🟦) que contém um único elemento, que é uma instância deUser
(🍉). Este contêiner só pode conter até um elemento.Flux<User>
: Neste caso,Flux
é o contêiner (🟦) que pode conter múltiplas instâncias deUser
(várias 🍉). Este contêiner pode conter zero, um ou vários elementos.Flux<List<User>>
: 🟦(🟦(🍉,🍉,🍉...)) - OFlux
é a 🟦 externa que pode conter múltiplas listas deUser
(cada lista é uma 🟦 interna). Cada lista pode conter várias instâncias deUser
(🍉).
Podemos ver isso no código abaixo:
Vamos dissecar ainda mais a parte do flatMap
:
Flux<String> flatBox = boxes.flatMap(fruitMono -> fruitMono);
ATENÇÃO! ⚠️ Nesta linha, estamos aplicando a operação flatMap
a boxes
, que é um Flux<Mono<String>>
. Isto é, temos um fluxo que emite Monos, cada um contendo uma fruta.
A função dentro do flatMap
(fruitMono -> fruitMono
) é chamada para cada elemento do fluxo. Cada elemento é um Mono<String>
. Esta função retorna o próprio Mono<String>
sem modificação.
No entanto, a parte crucial aqui é que o flatMap
vai "abrir" ou "extrair" o valor de cada Mono<String>
(ou seja, a fruta). O resultado é que, o flatMap retorna um Flux<String> ao invés de um Flux<Mono<String>>. Ou seja, transformamos um fluxo de Monos (caixas menores) em um fluxo de Strings (frutas), removendo as caixas menores com sucesso. É por isso que chamamos flatMap
de "achatamento" - estamos achatando um Flux<Mono<String>>
em um Flux<String>
. A essência do flatMap
é achatar os dados.
Agora, voltando para o exemplo da controller, vemos que:
postRepository.findPostsByUserId(user.getId());
Retorna um Flux<Post>
, que é uma "caixa" que pode conter várias "frutas" (neste caso, Post
objetos). Esta "caixa" é uma "caixa" única, não uma caixa dentro de outra caixa. No entanto, quando usamos o collectList()
na linha:
user -> postRepository.findPostsByUserId(user.getId()).collectList()
,
Estamos transformando o Flux<Post>
em Mono<List<Post>>.
Isso pode ser visto como uma caixa (Mono
) contendo outra caixa (List
). A "caixa" externa é o Mono
, que pode conter uma única "caixa" interna. A "caixa" interna é a List
, que pode conter várias "frutas" (Post
objetos).
Espero que agora tenha ficado mais claro. Ah e se quiser testar, segue o exemplo em código:
public static void main(String[] args) {
// Criamos um Flux de Monos para representar as caixas menores dentro da caixa maior
Flux<Mono<String>> boxes = Flux.just(
Mono.just("Apple"),
Mono.just("Orange"),
Mono.just("Pineapple"),
Mono.just("Grapes"),
Mono.just("Watermelon")
);
// Usando o map: trocamos as frutas por números
Flux<Mono<Integer>> boxesWithNumbers = boxes.map(fruitMono ->
fruitMono.map(fruit -> fruit.length())
);
System.out.println("Using map:");
boxesWithNumbers.subscribe(fruitMono ->
fruitMono.subscribe(length -> System.out.println(length))
);
// Usando o flatMap: removemos as caixas menores
Flux<String> flatBox = boxes.flatMap(fruitMono -> fruitMono);
System.out.println("Using flatMap:");
flatBox.subscribe(fruit -> System.out.println(fruit));
}
Lembre-se que não podemos acessar o valor dentro de um Mono
ou Flux
diretamente, pois eles são contêineres para um processo assíncrono. As transformações ou operações que queremos executar no valor dentro do Mono
ou Flux
devem ser feitas dentro de operações como map()
, flatMap().
Outro importante lembrete! ⚠️👇
Embora as operações sejam assíncronas, a ordem em que os itens são emitidos pode ser diferente entre map e flatMap devido ao fato de flatMap combinar as emissões de vários Publishers.
Vamos iniciar com o map()
, o operador aplica uma função a cada item da sequência, mas essa função retorna um valor simples, não um Publisher
.
Nesse exemplo, cada "bolinha" 🔵 pode representar um pedido, e a função aplicada em map()
é uma operação que, digamos, transforma o pedido em uma nota fiscal (representada aqui como 📃).
O processo seria assim:
🔵(Pedido 1)→ map() →📃(Nota fiscal 1)
🔵(Pedido 2)→ map() →📃(Nota fiscal 2)
🔵(Pedido 3)→ map() →📃(Nota fiscal 3)
E a saída final seria:
📃(Nota fiscal do Pedido 1)📃(Nota fiscal do Pedido 2)📃(Nota fiscal do Pedido 3)
Como o map()
transforma diretamente cada item em um novo valor, a saída é uma sequência de notas fiscais correspondentes a cada pedido. Nesse cenário, a ordem dos pedidos é preservada, já que não há a complexidade adicional de lidar com vários Publisher
s
No caso de um flatMap()
, a função aplicada a cada item retorna um Publisher
(que pode ser um Mono
ou um Flux
), e o flatMap()
combina as emissões de todos esses Publisher
s em um único Flux
. Imagine que cada "bolinha" 🔵 seja um pedido de e-commerce, e a função aplicada em flatMap()
seja uma operação que recupera todos os itens desse pedido do banco de dados. Cada item do pedido é representado por uma fruta (🍎,🍊,🍋, etc.). O resultado seria assim:
🔵(Pedido 1)→ flatMap() →🍎(Item 1)🍊(Item 2)🍋(Item 3)
🔵(Pedido 2)→ flatMap() →🍓(Item 1)🍉(Item 2)🍇(Item 3)
🔵(Pedido 3)→ flatMap() →🥝(Item 1)🍒(Item 2)🍌(Item 3)
Como o flatMap()
executa operações assíncronas e combina as emissões de vários Publisher
s, a ordem dos itens na saída final pode variar dependendo de quando cada Publisher
interno completa suas emissões. Portanto, a ordem final dos itens pode ser diferente da ordem dos pedidos originais:
🍎(Item do Pedido 1)
🍓(Item do Pedido 2)
🥝(Item do Pedido 3)
🍊(Item do Pedido 1)
🍉(Item do Pedido 2)
🍒(Item do Pedido 3)
🍋(Item do Pedido 1)
🍇(Item do Pedido 2)
🍌(Item do Pedido 3)
Mas e se precisamos respeitar a ordem dos itens?
Podemos resolver isso utilizando operador concatMap
que é similar ao flatMap
, mas respeita a ordem original dos itens. Vamos representar isso usando emojis novamente.
O concatMap()
, assim como o flatMap()
, aplica uma função a cada item que retorna um Publisher
(que pode ser um Mono
ou um Flux
). A diferença crucial é que concatMap()
preserva a ordem dos resultados, garantindo que todas as emissões do primeiro Publisher
sejam concluídas antes de iniciar as emissões do próximo Publisher
.
Neste cenário, cada "bolinha" 🔵 é um pedido, e a função aplicada em concatMap()
é uma operação que recupera todos os itens desse pedido do banco de dados. Cada item do pedido é representado por uma fruta (🍎,🍊,🍋, etc.).
O processo seria assim:
🔵(Pedido 1)→ concatMap() →🍎(Item 1)🍊(Item 2)🍋(Item 3)
🔵(Pedido 2)→ concatMap() →🍓(Item 1)🍉(Item 2)🍇(Item 3)
🔵(Pedido 3)→ concatMap() →🥝(Item 1)🍒(Item 2)🍌(Item 3)
E a saída final seria:
🍎(Item 1 do Pedido 1)
🍊(Item 2 do Pedido 1)
🍋(Item 3 do Pedido 1)
🍓(Item 1 do Pedido 2)
🍉(Item 2 do Pedido 2)
🍇(Item 3 do Pedido 2)
🥝(Item 1 do Pedido 3)
🍒(Item 2 do Pedido 3)
🍌(Item 3 do Pedido 3)
Essa saída mantém a ordem dos pedidos e dos itens de cada pedido, diferente do que ocorreu no exemplo do flatMap. Portanto, concatMap()
é uma boa escolha quando a ordem dos dados é crucial.
Porém, vale lembrar que, ao usar concatMap
, estamos abrindo mão da vantagem da execução paralela que flatMap
oferece, pois concatMap
aguarda a conclusão de cada fluxo interno antes de iniciar o próximo. Isso pode resultar em tempos de execução mais longos se as operações dentro do concatMap
forem lentas ou bloqueantes.
Conclusão
A programação reativa e o uso de WebFlux em particular são uma maneira poderosa de construir aplicações que são eficientes, resilientes e flexíveis. Isso se torna especialmente relevante em sistemas altamente concorrentes ou que lidam com fluxos de dados em tempo real.
A programação reativa difere da programação imperativa tradicional, na qual os métodos e funções retornam resultados diretos. Em vez disso, funções reativas retornam "promessas" de resultados - wrappers, como Mono
e Flux
, que eventualmente conterão os dados quando eles estiverem prontos.
Os métodos map
e flatMap
são operações fundamentais no contexto reativo. Mono
e Flux
são os dois principais tipos de publicadores no Reactor.
Em resumo, o paradigma reativo é uma ferramenta poderosa para lidar com operações assíncronas e não bloqueantes de maneira eficiente e clara, tornando-o muito adequado para o desenvolvimento moderno de aplicativos web. É um conceito que pode exigir um pouco de mudança de mentalidade se você estiver acostumado com a programação síncrona e bloqueante, mas o esforço para entender e aplicar esses conceitos pode resultar em aplicações mais eficientes e responsivas. Espero trazer mais exemplos e operadores no futuro.
Fico por aqui neste post. Se gostou, por favor compartilhe com outros programadores! Deixe seu comentário. Estou à disposição para tirar dúvidas também pelo LinkedIn. Até o próximo post pessoal! 😉
Input/Output, que se refere a qualquer tipo de operação que envolve a transferência de dados para dentro ou para fora de um computador ou entre componentes de computação.
Map
é chamado de "map" porque aplica uma função (ou "mapeamento") a cada item em uma estrutura de dados.
Por exemplo, se tivermos um Flux<String>
contendo as strings "1", "2", e "3", e aplicarmos a operação map
com a função Integer::parseInt
, o Flux<String>
será "mapeado" para um Flux<Integer>
contendo os números 1, 2, e 3. Em outras palavras, cada string foi "mapeada" para um número correspondente.