Chcesz utrzymać asynchroniczny stan w łańcuchu wywołań zwrotnych? Poznaj AsyncLocalStorage
Node.js jest jednym z popularniejszych języków programowania do tworzenia nowoczesnych aplikacji webowych. Jego jednowątkowość ma wiele atutów, jednak niekiedy jest utrudnieniem dla programistów, zwłaszcza gdy zaczynamy przesyłać kontekst pomiędzy wieloma operacjami asynchronicznymi. Jednak wcale tak nie musi być! Dzięki nowej funkcjonalności – AsyncLocalStorage
możemy poradzić sobie z tym problemem. Z tego artykułu dowiesz się, jak to zrobić.
Piotr Moszkowicz. Senior TypeScript FullStack Developer w Code&Pepper – producenta technologii finansowych. Programuje od dziesięciu lat, chętnie dzieli się swoją wiedzą współtworząc Koło Naukowe Informatyków „Kernel” na AGH. W ramach studiów tworzył oprogramowanie dla CERN. W wolnym czasie gra na pianinie oraz (niestety tylko) kolekcjonuje repliki ASG.
Mogę się założyć, że większość z Was pisała kiedyś logger requestów HTTP, aby przekonać się co dzieje się w trakcie każdego zapytania do serwera. Kod tego typu funkcji mógł wyglądać następująco.
const express = require("express"); const { v4: uuid } = require("uuid"); const indexRouter = require("./routes/index"); const app = express(); function requestLogger(req, ...args) { console.log(req.id, args); } app.use(express.json()); app.use(express.urlencoded({ extended: false })); app.use((req, res, next) => { req.id = uuid(); requestLogger(req, "request started"); next(); }); app.use("/", indexRouter); app.use((req, res, next) => { requestLogger(req, "request ended"); next(); }); module.exports = app;
Zerknijmy na funkcję requestLogger
: pierwszy parametr zawiera obiekt z danymi na temat zapytania, natomiast drugi wiadomość, która powinna zostać zalogowana. Patrząc na ten kawałek kodu od razu możemy zauważyć wspomniany wyżej problem – musimy przekazywać zmienną “req” (którą nazwijmy kontekstem zapytania) do naszego loggera.
Tego typu pattern znany jest w community Node’owców od lat, jednakże to nie czyni go poprawnym wzorcem. Zwróćmy uwagę, iż jest to relatywnie prosty przykład – wielokrotnie zdarza się, że nasz logger musi zapisywać szczegóły transakcji SQL czy też operacji związanych z chmurą publiczną (na przykład upload pliku do AWS S3). Tego typu kod może stać się złożony bardzo szybko, co skutecznie przeszkadza w generyczności tego typu funkcji – należałoby pisać logger dla każdego use-case’a. Ale rozwiązanie już jest na horyzoncie!
AsyncLocalStorage
to klasa, która tworzy asynchroniczny stan dostępny z poziomu callback’ów oraz promise’ów. Pozwala utrzymywać przy życiu nasz kontekst w trakcie operacji asynchronicznych takich jest requesty HTTP, eventy RabbitMQ czy odpowiedzi na wiadomość WebSocket. Zanim przejdę do wyjaśnienia jak z niej skorzystać – upewnij się, że korzystasz z Node.js’a w wersji minimum 12.17.0 lub 13.10.0 (wersję 14+ wspierają AsyncLocalStorage
defaultowo).
Spróbujmy zrefactorować kod z poprzedniego przykładu wykorzystując tą nową funkcjonalność. Również, aby zobrazować możliwości AsyncLocalStorage
pokusimy się o poszerzenie naszego loggera o informację o ścieżce request’a oraz różnicy czasowej pomiędzy startem zapytania oraz zawołanie loggera.
const express = require("express"); const { AsyncLocalStorage } = require("async_hooks"); const { v4: uuid } = require("uuid"); const indexRouter = require("./routes/index"); const app = express(); const context = new AsyncLocalStorage(); function requestLogger(...args) { const store = context.getStore(); const id = store.get("id"); const timeStart = store.get("timeStart"); const { originalUrl } = store.get("request"); console.log(`${id}, ${originalUrl}, ${+new Date() - timeStart}ms`, args); } app.use(express.json()); app.use(express.urlencoded({ extended: false })); app.use((req, res, next) => { const store = new Map(); context.run(store, () => { store.set("id", uuid()); store.set("timeStart", +new Date()); store.set("request", req); requestLogger("request started"); next(); }); }); app.use("/", indexRouter); app.use((req, res, next) => { requestLogger("request ended"); next(); }); module.exports = app;
Na początku należy zaimportować oraz stworzyć kontekst AsyncLocalStorage
. Następnie zerknijmy na nasz middleware z linii 22 – tworzymy Mapę, w której będziemy przechowywać cały nasz kontekst wykonania. Dzięki temu w prosty, uporządkowany oraz optymalny sposób możemy przechowywać wiele informacji. Wołając context.run
z naszym store w formie argumentu tworzymy instancję naszego kontekstu.
W ramach callbacku dostarczamy wszystkie informacje, które chcemy przechowywać w kontekście – dla każdego request’a tworzymy unikalne ID, zapisujemy cały obiekt zawierający szczegóły zapytania oraz timestamp wywołania callbacku. Wróćmy do funkcji requestLogger
. Na początku możemy z jej deklaracji usunąć wszystkie argumenty związane z kontekstem. Następnie wystarczy, abyśmy usunięte dane wyciągnęli z naszego store znajdującego się w instancji AsyncLogalStorage
. To co pozostało to prosta logika biznesowa naszego loggera. Jak widać kod stał się prostszy, bardziej zwięzły i łatwiejszy do przeczytania. Również z pomocą jednej struktury – mapy jesteśmy w stanie przechowywać tak dużo informacji w kontekście jak tylko chcemy.
Przykład pokazany w artykule jest naprawdę prosty – dzięki temu łatwiej zrozumieć korzyści. Jednak możliwości jest wiele więcej – w łatwy sposób możemy implementować wyrafinowane narzędzia do monitorowania, loggery z wieloma funkcjonalnościami, error handling na dużo wyższym poziomie, wykorzystanie pojedynczej transakcji SQL dla każdego request’a HTTP i wiele wiele więcej…
Jednak jest kilka wskazówek, z których zawsze warto korzystać:
- W związku z limitami po stronie wydajności nie powinno się zakładać więcej niż 10-15
AsyncLocalStorage
. Również autorzy bibliotek nie powinni z nich korzystać. - Jeśli nie masz pewności czy dane zostaną zebrane przez Garbage Collector – użyj metody
context.exit()
. - Nie zapominaj o limitach
async/await
– operacje asynchroniczne zajmujące mniej niż 2ms nie powinny być wrapowane przez Promise’y.
Pamiętając o tych uwagach mam nadzieję, że będziesz w stanie troszkę oczyścić swój kod i skorzystać z nowych funkcjonalności języka!
Źródła: Dokumentacja Node.js oraz prelekcja pt. “Request context tracking (AsyncLocalStorage use cases and best practices)“ wygłoszona przez Vladimir de Turckheim, Lead Node.js Engineer @ Sqreen.
Zdjęcie główne artykułu pochodzi z unsplash.com.