Галоўная / Артыкулы / Вылечыць багі ў обробцы адзінакоў Async/Await у працоўным коде Node.js

Вылечыць багі ў обробцы адзінакоў Async/Await у працоўным коде Node.js

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

1766 слоў

Система адміністрування пакетаў, яуююцьяя адной камандай ў пачатку минулага квартала, прычыніла двойную оплату ад зямлячоў за адной і той жа замовленнем, два разы на тыдзень, прыбліжна месяц, перш чым стала вядома проблема. Асновной прычынай не быў ненадзеяны платежны шлюз. Цэю прычыной быў блок try/catch, які абгортаваў вызов await і робіў самэ тое, для чаго быў напісан — прыхавляў адказку пра аблык і продовжваў работу, — тады як логіка павторных спроб на вышэйшых роўнях прыпускала, што автаматычнае вырашэнне задачі значыць успех.

Ніхто няўмоўна не стварае такіх багоў. Адтолькі, калі синтаксіс async/await выглядае як звычны сінхронны код, разработчыкі сама сабою працуюць з яным таксама. Але асновная модэль карыстоўванняся падазрамі все ж такі развязваецца через адхіленне прабам, планаванне мікрзадач і правіла анулювання, якія не паўнасцю адпаведаюць інтуіцыі try/catch. Нават апытныя інжынеры паддаюцца гэтаму, часта ў кодзе, які вялікі час праходзіў пераказку, таму што такія баги прыходзяць на вочы толькі пад умовамі канкурэнціі або частковых абяканняў — самэ тых сцэнарыёў, якія юнітныя тэсты часта прахоўваюць.

Нижчэ прыведзены пяць частаўпадаючых адмылак, якія з’яўляюцца ў продакшн-кодзе на JavaScript і Node.js 22/24, а таксама способы ўладзення з ямі, якія працуюць пад рэальным навантажэнням.

Адмылка 1: Прыйманне падазраў і мовчкі продаж чынення

Няўтэрплівы спосаб:

async function getUserProfile(userId) {
  try {
    const res = await fetch(`/api/users/${userId}`);
    return await res.json();
  } catch (err) {
    console.error('Failed to fetch user', err);
    return null;
  }
}
async function renderDashboard(userId) {
  const profile = await getUserProfile(userId);
  // profile.name throws here if fetch failed — but the stack trace
  // now points at renderDashboard, not at the network call that actually failed
  document.title = `${profile.name}'s Dashboard`;
}

Закамуфляванне аперацыі fetch за дапамою блакетнага catch ператварая конкрэтную, адступню галузь каштоў (адказ 500 з /api/users/42) у нечыткую (profile is null). Калі эксцэпцыя нарэшце выйдае на паверх — напрыклад, TypeError: Cannot read properties of null — гэта выходзіць далека ад практычнай прычыны, і няма нічога, што бы паведамляла пра тое, што ўсё-такі была сетавая запытка. У працоўным режыме гэтыя разлікі ствараюць разніцу межу шыраным рашэнням за пяць хвілін і двагадзінным аналізам логаў.

Правільнае выкарыстоўванне:

async function getUserProfile(userId) {
  const res = await fetch(`/api/users/${userId}`);
  if (!res.ok) {
    throw new Error(`Failed to fetch user ${userId}: ${res.status}`, {
      cause: { status: res.status, userId },
    });
  }
  return res.json();
}
async function renderDashboard(userId) {
  try {
    const profile = await getUserProfile(userId);
    document.title = `${profile.name}'s Dashboard`;
  } catch (err) {
    console.error('Dashboard render failed', err, err.cause);
    showErrorBanner('Could not load your profile. Please retry.');
  }
}

Функцыя, яка менш за іншыя розумее сетавы запит — функцыя загрузкі дадзеных — павінна проста выклікваць аберанцыю, калі ўтвараецца проблема. Рашэнне па кансэкту таго, што на самай працо значыць „аберанцыя“ — чы то паказаць спецыяльны банер, перапрыткаваць запит, чы то вярнуцца да дадзеных з кэша — належыць той функцыі, якай фактычна мае стратэгію вярнення до нормальнага стану. Іспытакоўванне Error.cause (уведзенага ў ES2022 і доступнага ў всіх сучасных прыгледачах, а таксама ў Node.js 16.9 і новейшых версіях) дапамагае зберагчы структураваны контекст, у той час як без яго іспытакоўвання месца праблемы ператвараецца на незрозумелую строчку.

Памылка 2: Іспытакоўванне Promise.all, калі трэба Promise.allSettled

Няправільны спосаб:

async function loadDashboardData(userId) {
  const [profile, orders, recommendations] = await Promise.all([
    fetchProfile(userId),
    fetchOrders(userId),
    fetchRecommendations(userId), // a third-party service with a 2% error rate
  ]);
  return { profile, orders, recommendations };
}

Promise.all прыметная спосабом быстрага абароткі: як толькі хоць адна з прамісаў вядзе ся да абароткі, весь вызов таксама абаротваецца, і рэзультаты іншых вызоў відхоўляюцца, нават якщы тыя ўжо былі выкарыстаны без проблем. Таму, якщо fetchRecommendations выйдзе за час, корыстувач таксама втрачае доступ да свайго прафіля і історыі замоў, незважаючы на тое, што оба запиты фактычна былі завершаны без будзь-яких проблем. Гэты патэрн стоіць за вялікай часткай скарг на „нестабільную панель керування“, якія надаюцца пра код, які інакш ўсё правільна працуе.

Правільны выкарыстоўванне:

async function loadDashboardData(userId) {
  const results = await Promise.allSettled([
    fetchProfile(userId),
    fetchOrders(userId),
    fetchRecommendations(userId),
  ]);

const [profile, orders, recommendations] = results.map((r) =>
    r.status === 'fulfilled' ? r.value : null
  );
  results.forEach((r, i) => {
    if (r.status === 'rejected') {
      logNonFatal(['profile', 'orders', 'recommendations'][i], r.reason);
    }
  });
  return { profile, orders, recommendations };
}

Практычныя паказанні: выкорыстоўваеце Promise.all толькі тады, калі кожная операцыя дасканальна неабходная, а частковы результат будзе беззначны, напрыклад, калі тры запісы складаюць адну атомную транзакцыю. Выкорыстоўваеце Promise.allSettled, калі операцыі ўзаіндэпентныя, і часткова працюючы інтерфейс кращы, чым порожні — што на практыцы стосуецца большасці панелей керавання, канцэнтраў аграгацыі дадзенаў і заданняў пакетной обробкі.

Памылка 3: Запуск асінхронных заданняў і неразбіранне ўсіх их адмов

Проблематычны патэрн:

function handleClick(event) {
  logAnalyticsEvent(event); // returns a promise, nobody awaits it
  updateUI();
}

Хутчэй, logAnalyticsEvent адзявляецца як async, што значыць, ён вяртае обяву (promise), незалежна ад таго, чыім гэта зацікавіцца. Паколькі ніхто не прыўязвае метод .catch, абмова гэтай обявы не мае куды прайсці. Якщо служба аналітыки раптам стане недоступной, абмова ператвараецца на неапранутую абмову обявы — якая дзеянкія браузероў ігнаруюць, але якая прыводзіць да немедленнага зупінення процесу ў Node.js, дзе з 15-й версіі Node.js неапранутыя абмовы за замовчаннем завершаюць процес. У працоўніку запыту гэта значыць, што кожны корыстнік, який запускае гэты маршрут, атрымвае памылку 500, і ўсё гэта чераз фонавы вызов, на які ніхто нават не чакаў.

Бяспечнейшая версія:

function handleClick(event) {
  void logAnalyticsEvent(event).catch((err) => {
    logNonFatal('analytics', err);
  });
  updateUI();
}

Дадзенне прыемніка вызову значэнням void паведамляе як будучых адміністратараў коду, так і інструменты, такія як @typescript-eslint/no-floating-promises, што ігнораванне await у гэтым месцы ўсвядомленае, а не памилка. Аднак самэй роботу па запобежчанню таго, каб фонавая памылка ператворылася на працоўную адсутнасьць функцыянавання, выконвае блок .catch. Якщо ваш код работае на Node.js, таксама варта дадаць слухача на верхнім рэвэле process.on('unhandledRejection', ...) як апошняе захістнае мера — але спрыяйце яму як запаснай сетцы, а не як галоўной стратэгіі. Яго задача — запісваць проблему і павесты ўвагу когосі, а не компенсаваць відсутнасьць напісанага блока .catch.

Памылка 4: Дазволенне асінхронным вызовам конкуруваць без анулювання тых, якія зазнаюць невыпадку

Проблематычны шаблон:

async function search(query) {
  const results = await fetch(`/api/search?q=${query}`).then((r) => r.json());
  renderResults(results);
}

searchInput.addEventListener('input', (e) => search(e.target.value));

Кожны раз, калі натыкаецца клавіша, выклікаецца новы запит да сеті. Пакалькі не гарантуецца, што адпаведныя рэспонсы прыйду ў той самы порядак, у яком былі апрашаны, якщо пошук "reac" завершыцца пасля пошуку "react", застарелыя рэзультаты заменяюць правілівыя на экране. Гэта не якісь рэдкі крайні случай — на повольным або обмежаным з’ўязку гэта відбываецца ўсё час, і гэта адна з найчастыя прычын скарг на баг «у поле пошуку паказваюць некоректныя рэзультаты» у будзь-ям прыложэнні з функцыяйю реальнага пошуку.

Безпечнейшая версія:

let activeController = null;

async function search(query) {
  activeController?.abort();
  activeController = new AbortController();
  const { signal } = activeController;

try {
    const res = await fetch(`/api/search?q=${query}`, { signal });
    const results = await res.json();
    renderResults(results);
  } catch (err) {
    if (err.name !== 'AbortError') {
      logNonFatal('search', err);
    }
  }
}
searchInput.addEventListener('input', (e) => search(e.target.value));

AbortController, які ўжо є в Node.js з версіі 15 і падтрымваецца кожнай сучасной рэалізацыяй fetch, ператварае неявную борбу за ресурсы на явную, кантролюемую: выграўае найсяроднейшы запит, таму што всі паканальныя запыты актыўна абрываюцца, а не таму, што ўдаецца ім выграць у гэйме з часам. Той самы прынцып дапраўдзіцца, калі компонент абнімаецца часткова падчас выканання запыту ў React, Angular чыю Vue — запыт трэба анулюваць падчас чысткі, а не проста спадзявацца, што яго адпаведзь ніколи не прыйдзе.

Памылка 5: Апоўзанне на finally замест правильнага карыстоўванняя абрабаткай памылак

Проблемны шаблон:

async function processOrder(order) {
  let lock;
  try {
    lock = await acquireLock(order.id);
    await chargeCard(order);
    await updateInventory(order);
  } finally {
    releaseLock(lock);
  }
}

На першы погляд гэта здаецца безпечным, адколькі блок finally завжды выконваецца і павінен завжды звалічваць блокіраванне. Але якщо сама функцыя acquireLock выклекчваеся раней, чым будзе ўжо выконана якая-небудзь інструкцыя, то пасля выконання finally змэнная lock застаецца undefined. У такі момент вызов releaseLock(undefined) або выклекчвае другую, несувязаную памяту пра бяг, якая маскіруе первоначальную памяту, або нічога не робіць — залежна ад таго, як реалізавана механіка блокіравання — тады ж іншы, абсалютна разны блокіраванне застаецца утримваным няскончана. Блок finally толькі гарантуе, што яго код будзе выконвацца; ён нічога не кажа пра тое, чы роўнасць логіка чысткі ўнутры дзе-небудзь практычна працюе для кожнага варыянта ситуацыі, які мог ўсё гэта спрычыніць.

Безпечнейшая версія:

async function processOrder(order) {
  const lock = await acquireLock(order.id); // outside try — nothing to release yet
  try {
    await chargeCard(order);
    await updateInventory(order);
  } finally {
    await releaseLock(lock);
  }
}

Толькі тыя операцыі, якія залежнаць ад таго, чы рэальна ўжо была захавана блокірацыя, должны знаходзіцца ў блоке try, які запускае процедуру чысткі. Цэлая невелика структурная змена, але яна выдзеляе процедуру чысткі, якая запускаецца толькі тады, калі гэта неабходна, ад процедуры чысткі, якая можа запусціцца за адзінаковых умоў і тады, калі гэта можа спрабаваць зіпсаваць стан умоў узамест таго, каб яго вылечыць.

Падсумак

Async/await ніколі не пазырэўла труднасцяў JavaScript у рашэнні проблем з адхыленнямі — ён проста маскаваў іх за сынтаксам, які выглядае як сінхронны код. Пяць прыказоў дапамагаюць супрацаваць з большасцю проблем у рэальных умовах, якія ствараецца гэтым падходам: выкарыстоўваць чытальна апісаныя типы адхылення замест таго, каб іх проста паспакоўваць; выкарыстоўваць Error.cause, каб захаваць первісную прычыну адхылення; выкарыстоўваць Promise.allSettled, калі аперацыі, якія выкананы, не залежаць адзіна ад другога; ніколі не заставляць обявлены проміс без .catch; ануляваць застарэлыя асінхронныя задачы за дапамогою AbortController, а не пакладацца на тое, што сетевыя проблемы самі рашуцца; і лімітаваць выкарыстоўванне блакоў finally толькі для чысткі стану, які дзе-небудзь быў справжняяму здобыты.

Ніякі з эых практык не ўскладненыя чы роzwітаныя. Яны паказваюць разлік межу асінхронным кодам, який працюе эфектыўна пад рэальнымі сетавымі умовамі, і асінхронным кодам, який працаваў толькі пад час дэманстрацыі.

Спадневае чытанне

  • Напады на ланцугах снабжэння npm: як яны функцыонуюць і як захістіць Node.js — Адказвае, як функцыонуюць напады на ланцугах снабжэння npm, такія як захопленне адзынакоў, тайпоскуатінг і плутанне залежнасцяў, а таксама прыкладныя крокі для зміцнення інсталяцый Node.js.
  • Развіцце локальных Azure Functions: выправленне частых проблем — Дзеяўцы даведаюць, як Core Tools, мовныя рантаймы і Azurite павінны быць сумелесныя, а таксама прыносяць практычныя рэшэння для local.settings.json, трыгероў і падчысленняя памылак.
  • Выбір между Promise.all, Promise.race і парадыгмай sequential Awaits — Дазвольце дазнацца, калі Promise.all() прыяўляе швальнасць API Node.js, чаму ён быстра збіваецца праз будзь-яю адмову, і які крэтынары дапамагаюць выбраць правія асінхронная парадыгму.
  • Спаўненне логаў між асінхроннымі вызывамі за дапамогою AsyncLocalStorage — Дазвольце дазнацца, як Node.js AsyncLocalStorage фіксуе контэкст кожнага запытку, такі як requestId, за межамі await, не трэбаючы ручна праходзіць яго чераз кожную функцыю.