Pierwsza aplikacja w Express.js - okładka

Pierwsza aplikacja w Express.js

Opublikowano Kategorie BackendCzas czytania 14min

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 PUTPOST. 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 RequestResponse. 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 RequestResponse 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 😁

Źródła i materiały dodatkowe

Dominik Szczepaniak

Zawodowo Senior Software Engineer w CKSource. Prywatnie bloger, fan włoskiej kuchni, miłośnik jazdy na rowerze i treningu siłowego.

Inne wpisy, które mogą Cię zainteresować

Zapisz się na mailing i odbierz e-booka

Odbierz darmowy egzemplarz e-booka 106 Pytań Rekrutacyjnych Junior JavaScript Developer i realnie zwiększ swoje szanse na rozmowie rekrutacyjnej! Będziesz też otrzymywać wartościowe treści i powiadomienia o nowych wpisach na skrzynkę e-mail.

Subscribe
Notify of
guest

2 komentarzy
Most Voted
Newest Oldest
Inline Feedbacks
View all comments
Adrian
Adrian
1 year ago

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 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/