Múltiplas requisições com forkJoin - RxJS - Angular

Veja como paralelizar as requisições e saber quando todas foram finalizadas com o operador forkJoin do RxJS em um projeto Angular.

Múltiplas requisições com forkJoin - RxJS - Angular

Algumas aplicações precisam consultar várias APIs antes de mostrar a primeira tela para o usuário. Durante este carregamento é de praxe mostrar uma mensagem de "loading" para o usuário saber que sua aplicação está sendo carregada.

O que complica as coisas é quando temos várias requisições acontecendo ao mesmo tempo e precisamos saber se todas estas requisições foram finalizadas para que o "loading" possa ser ocultado.

Para entender este cenário considere o código abaixo onde o componente realiza 2 consultas à APIs e depois oculta o "loading":

import { Component, OnInit } from '@angular/core';
import { Teste1Service } from './teste1.service';
import { Teste2Service } from './teste2.service';

@Component({
  selector: 'my-app',
  templateUrl: './app.component.html',
  styleUrls: [ './app.component.css' ]
})
export class AppComponent implements OnInit  {
  // Variáveis para mensurar o tempo de execução
  inicio: Date;
  termino: Date;

  carregando: boolean = true; // mostra o loading inicialmente
  dadosService1: any;
  dadosService2: any;
  
  constructor(private teste1Service: Teste1Service,
              private teste2Service: Teste2Service) { }
  
  ngOnInit() {
    console.log(1);
    this.carregarDadosService1();
  }
  
  carregarDadosService1() {
    this.inicio = new Date();
    console.log(2);
    
    this.teste1Service.carregar().subscribe(dados1 => {
      console.log(3);
      
      this.dadosService1 = dados1;
      this.carregarDadosService2();
    });
  }
  
  carregarDadosService2() {
    console.log(4);
    
    this.teste2Service.carregar().subscribe(dados2 => {
      console.log(5);
      
      this.dadosService2 = dados2;
      this.carregando = false;  // esconde o loading

      this.termino = new Date();
      const tempoMS = this.termino.getTime() - this.inicio.getTime();
      console.log('Tempo de execução (ms): ', tempoMS);
    });
  }
}
meu.component-ts
<div *ngIf="!carregando; else templateCarregando">
    <div>Dados1: {{dadosService1 | json}}</div>
    <div>Dados2: {{dadosService2 | json}}</div>
</div>

<ng-template #templateCarregando>
  Carregando, por favor aguarde...
</ng-template>
meu.component.html

Analisando o código podemos ver que inicialmente o usuário irá visualizar a mensagem Carregando, por favor aguarde... já que a variável carregando tem o valor inicial false. Enquanto isto a aplicação irá consultar as APIs e quando tudo isso acabar, o "loading" será ocultado dando lugar aos dados na tela.

Deixei alguns pontos de console no código acima para facilitar a explicação e o entendimento da ordem de execução:

  1. Chamamos a função carregarDadosService1()
  2. console.log logo no início da função carregarDadosService1()
  3. Depois de alguns milisegundos a primeira service nos entrega os dados e então a carregarDadosService2() é chamada
  4. console.log na primeira linha dentro da função carregarDadosService2()
  5. Depois de alguns milissegundos a segunda service nos entrega os dados e então ocultamos a mensagem de "loading..." através da variável carregando = false
Animação mostrando a execução do código acima
Resultado de dois carregamentos iniciais

Observação: deixei algumas variáveis para calcularmos o tempo para finalizar as 2 requisições. Os resultados foram 753ms e 645ms para terminar o carregamento das 2 requisições. Mais a frente vamos compará-lo com o uso do forkJoin.

O ponto negativo que podemos destacar no exemplo acima é que as requisições são sequenciais, ou seja, só iniciamos a segunda requisição quando a primeira for finalizada.

Se tentarmos paralelizar as duas requisições, teremos um aumento na complexidade do controle da variável carregando porque que teremos que acompanhar o andamento das duas requisições e somente quando as duas requisições forem finalizadas atualizaremos a variável carregando para false.

Aguardando o resultado das requisições com forkJoin

Para resolver este problema podemos utilizar o forkJoin do RxJS. Ele acompanha o andamento das requisições (observables) passadas como parâmetro e emite um sinal quando todas as requisições forem finalizadas. Bem mais simples, não acha?

Refatorando um pouco, temos:

import { Component, OnInit } from '@angular/core';
import { Teste1Service } from './teste1.service';
import { Teste2Service } from './teste2.service';
import { forkJoin } from 'rxjs';

@Component({
  selector: 'my-app',
  templateUrl: './app.component.html',
  styleUrls: [ './app.component.css' ]
})
export class AppComponent implements OnInit  {
  inicio: Date;
  termino: Date;

  carregando: boolean = true;
  dadosService1: any;
  dadosService2: any;
  
  constructor(private teste1Service: Teste1Service,
              private teste2Service: Teste2Service) { }
  
  ngOnInit() {
    this.inicio = new Date();

    forkJoin({
      dados1: this.teste1Service.carregar(),
      dados2: this.teste2Service.carregar(),
    }).subscribe(resultado => {
      this.dadosService1 = resultado.dados1;
      this.dadosService2 = resultado.dados2;
      
      this.carregando = false;
      
      this.termino = new Date();
      const tempoMS = this.termino.getTime() - this.inicio.getTime();
      console.log('Tempo de execução (ms): ', tempoMS);
    });
  }
}
forkJoin will wait for all passed observables to complete and then it will emit an array or an object with last values from corresponding observables.

fonte: https://rxjs-dev.firebaseapp.com/api/index/function/forkJoin
Animação mostrando o resultado das duas requisições na tela e o tempo de execução no console do navegador
Exemplo das requisições paralelizadas

O resultado final é exatamente o mesmo, porém podemos destacar que a execução das consultas em paralelo é bem mais performática. Enquanto na consulta sequêncial obtivemos algo em torno de 700ms, na execução em paralelo obtivemos aproximadamente 270ms para concluir as requisições. Além disto,  deixamos o código muito mais simples.

Tratando erros

Não podemos deixar de falar sobre o tratamento de erros, a final de contas, eventualmente uma das requisições pode falhar.

Quando usamos o forkJoin, caso alguma das requisições retorne erro, ele emitirá o erro imediatamente e efetuará o unsubscribe nos outros observables. Veja no exemplo abaixo:

import { Component, OnInit } from '@angular/core';
import { Teste1Service } from './teste1.service';
import { Teste2Service } from './teste2.service';
import { forkJoin } from 'rxjs';

@Component({
  selector: 'my-app',
  templateUrl: './app.component.html',
  styleUrls: [ './app.component.css' ]
})
export class AppComponent implements OnInit  {
  inicio: Date; 
  termino: Date;

  carregando: boolean = true;
  dadosService1: any;
  dadosService2: any;
  
  constructor(private teste1Service: Teste1Service,
              private teste2Service: Teste2Service) { }
     
  ngOnInit() {
    this.inicio = new Date();

    forkJoin({
      dados1: this.teste1Service.carregar(),
      dados2: this.teste2Service.carregar(),
      dados3: this.teste2Service.carregarErro(),  // << irá gerar erro
    }).subscribe(resultado => {
      this.dadosService1 = resultado.dados1;
      this.dadosService2 = resultado.dados2;
      
      this.carregando = false;

      this.termino = new Date();
      const tempoMS = this.termino.getTime() - this.inicio.getTime();
      console.log('Tempo de execução (ms): ', tempoMS);
    }, erro => {
        // Se umas das 3 requisições der erro, este
        // trecho será executado
        console.log('Ocorreu um erro: ', erro);
        this.carregando = false;
    });
  }
}

Na imagem abaixo deixei marcado o ponto de console que mostra o erro e na variável erro conseguimos acessar o body da resposta através do erro.error:

Apresentação do erro no console do navegador emitido pelo forkJoin devido ao erro de uma das requisições
Imagem mostrando o que ocorre quando uma requisição apresenta erro como forkJoin

Considerações

Veja que utilizando o forkJoin as coisas ficaram bem mais simples. Podemos ter várias requisições e ainda assim saber exatamente quando todas forem concluídas.

Se você já trabalhou com Promises do JavaScript, com certeza vai lembrar do Promise.all que tem o mesmo comportamento.

Abaixo há o link com o código fonte completo:

forkjoin-angular-requisicoes - StackBlitz
Como utilizar o forkJoin em requisições HTTP - Angular
Exemplo dos códigos utilizados neste artigo

Links interessantes: