Używanie tras Express.js do obsługi błędów opartej na obietnicach
Opublikowany: 2022-03-11Slogan Express.js brzmi prawdziwie: to „szybka, nieoceniona, minimalistyczna platforma internetowa dla Node.js”. Jest tak nieoceniony, że pomimo aktualnych najlepszych praktyk JavaScript zalecających korzystanie z obietnic, Express.js domyślnie nie obsługuje obsługi tras opartych na obietnicach.
Ponieważ wiele samouczków Express.js pomija ten szczegół, programiści często mają zwyczaj kopiowania i wklejania kodu wysyłania wyników i obsługi błędów dla każdej trasy, tworząc dług techniczny na bieżąco. Możemy uniknąć tego antywzorca (i jego skutków) dzięki technice, którą omówimy dzisiaj — takiej, której z powodzeniem używałem w aplikacjach z setkami tras.
Typowa architektura tras Express.js
Zacznijmy od aplikacji samouczka Express.js z kilkoma trasami dla modelu użytkownika.
W rzeczywistych projektach przechowujemy powiązane dane w jakiejś bazie danych, takiej jak MongoDB. Ale dla naszych celów specyfika przechowywania danych jest nieistotna, więc dla uproszczenia będziemy je wyśmiewać. To, czego nie uprościmy, to dobra struktura projektu, klucz do połowy sukcesu każdego projektu.
Yeoman może ogólnie uzyskać znacznie lepsze szkielety projektów, ale do tego, czego potrzebujemy, po prostu utworzymy szkielet projektu z ekspresowym generatorem i usuniemy niepotrzebne części, dopóki nie otrzymamy tego:
bin start.js node_modules routes users.js services userService.js app.js package-lock.json package.jsonSkróciliśmy linie pozostałych plików, które nie są związane z naszymi celami.
Oto główny plik aplikacji Express.js, ./app.js :
const createError = require('http-errors'); const express = require('express'); const cookieParser = require('cookie-parser'); const usersRouter = require('./routes/users'); const app = express(); app.use(express.json()); app.use(express.urlencoded({ extended: false })); app.use(cookieParser()); app.use('/users', usersRouter); app.use(function(req, res, next) { next(createError(404)); }); app.use(function(err, req, res, next) { res.status(err.status || 500); res.send(err); }); module.exports = app; Tutaj tworzymy aplikację Express.js i dodajemy podstawowe oprogramowanie pośredniczące do obsługi JSON, kodowania adresów URL i parsowania plików cookie. Następnie dodajemy usersRouter dla /users . Na koniec określamy, co zrobić, jeśli nie zostanie znaleziona żadna trasa, i jak obsługiwać błędy, które później zmienimy.
Skrypt do uruchomienia samego serwera to /bin/start.js :
const app = require('../app'); const http = require('http'); const port = process.env.PORT || '3000'; const server = http.createServer(app); server.listen(port); Nasz /package.json to także kadłubki:
{ "name": "express-promises-example", "version": "0.0.0", "private": true, "scripts": { "start": "node ./bin/start.js" }, "dependencies": { "cookie-parser": "~1.4.4", "express": "~4.16.1", "http-errors": "~1.6.3" } } Użyjmy typowej implementacji routera użytkownika w /routes/users.js :
const express = require('express'); const router = express.Router(); const userService = require('../services/userService'); router.get('/', function(req, res) { userService.getAll() .then(result => res.status(200).send(result)) .catch(err => res.status(500).send(err)); }); router.get('/:id', function(req, res) { userService.getById(req.params.id) .then(result => res.status(200).send(result)) .catch(err => res.status(500).send(err)); }); module.exports = router; Ma dwie drogi: / do pobrania wszystkich użytkowników i /:id do pobrania pojedynczego użytkownika według ID. Używa również /services/userService.js , który ma metody oparte na obietnicach, aby uzyskać te dane:
const users = [ {id: '1', fullName: 'User The First'}, {id: '2', fullName: 'User The Second'} ]; const getAll = () => Promise.resolve(users); const getById = (id) => Promise.resolve(users.find(u => u.id == id)); module.exports = { getById, getAll }; Tutaj uniknęliśmy używania rzeczywistego złącza DB lub ORM (np. Mongoose lub Sequelize), po prostu naśladując pobieranie danych za pomocą Promise.resolve(...) .
Problemy z routingiem Express.js
Patrząc na nasze procedury obsługi tras, widzimy, że każde wywołanie usługi używa zduplikowanych .then(...) i .catch(...) w celu wysłania danych lub błędów z powrotem do klienta.
Na pierwszy rzut oka może się to wydawać niepoważne. Dodajmy kilka podstawowych wymagań rzeczywistych: będziemy musieli wyświetlać tylko niektóre błędy i pominąć ogólne błędy 500-poziomowe; również to, czy stosujemy tę logikę, czy nie, musi być oparte na środowisku. Z tym, jak to będzie wyglądać, gdy nasz przykładowy projekt rozrośnie się z dwóch tras w prawdziwy projekt z 200 trasami?
Podejście 1: Funkcje użytkowe
Może stworzylibyśmy oddzielne funkcje narzędziowe do obsługi resolve i reject , i stosowanie ich wszędzie w naszych trasach Express.js:
// some response handlers in /utils const handleResponse = (res, data) => res.status(200).send(data); const handleError = (res, err) => res.status(500).send(err); // routes/users.js router.get('/', function(req, res) { userService.getAll() .then(data => handleResponse(res, data)) .catch(err => handleError(res, err)); }); router.get('/:id', function(req, res) { userService.getById(req.params.id) .then(data => handleResponse(res, data)) .catch(err => handleError(res, err)); }); Wygląda lepiej: nie powtarzamy naszej implementacji wysyłania danych i błędów. Ale nadal będziemy musieli zaimportować te procedury obsługi do każdej trasy i dodać je do każdej obietnicy przekazanej do then() i catch() .
Podejście 2: oprogramowanie pośredniczące
Innym rozwiązaniem może być wykorzystanie najlepszych praktyk Express.js w zakresie obietnic: Przenieś logikę wysyłania błędów do oprogramowania pośredniczącego z błędami Express.js (dodanego w app.js ) i przekaż do niego błędy asynchroniczne za pomocą next wywołania zwrotnego. Nasza podstawowa konfiguracja oprogramowania pośredniego o błędach korzystałaby z prostej anonimowej funkcji:
app.use(function(err, req, res, next) { res.status(err.status || 500); res.send(err); }); Express.js rozumie, że dotyczy to błędów, ponieważ podpis funkcji ma cztery argumenty wejściowe. (Wykorzystuje fakt, że każdy obiekt funkcji ma właściwość .length opisującą liczbę parametrów oczekiwanych przez funkcję).
Przekazywanie błędów przez next wyglądałoby tak:
// some response handlers in /utils const handleResponse = (res, data) => res.status(200).send(data); // routes/users.js router.get('/', function(req, res, next) { userService.getAll() .then(data => handleResponse(res, data)) .catch(next); }); router.get('/:id', function(req, res, next) { userService.getById(req.params.id) .then(data => handleResponse(res, data)) .catch(next); }); Nawet korzystając z oficjalnego przewodnika po najlepszych praktykach, nadal potrzebujemy naszych obietnic JS w każdym programie obsługi trasy, aby rozwiązać za pomocą funkcji handleResponse() i odrzucić, przekazując next funkcję.
Spróbujmy to uprościć lepszym podejściem.
Podejście 3: Oprogramowanie pośredniczące oparte na obietnicach
Jedną z największych cech JavaScript jest jego dynamiczna natura. Możemy dodać dowolne pole do dowolnego obiektu w czasie wykonywania. Wykorzystamy to do rozszerzenia obiektów wynikowych Express.js; Funkcje oprogramowania pośredniczącego Express.js są do tego wygodnym miejscem.
Nasza obietnica Funkcja promiseMiddleware()
Stwórzmy nasze obiecujące oprogramowanie pośredniczące, które zapewni nam elastyczność w bardziej eleganckiej strukturze naszych tras Express.js. Potrzebujemy nowego pliku /middleware/promise.js :
const handleResponse = (res, data) => res.status(200).send(data); const handleError = (res, err = {}) => res.status(err.status || 500).send({error: err.message}); module.exports = function promiseMiddleware() { return (req,res,next) => { res.promise = (p) => { let promiseToResolve; if (p.then && p.catch) { promiseToResolve = p; } else if (typeof p === 'function') { promiseToResolve = Promise.resolve().then(() => p()); } else { promiseToResolve = Promise.resolve(p); } return promiseToResolve .then((data) => handleResponse(res, data)) .catch((e) => handleError(res, e)); }; return next(); }; } W app.js zastosujmy nasze oprogramowanie pośredniczące do ogólnego obiektu app Express.js i zaktualizujmy domyślne zachowanie błędów:

const promiseMiddleware = require('./middlewares/promise'); //... app.use(promiseMiddleware()); //... app.use(function(req, res, next) { res.promise(Promise.reject(createError(404))); }); app.use(function(err, req, res, next) { res.promise(Promise.reject(err)); }); Zauważ, że nie pomijamy naszego oprogramowania pośredniczącego z błędami . Jest to nadal ważna procedura obsługi błędów dla wszystkich błędów synchronicznych, które mogą istnieć w naszym kodzie. Ale zamiast powtarzać logikę wysyłania błędów, oprogramowanie pośredniczące błędów przekazuje teraz wszelkie błędy synchroniczne do tej samej centralnej funkcji handleError() za pośrednictwem Promise.reject() wysłanego do res.promise() .
Pomaga nam to radzić sobie z błędami synchronicznymi, takimi jak ten:
router.get('/someRoute', function(req, res){ throw new Error('This is synchronous error!'); }); Na koniec użyjmy naszej nowej res.promise() w /routes/users.js :
const express = require('express'); const router = express.Router(); const userService = require('../services/userService'); router.get('/', function(req, res) { res.promise(userService.getAll()); }); router.get('/:id', function(req, res) { res.promise(() => userService.getById(req.params.id)); }); module.exports = router; Zwróć uwagę na różne zastosowania .promise() : możemy przekazać mu funkcję lub obietnicę. Przekazywanie funkcji może pomóc w metodach, które nie mają obietnic; .promise() widzi, że jest to funkcja i otacza ją obietnicą.
Gdzie lepiej faktycznie wysyłać błędy do klienta? To dobre pytanie dotyczące organizacji kodu. Moglibyśmy to zrobić w naszym oprogramowaniu pośredniczącym z błędami (ponieważ ma działać z błędami) lub w naszym oprogramowaniu pośredniczącym obietnicy (ponieważ ma już interakcje z naszym obiektem odpowiedzi). Zdecydowałem się zachować wszystkie operacje odpowiedzi w jednym miejscu w naszym obiecującym oprogramowaniu pośredniczącym, ale każdy programista musi zorganizować swój własny kod.
Technicznie rzecz biorąc, res.promise() jest opcjonalna
Dodaliśmy res.promise() , ale nie jesteśmy ograniczeni do jej używania: możemy operować bezpośrednio z obiektem odpowiedzi, kiedy jest to konieczne. Przyjrzyjmy się dwóm przypadkom, w których byłoby to przydatne: przekierowaniu i strumieniowaniu.
Przypadek specjalny 1: Przekierowanie
Załóżmy, że chcemy przekierować użytkowników na inny adres URL. Dodajmy funkcję getUserProfilePicUrl() w userService.js :
const getUserProfilePicUrl = (id) => Promise.resolve(`/img/${id}`); A teraz użyjmy go w routerze naszych użytkowników w stylu async / await z bezpośrednią manipulacją odpowiedzią:
router.get('/:id/profilePic', async function (req, res) { try { const url = await userService.getUserProfilePicUrl(req.params.id); res.redirect(url); } catch (e) { res.promise(Promise.reject(e)); } }); Zwróć uwagę, jak używamy async / await , wykonujemy przekierowanie i (co najważniejsze) nadal mamy jedno centralne miejsce do przekazania każdego błędu, ponieważ użyliśmy res.promise() do obsługi błędów.
Przypadek specjalny 2: Rurociągi strumieniowe
Podobnie jak w przypadku naszej trasy zdjęcia profilowego, potokowanie strumienia to kolejna sytuacja, w której musimy bezpośrednio manipulować obiektem odpowiedzi.
Aby obsłużyć żądania do adresu URL, do którego teraz przekierowujemy, dodajmy trasę, która zwraca ogólny obraz.
Najpierw powinniśmy dodać profilePic.jpg w nowym podfolderze /assets/img . (W prawdziwym projekcie używalibyśmy przechowywania w chmurze, takiego jak AWS S3, ale mechanizm potoków byłby taki sam.)
Prześlijmy ten obraz w odpowiedzi na żądania /img/profilePic/:id . W tym celu musimy utworzyć nowy router w /routes/img.js :
const express = require('express'); const router = express.Router(); const fs = require('fs'); const path = require('path'); router.get('/:id', function(req, res) { /* Note that we create a path to the file based on the current working * directory, not the router file location. */ const fileStream = fs.createReadStream( path.join(process.cwd(), './assets/img/profilePic.png') ); fileStream.pipe(res); }); module.exports = router; Następnie dodajemy nasz nowy router /img w app.js :
app.use('/users', require('./routes/users')); app.use('/img', require('./routes/img')); Prawdopodobnie wyróżnia się jedna różnica w porównaniu z przypadkiem przekierowania: nie użyliśmy res.promise() w routerze /img ! Dzieje się tak, ponieważ zachowanie obiektu odpowiedzi już potokowego, który jest przesyłany z błędem, będzie inne niż w przypadku wystąpienia błędu w środku strumienia.
Deweloperzy Express.js muszą zwracać uwagę podczas pracy ze strumieniami w aplikacjach Express.js, obsługując błędy w różny sposób w zależności od tego, kiedy wystąpią. Musimy poradzić sobie z błędami przed potokami ( res.promise() może nam w tym pomóc), a także w połowie strumienia (w oparciu o procedurę obsługi .on('error') ), ale dalsze szczegóły wykraczają poza zakres tego artykułu.
Wzmocnienie res.promise()
Podobnie jak w przypadku wywołania res.promise() , nie jesteśmy ograniczeni do zaimplementowania go w taki sposób, w jaki mamy. promiseMiddleware.js można rozszerzyć, aby akceptować niektóre opcje w res.promise() , aby umożliwić wywołującym określenie kodów stanu odpowiedzi, typu treści lub czegokolwiek innego, czego może wymagać projekt. To do programistów należy ukształtowanie narzędzi i zorganizowanie kodu tak, aby jak najlepiej odpowiadał ich potrzebom.
Obsługa błędów Express.js spełnia współczesne kodowanie oparte na obietnicach
Przedstawione tutaj podejście pozwala na bardziej eleganckie procedury obsługi tras niż na początku oraz pojedynczy punkt przetwarzania wyników i błędów — nawet tych uruchamianych poza res.promise(...) — dzięki obsłudze błędów w app.js . Mimo to nie jesteśmy zmuszeni do korzystania z niego i możemy przetwarzać przypadki brzegowe tak, jak chcemy.
Pełny kod z tych przykładów jest dostępny w serwisie GitHub. Stamtąd programiści mogą w razie potrzeby dodać niestandardową logikę do funkcji handleResponse() , na przykład zmienić stan odpowiedzi na 204 zamiast 200, jeśli nie są dostępne żadne dane.
Jednak dodatkowa kontrola nad błędami jest znacznie bardziej przydatna. Takie podejście pomogło mi zwięźle wdrożyć te funkcje w produkcji:
- Formatuj wszystkie błędy konsekwentnie jako
{error: {message}} - Wyślij ogólną wiadomość, jeśli nie podano statusu lub przekaż daną wiadomość w przeciwnym razie
- Jeśli środowisko to
dev(lubtest, itp.), wypełnij poleerror.stack - Obsługuj błędy indeksowania bazy danych (tj. istnieje już pewna jednostka z polem o unikalnym indeksowaniu) i uprzejmie odpowiadaj, wysyłając znaczące błędy użytkownika
Ta logika tras Express.js była w jednym miejscu, bez dotykania żadnej usługi — oddzielenie, które sprawiło, że kod był znacznie łatwiejszy w utrzymaniu i rozszerzaniu. W ten sposób proste, ale eleganckie rozwiązania mogą radykalnie poprawić strukturę projektu.
Dalsza lektura na blogu Toptal Engineering:
- Jak zbudować system obsługi błędów Node.js?
