Paralelismo em chamadas http com Angular

Apresentação

Olá dev, vamos escovar bits no lado do cliente usando tudo que o RxJS nos oferece. Pra quem não sabe, o RxJS está incluido no core do Angular, onde trabalhamos na arquitetura MVVM e Observable.

O que são observables?

Observables, são:

Uma funcionalidade da biblioteca RxJS, utilizada principalmente em aplicações Angular/Nestjs para lidar com transferência de dados assíncrona.

RxJS

O RxJS é uma poderosa biblioteca reativa baseada no reactive X da Microsoft.

Nota:

Todas as funcionalidades a seguir, também podem ser usadas em quaisquer projetos que contenha o RxJS instalado. A instalação do RxJS poder ser feita a partir do seu instalador de pacotes favorito (yarn, npm, pnpm).

npm install rxjs

typings install es6-shim --ambient

npm install @reactivex/rxjs

npm install @reactivex/rxjs@7.3.0

#CDN

https://unpkg.com/rxjs@^7/dist/bundles/rxjs.umd.min.js

Adapte a instalação ao seu projeto e seu instalador de pacotes. Para mais detalhes, consulte a documentação oficial do RxJS aqui

Obs: Reactive X é um padrão, existe implementação confirmada em 18 linguages e 3 plataformas. Consulte a lista de linguagens na documentação oficial do Reactive X aqui.

Inicio

Agora, vamos falar de Angular! Se você ainda não precisou lidar com multiplas chamadas http no Angular, fica aqui os meus R$ 0,20 (vinte centavos) de contribuição para vocês.

Quando estamos aprendendo fazer chamadas http, é normal usarmos fetch API, XMLHttpRequest, Axios e até mesmo o modulo http do node. Mas nunca é suficiente para nos ajudar a solucionar problemas de multiplas chamadas http, onde temos varias chamadas para a API de forma recursiva e desnecessaria, causando vazamentos de memoria, aumentando o custo da aplicação e causando problemas serios de desempenho.

Simulação com Promise<T>

Vamos imaginar, que você tenha uma rota na sua URL que recebe um "array" de IDs para buscar metadados na API. Então, teremos algo semelhante a:

/*
Sim, estamos dizendo que ExampleForRequest é uma extensão de HTTP
e precisa seguir o contrato URLQuerys
*/
public class ExampleForRequest extends HTTP implements URLQuerys {

    private urlSegmentQuery = new URLSearchParams(window.location.search).get('id');

    private getMetadataByUrlSegmentId(): Metadata[] {
        try {
            const ids = this.urlSegmentQuery.split(',' || '');
            if (!ids) return;
            const responses = [];

            for (const id of ids) {
                const request = axios.get('/api/metadata', id);
                responses.push(resquest.data);
            }

            return responses.map( it => it.metadata );  

        } catch (err) {
            if (err instaceof MyCustomExeption){
                console.error(err.message);
                return;
            }
            
            console.error("Erro desconhecido\n\n %o", err )
        }
    }
    
    public getMetadataByUrlParams(): Metadata | null {
        return this.getMetadataByUrlSegmentId()
    }

}

Você talvez esteja se perguntando o "Porquê diabos esse cara perdeu tanto tempo escrevendo esse codigo imenso na mão?" A resposta? Porque quando estamos desenvolvendo em javascript, o codigo tem no minimo o dobro do tamanho, existem tratamentos para 90% dos erros e ainda acontecem bugs. Mas então, exaustivo esse codigo né? vai ter que fazer um sempre que fizer algo que retorna uma Promise. Isso porque o codigo NÃO ESTÁ COMPLETO, apenas coloquei o necessario para bom entendimento.

Agora, vamos comar a escovação de bits 🪥.

Sempre que chamamos this.getMetadataByUrlSegmentId() podemos receber de volta um Array<Metadata> ou void. Porque void? porque se não tem querys na url, o metodo nunca será executado if (!ids) return.

Para cada id que esteja presente na URL, teremos que transformar em um array usando a bom e velha força bruta, onde a gente intera por todos os elementos, faz uma chamada get e aloca dentro de um array.

const responses = [];

    for (const id of ids) {
        const request = axios.get('/api/metadata', id);
        responses.push(resquest.data);
    }

Até o momento, estaria tudo ok se fosse um projeto rodando no seu pc com seu poderoso i7 12º com nada menos que 32GB de RAM que você não usa pra nada.

Mas se você não pulou o ensino fundamental da programação, que é nada menos que logica de programação com fundamentos da computação, você sabe que arrays são imutaves em memoria. Portanto, para cada push, você busca o array alocado na Heap, cria um novo array com um elemento no final e devolve para a Heap. Se você sabe como promises funcionam, você sabe que getMetadataByUrlSegmentId deve ser assincrona e deve esperar axios.get completar para dar andamento no codigo. Porque? Novamente, se você estudou como o javascript funciona antes de sair escrevendo codigo, você sabe que javascript no lado do cliente é singlethread, portanto ele faz paralelismo. Significa que se você não executar tudo que precisa de forma assincrona, você corre o risco de travar sua unica threaded e travar a aplicação web inteira. Portanto adicione isso no peso para a execução de getMetadataByUrlSegmentId.

Agora finalmente, depois de toda a execução de um loop esquisito, temos a ultima linha. return responses.map( it => it.metadata ). O que isso significa? Que estou criando um novo array que contenha somente o que está presente na propiedade metadata. O equivalente seria:

return [responses[0].metadata, responses[1].metadata ... responses[n].metadata ]

E como resolver essa gambiarra? Muito simples, vamos reduzir esse codigo para isso:

try {
    const ids = this.urlSegmentQuery.split(',' || '');
    const requests = ids.map(id => axios.get('/metadata', id));
    const response = Promise.all(requests)
    return response.map(res => res.data.metadata)
} catch () {...}

Por mais que um map seja mais custoso que um for..of, reduzimos a complexidade do codigo e nelhoramos o processamento dos dados. Da pra melhorar? Obvio, mas não é nosso objetivo.

Isso é perfeito para utilizar chamadas http com frameworks que usam axios, como React por exemplo.

Mas e o Angular?

No Angular, não usamos Axios para fazer requisições (e nem devemos usar, angular já inventou a roda, não coloque uma roda de carro em um trator), portanto, não existem promises no Angular, e sim Observables. Então como fazer um Promise.all? Usamos o metodo descontinuado .toPromise()? Claro, e adicionamos uma divida tecnica no projeto, daqui a 3 atualizações o angular remove esse metodo (se já não tiver removido) e você vai ter que refazer tudo de novo!

Bom, vamos olhar o codigo, eu sei que é pra isso que você veio!

Primeiro, vamos fazer as coisas da maneira Angular e criar uma service com ng g s pasta/para/minha/service/minha-chamada-http.

Ótimo, agora vamos para nossa service.

@Injectable({
  providedIn: 'root',
})
export class MinhaChamadaHttpService {}

Vamos INJETAR o HttpClient na nossa service. E fazer duas chamadas http com 2 metodos diferentes do RxJS.


@Injectable({
  providedIn: 'root',
})
export class MinhaChamadaHttpService {
    constructor(private http: HttpClient){}
    
    private urlSegmentQuery = new URLSearchParams(window.location.search).get('id');
    
    private getMetadataByUrlSegmentId(id: number): Observable<Metadata> {
        return this.http.get<Data>('/api/metadata', id)
            .pipe(
                catchError((err) => {
                  console.error(err)
                  return EMPTY;
                }),
                
                map( data => data.metadata )
            )
    }
    
    public getMetadataByUrlParamsUsingForkjoin(): Observable<Metadata[]> {
        const parseIdsInObservables: Observable<Metadata>[] = this.urlSegmentQuery.map( id => this.getMetadataByUrlSegmentId(id) )
        
        return forkjoin(parseIdsInObservables)
    }
    
    public getMetadataByUrlParamsUsingCombineLatest(): Observable<Metadata[]> {
        const parseIdsInObservables: Observable<Metadata>[] = this.urlSegmentQuery.map( id => this.getMetadataByUrlSegmentId(id) )
        
        return combineLatest(parseIdsInObservables)
    }
}

Você pode ter notado que ambos os metodos são praticamente identicos, isso ocorre porque tanto o Angular quando o RxJS são PADRONIZADOS. Do jeito que uma coisa funciona, todas do mesmo tipo funcionam iguais. Nesse caso, estamos enfileirando observables para que todos sejam chamados ao mesmo tempo de uma unica vez. Vamos a explicação antes de usar no nosso componente, guards, ou seja lá onde você vai usar (mas um dia vai).

Começamos injetando o HttpClient do Angular na nossa service constructor(private http: HttpClient){}. Não esqueça de ativar ele no seu AppModule ou app.config.

Vamos olhar o método getMetadataByUrlSegmentId um pouco mais de perto.


private getMetadataByUrlSegmentId(id: number): Observable<Metadata> {

    return this.http.get<Data>('/api/metadata', id)
        .pipe(
            catchError((err) => {
              console.error(err)
              return EMPTY;
            }),

            map( data => data.metadata ) )
}

Aqui estamo fazendo o equivalente ao axios.get(...) com o httpClient do Angular. Estamos retornando a chamada de um get do tipo Data (o tipo da requisição que você pré definiu. No axios seria AxiosResponse) para o endpoint /api/metadata levando o id no corpo da requisição (o correto seria na url /api/metadata/${id}). Porém, nossa função diz que precisa retornar um Observable contendo um Metadata. Nesse momento, o linter está estourando no nosso vscode, então vamos corrigir. Para remover essa linha vermelha na tela, usamos o metodo .pipe() para interceptar o resultado do Observable.

Aqui temos dois operadores dentro do nosso pipe, um catchError para tratamento de erros que venha dentro do Observable e um map (note que esse map não é o Array.map, e sim o operador map do RxJS).

O nosso catchError está imprimindo o erro no console e retornando para a gente um EMPTY (Observable vazio). Poderiamos adicionar o mesmo tratamento de erros que fizemos com o axios aqui dentro também.

Agora o nosso map, está percorrendo todos os elementos que foram emitidos do nosso observable que não é uma instancia de Error e retornando um array de Metadata (o equivalente a response.map(res => res.data.metadata)).

Agora, note que ambos os metodos privados estão quase identicos, mas retornando metodos diferentes (return combineLatest(parseIdsInObservables), return forkjoin(parseIdsInObservables)). Isso porque ambos fazem a mesma coisa, eles engatilham um array de observables e paralelamente começam a resolver todos os elementos do array. Então de forma assincrona, ambos vão retornar o equivalente a Promise.all([]). Mas atenção aos detalhes! Por mais que a essencia seja a mesma, o comportamento é completamente diferente! Vamos a explicação.

combineLatest documentação

Combina vários Observáveis ​​para criar um Observável cujos valores são calculados a partir dos valores mais recentes de cada um de seus Observáveis ​​de entrada

O combineLatest vai emitir um array de valores sempre que um dos observables emitir um valor. Ou seja, se um dos observables não emitir valor, ele ainda vai estar presente no array de valores recebido em subscribe.

Uma representação visual seria algo proximo disso: OBS: Usei um marmaid para visualização, se não renderiar, F5 ou acesse a tag abaixo:

%%{init: {"flowchart": {"htmlLabels": false}} }%%
flowchart LR
    function["combineLatest"]
    observable["
    observable1.subscription()
    observable2.subscription()
    observable3.subscription()
    observable4.subscription()
    "]
    emit["
        value1
        value2
        value3
        value4
    "]
    function --> observable
    observable --> emit

Então, mas isso não é ruim? Depende! Existem outras estrategias de combineLatest, como o combineLatestAll para você adaptar conforma a necessidade do seu código. Mas a ideia continua igual, enfileirar e resolver todos os observables ao mesmo tempo. Melhor do que utilizar um for of. Imagina se cada req demorar 5 segundos para emitir um valor e você tem 10 observables/promises para resolver? 50 segundos pendurado do lado do navegador? TimeOut na certa!

forkjoin documentação

Aguarde a conclusão dos Observáveis ​​e combine os últimos valores emitidos; complete imediatamente se uma matriz vazia for passada.

Diferente do combineLatest, o forkjoin aguarda todos os observables serem resolvidos e vai emitir apenas UM valor UMA unica vez. Ou seja, sempre que você precisar de novos valores, vai ter que chamar novamente o forkjoin, realizando um novo subscribe, porque ele se autocompleta.

Observação importante

Alguns observables possui o mesmo comportamento do forkjoin, de se auto completar. Esse é o caso do httpClient também, até porque ele não é um websocket, portanto não vai ficar conectado no backend, ele faz a requisição e se auto completa. Porem, o combineLatest não tem esse mesmo comportamento, para evitar vazamento de memoria, você deve se desinscrever manualmente.

Usando nosso serviço em um componente real

Otimo, temos nossa service e eu já estou cansado de digitar, portanto vamos para o componente. Não tirei isso aqui de nenhum projeto ou nada do tipo, apenas acorde e resolvi escrever isso na base do improviso. E por esse motivo vocês vão me perdoar pela falta de ascentuação e codigo incompleto 🖤.

Criando nosso componente com ng g c pages/demo-http


@Component({...})
export class DemoHttpComponent implements OnInit, OnDestroy {
    constructor(private exampleHttp: MinhaChamadaHttpService){}
    
    private forkjoinSubscription$!: Subscription;
    private combineLatestSubscription$!: Subscription;
    
    ngOnInit(): void {
        this.forkjoinSubscription$ = this.exampleHttp.getMetadataByUrlParamsUsingCombineLatest().subscription( data => {
            console.log(data)
        })
        
        this.combineLatestSubscription$ = this.exampleHttp.getMetadataByUrlParamsUsingForkjoin().subscription( data => {
            console.log(data)
        })
    }
    
    ngOnDestroy() {
        this.combineLatestSubscription$.unsubscribe()
        this.forkjoinSubscription$.unsubscribe()
    }
    
}

E finalmente chegamos ao fim, com uma rica demonstração de uso para ambos os metodos com um simples console.log.

Mas você poderia anexar o valor em um atributo publico para acessar dentro do html ou usar como parametro de outra função, como um guarda de rotas por exemplo.

Fale comigo na comunidade allstack, onde multiplos sofredores da area de tech se reunem para ajudar os iniciantes e intermediarios.

https://chat.whatsapp.com/J8MKSEqcBI03k3CLn3Mr77