Галоўная / Артыкулы

Адаптация скрыпта для роботы даўжыну до API на Node.js у працэсе вырабаткі: цыкл здарэнняў, рабочыя ніткі, логаванне

Як цыкл задач, блакуячыя задачы, ніткі працоўнікаў, таймауты, канкуранцыя, структураваныя логі і доступ да базы дадзеных паўтараюць форму API Node.js, якае можа функцыонаваць у працоўным сераўеры.

Выдавец: REACTAPP.TOP11 хв чытання2621 слоў

Крыніцы: The Node.js Runtime Is the Easy Part

З’ясаванне таго, як API на базе Node.js павінен адказваць, рэдкасцю не ёсць складным завданнем. Канцэнтр падае назад да дадзейнаў, фронтэнд іх адрасавае, а набор тэстаў паказвае паспэшны рэзультат. Складнасць пачынаецца ў працэсе рэалайзацыі, калі адразу прыходзяць тыячы запытоў, база дадзенаў спавалюецца, API трэціх сторон перестае адказваць, памяць постаўна зрасае, а запыт, які зазвычай выконваецца за 200 мілісекунд, раптам захопляе 12 секунд. Існуе таксама менш выразны пытанне: чы розумець гэты код іншы разработчык за шасць месцаў?

На гэтыя пытанні даўаць адказы тое, як паводзіцца сэрвіс пад час роботы, і калькі правіла проектавання. Чытальнік пабачыць, дзе модэль выконання Node.js праглядаецца, дзе ён паспяшае, і якія патэрны дапамагаюць падтрымваць адказнасць сэрвіса, калі пачынаецца рэальны трафік: пераказваўанне роботы CPU на рабочыя ніткі, настаўленне таймаў, адночасныя сецавыя запыты, структураваная логгірацыя і астаточныя запыты.

У чым насправдзе супэрнасць сэрвіса пад час роботы

Стандартная версія — ў Node.js можна запускать JavaScript паzaяўнікам. Це правда, але гэта не адбівае самай цікавай часткі. Насамперадзе Node.js прыносіць рэальную практычную корысть у вачынку рэалізацыі рантайму, які працюе на адбытку змаганняў з іншымі задачамі, з асінхронным вводам-выходам і вельмі большым екасістэмам, які ўсё гэта падтрымвае. Такіяя дизайнавыя рашэнні становяцца важлівымі, калі сервер вялікую частку свага часу пасвячае чаканню на чыясьці іншыя дзеянні:

  • запыты да базы дадзеных
  • выходныя HTTP-запыты
  • чытанне і запіс файлаў
  • мера сетевага трафіку
  • чаргі паведамленняў
  • дзейнасныя з’ѐеднанні WebSocket

Разберім адзін запыт у тыповым бэкендзе:

Client
  ↓
API Server
  ↓
Database
  ↓
External API
  ↓
Response

Практычна ніколі ў гэтай ланцузе сервер не зайняты вычысленнямі. Ён пераважна чакае на базу дадзеных, а потым на зовнішній API. Модель Node.js створана так, каб выкарыстаць гэты вільны час для обслужвання іншых запитоў, і розумеўце гэтага є болей ценным, чым будзь-які конкрэтны API.

Як цыкл змаганняў зменяе вашу ментальную модель

JavaScript у прыкладнай програме Node.js зазвычай запускаецца на аднай галоўнай ніті, тады як цыкл змаганняў координуе асінхронныя операцыі навакола яе. Апроставаныяе зображэнне жыцця запита:

Incoming Request
       ↓
   JavaScript
       ↓
Start I/O operation
       ↓
Don't block execution
       ↓
   Event Loop
       ↓
I/O completes
       ↓
Callback / Promise
       ↓
Continue execution

Ёсць так, як это выглядае ў звычным коде:

const user = await database.getUser(userId);
const orders = await database.getOrders(userId);
return {
  user,
  orders
};

await зупіняе толькі гэтую функцыю, а не весь процэс. Калі база дадзеных працуе над першым запитам, сэрвіс можа адразу обрабатваць іншыя запыты. Самэй таго адзін процэс Node.js можа адрабатваць вялікі калекцыя запытоў, якія здэйсніваюцца пераважна через сеть.

Є адна нюанса, якую варта згадаць: два вышэйзгаданыя запыты выконваюцца адны пасля іншага, таму што другі await пачынаецца толькі пасля таго, як першы завершыцца. Мы вернемся да гэтага у раздзеле пра паралельную роботу.

Асінхронна робота — гэта не тое ж сама, што шырока скорасць

У гэтай модэлі є строгія меры. Якщо ваш JavaScript выкананая длігая вычыслення, нічога іншага на гэтым потоку не можа выкананаць, пакуль яна не завершыцца. Такі цикл заставляе поток зайнятым працэсам увесь час свайго выканання:

function expensiveOperation() {
  let result = 0;
for (let i = 0; i < 10_000_000_000; i++) {
    result += i;
  }
  return result;
}

Калі ён працюе, новыя запыткі не прыймаюцца, обявы не выконваюцца, а таймеры не запускаюцца за планам. Не існуе скарбніка, які мог бы перараніць цыкл, ўзначыўшы час для іншых запыткаў. Гэта галоўная прычына, чаму важлівая ўсведамленасць пра спосаб працы рантайму, чым запамятовваць яго API.

Проблема блакуючага хэндлера

Тепер практыкуйце такую работу ўнутрь HTTP-хэндлера. Змена размеру атракцыяў, трансфармацыі вялікіх дадзеных чы супернавантажаныя вычысленні, якія выкананы безпосередна ў шляху запытка, у коде выглядаюць безнебяжнымі:

app.post("/process", async (req, res) => {
  const result = expensiveComputation(req.body);
res.json(result);
});

Проблемы з’являюцца толькі пад навантажэннем. Одна запитанне запускае дорогую функцыю і монаполізуе цыкл запуску задач. Усе іншы запитанні застаюцца ў черзі пасля яго. Затрымкі зростаюць у всіх аспектах, не толькі для важкага канцэнтра, а сам процес прыкладнага програмістыкі стае вузькім месцам. Пазначэнне обробніка async не дапамагае, таму што вычысленні ўсередине застаюцца сінхроннымі.

Болей міцны дизайн выводзіць роботу, якая залежыць ад CPU, цэлым чынам за межы шляху запитання:

Client
  ↓
Node.js API
  ↓
Queue
  ↓
Worker
  ↓
Heavy computation
  ↓
Database / Storage

API прыме заданне, ставіць яго ў чергу і шырока адпавядае, тады калі спецыяльныя працоўнікі обрабоцваюць заданні сваім тэмпам і запісваюць рэзультаты ў базу дадзеных або сховішча об’ектаў. API застаецца рэагіруючым, і вы можете масштабаваць працоўнікі незалежна ад веб-слоя. Гэта пераход з напісвання обробнікаў на проектаванне прабегу роботы ў значныяй меры і ўскладнюе розлік між простым вжываннем Node.js і стварэннем систем на яго аднойчыне.

Калі не трэба вжываць чергу

Черга дагадвае пра неабходнасць інфраструктуры, павторных спробах і евалюятыванай консыстэнцыі. Для короткіх заданняў, якія выкананыя на CPU і якія павінны сінхронна вернуць рэзультат, працоўныя ніткі Node заставляюць роботу в межах процэса, але не на галоўнай нітцы. Для заданняў, якія выкананыя за каліксекунды, часта дастатнька ўжываць іх безпосередна. Рашэнне залежыць ад таго, сколькі часу займае робота і чы рэчыпер павінен чакаць на адпаведзь.

Express — это фрэймворк, а не среда выканання

Багато разработчыкаў плутаюць межу Node.js і Express. Node.js — это среда выканання. Express — это веб-фреймворк, створаны на яе аднойчыні. Вы можете аддаўаць HTTP-запыткі, выкорыстоўваючы толькі вбудованы модуль http:

const http = require("http");
const server = http.createServer((req, res) => {
  res.writeHead(200, {
    "Content-Type": "application/json"
  });
  res.end(JSON.stringify({
    message: "Hello"
  }));
});
server.listen(3000);

Гэта працюе, але калі аплікацыя расте, задачы з маршрутызацыяй, мідлвэрам, парсінгам тэксту, перакананнем даных і карэстацыяй адзінакоў стаюць вашымі. Існуюць фрэймворкі, якія памагаюць паспрабаваць гэты трыбут з вас зняць. Тая ж ідея ў Express выглядае так:

import express from "express";
const app = express();
app.use(express.json());
app.get("/users/:id", async (req, res) => {
  const user = await getUser(req.params.id);
  res.json(user);
});
app.listen(3000);

Express не заменяе нічога ў Node.js; ён проста прадстаўляе болей зручную абстракцыю над тымі ж прымітівамі. Чыстае разгранічэнне гэтых падходаў дапамагае пад час дэбаггу: медленны эндпоінт можа быць проблемай фрэймворку, проблемай часу выканання або ж ні таго, ні другога.

Код для працы ў працэсе стварэння продукту большасцю стосуецца разгранічэнняў

Маленькі сервіс можа знаходзіцца ў аднам файле. Система для працы ў працэсе стварэння продукту не можа так гэта делаць, адтуды разныя абавязкі патрабуюць чыстых меж між сабою. Адны з разумных падзеяў:

src/
├── api/
│   ├── routes/
│   ├── controllers/
│   └── middleware/
│
├── services/
│   └── userService.js
│
├── repositories/
│   └── userRepository.js
│
├── config/
│   └── environment.js
│
├── jobs/
│   └── emailWorker.js
│
└── app.js

Конкрэтныя назвы папак не ўсё. Галоўнае — каб адпаведальнасці заставаліся роздзеленымі:

  • Контролер не должен скарабаты сяценыя рядків правіл бізнесу.
  • Репазітарый не должен ніч не ведаць пра тое, як формуюцца адпаведныя HTTP-адказы.
  • Сэрвіс не должен прыймаць об’екты Express req чы res як інпут.

Самэ гэтыя межы дазволяюць працаваць з кодам незалежна, заменяць яго часткамі і розумець яго для наступнага чалавека, які яго чытае. Чырвонейша інфармацыя пра гэтую структуру можна знайсці ў нашым кяліку пра проектаванне шаравых API на Node.js.

Проектаванне коду для ситуацый абярэння ў асінхронным кодзе

Кожны разработчык на Node.js рано чыраў гэты прыказ:

const data = await fetchData();

Процес вырабаткі прымушае ставіць ўжо болей складныя запытанні. Што, якфункцыя fetchData() будзе выконвалася 30 секунд? Што, якфункцыя кінуе адзінку? Што, якфункцыя кінуе адзінку тры разы паспялова? Што, яксервіс, які знаходзіцца вышэй у ланцузе, будзе абсалютна недзеяльны? Што, якзапіс у базе дадзеных успяў, але наступны крок зазнае невясці, і система застанецца часткова апцэнтаваная?

Надзеяны код бэкэнду визначае поведзенне для такіх сцэнарыяў, а не толькі описвае успешны ход задачы. Маленькі прыклад, які чытка паказвае можлівасць невясці:

async function getUserProfile(userId) {
  try {
    const user = await userService.getUser(userId);
if (!user) {
      throw new Error("User not found");
    }
    return user;
  } catch (error) {
    logger.error({
      userId,
      error: error.message
    });
    throw error;
  }
}

Функцыя ператвараеясць відсутнага пользователя ў адзину памылку, логуе няудачы разам з неабходным контекстам для ўсвітлення прычын і зноў выклікае ўзворотнае выкананне, каб вызываючы код могаў сам адначыць, як рэагаваць. Основны прынцып заключаецца ў тым, што обработка няудач ёсць часткая дыякрэтыва дизайну, а не ўскладненне, якое дадаецца пасля інцыдэту.

Практычны застерэжны момент: якщо кожны слой логуе і зноў выклікае выкананне, адна няудача стварае калькі ліній у логах. Неабходна выбраць месца, дзе будуць логуваны памылкі (часта аднойчы, на межы, якая іх обрабоцвае), і дазволіць внутрэшнім слоям дадаць неабходны контекст.

Ніколі не чакайце безканечна: таймауты

Ёсць прыклад вызову іншага сервіса без жадных захоўнаў:

const response = await fetch("https://example.com/api");

Якщо адміністратыўны сервер прыме з’яўленне, але ніколі не адпавісіць, тады await можа застрягнуць на значна дэльцы часу, большую, чым можа вытрымаць корыстнік, пры чым рэсурсы будуць у захопленым стане ўвесь час. Система для працы ў працэсе вырабніцтва патрабуе чыста вызначанаг верхньяго ліміту. За дапамогою стандартнаг AbortController, які є ў сучасным Node.js, можна задаць такі ліміт:

const controller = new AbortController();
const timeout = setTimeout(() => {
  controller.abort();
}, 5000);
try {
  const response = await fetch(
    "https://example.com/api",
    {
      signal: controller.signal
    }
  );
  return await response.json();
} finally {
  clearTimeout(timeout);
}

Таймер завершае запит пасля пяці секунд, а блок finally скасоўвае гэта дзеянне, ўнаследке яго не будзе выканана пасля успешнай адпаведзі. Новэйшыя среды таксама апраносяць метод AbortSignal.timeout(ms), які выражае тую ж ідею за дапамогою адной выразы.

Пяць секунд — гэта не магічная цифра, і гэты тэрмін можа быць занадта вялікі для канцэнтраў, якія выкорыстоўваюцься корыстнікамі. Выберыце значэнне, якое адпавядае латэнційным праграмам канцэнтраў. Непрыемлема ўмова — гэта, калі неканчатлівая чаканне не ўважаецца прийнятным спосабам абароткі.

Канкурантнасць без паралелізму

Калі два запісы не залежыць адзін ад другога, чаканне на іх адна за другой марнуе час:

const user = await getUser();
const orders = await getOrders();

Другій вызов не пачынаецца аж пасля таго, як завершыцца першы, таму абсалютны час адзначэння ёстся сумой часаў абох вызоў. Зазвычай краща запускать іх адночасна і чакаць, пакуль якія-небудзь з іх завершыцца:

const [user, orders] = await Promise.all([
  getUser(),
  getOrders()
]);

Обе запчыты зараз выконваюцца адночасова, і аб’ём затрымкі прыблізна такі ж, як у спаднейшай з іх. Це канкурэнція, а не паралельны вычысленні: ваш JavaScript все ўсё выконваецца на аднай ніті, але час чакання перакрываецца. Разлік мае вялікое значэнне для тэрмінаў, якіе пінаюцца да калькі некалькіх служб-прыемнікаў.

Есць два крайнія случаі, якія трэба памятаць. Promise.all адхіляеся як толькі хоць якая з запчыт прыме адхіленне, таму калі дапускаюцься частковыя рэзультаты, трэба вжываць Promise.allSettled. А запуск сотняў вызоў адночасова можа перавантажыць базу дадзенаў чы API з обмежэннем частоты, таму трэба обмежыць канкурэнцію, калі спіс вялікі.

Прыдатнасць залежыць ад архітэктуры

Часта бывае, калі каманды спарчваюцься над выбарамі на мікрарывень, такімі як класычны

for

цыкл у пораўнанні з

forEach

У той жа час той самы канцэнтры выконвае пяць непатрэбных запытанняў да базы дадзеных. Або можна пабачыць невялікі дапаможны прыемак, які рэгулярна налаштовваецца, калі API вяртае вельмі вялікія JSON-даныя, або калі кожны запыт чакае 800 мілісекунд на виконанне повольнага запытання.

У рэальных сервісах часы адпаведзення зазвычай залежаць ад:

  • способу доступу да базы дадзеных
  • затрымкі ў сеціі
  • неэфектыўных або неіндексаваных запытанняў
  • чрэзмернай серыялізацыі
  • залежнасці ад зовнішніх API
  • работы, якая вовсе не патрэбна
  • тиску на памяць
  • відсутнасці аб паслабна адзначанай кэшаванні

Найбольшыя практычныя рэзультаты часта даходзяць ад змян у архітэктуре, а не ад змян у сынтаксі. Якщо вы хочаце структураваны спосаб прыорітэтавання, адзірніце нашу праметку пра прыорітэтаванне для парадкурсу працысоў API у Node.js.

Возможна адміністрацыя ўскладненая ўчастком продукту

Сервер, які не можа паведаміць, што ён зрабіў, вельмі складны для керавання. Логгіраванне — гэта першы слой, але такі рэчыцы практычна бесполезныя ў рэальных умовах:

console.log("Something went wrong");

Структураваныя лог-запісы фіксуюць назву западання і ідэнтыфікаторы, якія патрэбны для яго аналізу:

logger.error({
  event: "payment_failed",
  orderId,
  userId,
  error: error.message
});

Паколькі кожны запіс ёсць об’ектам, а не вольным текстам, логі можна фільтрацыяваць, аграгаваць і запытваць: напрыклад, усі запісы пра змест payment_failed для адного пользователя.

Логі самыя по сабе застаюцца непаштоўнымі. Системы для працы ў рэальным часе зазвычай выкарыстоўваюць тры взаімадопамагаючыяся сигналы:

Logs
  ↓
What happened?

Metrics
  ↓
How often / how much?

Traces
  ↓
Where did the request spend its time?

Логі паведамляюць, што сталося, метрыкі — як часта і насколькі значна была дзейнасць, а трэсы паказваюць, дзе запыт паспяліў час у разных службах. Калі хтось адзвяртае увагу, што «прыкладніка недастатньо быстра», гэтыя сигналы дазволяюць адпаведзець на адзін у падтрымку доказаў, а не толькі здагадкаў.

Час выканання не прызначае спосаб прыступу да дадзэнняў

Выбір Node.js не наказвае викорыстоўваць адпаведную базу дадзэння. Ён добра работае з PostgreSQL, MySQL, MongoDB, Redis, SQLite, Elasticsearch і багатымі іншымі. Набаго важлівейшым є тое, як код вяршыць вяроўнаванне з выбраным сховішчам дадзэнняў. Гэты канцэнтр паводзіцца абоўсумо разумна:

app.get("/products", async (req, res) => {
  const products = await db.query(
    "SELECT * FROM products"
  );
res.json(products);
});

У рэальных умовах адразу жа выклікае нямало пытанняў:

  • Скількі рэядоў зараз мае products, а скількі будзе наступнага года?
  • Чы є пагінацыя?
  • Чы запыт падтрымліваецца індэксам?
  • Чы можа гэты адпаведзень выраснуць да 50 MB?
  • Як ён працуе пад адночасным трафікам?
  • Чы пул з’яўленняў мае разумныя ліміты?
  • Чы можа бяспрацэсавая памылка базы дадзенаў прасочыцца да кліента?

Node.js не будзе адпавядаць на жадна з гэтых пытанняў. Гэтыя рашэнні належаць тым, хто проектуе службу.

Node.js як адны з многіх компанентаў

Node.js шырока практыкуецца ў хмарных средах часткова таму, што яго лёгкія модэлі працы добра падходзяць для контэйнероў і проектаў, оріентаваных на абслужванне. У такіх системах ён зазвычай ўжо ўскладненая частка: шар API паўстае паўстае разам з очаквальнымі лістамі, рабочымі процесамі, кэшамі, базамі дадзенаў і іншымі службамі, кожная з якіх мае сваю адпаведальнасць. Ён не павінен быть цэлай системай, а справжній бэкенд рэдка калі ёсць проста „Express API“.

Дзе Node.js патрэбна дапамога

Някія среды выканання падходзяць не для кожных завадзяў. Node.js ўсёраслівей, калі робота пераважна абумована I/O, а задачі, якія выкарыстоўваюць моц CPU, выклікаюць болей рэшучы дизайн. Прыклады:

  • обработка зображэнняяў у великых масштабах
  • кодаванне відэа
  • інтэнсіўныя науковыя вычысленні
  • дзеякія завадзі ў галузі машыннага навчання
  • складныя крыптаграфічныя операцыі
  • вельмі большыя трансфармацыі дадзенаў

Нічна з гэтых пунктов не выключае Node.js з такіх систем. Это значыць, што процес, які обрабоцвае HTTP-запыты, не павінен выкананыя важкую роботу. Варыянты включаюць:

  • рабочыя ніткі ў тым жа процесе
  • незалежныя фонавыя рабочыя процесы
  • чаргі межу API і рабочымі процесамі
  • спецыяльныя службы для вартых аперацый
  • іншыя мовы або среды выканання, якія лепей падходзяць для вычыслаў

Мета не ў тым, каб прымусіць рашаць кожную проблему за дапамою Node.js, а ў тым, каб разместіць яго там, дзе паслугоўвае яго модэль выканання.

Задаваць „чаму“, а не толькі „як“

Спачатку пытанне — як стварыць API за дапамою Node.js. З падаходам падае пытанне, чаму API павінен быць створаны адзінаковым спосабам, што прыводзіць да іншага набора пытанняў:

  • Чы гэтая аперацыя павінна быць сінхронной, чы праходзіць через чаргу?
  • Чы гэты адказ трэба кэшаваць?
  • Чы гэтыя службы ўладаюць гэтымі дадзеннямі?
  • Чы гэты эндпаінт должен завісіць ад трох другіх служб, і што будзе, калі адна з іх зламаецца?
  • Дзе трэба выконваць пракаты?
  • Што трэба ліставаць і што трэба мераваць?
  • Як гэта будзе працаваць пад навантажэнням?

Нічога з гэтага ня ўзначальна толькі для Node.js. Це запытанні інжынеріі прыкладнага програмнага забезпечэння, а Node.js з’являе інструменты для рэалізаціі адпаведных рашэнняў.

Короткая чэк-ліст для працы ў прыметнай среде

  • Цыкл змаганняў робіць Node.js адлічным у супаралельнай обработцы чаканняў на сеті і дыску, але будзь-яя сінхронная робота CPU зупіняе кожны запит у тым жа потоку.
  • Перанесіце важкія вычысленні за межы шляху запита за дапамою рабочых потакоў, канэў і рабочых процэсаў, або окремых служб.
  • Спрыяйце розгляду сцэнарыяў збою і явных тайма-аўтаматав як часткі дизайну; необмежанае чаканне ўважаецца багам.
  • Выконваюць незалежныя запыты паралельна з Promise.all, а таксава контролююць ступень паралелізма, калі запыты розпадаюцца на вялікую колькасць.
  • Проблемы з працэйнасцю зазвычай стаюць у запытках, размерах дадзейнаў і колькасці пераходаў па сеті, а не у синтаксі цыклаў.
  • Структураваныя логі, метрыкі і трэйсы ператвараюць прычыны проблем у працэйнасці на паводлівыя доказы.
  • Фрэймворкі і среды выконання ўжо є інструментамі; архітектура, межы і доступ да дадзэйнаў застаюцца адпаведальнасцю разработчыка.