Express.js pomimo wieloletniej obecności na rynku, wciąż jest jednym z popularniejszych frameworków w środowisku Node.js. Wszelkie znaki na niebie i ziemi wskazują, że w najbliższej przyszłości się to raczej nie zmieni. W tym artykule przedstawię Ci szablon pozwalający na stworzenie pierwszej aplikacji w Express.js. Możesz go wykorzystać do nauki, prototypowania, a nawet do jako baza pod biznesową aplikację.
Zdecydowałem się postawić na Expressa z kilku powodów. Po pierwsze, Express.js ma już kilkanaście lat. Przez ten czas technologia dojrzała, przez co powstała wokół niej ogromna ilość produktów i narzędzi. Zapotrzebowanie na znajomość Expressa przez długi czas będzie duże, a ten artykuł pomoże Ci dowiedzieć się o nim nieco więcej.
Po drugie, próg wejścia w pracę z Expressem jest, moim subiektywnym zdaniem, bardzo niski. Dokumentacja Expressa jest bardzo przystępna i zwięzła. Pozwala wręcz na kopiowanie fragmentów kodu, które po małym dostosowaniu mogą być uruchomione jako pełnoprawne aplikacje.
Po trzecie, niski próg wejścia, czas życia i powszechność wykorzystania Expressa powoduje, że wokół Expressa zbudowane jest spore community. Duże community i duża ilość treści związanych z Expressem sprawia, że w razie napotkania jakiegoś problemu, istnieje marginalna szansa, że nikt przed Tobą go nie miał. Tag #express na Stack Overflow jest żywy i ma się dobrze. W rankingach popularności frameworków dla Node.js Express nadal jest obecny. Z drugiej strony, być może stwierdzenie „miliony much nie mogą się mylić” jest w tym przypadku prawdziwe? Zostawiam temat pod dyskusję w komentarzach 😉
Opis i komponenty aplikacji
Przygotowany przeze mnie szablon aplikacji pozwala na szybkie wystartowanie projektu w Express.js. Zdecydowałem się na wykorzystanie TypeScripta z uwagi na możliwość typowania oraz większą wygodę mojej i Twojej pracy. Jeśli z jakiegoś powodu wolisz pisać w czystym JavaScript, usunięcie typów, dodatkowych zależności i konfiguracji TypeScripta nie powinno stanowić problemu. Całość kodu opisanego w tym artykule możesz znaleźć w repozytorium na GitHubie, które przygotowałem na potrzeby tego wpisu. Aby pobrać aplikację, wystarczy, że wykonasz polecenie:
git clone [email protected]:elszczepano/expressjs-boilerplate.git && cd expressjs-boilerplate
Po pobraniu projektu możesz uruchomić aplikację, wywołując polecenie:
docker-compose up --build
Polecenie buduje obraz z aplikacją, uruchomi kontener z bazą danych i aplikacją oraz skonfiguruje sieć, w której będą uruchomione kontenery.
Gdy w wierszu poleceń zobaczysz potwierdzenie, że aplikacja działa, możesz odwiedzić adres http://localhost:8000/hello/:name
lub wysłać pod wskazany adres żądanie HTTP z wykorzystaniem jednego z klientów HTTP. W miejscu parametru :name
możesz wpisać swoje imię. W rezultacie zwrócony zostanie JSON z listą użytkowników, którzy odpytali wskazany adres oraz przekazana wartość.
Infrastruktura aplikacji
Oprócz samego Expressa, do uruchomienia aplikacji wykorzystałem kilka dodatkowych komponentów.
Pierwszym z nich jest baza danych MySQL. Zastąpienie MySQL dowolnym innym rozwiązaniem bazodanowym zajmie Ci dosłownie chwilę, więc nie musisz się do niego za bardzo przywiązywać. Aby utrzymać prostotę szablonu, zdecydowałem się też na rezygnację z wykorzystania ORM-ów. Zamiast ORM zdecydowałem się na bezpośrednie wykorzystanie drivera dla MySQL. Oczywiście nic nie stoi na przeszkodzie w dodaniu do projektu wybranego przez Ciebie ORM-a.
Przygotowana aplikacja została dostosowana do uruchamiania jej w kontenerach. W tym celu przygotowałem Dockerfile
oraz plik docker-compose.yml
. Dzięki temu możliwe jest uruchomienie przygotowanej aplikacji z wykorzystaniem Dockera. Zanim uruchomisz aplikację, upewnij się, że na Twojej maszynie jest zainstalowany Dockera oraz Docker Compose. Dockerfile
dla przygotowanej aplikacji wygląda następująco:
FROM node:18.16.0-alpine
WORKDIR /usr/src/app
COPY package.json ./
RUN npm install
COPY tsconfig.json ./
COPY src src
RUN npm run build
EXPOSE 8000
CMD [ "npm", "start" ]
Jako obraz bazowy wykorzystałem obraz z Alpine Linux dla środowiska Node.js. Możesz śmiało zamienić go na dowolny inny, w zależności od Twoich preferencji. Zaletą obrazu opartego o Alpine Linux jest jego mały rozmiar. Plik docker-compose.yml
, oprócz definicji kontenera aplikacji, zawiera dodatkowo definicję kontenera dla bazy danych MySQL, oraz konfigurację sieci, w której będą uruchamiane kontenery:
version: '3.9'
services:
db:
image: mysql:8.0
command: --default-authentication-plugin=mysql_native_password
restart: always
env_file: .env
environment:
- MYSQL_DATABASE=$DB_NAME
- MYSQL_USER=$DB_USER
- MYSQL_PASSWORD=$DB_PASSWORD
- MYSQL_ROOT_PASSWORD=$DB_ROOT_PASSWORD
ports:
- '3306:3306'
healthcheck:
test: mysqladmin ping -h localhost -P 3306 -u root -p$$DB_ROOT_PASSWORD
interval: 5s
timeout: 10s
retries: 3
volumes:
- db:/var/lib/mysql
express_boilerplate:
build: .
env_file: .env
restart: always
ports:
- '8000:8000'
depends_on:
db:
condition: service_healthy
networks:
app-network:
driver: bridge
volumes:
db:
Z ciekawszych opcji konfiguracyjnych, zdecydowałem się na implementację healthchecka dla bazy danych. Kontener z aplikacją w Express.js uruchomi się, dopiero gdy healthcheck dla bazy danych zwróci status healthy
. Dodatkowo wszystkie zmienne środowiskowe pochodzą z pliku .env
, który zawiera wartości zmiennych środowiskowych. Wykorzystanie zmiennych środowiskowych pozwala na uniknięcie trzymania sekretów w repozytorium i na dynamiczne wstrzykiwanie wartości zmiennych. Struktura pliku .env
bazuje na strukturze z pliku .env.example
:
PORT=
DB_HOST=
DB_NAME=
DB_USER=
DB_PASSWORD=
DB_ROOT_PASSWORD=
Komponenty aplikacji
Przygotowana aplikacja została przygotowana w podejściu Minimum Viable Product. Zależało mi na dostarczeniu działającego szkieletu w krótkim czasie. Szkielet aplikacji ma pozwolić na szybkie prototypowanie aplikacji, z pominięciem narzutu związanego z przygotowaniem struktury aplikacji i niskopoziomowych komponentów. Jeśli podejście MVP jest Ci obce, to więcej dowiesz się o nim z artykułu Minimum Viable Product na moim blogu. Wykorzystanie podejścia MVP oznacza, że szkielet aplikacji jest daleki od perfekcji. Pewne zmiany i usprawnienia będą wręcz konieczne do implementacji przed produkcyjnym uruchomieniem aplikacji, zbudowanej na podstawie przygotowanego przeze mnie rozwiązania.
Service
Głównym komponentem aplikacji jest klasa Service
. W pliku znajduje się inicjalizacja Expressa oraz włączenie serwera HTTP. Dodatkowo w tym pliku dołączane są dodatkowe middleware — obecnie dodałem tylko body-parser
. Sama klasa Service
prezentuje się następująco:
class Service {
private readonly _service: Express;
public constructor( port: number ) {
this._service = express();
this._service.use( bodyParser.json() );
this._service.listen( port, () => {
console.log( `Server is working on ${ port } port...` )
} );
}
public addRouter( path: string, router: Router ) {
this._service.use( path, router.get() );
}
}
Router
Pojęcie routera jest konieczny do zrozumienia, aby rozwijać aplikacje w Express.js. Zdecydowałem się na przygotowanie abstrakcji, która umożliwia na wygodne definiowanie ścieżek poprzez zdefiniowanie interfejsu IRouteDefiniton
. Własna abstrakcja pozwala ponadto na potencjalną rozbudowę o dodatkowe mechanizmy w razie potrzeby. Na chwilę obecną, główną wartością routera jest automatyczna obsługa przetwarzania body
zapytania w zapytaniach typu PUT
i POST
. Kod mojego Routera
prezentuje się następująco:
interface IRouteDefinition {
method: 'get' | 'post' | 'put' | 'delete';
path: string;
handler: IHandler;
}
class Router {
private readonly _router: ExpressRouter;
public constructor( routes: IRouteDefinition[] = [] ) {
this._router = ExpressRouter();
for ( const { method, path, handler } of routes ) {
if ( [ 'put', 'post' ].includes( method ) ) {
this._router[ method ](
path,
bodyParser.urlencoded( { extended: false } ),
handler.process.bind( handler )
);
continue;
}
this._router[ method ]( path, handler.process.bind( handler ) );
}
}
get(): ExpressRouter {
return this._router;
}
}
Dodawanie Routera
do aplikacji zostało obsłużone w klasie Service
w metodzie addRouter()
. Metoda pozwala na zdefiniowanie parametru path
, w przypadku, gdyby wszystkie ze ścieżek miały zawierać konkretny prefiks.
Inicjalizacja oraz dodanie routera wygląda następująco:
const router: Router = new Router(
[
{
method: 'get',
path: '/hello/:name',
handler: helloHandler
}
]
);
service.addRouter( '/', router );
Oczywiście nic nie stoi na przeszkodzie, aby zdefiniować więcej niż jeden router.
Handler
W klasie Handler
, do dyspozycji są dostępne znane z Expressa, jak i samej pracy z protokołem HTTP w Node.js, obiekty Request
i Response
. Odpowiedzialnością Handlera
jest przygotowanie danych otrzymanych w zapytaniu do spodziewanego formatu. Handler
odpowiada również za zwrócenie odpowiedzi klientowi w odpowiednim formacie. Każdy Handler
implementuje interfejs IHandler
:
interface IHandler {
process( request: Request, response: Response, next?: NextFunction ): Promise<unknown>;
}
W aplikacji przygotowałem przykładową implementację interfejsu IHandler
. HelloHandler
może posłużyć Ci za wzór pod kolejne handlery:
class HelloHandler implements IHandler {
public constructor( private readonly _controller: IController<IHelloParams, IHelloResults> ) {}
public async process( request: Request, response: Response ): Promise {
const name: string = request.params.name;
const result: IHelloResults = await this._controller.execute( { name } );
response.json( result );
}
}
Controller
W konstruktorze Handlera
przekazałem instancję Controllera
, na której następnie wołana jest metoda execute()
. W kontrolerach zawarta jest logika biznesowa aplikacji. Kontroler definiowany jest przez interfejs IController
:
interface IController<TParams, TResult> {
execute( params: TParams ): Promise<TResult>;
}
Interfejs wykorzystuje dwa typy generyczne — TParams
do typowania obiektu z parametrami i TResult
do typowania odpowiedzi. Implementację przykładowego kontrolera przedstawia poniższy kod:
interface IHelloParams {
name: string;
}
interface IHelloResults {
name: string;
guests: string[];
}
class HelloController implements IController<IHelloParams, IHelloResults> {
public constructor( private readonly _repository: IExampleRepository ) {}
public async execute( params: IHelloParams ): Promise<IHelloResults> {
await this._repository.saveGuest( params.name );
const guests: string[] = await this._repository.getGuests();
return { guests, name: params.name };
}
}
Przykładowy kontroler wykorzystuje repozytorium wstrzyknięte w konstruktorze do zapisania przekazanego imienia. Następnie pobierana jest lista gości z repozytorium oraz zwracany jest rezultat.
Repozytorium
Klasy repozytorium będą służyły jako komponenty warstwy dostępu do danych. Dzięki wykorzystaniu repozytoriów logikę związaną z dostępem do danych odizolowano od logiki biznesowej zawartej w kontrolerze. Kontroler otrzymuje gotową klasę, która pozwala wykonać określone operacje na danych, takie jak odczyt, zapis czy aktualizacja. Co więcej, kontrolera nawet nie wie, z jakiego źródła danych korzysta ani nie zna struktury zapisanych danych. Dzięki takiemu zabiegowi bazę MySQL można zastąpić innym rozwiązaniem, bez konieczności modyfikowania kodu logiki biznesowej. Przykładowe repozytorium przedstawia poniższy fragment kodu:
interface IExampleRepository {
saveGuest( name: string ): Promise<void>;
getGuests(): Promise<string[]>;
}
class ExampleRepository implements IExampleRepository {
public constructor( private readonly _driver: IDriver ) {}
public async saveGuest( name: string ): Promise<void> {
await this._driver.query( `INSERT INTO guests (name) VALUES ('${ name }');` );
}
public async getGuests(): Promise<string[]> {
const guests: {name: string; }[] = await this._driver.query( 'SELECT DISTINCT name FROM guests;' );
return guests.map( guest => guest.name );
}
}
W przypadku repozytoriów nie ma konieczności definiowania bazowego interfejsu, wspólnego dla wszystkich repozytoriów. Metody zdefiniowane w repozytorium będą ściśle zależały od struktury danych oraz wymagań biznesowych.
Driver
Klasa Driver
jest dodatkową abstrakcją, w którą opakowałem driver dla MySQL. Dzięki temu, repozytoria wykorzystujące MySQL mogą zignorować szczegóły implementacyjne zewnętrznego komponentu. Programista otrzymuje klasę z wygodną metodą query
, która oczekuje jedynie podania zapytania do bazy danych. Wprawne oko mogło dostrzec, że przedstawione rozwiązanie jest podatne na atak SQL Injection. W celu uproszczenia przykładu zdecydowałem się pominąć ten aspekt, lecz jest to jedno z koniecznych usprawnień, które należałoby dodać do szkieletu przed wykorzystaniem go produkcyjnie. W przypadku chęci wykorzystania ORM Driver
mógłby zostać zastąpiony innym rozwiązaniem. Klasa MySQLDriver
prezentuje się następująco:
interface IDriver {
query( queryString: string ): Promise<T>;
connect(): Promise;<void>
disconnect(): Promise<void>;
}
interface IDriverConfig {
dbHost: string;
dbUser: string;
dbPassword: string;
dbName: string;
dbPort: number;
}
class MySQLDriver implements IDriver {
private readonly _connection: Connection;
public constructor( config: IDriverConfig ) {
this._connection = mysql.createConnection( {
host: config.dbHost,
user: config.dbUser,
password: config.dbPassword,
database: config.dbName,
port: config.dbPort
} );
}
public async connect(): Promise<void> {
await this._connection.connect();
}
public async disconnect(): Promise<void> {
await this._connection.end();
}
public async query<T>( queryString: string ): Promise<T> {
return new Promise( ( resolve, reject ) => {
this._connection.query( queryString, ( error, results ) => {
if ( error ) {
reject( error );
};
resolve( results );
} )
} );
}
}
Oprócz metody query
służącej wykonywaniu zapytań Driver
implementuje również metody do nawiązania i zakończenia połączenia do bazy danych. Bazując na interfejsie IDriver
, można przygotować drivery dla innych rozwiązań bazodanowych. Wymagałoby to jednak obsługi zapytań w różnych dialektach, np. dynamiczne wstrzykiwanie lub generowanie zapytań w zależności od wybranego dialektu.
Migrator
Aby móc korzystać z bazy danych, dobrze byłoby przedtem zainicjalizować jej strukturę. ORM’y zajmują się tym automatycznie, ale ponieważ zdecydowałem się na zrezygnowanie z ORM’ów, konieczne było przygotowanie własnego migratora. Migrator
jest stosunkowo prostą klasą i wykorzystuje opisany wcześniej driver. Jedyną odpowiedzialnością migratora jest wykonanie migracji:
class Migrator {
public constructor( private readonly _driver: IDriver ) {}
public async migrate(): Promise {
const migrations: string[] = ( await fs.readdir( `${ __dirname }/queries/`, { withFileTypes: true } ) ).map( file => file.name );
for ( const migration of migrations ) {
const query: string = ( await fs.readFile( `${ __dirname }/queries/${ migration }` ) ).toString();
await this._driver.query( query );
}
console.log( 'Migrations done' );
}
}
Metoda migrate
w pętli wykonuje wszystkie migracje. Migracje zostały zdefiniowane w plikach w formacie .sql
w katalogu /queries
. Polem do usprawnienia migratora jest zapisywanie informacji o stanie migracji. Obecnie, uruchomienie aplikacji na już zmigrowanej bazie skutkować może błędami lub niespójnością danych. W tym celu można wykorzystać osobną tabelę.
Testy
Aby szkielet był jak najbardziej kompletnym rozwiązaniem, przygotowałem konfigurację pozwalającą na pisanie testów. Jako test runner wykorzystałem AVA. Wybrany test runner pozwala na łatwiejsze pisanie testów zgodnych z FIRST, w szczególności z zasadami Fast oraz Isolated dzięki równoległemu uruchamianiu testów. Do przygotowywania stubów wykorzystałem bibliotekę Sinon. Przygotowałem kilka przykładowych testów, aby zademonstrować, jak można pisać testy z wykorzystaniem AVA i Sinona. Do przygotowania mocków klas Request
i Response
wykorzystałem bibliotekę node-mocks-http. Testy jednostkowe dla klasy HelloController
wyglądają następująco:
import { IController } from '@src/controllers/Controller';
import HelloController, { IHelloParams, IHelloResults } from '@src/controllers/HelloController';
import { IExampleRepository } from '@src/repositories/ExampleRepository';
import anyTest, { TestFn } from 'ava';
import sinon from 'sinon';
const test = anyTest as TestFn<{
controller: IController<IHelloParams, IHelloResults>;
repositoryStub: sinon.SinonStubbedInstance;
}>;
test.beforeEach( t => {
t.context.repositoryStub = {
getGuests: sinon.stub(),
saveGuest: sinon.stub()
};
t.context.controller = new HelloController( t.context.repositoryStub );
t.context.repositoryStub.getGuests.resolves( [ 'foo', 'bar' ] );
} );
test( 'execute(): should save guest', async t => {
const { repositoryStub, controller } = t.context;
const name: string = 'name';
await controller.execute( { name } );
sinon.assert.calledOnceWithExactly( repositoryStub.saveGuest, name );
t.pass();
} );
test( 'execute(): should return guest list and name', async t => {
const { controller } = t.context;
const name: string = 'name';
const results: IHelloResults = await controller.execute( { name } );
t.deepEqual( results, { name, guests: [ 'foo', 'bar' ] } );
} );
Z wykorzystaniem AVA można również przygotować testy integracyjne i E2E.
Podsumowanie
Kilkukrotnie już wspomniałem, że przygotowany szkielet wymaga kilku poprawek, aby móc stanowić bazę pod produkcyjne aplikacje. Niemniej jednak stanowi on solidny fundament, na którym możesz zbudować swoją pierwszą aplikację w Express.js. Poza wymienionymi wcześniej usprawnieniami, elementem wartym dodania jest mechanizm walidacji danych wejściowych. Po uruchomieniu aplikacji sprawdź co się stanie, gdy podasz imię dłuższe niż 30 znaków. Warto zabezpieczyć aplikację przed takimi sytuacjami.
Kolejnym użytecznym komponentem jest logger. Możesz wykorzystać jedno z gotowych rozwiązań, np. Pino.js.
Gorąco zachęcam do zapoznania się z repozytorium na GitHubie, do sklonowania i uruchomienia projektu. Pamiętaj, że ten szablon ma służyć przede wszystkim Tobie, więc nie bój się w nim czegoś dodać, zmienić czy usunąć.
Zachęcam też do dyskusji nad rozwiązaniem i podjętymi decyzjami w komentarzach. W szczególności zachęcam do proponowania zmian i usprawnień. Możesz to również zrobić bezpośrednio na GitHubie w postaci issue lub pull requesta 😁
Po takim wprowadzeniu, pytanie – dlaczego zdecydowałeś się na użycie klasowego podejścia? (w dokumentacji Expressa nie widziałem żadnego przykładu z klasami)
Rozumiem samą ideę separacji konceptów i przygotowania aplikacji pod dalszą rozbudowę, tyle tylko, że z lekkiego funkcyjnego Expressa zbudowałeś spory klasowy boilerplate i coś bliższego Nest.js, więc skoro tak „korporacyjnie” przygotowałeś Expressa, czemu po niego sięgnąłeś, zamiast wybrać klasowego Nest.js?
Zastanawia mnie też, dlaczego do testów nie użyłeś Jest/Vitest tylko już raczej wychodzące z użycia AVA + Sinon.
https://2022.stateofjs.com/en-US/libraries/testing/
Z Nestem nie mam dużego doświadczenia, w przeciwieństwie do Expressa. Nie wynika to z mojej niechęci do Nesta, raczej z nawału pracy i braku okazji popracowania z projektami w Nest.js.
Samego Expressa starałem się jak najbardziej ukryć. W przypadku prostych CRUDów, developer mógłby się nawet nie zorientować że w tym boilerplate siedzi Express. BTW podrzucam ciekawostkę odnośnie Nesta z ich readme: Under the hood, Nest makes use of Express 😄
Podobnie sytuacja wygląda z Jest/Vitest. W podrzuconym linku widzę, że Vitest dopiero zdobywa popularność i gdyby nie Twój komentarz, to nawet bym o tej bibliotece nie wiedział. Z Jestem również nie miałem zbyt dużej styczności. Rozważałem też wykorzystanie stacku Mocha/Chai, ale moim subiektywnym zdaniem wygoda pisania testów w AVA bije go na głowę. Wiem, że AVA jest nieco egzotycznym wyborem patrząc na statystyki, ale bardzo ją lubię. Jeśli ten wpis przyczyni się do tego, że komuś innemu też się spodoba, to bardzo mnie to ucieszy.
Być może przygotuję kiedyś analogiczne wpisy dla innych bibliotek. Dzięki za feedback!