Budowanie interfejsu API REST Node.js/TypeScript, część 1: Express.js
Opublikowany: 2022-03-11Jak napisać REST API w Node.js?
Podczas tworzenia zaplecza dla interfejsu API REST Express.js jest często pierwszym wyborem spośród frameworków Node.js. Chociaż obsługuje również tworzenie statycznego kodu HTML i szablonów, w tej serii skupimy się na programowaniu zaplecza przy użyciu języka TypeScript. Powstały interfejs API REST będzie takim, do którego będzie mogła wysłać zapytanie dowolna platforma frontonu lub zewnętrzna usługa zaplecza.
Będziesz potrzebował:
- Podstawowa znajomość JavaScript i TypeScript
- Podstawowa znajomość Node.js
- Podstawowa znajomość architektury REST (w razie potrzeby por. tę sekcję mojego poprzedniego artykułu o interfejsie API REST)
- Gotowa instalacja Node.js (najlepiej wersja 14+)
W terminalu (lub wierszu poleceń) utworzymy folder dla projektu. Z tego folderu uruchom npm init
. To stworzy kilka podstawowych plików projektu Node.js, których potrzebujemy.
Następnie dodamy framework Express.js i kilka pomocnych bibliotek:
npm i express debug winston express-winston cors
Istnieją dobre powody, dla których te biblioteki są ulubionymi programami Node.js:
-
debug
to moduł, którego użyjemy, aby uniknąć wywoływaniaconsole.log()
podczas tworzenia naszej aplikacji. W ten sposób możemy łatwo filtrować instrukcje debugowania podczas rozwiązywania problemów. Można je również całkowicie wyłączyć podczas produkcji, bez konieczności ręcznego ich zdejmowania. -
winston
jest odpowiedzialny za rejestrowanie żądań do naszego API oraz zwracanych odpowiedzi (i błędów).express-winston
integruje się bezpośrednio z Express.js, dzięki czemu cały standardowy kod rejestrowaniawinston
związany z interfejsem API jest już gotowy. -
cors
jest częścią oprogramowania pośredniczącego Express.js, które umożliwia nam udostępnianie zasobów między źródłami. Bez tego nasz interfejs API byłby użyteczny tylko z interfejsów obsługiwanych z dokładnie tej samej subdomeny, co nasz back-end.
Nasz backend używa tych pakietów, gdy jest uruchomiony. Ale musimy również zainstalować pewne zależności programistyczne dla naszej konfiguracji TypeScript. W tym celu pobiegniemy:
npm i --save-dev @types/cors @types/express @types/debug source-map-support tslint typescript
Te zależności są wymagane, aby włączyć TypeScript dla własnego kodu naszej aplikacji, wraz z typami używanymi przez Express.js i innymi zależnościami. Może to zaoszczędzić dużo czasu, gdy używamy IDE, takiego jak WebStorm lub VSCode, umożliwiając nam automatyczne uzupełnianie niektórych metod funkcji podczas kodowania.
Ostateczne zależności w package.json
powinny wyglądać tak:
"dependencies": { "debug": "^4.2.0", "express": "^4.17.1", "express-winston": "^4.0.5", "winston": "^3.3.3", "cors": "^2.8.5" }, "devDependencies": { "@types/cors": "^2.8.7", "@types/debug": "^4.1.5", "@types/express": "^4.17.2", "source-map-support": "^0.5.16", "tslint": "^6.0.0", "typescript": "^3.7.5" }
Teraz, gdy mamy już zainstalowane wszystkie wymagane zależności, zacznijmy tworzyć własny kod!
Struktura projektu TypeScript REST API
W tym samouczku stworzymy tylko trzy pliki:
-
./app.ts
-
./common/common.routes.config.ts
-
./users/users.routes.config.ts
Ideą stojącą za dwoma folderami struktury projektu ( common
i users
) jest posiadanie indywidualnych modułów, które mają swoje własne obowiązki. W tym sensie ostatecznie będziemy mieć niektóre lub wszystkie z poniższych dla każdego modułu:
- Konfiguracja trasy w celu zdefiniowania żądań obsługiwanych przez nasze API
- Usługi do zadań, takich jak łączenie się z naszymi modelami baz danych, wykonywanie zapytań lub łączenie się z usługami zewnętrznymi, które są wymagane przez określone żądanie
- Oprogramowanie pośredniczące do uruchamiania walidacji określonych żądań, zanim końcowy kontroler trasy obsłuży jej specyfikę
- Modele do definiowania modeli danych pasujących do danego schematu bazy danych w celu ułatwienia przechowywania i wyszukiwania danych
- Kontrolery do oddzielenia konfiguracji trasy od kodu, który ostatecznie (po dowolnym oprogramowaniu pośredniczącym) przetwarza żądanie trasy, w razie potrzeby wywołuje powyższe funkcje usługowe i udziela odpowiedzi klientowi
Ta struktura folderów zapewnia podstawowy projekt interfejsu API REST, wczesny punkt wyjścia do dalszej części tej serii samouczków i wystarczającą do rozpoczęcia ćwiczeń.
Typowy plik tras w TypeScript
W folderze common
utwórzmy plik common.routes.config.ts
, który będzie wyglądał następująco:
import express from 'express'; export class CommonRoutesConfig { app: express.Application; name: string; constructor(app: express.Application, name: string) { this.app = app; this.name = name; } getName() { return this.name; } }
Sposób, w jaki tworzymy tutaj trasy, jest opcjonalny. Ale ponieważ pracujemy z TypeScript, nasz scenariusz tras jest okazją do przećwiczenia używania dziedziczenia ze słowem kluczowym extends
, jak zobaczymy wkrótce. W tym projekcie wszystkie pliki tras zachowują się tak samo: mają nazwę (której użyjemy do debugowania) i dostęp do głównego obiektu Application
Express.js.
Teraz możemy zacząć tworzyć plik tras użytkowników. W folderze users
stwórzmy users.routes.config.ts
i zacznijmy go kodować w ten sposób:
import {CommonRoutesConfig} from '../common/common.routes.config'; import express from 'express'; export class UsersRoutes extends CommonRoutesConfig { constructor(app: express.Application) { super(app, 'UsersRoutes'); } }
Tutaj importujemy klasę CommonRoutesConfig
i rozszerzamy ją na naszą nową klasę o nazwie UsersRoutes
. Za pomocą konstruktora wysyłamy aplikację (główny obiekt express.Application
) i nazwę UsersRoutes do konstruktora CommonRoutesConfig
.
Ten przykład jest dość prosty, ale podczas skalowania w celu utworzenia kilku plików tras pomoże nam to uniknąć powielania kodu.
Załóżmy, że chcielibyśmy dodać do tego pliku nowe funkcje, takie jak rejestrowanie. Moglibyśmy dodać potrzebne pole do klasy CommonRoutesConfig
, a wtedy wszystkie trasy rozszerzające CommonRoutesConfig
będą miały do niego dostęp.
Używanie abstrakcyjnych funkcji TypeScript do podobnych funkcji w klasach
A co, jeśli chcielibyśmy mieć pewną funkcjonalność, która jest podobna między tymi klasami (np. konfigurowanie punktów końcowych API), ale wymaga to innej implementacji dla każdej klasy? Jedną z opcji jest użycie funkcji TypeScript o nazwie abstrakcja .
Stwórzmy bardzo prostą abstrakcyjną funkcję, którą klasa UsersRoutes
(i przyszłe klasy routingu) odziedziczy po CommonRoutesConfig
. Załóżmy, że chcemy wymusić na wszystkich trasach funkcję (abyśmy mogli ją wywołać z naszego wspólnego konstruktora) o nazwie configureRoutes()
. W tym miejscu zadeklarujemy punkty końcowe każdego zasobu klasy routingu.
Aby to zrobić, dodamy trzy szybkie rzeczy do common.routes.config.ts
:
- Słowo kluczowe
abstract
do wiersza naszejclass
, aby włączyć abstrakcję dla tej klasy. - Nowa deklaracja funkcji na końcu naszej klasy,
abstract configureRoutes(): express.Application;
. Wymusza to, aby każda klasa rozszerzającaCommonRoutesConfig
dostarczała implementację pasującą do tej sygnatury — w przeciwnym razie kompilator TypeScript zgłosi błąd. - Wywołanie
this.configureRoutes();
na końcu konstruktora, ponieważ teraz możemy być pewni, że ta funkcja będzie istnieć.
Wynik:
import express from 'express'; export abstract class CommonRoutesConfig { app: express.Application; name: string; constructor(app: express.Application, name: string) { this.app = app; this.name = name; this.configureRoutes(); } getName() { return this.name; } abstract configureRoutes(): express.Application; }
Dzięki temu każda klasa rozszerzająca CommonRoutesConfig
musi mieć funkcję o nazwie configureRoutes()
, która zwraca obiekt express.Application
. Oznacza to, że users.routes.config.ts
wymaga aktualizacji:
import {CommonRoutesConfig} from '../common/common.routes.config'; import express from 'express'; export class UsersRoutes extends CommonRoutesConfig { constructor(app: express.Application) { super(app, 'UsersRoutes'); } configureRoutes() { // (we'll add the actual route configuration here next) return this.app; } }
Jako podsumowanie tego, co zrobiliśmy:
Najpierw importujemy plik common.routes.config
, a następnie moduł express
. Następnie definiujemy klasę UserRoutes
, mówiąc, że chcemy, aby rozszerzała klasę bazową CommonRoutesConfig
, co oznacza, że obiecujemy, że zaimplementuje configureRoutes()
.
Aby przesłać informacje wraz z klasą CommonRoutesConfig
, używamy constructor
tej klasy. Oczekuje na otrzymanie obiektu express.Application
, który dokładniej opiszemy w następnym kroku. Za pomocą super()
przekazujemy do CommonRoutesConfig
aplikację i nazwę naszych tras, która w tym scenariuszu to UsersRoutes. ( z kolei super()
wywoła naszą implementację configureRoutes()
.)
Konfigurowanie tras Express.js punktów końcowych użytkowników
Funkcja configureRoutes()
służy do tworzenia punktów końcowych dla użytkowników naszego interfejsu API REST. Tam będziemy korzystać z aplikacji i jej funkcjonalności tras z Express.js.
Ideą korzystania z funkcji app.route()
jest uniknięcie duplikacji kodu, co jest łatwe, ponieważ tworzymy REST API z dobrze zdefiniowanymi zasobami. Głównym zasobem tego samouczka są użytkownicy . W tym scenariuszu mamy dwa przypadki:
- Gdy wywołujący interfejs API chce utworzyć nowego użytkownika lub wyświetlić listę wszystkich istniejących użytkowników, punkt końcowy powinien początkowo mieć tylko
users
na końcu żądanej ścieżki. (W tym artykule nie będziemy zajmować się filtrowaniem zapytań, stronicowaniem ani innymi tego typu zapytaniami). - Gdy osoba wywołująca chce zrobić coś konkretnego dla określonego rekordu użytkownika, ścieżka zasobów żądania będzie zgodna ze wzorcem
users/:userId
.
Sposób, w jaki .route()
działa w Express.js, pozwala nam obsługiwać czasowniki HTTP z eleganckim łańcuchem. Dzieje się tak, ponieważ .get()
, .post()
, itd., wszystkie zwracają tę samą instancję IRoute
, co pierwsze .route()
. Ostateczna konfiguracja będzie wyglądać tak:
configureRoutes() { this.app.route(`/users`) .get((req: express.Request, res: express.Response) => { res.status(200).send(`List of users`); }) .post((req: express.Request, res: express.Response) => { res.status(200).send(`Post to users`); }); this.app.route(`/users/:userId`) .all((req: express.Request, res: express.Response, next: express.NextFunction) => { // this middleware function runs before any request to /users/:userId // but it doesn't accomplish anything just yet--- // it simply passes control to the next applicable function below using next() next(); }) .get((req: express.Request, res: express.Response) => { res.status(200).send(`GET requested for id ${req.params.userId}`); }) .put((req: express.Request, res: express.Response) => { res.status(200).send(`PUT requested for id ${req.params.userId}`); }) .patch((req: express.Request, res: express.Response) => { res.status(200).send(`PATCH requested for id ${req.params.userId}`); }) .delete((req: express.Request, res: express.Response) => { res.status(200).send(`DELETE requested for id ${req.params.userId}`); }); return this.app; }
Powyższy kod umożliwia dowolnemu klientowi REST API wywołanie punktu końcowego naszych users
z żądaniem POST
lub GET
. Podobnie umożliwia klientowi wywołanie naszego punktu końcowego /users/:userId
z żądaniem GET
, PUT
, PATCH
lub DELETE
.

Ale w przypadku /users/:userId
dodaliśmy również ogólne oprogramowanie pośredniczące za pomocą funkcji all()
, która zostanie uruchomiona przed jakąkolwiek funkcją get()
, put()
, patch()
lub delete()
. Ta funkcja będzie korzystna, gdy (w dalszej części serii) tworzymy trasy, do których dostęp mają mieć tylko uwierzytelnieni użytkownicy.
Być może zauważyłeś, że w naszej funkcji .all()
— tak jak w przypadku każdego oprogramowania pośredniczącego — mamy trzy typy pól: Request
, Response
i NextFunction
.
- Request to sposób, w jaki Express.js reprezentuje żądanie HTTP, które ma być obsługiwane. Ten typ aktualizuje i rozszerza natywny typ żądania Node.js.
- Odpowiedź jest również sposobem, w jaki Express.js reprezentuje odpowiedź HTTP, ponownie rozszerzając natywny typ odpowiedzi Node.js.
- Nie mniej ważne jest to, że
NextFunction
służy jako funkcja zwrotna, umożliwiająca przekazywanie kontroli przez dowolne inne funkcje oprogramowania pośredniego. Po drodze całe oprogramowanie pośredniczące będzie współużytkować te same obiekty żądania i odpowiedzi, zanim kontroler w końcu wyśle odpowiedź z powrotem do żądającego.
Nasz plik punktu wejścia Node.js, app.ts
Teraz, gdy mamy już skonfigurowane podstawowe szkielety tras, zaczniemy konfigurować punkt wejścia aplikacji. Utwórzmy plik app.ts
w katalogu głównym naszego folderu projektu i zacznijmy go tym kodem:
import express from 'express'; import * as http from 'http'; import * as winston from 'winston'; import * as expressWinston from 'express-winston'; import cors from 'cors'; import {CommonRoutesConfig} from './common/common.routes.config'; import {UsersRoutes} from './users/users.routes.config'; import debug from 'debug';
Tylko dwa z tych importów są nowe w tym momencie artykułu:
-
http
to natywny moduł Node.js. Jest wymagany do uruchomienia naszej aplikacji Express.js. -
body-parser
to oprogramowanie pośredniczące dostarczane z Express.js. Analizuje żądanie (w naszym przypadku jako JSON), zanim kontrola trafi do naszych własnych programów obsługi żądań.
Po zaimportowaniu plików zaczniemy deklarować zmienne, których chcemy użyć:
const app: express.Application = express(); const server: http.Server = http.createServer(app); const port = 3000; const routes: Array<CommonRoutesConfig> = []; const debugLog: debug.IDebugger = debug('app');
Funkcja express()
zwraca główny obiekt aplikacji Express.js, który będziemy przekazywać w całym kodzie, zaczynając od dodania go do obiektu http.Server
. (Będziemy musieli uruchomić http.Server
po skonfigurowaniu naszego express.Application
.)
Będziemy nasłuchiwać na porcie 3000 — który TypeScript automatycznie wywnioskuje, że jest Number
— zamiast na standardowych portach 80 (HTTP) lub 443 (HTTPS), ponieważ są one zwykle używane w interfejsie aplikacji.
Dlaczego port 3000?
Nie ma reguły, że port powinien wynosić 3000 — jeśli nie zostanie określony, zostanie przypisany dowolny port — ale 3000 jest używane w przykładach dokumentacji zarówno dla Node.js, jak i Express.js, więc kontynuujemy tutaj tradycję.
Czy Node.js może współdzielić porty z interfejsem?
Nadal możemy działać lokalnie na niestandardowym porcie, nawet jeśli chcemy, aby nasz backend odpowiadał na żądania na standardowych portach. Wymagałoby to odwrotnego serwera proxy do odbierania żądań na porcie 80 lub 443 z określoną domeną lub subdomeną. Następnie przekieruje je do naszego wewnętrznego portu 3000.
Tablica route będzie śledzić nasze pliki routes
do celów debugowania, jak zobaczymy poniżej.
Wreszcie debugLog
skończy jako funkcja podobna do console.log
, ale lepsza: łatwiej jest ją dostroić, ponieważ jest ona automatycznie objęta zakresem tego, co chcemy nazwać kontekstem pliku/modułu. (W tym przypadku nazwaliśmy to „app”, kiedy przekazaliśmy to w ciągu do konstruktora debug()
.)
Teraz jesteśmy gotowi do skonfigurowania wszystkich naszych modułów oprogramowania pośredniczącego Express.js i tras naszego API:
// here we are adding middleware to parse all incoming requests as JSON app.use(express.json()); // here we are adding middleware to allow cross-origin requests app.use(cors()); // here we are preparing the expressWinston logging middleware configuration, // which will automatically log all HTTP requests handled by Express.js const loggerOptions: expressWinston.LoggerOptions = { transports: [new winston.transports.Console()], format: winston.format.combine( winston.format.json(), winston.format.prettyPrint(), winston.format.colorize({ all: true }) ), }; if (!process.env.DEBUG) { loggerOptions.meta = false; // when not debugging, log requests as one-liners } // initialize the logger with the above configuration app.use(expressWinston.logger(loggerOptions)); // here we are adding the UserRoutes to our array, // after sending the Express.js application object to have the routes added to our app! routes.push(new UsersRoutes(app)); // this is a simple route to make sure everything is working properly const runningMessage = `Server running at http://localhost:${port}`; app.get('/', (req: express.Request, res: express.Response) => { res.status(200).send(runningMessage) });
expressWinston.logger
łączy się z Express.js, automatycznie rejestrując szczegóły — za pośrednictwem tej samej infrastruktury, co debug
— dla każdego zakończonego żądania. Opcje, które mu przekazaliśmy, ładnie sformatują i pokolorują odpowiednie dane wyjściowe terminala, z bardziej szczegółowym rejestrowaniem (domyślnym), gdy jesteśmy w trybie debugowania.
Zauważ, że musimy zdefiniować nasze trasy po skonfigurowaniu expressWinston.logger
.
Wreszcie i co najważniejsze:
server.listen(port, () => { routes.forEach((route: CommonRoutesConfig) => { debugLog(`Routes configured for ${route.getName()}`); }); // our only exception to avoiding console.log(), because we // always want to know when the server is done starting up console.log(runningMessage); });
To faktycznie uruchamia nasz serwer. Po uruchomieniu Node.js uruchomi naszą funkcję zwrotną, która w trybie debugowania zgłasza nazwy wszystkich skonfigurowanych tras — do tej pory tylko UsersRoutes
. Następnie nasze wywołanie zwrotne powiadamia nas, że nasz backend jest gotowy do odbierania żądań, nawet w trybie produkcyjnym.
Aktualizacja package.json
do Transpile TypeScript do JavaScript i uruchomienie aplikacji
Teraz, gdy mamy gotowy szkielet do uruchomienia, najpierw potrzebujemy pewnej konfiguracji standardowej, aby umożliwić transpilację TypeScript. Dodajmy plik tsconfig.json
w katalogu głównym projektu:
{ "compilerOptions": { "target": "es2016", "module": "commonjs", "outDir": "./dist", "strict": true, "esModuleInterop": true, "inlineSourceMap": true } }
Następnie wystarczy dodać ostatnie poprawki do package.json
w postaci następujących skryptów:
"scripts": { "start": "tsc && node --unhandled-rejections=strict ./dist/app.js", "debug": "export DEBUG=* && npm run start", "test": "echo \"Error: no test specified\" && exit 1" },
Skrypt test
to symbol zastępczy, który zastąpimy w dalszej części serii.
TSC w skrypcie start
należy do TypeScript. Jest odpowiedzialny za transpilację naszego kodu TypeScript do JavaScript, który wyprowadzi do folderu dist
. Następnie po prostu uruchamiamy zbudowaną wersję z node ./dist/app.js
.
Przekazujemy --unhandled-rejections=strict
do Node.js (nawet z Node.js v16+), ponieważ w praktyce debugowanie przy użyciu prostego podejścia „awaria i pokazanie stosu” jest prostsze niż bardziej wyszukane rejestrowanie za pomocą obiektu expressWinston.errorLogger
. Najczęściej dzieje się tak nawet w środowisku produkcyjnym, gdzie pozwolenie Node.js na dalsze działanie pomimo nieobsłużonego odrzucenia prawdopodobnie pozostawi serwer w nieoczekiwanym stanie, umożliwiając wystąpienie dalszych (i bardziej skomplikowanych) błędów.
Skrypt debug
wywołuje skrypt start
, ale najpierw definiuje zmienną środowiskową DEBUG
. Skutkuje to włączeniem wszystkich naszych debugLog()
(plus podobnych z samego Express.js, który używa tego samego modułu debug
, co my) w celu wyprowadzania przydatnych szczegółów do terminala — szczegóły, które są (wygodnie) w inny sposób ukryte podczas uruchamiania serwer w trybie produkcyjnym ze standardowym npm start
.
Spróbuj samodzielnie uruchomić npm run debug
, a następnie porównaj to z npm start
, aby zobaczyć, jak zmieniają się dane wyjściowe konsoli.
Wskazówka: możesz ograniczyć dane wyjściowe debugowania do własnych debugLog()
naszego pliku app.ts
, używając DEBUG=app
zamiast DEBUG=*
. Moduł debug
jest ogólnie dość elastyczny i ta funkcja nie jest wyjątkiem.
Użytkownicy systemu Windows prawdopodobnie będą musieli zmienić export
na SET
, ponieważ export
działa na komputerach Mac i Linux. Jeśli Twój projekt musi obsługiwać wiele środowisk programistycznych, pakiet cross-env zapewnia tutaj proste rozwiązanie.
Testowanie zaplecza Live Express.js
Gdy npm run debug
lub npm start
nadal będą działać, nasz REST API będzie gotowy do obsługi żądań na porcie 3000. W tym momencie możemy użyć cURL, Postman, Insomnia itp. do przetestowania zaplecza.
Ponieważ stworzyliśmy tylko szkielet dla zasobu użytkowników, możemy po prostu wysyłać żądania bez treści, aby sprawdzić, czy wszystko działa zgodnie z oczekiwaniami. Na przykład:
curl --request GET 'localhost:3000/users/12345'
Nasz backend powinien odesłać GET requested for id 12345
.
Jeśli chodzi o POST
ing:
curl --request POST 'localhost:3000/users' \ --data-raw ''
To i wszystkie inne rodzaje żądań, dla których zbudowaliśmy szkielety, będą wyglądały dość podobnie.
Gotowy do szybkiego tworzenia interfejsu API REST Node.js za pomocą TypeScript
W tym artykule zaczęliśmy tworzyć REST API, konfigurując projekt od podstaw i zagłębiając się w podstawy frameworka Express.js. Następnie zrobiliśmy pierwszy krok w kierunku opanowania języka TypeScript, budując wzorzec z UsersRoutesConfig
rozszerzającym CommonRoutesConfig
, wzorzec, którego użyjemy ponownie w następnym artykule z tej serii. Zakończyliśmy konfiguracją naszego punktu wejścia app.ts
, aby używał naszych nowych tras i package.json
ze skryptami do tworzenia i uruchamiania naszej aplikacji.
Ale nawet podstawy interfejsu API REST stworzonego za pomocą Express.js i TypeScript są dość zaangażowane. W kolejnej części tej serii skupimy się na stworzeniu odpowiednich kontrolerów dla zasobu użytkowników i zagłębimy się w kilka przydatnych wzorców dla usług, oprogramowania pośredniczącego, kontrolerów i modeli.
Pełny projekt jest dostępny na GitHub, a kod na koniec tego artykułu znajduje się w toptal-article-01
.