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

Сувязоўка логаў між асінхроннымі вызывамі за дапамою AsyncLocalStorage

Дазвольце дазнаць, як Node.js AsyncLocalStorage фіксуе контэкст кожнага запытку, такі як requestId, за межамі await, без неабяжнага перадачы яго через кожную функцыю.

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

Крыніцы: The Node.js Feature That Replaces Passing `requestId` Through Every Single Function

Кожны разработчык бэкенду раней чым пазней сталкаецца з гэтым самым сценарыем: калі прыходзіць запит, ствараецца requestId, і где-то на шостым роўні, у дапаможным модуле базы дадзеных або спяльнай функцыі логавання, патрэбны той самы ідентыфікатор, каб можна было прыяўязаць запіс логу да запита, які яго выклікаў. Вочывыя рашэння — перадаць ID як параметр через кожную функцію ў ланцузе. Гэта працуючы метод, але яго ведача складная, таму што кожная проміжная функцыя несе параметр, які ёй не патрабуецца, кроме як для перадачы наступнаму вызову.

Простыя рашэння і прычыны ўсіх іх невялікасці

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

let currentRequestId = null;

app.use((req, res, next) => {
  currentRequestId = req.headers["x-request-id"];
  next();
});

function logSomething(message) {
  console.log(`[${currentRequestId}] ${message}`); // works... until it doesn't
}

Гэты падход здаецца разумным, якщо выявіць, што адчыняецца толькі адна запита за раз, але ён перестае працаваць, калі взяць у расчыт тое, як на самай працэ Node адчыняе канкурэнтнае відвядзенне. Node адчыняе багато запитаў „одночасна“, перамешваючы ўжыванне іх коду па точкі await, а не выканання іх строго аднаго пасля другога. Запит А запішвае значэнне ў currentRequestId, пасля чаго доходзіць да await і вяртае кантроль назад у цыкл змагання, які можа адразу ж пачаць обробку запита B і перазапісаць тую ж зменную ID запита B. Калі код запита А продовжыць свою роботу, currentRequestId вяскрава будзе аднасоўвацца да B, і з таго моменту кожна запіс у логах запита А будзе тылуцьна, без жадных проблем, асоўваць некоректны ID.

AsyncLocalStorage: Контэкст, який следуе за выкананнем, а не за стаком вызываў

AsyncLocalStorage, які ўключаецца ў вбудованы модуль async_hooks у Node, створаны саме для рашэння гэтай проблемы. Ён зберагае адзінаковую значэнне для кожнай логічной ланцуга асінхроннага выканання і робіць гэта правільна, нават калі запыткі перакрываюцца між вызовамі await, ланцугамі promise і калебэкамі.

const { AsyncLocalStorage } = require("async_hooks");
const requestContext = new AsyncLocalStorage();

app.use((req, res, next) => {
  const store = { requestId: req.headers["x-request-id"] || crypto.randomUUID() };
  requestContext.run(store, next); // everything inside next() runs "inside" this context
});

function logSomething(message) {
  const store = requestContext.getStore();
  console.log(`[${store?.requestId}] ${message}`); // correct, regardless of interleaving
}

Выклік requestContext.run(store, next) не проста ўсталоўка значэння, а стварэнне контексту, які автаматычна праходзіць чераз усе, што запускаецца з іншага боку гэтага выкліку, незалежна ад таго, чы ўсё відбываецца сінхронна чы асінхронна, включаючы код, які продовжуееся пасля await, выконваецца внутры .then()-хендлера чы запускаецца пазней як калебэк з якой-небудзь операцыі I/O. Нават глэбока ў дапаможніку для запытанняў да базы дадзенаў, заданам у абсалютна разным файле, выклік requestContext.getStore() вяртае контекст, які належыць конкрэтнаму запыту, а не той, які быў позычаны з першага ж запускаецца запыту. Гэта таму, што контекст прыязначаны да конкрэтнага ланцуга асінхроннага выконання, яке здзейснілае выклік, а не да ўзаўмовыя зменнай, у якую кожны запыт піша свои данні.

Чаму гэта насправды працюе праз межы await

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

app.get("/orders/:id", async (req, res) => {
  requestContext.run({ requestId: req.headers["x-request-id"] }, async () => {
    logSomething("fetching order"); // context is present
    const order = await db.getOrder(req.params.id); // pauses, other requests may run here
    logSomething("order fetched"); // context is STILL correctly present after resuming
    res.json(order);
  });
});

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

Праўя выгода: структураваная логіроўка без параметраў прамянювання ўсюды

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

function createLogger() {
  return {
    info(message, extra = {}) {
      const store = requestContext.getStore();
      logger.info({ requestId: store?.requestId, ...extra }, message);
    },
  };
}

// deep inside some completely unrelated file, five layers into the call stack
const log = createLogger();
log.info("Payment processed", { amount: 4999 }); // requestId is present, automatically

Нічога ў сигнатуре функцыі processPayment не паўінна было змяніцца, каб гэта запрацавала. Яна ніколі не прыймае аргумент requestId і ніколи не перадае йога нічаму з таго, што выклекае, пры чым кожны рядок логу, які яна выдае, правільна пазначаецца запитам, які яго створыў. Гэта і є праўая выгода: даныя, прыналежны конкретнаму запиту, стаюць доступнымі ў будзь-якай частыні ланцуга вызоў, не заплямавляючы кожную сигнатуру функцыі між пачатком запиту і моментам, калі гэтыя даныя фактычна выкарыстоўваюцца.

Граніца, яка створяе працоўнікам проблемы

Контэкст, які вы ствараеце ўнутрь .run(), распрастраняецца толькі на код, які ўласна є часткаю той самай ланцуга выконання: на все, што запускаецца з ўсередзіні калбэку, чы то безпосередна, чы опосередка, укладаючы ў сябе ланцугі чакаемых прамісаў. Ён не распрастраняецца назад на код, які выканаўся раней, чым быў вызваны .run(), і не распрастраняецца вперад на задачы, якія фактычна ад’юнкуюцца ад гэтага ланцуга:

app.use((req, res, next) => {
  requestContext.run({ requestId: req.headers["x-request-id"] }, () => {
    setTimeout(() => {
      logSomething("delayed log"); // still correct — setTimeout inside the context stays tracked
    }, 1000);
    next();
  });
});

Выклік setTimeout з усередзіння цього контэкста все ўсё працюе як і планавалася, адказна механізм распространення async_hooks у Node пакрывае таймеры, запаветі і майже всі стандартныя асінхронныя прымітывы, з якімаі можна стаць. Проблемы вылучваюцца тады, калі праця выпрацоўваецца без слежэння і спецыяльна праектуецца так, каб перазісціць пасля запиту, які ўсё гэта запусціў. Хорашым прыкладам є задача, якая падаецца у окремы, незалежны працоўні процес: не існуе ланца выконання, який бы з’ўязаў ёю з контэкстам, з якога яна выйшла, адтакоў ёй не є продажчыкам той самай асінхронной серыі вызоваў. Це абдзельны, незв’язаны процес.

Чыстая адзначка пра выконвальную спроможнасць

AsyncLocalStorage не ў бесплатнае распаўсце. Збір і вярненне контэкста праз кожную асінхронную перамену трэба робіць з значнымі зусіллямі, а старэйшыя версіі Node справляліся з гэтым значна менш эфективна, чым рантайм сёння. У ныях версіях Node накладныя витраты для типовага веб-застосоўнага завантажэння настолькі малыя, што гэта не будзе значным фактам для пераважнай большасці проектаў, а прабыткі ў правільнасці та чыстасцы коду практычна завжды пераважаюць гэтыя маленькія витраты. Якщо вы працуеце у экстремальных масштабах, дзе кожны мікросекунды на запит мае значэнне, варта працаваць над стварэнням бенчмарка для вашага конкретнага случая. Але для практычна кожнай бэкенд-сервісы ўзяць гэта як безпечны стандарт є разумным. Гэта не якаясь спецыяльная трык з оптымізацыі, яку вы вжываеце час а час; гэта інструмент з малымі, вядомымі витратамі, якія компенсуюцца практычна ў всіх нормальных супакойненнях.

Асалодная ідея, якая стоіць за гэтым

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