Manual do Node.js., Parte 7: Programação assíncrona

Hoje, na tradução da sétima parte do manual do Node.js., falaremos sobre programação assíncrona, consideraremos questões como o uso de retornos de chamada, promessas e a construção assíncrona / aguardada, além de discutir o trabalho com eventos.




Assincronia em linguagens de programação


O próprio JavaScript é uma linguagem de programação síncrona e de thread único. Isso significa que você não pode criar novos threads no código que são executados em paralelo. No entanto, os computadores são inerentemente assíncronos. Ou seja, certas ações podem ser executadas independentemente do fluxo de execução do programa principal. Nos computadores modernos, cada programa recebe uma certa quantidade de tempo do processador; quando esse tempo acaba, o sistema fornece recursos para outro programa, também por um tempo. Tais comutações são realizadas ciclicamente, são feitas tão rapidamente que uma pessoa simplesmente não consegue perceber, como resultado, pensamos que nossos computadores executam muitos programas simultaneamente. Mas isso é uma ilusão (para não mencionar máquinas multiprocessadoras).

Nas entranhas dos programas são utilizadas interrupções - sinais transmitidos ao processador e permitindo atrair a atenção do sistema. Não entraremos em detalhes, o mais importante é lembrar que o comportamento assíncrono, quando o programa é pausado até o momento em que precisa de recursos do processador, é completamente normal. No momento em que o programa não carrega o sistema com trabalho, o computador pode resolver outros problemas. Por exemplo, com essa abordagem, quando um programa está aguardando uma resposta a uma solicitação de rede feita a ele, ele não bloqueia o processador até que uma resposta seja recebida.

Como regra, as linguagens de programação são assíncronas, algumas delas dão ao programador a capacidade de controlar mecanismos assíncronos, usando as ferramentas de linguagem incorporadas ou as bibliotecas especializadas. Estamos falando de linguagens como C, Java, C #, PHP, Go, Ruby, Swift, Python. Alguns deles permitem programar em estilo assíncrono, usando threads, iniciando novos processos.

Assincronia do JavaScript


Como já mencionado, o JavaScript é uma linguagem síncrona de thread único. As linhas de código escritas em JS são executadas na ordem em que aparecem no texto, uma após a outra. Por exemplo, aqui está um programa JS muito normal que demonstra esse comportamento:

const a = 1 const b = 2 const c = a * b console.log(c) doSomething() 

Mas o JavaScript foi criado para uso em navegadores. Sua principal tarefa, desde o início, era organizar o processamento de eventos relacionados às atividades do usuário. Por exemplo, são eventos como onClick , onMouseOver , onChange , onSubmit e assim por diante. Como resolver esses problemas dentro da estrutura de um modelo de programação síncrona?

A resposta está no ambiente em que o JavaScript é executado. Nomeadamente, o navegador permite resolver efetivamente esses problemas, fornecendo ao programador as APIs apropriadas.

No ambiente do Node.js, existem ferramentas para executar operações de E / S sem bloqueio, como trabalhar com arquivos, organizar a troca de dados em uma rede e assim por diante.

Retornos de chamada


Se falamos de JavaScript baseado em navegador, pode-se notar que é impossível saber com antecedência quando o usuário clica em um botão. Para garantir que o sistema responda a esse evento, um manipulador é criado para ele.

O manipulador de eventos aceita uma função que será chamada quando o evento ocorrer. É assim:

 document.getElementById('button').addEventListener('click', () => { //    }) 

Essas funções também são chamadas de funções de retorno de chamada ou retornos de chamada.

Um retorno de chamada é uma função regular que é passada como um valor para outra função. Ele será chamado apenas quando um determinado evento ocorrer. JavaScript implementa o conceito de funções de primeira classe. Tais funções podem ser atribuídas a variáveis ​​e passadas para outras funções (chamadas funções de ordem superior).

No desenvolvimento JavaScript do lado do cliente, a abordagem é difundida quando todo o código do cliente é agrupado em um ouvinte do evento load de um objeto de window , que chama o retorno de chamada passado para ele depois que a página está pronta para o trabalho:

 window.addEventListener('load', () => { //  //     }) 

Os retornos de chamada são usados ​​em todos os lugares, e não apenas para manipular eventos DOM. Por exemplo, já conhecemos seu uso em temporizadores:

 setTimeout(() => { //   2  }, 2000) 

As solicitações XHR também usam retornos de chamada. Nesse caso, parece atribuir uma função à propriedade correspondente. Uma função semelhante será chamada quando um determinado evento ocorrer. No exemplo a seguir, esse evento é uma alteração do estado da solicitação:

 const xhr = new XMLHttpRequest() xhr.onreadystatechange = () => { if (xhr.readyState === 4) {   xhr.status === 200 ? console.log(xhr.responseText) : console.error('error') } } xhr.open('GET', 'https://yoursite.com') xhr.send() 

Handling Tratamento de erros em retornos de chamada


Vamos falar sobre como lidar com erros nos retornos de chamada. Há uma estratégia comum para lidar com esses erros, que também é usada no Node.js. Consiste no fato de que o primeiro parâmetro de qualquer função de retorno de chamada é um objeto de erro. Se não houver erros, null será gravado neste parâmetro. Caso contrário, haverá um objeto de erro contendo sua descrição e informações adicionais sobre ele. Aqui está o que parece:

 fs.readFile('/file.json', (err, data) => { if (err !== null) {   //    console.log(err)   return } // ,   console.log(data) }) 

▍ Problema de retorno de chamada


É conveniente usar retornos de chamada em situações simples. No entanto, cada retorno de chamada é um nível adicional de aninhamento de código. Se vários retornos de chamada aninhados são usados, isso rapidamente leva a uma complicação significativa da estrutura do código:

 window.addEventListener('load', () => { document.getElementById('button').addEventListener('click', () => {   setTimeout(() => {     items.forEach(item => {       //,  -      })   }, 2000) }) }) 

Neste exemplo, apenas 4 níveis de código são mostrados, mas, na prática, é possível encontrar um grande número de níveis, geralmente chamado de "inferno de retorno de chamada". Você pode lidar com esse problema usando outras construções de idioma.

Promessas e assíncronas / aguardam


Começando com o padrão ES6, o JavaScript apresenta novos recursos que facilitam a gravação de código assíncrono, eliminando a necessidade de retornos de chamada. Estamos falando das promessas que apareceram no ES6 e da construção assíncrona / aguardada que apareceu no ES8.

▍ Promessas


Promessas (objetos de promessa) são uma das maneiras de trabalhar com construções de software assíncronas em JavaScript, o que, em geral, reduz o uso de retornos de chamada.

Familiaridade com promessas


As promessas são geralmente definidas como objetos proxy para determinados valores, cuja aparência é esperada no futuro. As promessas também são chamadas de "promessas" ou "resultados prometidos". Embora esse conceito exista há muitos anos, as promessas foram padronizadas e adicionadas ao idioma apenas no ES2015. No ES2017, o design assíncrono / aguardado, baseado em promessas e que pode ser considerado como uma substituição conveniente, apareceu. Portanto, mesmo se você não planeja usar promessas regulares, é importante entender como elas funcionam para o uso efetivo da construção assíncrona / aguardada.

Como as promessas funcionam


Depois que uma promessa é chamada, ela entra em um estado pendente. Isso significa que a função que causou a promessa continua sendo executada, enquanto alguns cálculos são realizados na promessa, após o que a promessa informa sobre ela. Se a operação executada pela promessa for concluída com êxito, a promessa será transferida para o estado cumprido. Diz-se que essa promessa foi resolvida com sucesso. Se a operação for concluída com um erro, a promessa será colocada no estado rejeitado.

Vamos falar sobre o trabalho com promessas.

Criar promessas


A API para trabalhar com promessas nos fornece o construtor correspondente, chamado por um comando no formato new Promise() . Veja como as promessas são criadas:

 let done = true const isItDoneYet = new Promise( (resolve, reject) => {   if (done) {     const workDone = 'Here is the thing I built'     resolve(workDone)   } else {     const why = 'Still working on something else'     reject(why)   } } ) 

O Promis verifica a constante global done e, se seu valor for true , é resolvido com sucesso. Caso contrário, a promessa é rejeitada. Usando os parâmetros de resolve e reject , que são funções, podemos retornar valores da promessa. Nesse caso, retornamos uma string, mas aqui um objeto pode ser usado.

Trabalhar com promessas


Criamos uma promessa acima, agora considere trabalhar com ela. É assim:

 const isItDoneYet = new Promise( //... ) const checkIfItsDone = () => { isItDoneYet   .then((ok) => {     console.log(ok)   })   .catch((err) => {     console.error(err)   }) } checkIfItsDone() 

Chamar checkIfItsDone() levará à execução da isItDoneYet() isItDoneYet isItDoneYet() e à organização de aguardar sua resolução. Se a promessa for resolvida com êxito, o retorno de chamada passado para o método .then() funcionará. Se ocorrer um erro, ou seja, a promessa será rejeitada, ela poderá ser processada na função passada para o método .catch() .

Promessas de encadeamento


Os métodos Promise retornam promessas, o que permite combiná-las em cadeias. Um bom exemplo desse comportamento é a busca de API baseada no navegador, que é uma camada de abstração sobre XMLHttpRequest . Existe um pacote npm bastante popular para o Node.js que implementa a API de busca, que discutiremos mais adiante. Essa API pode ser usada para carregar certos recursos de rede e, graças à possibilidade de combinar promessas em cadeias, para organizar o processamento subsequente dos dados baixados. De fato, quando você chama a API Fetch por meio de uma chamada para a função fetch() , uma promessa é criada.

Considere o seguinte exemplo de promessas de encadeamento:

 const fetch = require('node-fetch') const status = (response) => { if (response.status >= 200 && response.status < 300) {   return Promise.resolve(response) } return Promise.reject(new Error(response.statusText)) } const json = (response) => response.json() fetch('https://jsonplaceholder.typicode.com/todos') .then(status) .then(json) .then((data) => { console.log('Request succeeded with JSON response', data) }) .catch((error) => { console.log('Request failed', error) }) 

Aqui, usamos o pacote npm node-fetch e o recurso jsonplaceholder.typicode.com como uma fonte de dados JSON.

Neste exemplo, a função fetch() é usada para carregar um item da lista TODO usando uma cadeia de promessas. Após executar fetch() , é retornada uma resposta que possui muitas propriedades, dentre as quais estamos interessados ​​no seguinte:

  • status é um valor numérico que representa o código de status HTTP.
  • statusText - uma descrição textual do código de status HTTP, representado pela sequência OK se a solicitação foi bem-sucedida.

O objeto de response possui um método json() que retorna uma promessa, após a resolução da qual o conteúdo processado do corpo da solicitação é apresentado, apresentado no formato JSON.

Dado o exposto, descrevemos o que está acontecendo neste código. A primeira promessa na cadeia é representada pela função status() que anunciamos, que verifica o status da resposta e, se indica que a solicitação falhou (ou seja, o código de status HTTP não está no intervalo entre 200 e 299), a promessa é rejeitada. Essa operação leva ao fato de que outras expressões .then() na cadeia de promessas não são executadas e chegamos imediatamente ao método .catch() , emitindo para o console, junto com a mensagem de erro, o texto Request failed .

Se o código de status HTTP nos convém, a função json() declarada por nós é chamada. Como a promessa anterior, se resolvida com êxito, retorna um objeto de response , nós o usamos como um valor de entrada para a segunda promessa.

Nesse caso, retornamos os dados JSON processados, para que a terceira promessa os receba, após o que eles, precedidos de uma mensagem de que, como resultado da solicitação, foi possível obter os dados necessários, são exibidos no console.

Tratamento de erros


No exemplo anterior, tínhamos um método .catch() anexado a uma cadeia de promessas. Se algo na cadeia de promessas der errado e ocorrer um erro, ou se uma das promessas for rejeitada, o controle será transferido para a expressão mais próxima .catch() . Aqui está a situação em que ocorre um erro em uma promessa:

 new Promise((resolve, reject) => { throw new Error('Error') }) .catch((err) => { console.error(err) }) 

Aqui está um exemplo de acionar .catch() após rejeitar uma promessa:

 new Promise((resolve, reject) => { reject('Error') }) .catch((err) => { console.error(err) }) 

Tratamento de erros em cascata


E se ocorrer um erro na expressão .catch() ? Para lidar com esse erro, você pode incluir outra expressão .catch() na cadeia de promessas (e, em seguida, anexar tantas .catch() à cadeia conforme necessário):

 new Promise((resolve, reject) => { throw new Error('Error') }) .catch((err) => { throw new Error('Error') }) .catch((err) => { console.error(err) }) 

Agora, vamos ver alguns métodos úteis usados ​​para gerenciar promessas.

Promise.all ()


Se você precisar executar alguma ação após resolver várias promessas, poderá fazer isso usando o comando Promise.all() . Considere um exemplo:

 const f1 = fetch('https://jsonplaceholder.typicode.com/todos/1') const f2 = fetch('https://jsonplaceholder.typicode.com/todos/2') Promise.all([f1, f2]).then((res) => {   console.log('Array of results', res) }) .catch((err) => { console.error(err) }) 

No ES2015, a sintaxe da atribuição destrutiva apareceu; usando-a, você pode criar construções da seguinte forma:

 Promise.all([f1, f2]).then(([res1, res2]) => {   console.log('Results', res1, res2) }) 

Aqui, como exemplo, consideramos a API Fetch, mas Promise.all() , é claro, permite que você trabalhe com todas as promessas.

Promise.race ()


O comando Promise.race() permite executar a ação especificada depois que uma das promessas passadas a ela for resolvida. O retorno de chamada correspondente que contém os resultados desta primeira promessa é chamado apenas uma vez. Considere um exemplo:

 const first = new Promise((resolve, reject) => {   setTimeout(resolve, 500, 'first') }) const second = new Promise((resolve, reject) => {   setTimeout(resolve, 100, 'second') }) Promise.race([first, second]).then((result) => { console.log(result) // second }) 

Erro TypeError não detectado que ocorre ao trabalhar com promessas


Se, ao trabalhar com promessas, você encontrar o Uncaught TypeError: undefined is not a promise erro de Uncaught TypeError: undefined is not a promise , verifique se a new Promise() construção new Promise() é usada em vez de apenas Promise() ao criar promessas.

▍ design assíncrono / aguardado


A construção assíncrona / espera é uma abordagem moderna da programação assíncrona, simplificando-a. As funções assíncronas podem ser representadas como uma combinação de promessas e geradores e, em geral, essa construção é uma abstração das promessas.

O design assíncrono / espera reduz a quantidade de código padrão que você precisa escrever ao trabalhar com promessas. Quando as promessas apareceram no padrão ES2015, elas visavam solucionar o problema de criar código assíncrono. Eles lidaram com essa tarefa, mas em dois anos, compartilhando a saída dos padrões ES2015 e ES2017, ficou claro que eles não podiam ser considerados a solução final para o problema.

Um dos problemas prometidos foi o famoso "inferno de retornos de chamada", mas eles, resolvendo esse problema, criaram seus próprios problemas de natureza semelhante.

Promessas eram construções simples em torno das quais se poderia construir algo com uma sintaxe mais simples. Como resultado, quando chegou a hora, a construção assíncrona / aguardada apareceu. Seu uso permite que você escreva um código que pareça síncrono, mas é assíncrono, em particular, não bloqueia o segmento principal.

Como a construção assíncrona / espera funciona


Uma função assíncrona retorna uma promessa, como no exemplo a seguir:

 const doSomethingAsync = () => {   return new Promise((resolve) => {       setTimeout(() => resolve('I did something'), 3000)   }) } 

Quando você precisar chamar uma função semelhante, deverá colocar a palavra-chave await antes do comando para chamá-la. Isso fará com que o código que está aguardando a permissão ou a rejeição da promessa correspondente. Deve-se observar que uma função que usa a palavra-chave await deve ser declarada usando a async :

 const doSomething = async () => {   console.log(await doSomethingAsync()) } 

Combine os dois fragmentos de código acima e examine seu comportamento:

 const doSomethingAsync = () => {   return new Promise((resolve) => {       setTimeout(() => resolve('I did something'), 3000)   }) } const doSomething = async () => {   console.log(await doSomethingAsync()) } console.log('Before') doSomething() console.log('After') 

Este código produzirá o seguinte:

 Before After I did something 

O texto I did something entra no console com um atraso de 3 segundos.

Sobre promessas e funções assíncronas


Se você declarar uma determinada função usando a async , isso significa que essa função retornará uma promessa, mesmo que não seja explicitamente feita. É por isso que, por exemplo, o exemplo a seguir é um código de trabalho:

 const aFunction = async () => { return 'test' } aFunction().then(console.log) //    'test' 

Esse design é semelhante a este:

 const aFunction = async () => { return Promise.resolve('test') } aFunction().then(console.log) //    'test' 

Pontos fortes de assíncrono / aguardar


Analisando os exemplos acima, você pode ver que o código que usa assíncrono / espera é mais simples que o código que usa o encadeamento de promessas ou código baseado em funções de retorno de chamada. Aqui, é claro, vimos exemplos muito simples. Você pode experimentar completamente os benefícios acima, trabalhando com código muito mais complexo. Aqui, por exemplo, é como carregar e analisar dados JSON usando promessas:

 const getFirstUserData = () => { return fetch('/users.json') //      .then(response => response.json()) //  JSON   .then(users => users[0]) //      .then(user => fetch(`/users/${user.name}`)) //       .then(userResponse => userResponse.json()) //  JSON } getFirstUserData() 

Aqui está a aparência da solução para o mesmo problema usando async / waitit:

 const getFirstUserData = async () => { const response = await fetch('/users.json') //    const users = await response.json() //  JSON const user = users[0] //    const userResponse = await fetch(`/users/${user.name}`) //     const userData = await userResponse.json() //  JSON return userData } getFirstUserData() 

Usando sequências de funções assíncronas


Funções assíncronas podem ser facilmente combinadas em projetos que se assemelham a cadeias Promise. Os resultados dessa combinação, no entanto, são de melhor legibilidade:

 const promiseToDoSomething = () => {   return new Promise(resolve => {       setTimeout(() => resolve('I did something'), 10000)   }) } const watchOverSomeoneDoingSomething = async () => {   const something = await promiseToDoSomething()   return something + ' and I watched' } const watchOverSomeoneWatchingSomeoneDoingSomething = async () => {   const something = await watchOverSomeoneDoingSomething()   return something + ' and I watched as well' } watchOverSomeoneWatchingSomeoneDoingSomething().then((res) => {   console.log(res) }) 

Este código produzirá o seguinte texto:

 I did something and I watched and I watched as well 

Depuração simplificada


É difícil depurar as promessas, porque, ao usá-las, você não pode usar efetivamente as ferramentas usuais do depurador (como "ignorar etapa", ignorar). O código gravado usando async / waitit pode ser depurado usando os mesmos métodos que o código síncrono comum.

Geração de Eventos no Node.js


Se você trabalhou com JavaScript em um navegador, sabe que os eventos desempenham um papel importante no tratamento das interações do usuário com as páginas. Trata-se de manipular eventos causados ​​por cliques e movimentos do mouse, pressionamentos de teclas no teclado e assim por diante. No Node.js, você pode trabalhar com eventos que o programador cria sozinho. Aqui você pode criar seu próprio sistema de eventos usando o módulo de eventos . Em particular, este módulo nos oferece a classe EventEmitter , EventEmitter recursos podem ser usados ​​para organizar o trabalho com eventos. Antes de usar esse mecanismo, você precisa conectá-lo:

 const EventEmitter = require('events').EventEmitter 

Ao trabalhar com ele, os métodos on() e emit() estão disponíveis para nós, entre outros. O método de emit usado para chamar eventos. O método on é usado para configurar retornos de chamada, manipuladores de eventos que são chamados quando um determinado evento é chamado.

Por exemplo, vamos criar um evento start . Quando isso acontecer, produziremos algo no console:

 eventEmitter = new EventEmitter(); eventEmitter.on('start', () => { console.log('started') }) 

Para acionar esse evento, a seguinte construção é usada:

 eventEmitter.emit('start') 

Como resultado da execução desse comando, o manipulador de eventos é chamado e a sequência started chega ao console.

Você pode passar argumentos para o manipulador de eventos, representando-os como argumentos adicionais para o método emit() :

 eventEmitter.on('start', (number) => { console.log(`started ${number}`) }) eventEmitter.emit('start', 23) 

O mesmo acontece nos casos em que o manipulador precisa passar vários argumentos:

 eventEmitter.on('start', (start, end) => { console.log(`started from ${start} to ${end}`) }) eventEmitter.emit('start', 1, 100) 

EventEmitter classe EventEmitter têm alguns outros métodos úteis:

  • once() - permite registrar um manipulador de eventos que pode ser chamado apenas uma vez.
  • removeListener() - permite remover o manipulador passado a ele da matriz de manipuladores do evento passado a ele.
  • removeAllListeners() - permite remover todos os manipuladores do evento passado para ele.

Sumário


Hoje falamos sobre programação assíncrona em JavaScript, em particular, discutimos retornos de chamada, promessas e a construção assíncrona / aguardada. Aqui abordamos a questão de trabalhar com eventos descritos pelo desenvolvedor usando o módulo de events . Nosso próximo tópico serão os mecanismos de rede da plataforma Node.js.

Caros leitores! Ao programar para o Node.js, você usa a construção async / waitit?

Source: https://habr.com/ru/post/pt424555/


All Articles