Почему стоит использовать обратные вызовы и асинхронный код на NodeJS

Знаю, разговор о предпочтении асинхронных операций в среде NodeJS в 2022 году может показаться довольно банальным. Я и не собираюсь говорить об этом  —  просто покажу, как выполнить рефакторинг кода, чтобы использовать преимущества производительности NodeJS.

Если вы читаете этот текст, то наверняка уже знакомы со средой NodeJS. Однако, чтобы как можно больше людей смогли понять то, о чем пойдет речь, начнем с нескольких определений.


Node.js  —  это кроссплатформенная среда выполнения JavaScript с открытым исходным кодом, основанная на движке V8 JavaScript (как и Google Chrome). Приложения, разработанные для этой платформы, выполняются в одном процессе, без создания нового потока для каждого запроса.

Эта однопоточная природа является основной концепцией NodeJS. В своей стандартной библиотеке NodeJS предоставляет набор асинхронных примитивов ввода-вывода, которые предотвращают блокировку кода JavaScript. Как это происходит? Благодаря четко определенному событийному циклу! Он регистрирует функции, которые должны выполняться периодически, что позволяет обрабатывать множество запросов (по одному за раз).

Подводя итог, можно сказать, что в этой среде приложение является однопоточным, и каждый запрос нужно обрабатывать как набор инструкций. Данный процесс можно приостанавливать и возобновлять, чтобы обслуживать максимальное количество запросов. Это должно быть общей идеей при программировании приложений для NodeJS.


Теперь переходим к реальному сценарию.

Я создавал приложение, работающее на платформе NodeJS. Оно было интегрировано со сторонним API  —  из него периодически извлекались некоторые данные, которые затем сохранялись в реляционной базе данных. Сохраненные реестры должны были обновляться, в противном случае необходимо было создавать новый реестр. Вроде бы все хорошо, но я столкнулся с некоторыми ограничениями.

  1. API выводил все данные (без какой-либо фильтрации).
  2. Данные были разбиты на страницы, о чем не было известно заранее, а можно было узнать только при достижении последней страницы, читая ответ сервера.

В своей первой реализации я постарался преодолеть эти ограничения. Она выглядела примерно так, как показано ниже:

const axios = require('axios');

const DataAPIService = () => {
const { API_URL } = process.env;
const pageSize = 50;

const listAll = async () => {
const resultList = [];
let pageToken;

do {
const url = `${API_URL}?pageSize=${pageSize}&nextPage=${pageToken ?? ''}`;
const apiResponse = await axios.get(url);
if (apiResponse?.data?.list?.length > 0) {
resultList.push(...apiResponse.data.list);
}

pageToken = apiResponse?.data?.pageToken;
} while (pageToken);

return resultList;
};

return { listAll };
};

Приведенный выше код получает данные со стороннего сервера API и добавляет их в список, повторяя процесс до тех пор, пока не будет найден последний постраничный результат (представленный pageToken). Однако у него есть недостатки.

  1. Он позволяет обрабатывать данные (на которые ссылается resultList) только после получения всех страниц.
  2. Цикл do ... while может долго завершаться, поэтому событийный цикл блокируется этой операцией (т.е. нарушается основное условие  —  не блокировать событийный цикл).

Это основные моменты, которые я преобразовал при рефакторинге кода таким образом, чтобы он по-прежнему был надежным, но стал более производительным в рамках приложения. Через некоторое время было найдено окончательное решение:

const axios = require('axios');

const DataAPIService = () => {
const { API_URL } = process.env;
const pageSize = 50;

const fetchAPIData = async (pageToken) =>
axios.get(`${API_URL}?pageSize=${pageSize}&nextPage=${pageToken ?? ''}`);

const processResponse = async (resposne, stream) => {
if (resposne?.data?.list?.length > 0) {
stream.push(resposne.data.list);
}

if (resposne?.data?.pageToken) {
const { pageToken } = resposne.data;
return processResponse(await fetchAPIData(pageToken), stream);
}

return stream.push(null);
};

const listAll = (inStream) =>
new Promise((resolve, reject) => {
inStream.on('end', () => resolve());
fetchAPIData()
.then((response) => {
try {
processResponse(response, inStream);
} catch (err) {
reject(err);
}
})
.catch((err) => reject(err));
});

return {
listAll,
};
};

Окончательный код немного сложнее предыдущего (совсем немного), но в нем стоит разобраться. И вот почему.

Прежде всего, функции listAll в обеих реализациях возвращают объект promise. Первая делает это неявно, используя ключевое слово async, вторая явно создает экземпляр promise. Однако вторая реализация также требует параметра inStream, который должен быть Readable-потоком.

Не знаете, что такое потоки? Можно определить их как интерфейс, который позволяет асинхронно отправлять данные через приложение NodeJS. Представьте, что у вас есть возможность отправить в одну часть приложения порцию данных, которая может быть прочитана из другой части.

Экземпляр входного потока  —  это то, что позволяет использовать асинхронную обработку в этой части кода.

В строке 26 происходит первый вызов метода fetchAPIData, который выполнит HTTP-запрос и получит порцию данных со стороннего API-сервера. В строке 27 регистрируется обратный вызов для получения ответа и передачи его в функцию processResponse вместе с входным потоком.

Строки 10–21 описывают обработку данных, в ходе которой просто происходит пересылка списка данных в экземпляр потока и, если осталась еще одна страница, происходит рекурсивный самовызов после очередного вызова fetchAPIData.

Немедленная передача списка данных через поток (строка 12) позволяет приложению обрабатывать данные API полностью по требованию, в соответствии с порядком их получения, без необходимости ждать обработки большого набора данных и добавления их в список (что может снизить потребление памяти). Кроме того, придерживаемся идеи неблокирования событийного цикла NodeJS: запрос обрабатывается (короткими) обратными вызовами, которые могут быть вызваны и возобновлены в течение нескольких циклов событийного цикла.

Итак, в последней реализации были исправлены недостатки, указанные в первом фрагменте кода. Теперь приложение получило прирост производительности.

Читайте также:

Читайте нас в TelegramVK и Дзен


Перевод статьи Patrick Lima: Remember to keep using callbacks and asynchronous code on NodeJS

Предыдущая статьяКак разбить монолитное приложение на микросервисы без рефакторинга
Следующая статья5 признаков того, что вы отличный разработчик