Od skryptu do użytku w produkcji w Node.js API: pętla zdarzeń, wątki robocze, logowanie
W jaki sposób pętla zdarzeń, praca blokująca, wątki robocze, timeouty, równoczesność, ustrukturyzowane logi oraz dostęp do bazy danych kształtują API Node.js, które funkcjonuje poprawnie w środowisku produkcyjnym.
Sprawienie, by API Node.js odpowiadało, rzadko stanowi trudny element. Endpoint zwraca dane, frontend je renderuje, a zestaw testów pokazuje pozytywne wyniki. Trudności pojawiają się w środowisku produkcyjnym, gdy naraz przychodzi tysiące żądań, baza danych zwalnia, API firmy trzeciej przestaje odpowiadać, pamięć stopniowo się wypełnia lub żądanie, które zwykle zajmuje 200 milisekund, nagle potrzebuje 12 sekund. Istnieje też cichsze pytanie: czy inny programista będzie rozumiał ten kod za sześć miesięcy?
Na te pytania odpowiada zachowanie środowiska wykonawczego oraz kilka nawyków projektowych. Poniżej zobaczysz, gdzie model wykonywania w Node.js sprawdza się najlepiej, gdzie zawodzi, oraz jakie wzorce zapewniają responsywność usługi, gdy pojawia się rzeczywisty ruch: przenoszenie obciążeń CPU do wątków roboczych, ustawianie czasów wygaśnięcia, jednoczesne wywołania sieciowe, uporządkowane logowanie oraz staranne formułowanie zapytań.
Co naprawdę potrafi środowisko wykonawcze
Standardowe wyjaśnienie brzmi, że Node.js umożliwia uruchamianie JavaScripta poza przeglądarką. To prawda, ale pomija najciekawszą część. To, co naprawdę oferuje Node.js, to środowisko działające na zasadzie zdarzeń z asynchronicznym I/O oraz bardzo dużym ekosystemem wokół niego. Taka architektura okazuje się korzystna, gdy serwer spędza większość czasu na czekaniu na coś innego:
- zapytania do bazy danych
- wyjściowe połączenia HTTP
- czytanie i zapisywanie plików
- ruch sieciowy w ogóle
- kolejki wiadomości
- długotrwałe połączenia WebSocket
Prześledźmy pojedyncze żądanie w typowym backendzie:
Client
↓
API Server
↓
Database
↓
External API
↓
Response
Prawie w żadnym momencie tego łańcucha serwer nie jest zajęty obliczeniami. Głównie czeka na bazę danych, a potem na zewnętrzną API. Model Node.js został zaprojektowany tak, aby wykorzystywać ten czas bezczynności do obsługi innych żądań, a zrozumienie tego jest cenniejsze niż jakakolwiek konkretne API.
Jak pętla zdarzeń zmienia twój model myślowy
JavaScript w aplikacji Node.js zazwyczaj działa na jednej głównej nitce, podczas gdy pętla zdarzeń koordynuje operacje asynchroniczne wokół niej. Uproszczony obraz życiorysu żądania:
Incoming Request
↓
JavaScript
↓
Start I/O operation
↓
Don't block execution
↓
Event Loop
↓
I/O completes
↓
Callback / Promise
↓
Continue execution
Oto jak to wygląda w zwykłym kodzie:
const user = await database.getUser(userId);
const orders = await database.getOrders(userId);
return {
user,
orders
};
await wstrzymuje wykonywanie tej funkcji, a nie całego procesu. Podczas gdy baza danych przetwarza pierwsze zapytanie, silnik może obsługiwać inne funkcje zwrotne oraz inne żądania. Dlatego jeden proces Node.js może zarządzać dużą liczbą żądań, które przeważnie czekają w sieci.
Jedna nuansowa kwestia, na którą warto zwrócić uwagę: powyższe dwa zapytania są wykonywane jeden po drugim, ponieważ drugie await rozpoczyna się dopiero po zakończeniu pierwszego. Wrócimy do tego w rozdziale poświęconym równoczesności.
Asynchroniczność to nie to samo co szybkość
Ten model ma sztywny limit. Jeśli Twój kod JavaScript wykonuje długotrwałe obliczenia, nic innego na tym wątku nie może działać, dopóki one się nie zakończą. Taki pętla utrzymuje wątek zajętym przez cały czas jego trwania:
function expensiveOperation() {
let result = 0;
for (let i = 0; i < 10_000_000_000; i++) {
result += i;
}
return result;
}
Podczas jego działania nie przyjmowane są nowe żądania, obietnice nie są realizowane, a timerzy nie uruchamiają się zgodnie z planem. Nie ma żadnego planeru, który mogłby przerwać pętlę, aby dać szansę innym żądaniom. To jest główny powód, dla którego zrozumienie zachowania środowiska wykonawczego jest ważniejsze niż zapamiętanie jego interfejsów API.
Problem blokującego obsługiwania żądań
A teraz umieść takie operacje wewnątrz obsługiwanego żądania HTTP. Zmiana rozdzielczości obrazów, przetwarzanie dużych ilości danych lub intensywne obliczenia wykonywane bezpośrednio w ścieżce żądania wyglądają w kodzie nieszkodliwie:
app.post("/process", async (req, res) => {
const result = expensiveComputation(req.body);
res.json(result);
});
Problem pojawia się tylko pod obciążeniem. Jeden żądanie uruchamia kosztowną funkcję i monopolizuje pętlę zdarzeń. Wszystkie pozostałe żądania czekają w kolejce za nim. Opóźnienia rosną we wszystkich przypadkach, nie tylko w przypadku intensywnie wykorzystywanego punktu końcowego, a sam proces aplikacji staje się wąskim gardłem. Oznaczenie obsługiwanego kodu jako async nie pomaga, ponieważ obliczenia wewnątrz nadal są synchroniczne.
Trwalszy projekt całkowicie przenosi prace zależne od CPU poza ścieżkę żądania:
Client
↓
Node.js API
↓
Queue
↓
Worker
↓
Heavy computation
↓
Database / Storage
API przyjmuje zlecenie, umieszcza je w kolejce i szybko odpowiada, podczas gdy dedykowani pracownicy przetwarzają zlecenia we własnym tempie i zapisują wyniki do bazy danych lub systemu przechowywania obiektów. API pozostaje responsywny, a ty możesz skalować pracowników niezależnie od warstwy webowej. To przejście od pisania obsługujących funkcji do projektowania przepływu pracy jest głównym czynnikiem, który odróżnia znajomość Node.js od budowania systemów z jego użyciem.
Kiedy nie należy korzystać z kolejki
Kolejka wprowadza konieczność uwzględnienia infrastruktury, prób ponownych oraz koncepcji spójności ewentualnej. W przypadku krótkich zadań wymagających obliczeń na procesorze, które muszą zwrócić wynik synchronicznie, wątki pracownicze Node przechowują pracę w ramach tego samego procesu, ale poza głównym wątkiem. Dla zadań trwających kilka milisekund ich wykonywanie bezpośrednio jest często wystarczające. Decyzja zależy od czasu trwania zadania oraz od tego, czy klient musi czekać na odpowiedź.
Express to framework, a nie środowisko wykonawcze
Wielu programistów łączy w jedno koncepcje Node.js i Express. Node.js to środowisko wykonawcze, natomiast Express to framework internetowy zbudowany na jego bazie. Można obsługiwać protokół HTTP wykorzystując jedynie wbudowany moduł 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);
To działa, ale w miarę rozwoju aplikacji twoim obowiązkiem staje się routowanie, middleware, analiza treści, walidacja oraz obsługa błędów. Frameworki istnieją po to, by ulżyć ci w tym obciążeniu. Ta sama koncepcja w Express wygląda w ten sposób:
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 nie zastępuje niczego w Node.js; oferuje wygodniejszą abstrakcję tych samych elementów podstawowych. Jasne rozróżnienie to ułatwia debugowanie: wolny endpoint może być problemem frameworka, problemem czasu wykonywania lub żadnym z nich.
Kod produkcyjny dotyczy przede wszystkim granic
Niewielki serwis może znajdować się w jednym pliku. System produkcyjny nie może tego robić, ponieważ różne obowiązki wymagają wyraźnych granic między nimi. Jedna rozsądna struktura:
src/
├── api/
│ ├── routes/
│ ├── controllers/
│ └── middleware/
│
├── services/
│ └── userService.js
│
├── repositories/
│ └── userRepository.js
│
├── config/
│ └── environment.js
│
├── jobs/
│ └── emailWorker.js
│
└── app.js
Konkretna nazwa foldera nie jest istotna. Ważne jest, aby obowiązki pozostawały rozdzielone:
- Kontroler nie powinien gromadzić setek linijek reguł biznesowych.
- Repozytorium nie powinno wiedzieć nic o tym, w jaki sposób kształtowane są odpowiedzi HTTP.
- Usługa nie powinna przyjmować jako argumentów obiektów Express
reqlubres.
To właśnie te rozdzielenia umożliwiają testowanie kodu w izolacji, jego stopniową wymianę oraz zrozumienie przez kolejną osobę, która go czyta. Aby dowiedzieć się więcej na temat tej struktury, zapoznaj się z naszym przewodnikiem po projektowaniu warstwowych API w Node.js.
Projektowanie kodu asynchronicznego z myślą o awariach
Każdy programista Node.js uczy się tego wcześnie:
const data = await fetchData();
Proces produkcji rodzi trudniejsze pytania na ten temat. A co, jeśli fetchData() zajmie 30 sekund? A co, jeśli wywoła błąd? A co, jeśli wywoła błąd trzy razy z rzędu? A co, jeśli usługa źródłowa jest całkowicie niedostępna? A co, jeśli zapis do bazy danych się powiedzie, ale następny krok się nie uda, co spowoduje częściową aktualizację systemu?
Niezawodny kod backendu określa zachowanie w takich sytuacjach, a nie tylko opisuje sukcesy. Oto mały przykład, który wyraźnie pokazuje możliwość awarii:
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;
}
}
Funkcja przekształca brakującego użytkownika w błąd, rejestruje niepowodzenia wraz z kontekstem niezbędnym do ich zbadania i ponownie rzuca błąd, aby osoba wywołująca mogła zdecydować, jak zareagować. Podstawową zasadą jest to, że obsługa niepowodzeń jest decyzją projektową, a nie czymś dodawanym po wystąpieniu incydentu.
Praktyczna uwaga: jeśli każda warstwa rejestruje i ponownie rzuca błędy, jedno niepowodzenie powoduje kilka wierszy w dzienniku. Decyduj, gdzie rejestrowane są błędy (zwykle raz, na granicy, która nimi się zajmuje) i pozwól warstwom wewnętrznym dodać odpowiedni kontekst.
Nigdy nie czekaj w nieskończoność: timeouty
Oto wywołanie innego usługi bez żadnej ochrony:
const response = await fetch("https://example.com/api");
Jeśli serwer zdalny przyjmuje połączenie, ale nigdy nie odpowiada, to await może utknąć na znacznie dłużej, niż ktoś jest w stanie to znieść, trzymając zasoby w użyciu przez cały czas. System produkcyjny wymaga wyraźnego górnego ograniczenia. Za pomocą standardowego AbortController, dostępnego w nowoczesnym Node.js, można je wprowadzić:
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);
}
Czasownik anuluje żądanie po pięciu sekundach, a blok finally go oczyszcza, dzięki czemu nie zostanie uruchomiony po udanej odpowiedzi. Nowsze środowiska wykonawcze oferują również AbortSignal.timeout(ms), który wyraża tę samą ideę w jednym wyrażeniu.
Pięć sekund nie jest liczbą magiczną i może być zbyt dużym limitem dla interfejsu użytkownika. Wybierz wartość z budżetu opóźnienia endpointu. Niepodważalnym warunkiem jest to, że nieskończone czekanie nie jest akceptowalnym sposobem na awarię.
Konkurencja bez paralelizmu
Gdy dwie operacje nie zależą od siebie, oczekiwanie na nie po kolei marnuje czas:
const user = await getUser();
const orders = await getOrders();
Drugi połączenie nie rozpoczyna się nawet wtedy, gdy pierwsze się zakończy, więc całkowity opóźnienie to suma obu. Zazwyczaj lepiej jest uruchomić je jednocześnie i czekać na oba:
const [user, orders] = await Promise.all([
getUser(),
getOrders()
]);
Oba żądania są teraz przetwarzane jednocześnie, a całkowity czas oczekiwania odpowiada mniej więcej czasowi przetwarzania wolniejszego z nich. To jest konkurencja, a nie obliczenia równoległe: twoje JavaScript nadal działa w jednym wątku, ale czasy oczekiwania się nakładają. Różnica ta ma największe znaczenie w punktach końcowych, które łączą się z kilkoma usługami poniżej w łańcuchu.
Dwa przypadki krawędziowe, o których należy pamiętać. Promise.all odrzuca żądanie, jak tylko jakikolwiek z wprowadzonych odrzuci je, więc użyj Promise.allSettled, gdy akceptowalne są częściowe wyniki. Ponadto uruchamianie setek żądań naraz może przeciążyć zasoby bazy danych lub API z ograniczeniami szybkości, więc należy ograniczyć poziom konkurencji, gdy lista jest duża.
Wydajność zależy od architektury
Po częstu zdarza się, że zespoły debatują nad wyborami na poziomie mikroelementów, takimi jak klasyczne
for
pętla w porównaniu z
forEach
podczas gdy ten sam punkt końcowy wywołuje pięć niepotrzebnych zapytań do bazy danych. Albo gdy patrzymy na mały narzędzie starannie dostosowane, podczas gdy API zwraca ogromne obciążenia w formacie JSON, albo gdy każde żądanie czeka 800 milisekund na wolne zapytanie.
W rzeczywistych usługach czasy odpowiedzi są zazwyczaj determinowane przez:
- sposób dostępu do bazy danych
- opóźnienia sieciowe
- nieskuteczne lub nieindeksowane zapytania
- przesadną serializację
- zależność od zewnętrznych API
- operacje, które w ogóle nie muszą się odbywać
- presję pamięciową
- brak lub słabo zaprojektowane buforowanie
Największe korzyści pochodzą zazwyczaj ze zmian architektonicznych, a nie ze zmian składniowych. Jeśli chcesz uporządkowany sposób ustalania priorytetów, zapoznaj się z naszą ramą uporządkowaną według priorytetów dla optymalizacji wydajności API w Node.js.
Obserwowalność jest częścią produktu
Server, który nie potrafi powiedzieć, co zrobił, jest bardzo trudny w obsłudze. Logowanie to pierwsza warstwa ochrony, ale taka linijka jest praktycznie bezużyteczna w środowisku produkcyjnym:
console.log("Something went wrong");
Strukturalne logowanie rejestruje nazwę zdarzenia oraz identyfikatory, które są potrzebne do jego zbadania:
logger.error({
event: "payment_failed",
orderId,
userId,
error: error.message
});
Ponieważ każda pozycja to obiekt, a nie tekst swobodny, logi można filtrować, agregować i analizować – na przykład wszystkie zdarzenia payment_failed dla jednego użytkownika.
Samych logów nadal brakuje, by uzyskać pełny obraz. Systemy produkcyjne zazwyczaj opierają się na trzech uzupełniających się sygnałach:
Logs
↓
What happened?
Metrics
↓
How often / how much?
Traces
↓
Where did the request spend its time?
Logi pokazują, co się wydarzyło, metryki informują o częstotliwości i ilości zdarzeń, a ślady wskazują, gdzie żądanie spędziło czas w poszczególnych usługach. Gdy ktoś zgłasza, że „aplikacja jest wolna”, te dane pozwalają odpowiedzieć na podstawie faktów, a nie domysłów.
Czas działania nie określa sposobu dostępu do danych
Wybór Node.js nie narzuca konkretnego systemu bazodanowego. Działa dobrze z PostgreSQL, MySQL, MongoDB, Redis, SQLite, Elasticsearch oraz wieloma innymi. O wiele ważniejsze jest to, w jaki sposób kod komunikuje się z wybranym magazynem danych. Ten endpoint wygląda zupełnie rozsądnie:
app.get("/products", async (req, res) => {
const products = await db.query(
"SELECT * FROM products"
);
res.json(products);
});
W środowisku produkcyjnym natychmiast rodzi szereg pytań:
- Ile wierszy zawiera obecnie
products, a ile w przyszłym roku? - Czy istnieje paginacja?
- Czy zapytanie jest obsługiwane przez indeks?
- Czy ta odpowiedź może osiągnąć rozmiar 50 MB?
- Jak zachowuje się przy jednoczesnym ruchu?
- Czy zbiór połączeń ma rozsądne ograniczenia?
- Czy błąd bazy danych może wyciec do klienta?
Node.js nie odpowie na żadne z tych pytań za ciebie. Te decyzje należą do osoby projektującej usługę.
Node.js jako jeden z wielu komponentów
Node.js jest powszechnie używany w środowiskach chmurowych, częściowo dlatego, że jego lekki model procesów dobrze pasuje do kontenerów i projektów zorientowanych na usługi. W takich systemach jest zazwyczaj tylko jedną z wielu komponentów: warstwą API obok kolejek, procesorów roboczych, pamięci cache, baz danych i innych usług, z których każda ma własne zadania. Nie musi stanowić całego systemu, a prawdziwy backend rzadko to po prostu „API Express”.
Gdzie Node.js potrzebuje pomocy
Żaden środowisko wykonawcze nie nadaje się do każdego rodzaju obciążenia. Node.js sprawdza się najlepiej, gdy praca polega głównie na operacjach wejścia/wyjścia, natomiast zadania wymagające dużej mocy obliczeniowej CPU wymagają bardziej przemyślanego projektu. Przykłady:
- przetwarzanie obrazów na dużą skalę
- kodowanie wideo
- intensywne obliczenia naukowe
- niektóre obciążenia związane z uczeniem maszynowym
- skomplikowane operacje kryptograficzne
- bardzo duża transformacja danych
Żadna z tych zasad nie wyklucza Node.js z takich systemów. Oznacza to, że proces obsługujący żądania HTTP nie powinien pełnić również najcięższych zadań. Możliwe rozwiązania to:
- wątki robocze w obrębie tego samego procesu
- oddzielne procesy robocze w tle
- kolejki między API a wątkami roboczymi
- specjalistyczne usługi do wykonywania kosztownych operacji
- inne języki lub środowiska programistyczne lepiej nadające się do takich obliczeń
Celem nie jest przymuszanie stosowania Node.js do rozwiązywania każdego problemu, lecz umieszczenie go tam, gdzie pasuje do jego modelu wykonywania.
Pytanie „dlaczego”, a nie tylko „jak”
Na początku pytanie brzmi, jak zbudować API przy użyciu Node.js. Z upływem doświadczenia zmienia się ono na to, dlaczego API powinno być zbudowane w określony sposób, co prowadzi do zupełnie innej serii pytań:
- Czy ta operacja powinna być synchroniczna, czy przechodzić przez kolejkę?
- Czy odpowiedź powinna być zapisywana w pamięci cache?
- Czy ten serwis posiada te dane?
- Czy ten endpoint powinien polegać na trzech innych usługach, i co się stanie, gdy jedna z nich zawiedzie?
- Gdzie należy umieszczać próby ponownego wysłania żądania?
- Co powinno być rejestrowane, a co mierzone?
- Jak będzie się to zachowywać pod obciążeniem?
Żadne z tych zagadnień nie jest specyficzne dla Node.js. Są to pytania z zakresu inżynierii oprogramowania, a Node.js dostarcza narzędzi do wdrożenia odpowiedzi na nie.
Krótka lista kontrolna dla środowiska produkcyjnego
- Pętla zdarzeń sprawia, że Node.js doskonale radzi sobie z jednoczesnym oczekiwaniem na odpowiedzi z sieci i dysku, ale każda synchroniczna operacja na CPU blokuje wszystkie żądania na tym wątku.
- Przenieś intensywne obliczenia poza ścieżkę obsługi żądań za pomocą wątków roboczych, kolejek lub oddzielnych usług.
- Traktuj ścieżki awarii oraz wyraźne limity czasowe jako elementy projektu; nieskończone oczekiwanie to błąd.
- Wykonywaj niezależne wywołania w dół równocześnie za pomocą
Promise.all, a także ograniczaj liczbę równoczesnych operacji, gdy rozprzestrzeniają się one na szeroką skalę. - Problemy wydajnościowe zazwyczaj wynikają z zapytań, rozmiaru danych przekazywanych i liczby przeskoków sieciowych, a nie z składni pętli.
- Strukturalne logi, metryki i ślady działania przekształcają skargi z środowiska produkcyjnego w konkretne dowody.
- Frameworki i środowiska wykonawcze to narzędzia; architektura, granice i dostęp do danych pozostają odpowiedzialnością programisty.