Ứng dụng web sử dụng DynamoDB, Typescript, Express, Generic repository pattern…(Phần 06) – Express server

Đây là một bài viết thuộc series, nhưng nội dung của bài viết này gần như độc lập với những bài viết trước. Bài viết này tập trung vào việc xây dựng một http server sử dụng express framework như thế nào. Việc này thì các bạn có thể tìm thấy rất nhiều ví dụ trên mạng internet, nhưng mình muốn trình bày với mọi người theo một cách mới (có thể). Cách viết này lợi dụng tính năng của Typescript để tổ chức các file và thư mục một cách mạch lạc, thuận tiện cho việc mở rộng.

Express

Điểm bắt đầu khởi tạo, và thực hiện chạy một http server của chúng ta sẽ có nội dung như thế này: src/index.ts

import express from 'express';
import { ENVIRONMENTS } from './config';
import { HomeController } from './modules/home/HomeController';
import { App } from './server/App';

(async () => {
  const app = new App({
    port: ENVIRONMENTS.PORT,
    controllers: [
      new HomeController(),
    ],
    middleware: [
      express.json(),
      express.urlencoded({ extended: true }),
      (req, res, next) => {
        console.log('Request logged:', req.method, req.path);
        next();
      },
    ],
  });

  app.start();
})();

Chúng tạo khởi tạo một đối tượng có tên App, truyền vào các thông tin cần thiết:

  • port: Cổng mà ứng dụng sẽ hoạt động
  • controllers: Là một mảng các controller, mỗi controller sẽ xử lý và đại diện cho một router
  • middleware: Là một mảng các express middleware, các middleware này sẽ được đặt ở mức application (mọi request đều đi qua những middleware này)

Sau cùng là gọi phương thức .start() để khởi động http server.

Chúng ta sẽ đi qua từng phần tạo nên ứng dụng.

BaseController

Đây là một abstract class kiểu controller, các controller đều kế thừa từ lớp này. Hay nói cách khác, các controller đều là con (hoặc cháu) của lớp này.

src/modules/base/BaseController.ts

import { Handler } from 'express';

export type HttpMethod = 'get' | 'post' | 'put' | 'delete' | 'patch' | 'options' | 'head';

export type ControllerRoute = {
  method: HttpMethod,
  path: string,
  handler: Handler,
}

export abstract class BaseController {
  protected path: string = '/';

  public abstract initRoutes(): ControllerRoute[];

  public getPath(): string {
    return this.path;
  }
}
  • HttpMethod: Các phương thức được hỗ trợ bởi express router. Ở đây mình dùng chữ cái in thường để đặt tên, tên phương thức cũng trùng với phương thức của express router. VD: route.get('/', (req, res) => ....), route.delete('/:id', (req, res) => ....)
  • ControllerRoute: Kiểu định nghĩa một route của controller sẽ được xử lý như thế nào: đường dẫn(path) + phương thức (method) sẽ được xử lý bởi handler nào.

Lớp BaseController có các thành phần:

  • Thuộc tính path: Base path của controller. Vd: Controller xử lý các route có tiền tố là “/cats”, thì path sẽ có giá trị là “/cats”. Các giá trị ở route không cần thêm tiền tố này. Ví dụ “/cats/:id” thì chỉ cần khai báo “/:id”.
  • Phương thức abstract initRoutes: Trả lại một mảng các ControllerRoute, mảng này sẽ được dùng ở lớp App (ở phần sau). Có thể bạn chưa biết, phương thức abstract là phương thức chỉ đặt ra input và output, chưa có nội dung, tất cả các lớp con đều phải thực thực việc “điền” nội dung cho phương thức này (bắt buộc).

HomeController

Đây là một ví dụ, lớp con của lớp BaseController. Controller này sẽ xử lý những route đơn giản để làm ví dụ:

src/modules/home/HomeController.ts

import { Request, Response } from 'express';
import { BaseController, ControllerRoute } from '../base/BaseController';

export class HomeController extends BaseController {
  constructor() {
    super();
    this.path = '/';
  }

  public initRoutes(): ControllerRoute[] {
    return [
      {
        path: '/',
        method: 'get',
        handler: this.index,
      },
      {
        path: '/info',
        method: 'get',
        handler: this.hello,
      },
    ];
  }

  public async index(): Promise<any> {
    return { message: 'Hello, World!'};
  }

  public async hello(req: Request, res: Response): Promise<any> {
    res.status(200).json({
      path: this.path,
      controller: this.constructor.name,
    })
  }
}

Lớp này kế thừa lại lớp BaseController, và có các thành phần:

  • path: Có giá trị là “/”. Các giá trị path được trả lại trong hàm initRoutes sẽ được giữ nguyên.
  • Phương thức index: Là một async function. Hàm này không nhận vào đối số nào, trả lại một json object có nội dung { message: 'Hello, World!'}
  • Phương thức hello: Là một async function. Hàm này nhận vào 2 đố số req – có kiểu là express.Request, res – có kiểu là express.Response. Hàm này không trả lại gì cả (void). Trong hàm, chúng ta thấy đối tượng res gọi phương thức .status.json giống như cách làm việc với express “truyền thống”.
  • Phương thức initRoutes: Đây là phương thức bắt buộc phải có như đã nói ở phần trên. Phương thức này trả lại một mảng gồm 2 phần tử, các phần tử có kiểu ControllerRoute.
    return [
      {
        path: '/',
        method: 'get',
        handler: this.index,
      },
      {
        path: '/info',
        method: 'get',
        handler: this.hello,
      },
    ];

    Điều này có nghĩa, route GET / sẽ được xử lý bằng phương thức index, route GET /info sẽ được xử lý bằng phương thức hello.

Error handling

Xử lý lỗi là một tác vụ cơ bản khi làm việc với express.

HttpException

Đây là lớp cơ bản nhất của một lỗi liên quan tới http (401, 403…). Một đối tượng lỗi này sẽ được throw trong những trường hợp cụ thể. Lỗi sẽ quyết định http status được trả về là bao nhiêu.

src/exceptions/HttpException.ts

export class HttpException extends Error {
  status: number;
  message: string;
  constructor(status: number, message: string) {
    super(message);
    // Set the prototype explicitly.
    Object.setPrototypeOf(this, HttpException.prototype);

    this.status = status;
    this.message = message;

    Error.captureStackTrace(this, this.constructor);
  }
}

Đối tượng sẽ được khởi tạo với 2 tham số:

  • status: Quyết định http status
  • message: Thông báo lỗi được trả về

NotFoundHandler

Một express middleware được gọi khi một request không được xử lý bởi bất kỳ route nào trước đó.

src/server/middleware/NotFoundHandler.ts

import { Handler } from 'express';

export const NotFoundHandler: Handler = (req, res) => {
  res.status(404).json({
    status: 404,
    message: 'Page not found!',
  });
};

ErrorHandler

Middleware này để xử lý các lỗi được throw ra trong chương trình.

import { ErrorRequestHandler } from 'express';
import { HttpException } from '../../exceptions/HttpException';

export const ErrorHandler: ErrorRequestHandler = (err: HttpException, req, res, next) => {
  // tslint:disable-next-line:no-console
  console.error(err.stack);

  if (res.headersSent) {
    return next(err);
  }

  const status = err.status || 500;
  const message = err.message || 'Something went wrong!';

  res.status(status).json({ status, message });
};

Middleware này có kiểu express.ErrorRequestHandler, bắt buộc nhận vào 4 tham số.

Chúng ta mặc định err sẽ có kiểu HttpException. Từ err này chúng ta sẽ quyết định http status được trả lại. Nếu err.status có giá trị, thì đây là lỗi do chúng ta chủ động throw ra, nếu không, đây là lỗi “hệ thống” chưa được kiểm soát – trả lại http status là 500. Tương tự với err.message.

Express application

Đây là lớp sẽ tạo ra một express app và xử lý những request được gửi tới server.

src/server/App.ts

import express, { Application, ErrorRequestHandler, Handler, Router } from 'express';
import { Server } from 'http';
import { BaseController } from '../modules/base/BaseController';
import { ErrorHandler } from './middleware/ErrorHandler';
import { NotFoundHandler } from './middleware/NotFoundHandler';

export type AppInitialize = {
  port: number;
  basePath: string;
  middleware: Handler[];
  controllers: BaseController[];
  notFoundHandler: Handler;
  errorHandler: ErrorRequestHandler;
}

export class App {
  public readonly app: Application;
  private readonly port: number;
  private readonly basePath: string;

  constructor(
    {
      port = 8080,
      basePath = '/',
      middleware = [],
      controllers = [],
      notFoundHandler = NotFoundHandler,
      errorHandler = ErrorHandler,
    }: Partial<AppInitialize>,
  ) {
    this.app = express();
    this.port = port;
    this.basePath = basePath!;

    this.registerMiddleware(middleware);

    this.registerControllers(controllers);

    this.registerErrorHandlers(notFoundHandler!, errorHandler!);
  }

  public start(): Server {
    return this.app.listen(this.port, () => {
      // tslint:disable-next-line:no-console
      console.log(`Server is running on http://localhost:${this.port}`);
    });
  }

  private registerMiddleware(middleware: Handler[] = []) {
    middleware.forEach((handler) => {
      this.app.use(handler);
    });
  }

  private registerControllers(controllers: BaseController[] = []) {
    const routeInfo: {
      method: string,
      path: string,
      handler: string,
    }[] = [];

    controllers.forEach((controller) => {
      const path = this.basePath + controller.getPath() !== '/' ? controller.getPath() : '';

      const controllerRoutes = controller.initRoutes();

      const router = Router({ mergeParams: true });

      controllerRoutes.forEach((controllerRoute) => {
        if (!router[controllerRoute.method]) {
          throw new Error(`Method ${controllerRoute.method} did not supported!`);
        }
        router[controllerRoute.method](controllerRoute.path, async (req, res, next) => {
          try {
            const result = await controllerRoute.handler.bind(controller)(req, res, next);
            if (!res.headersSent) {
              res.status(200).json(result);
            }
          } catch (e) {
            next(e);
          }
        });
      });

      routeInfo.push(...controllerRoutes.map((r) => {
        return {
          method: r.method.toUpperCase(),
          path: controller.getPath() + r.path !== '/' ? r.path : '',
          handler: `${controller.constructor.name}.${r.handler.name}`,
        }
      }));

      this.app.use(path, router);
    });

    console.log(`${'Method'.padEnd(10)}${'Path'.padEnd(20)}${'Handler'}`);
    routeInfo.forEach((info) => {
      console.log(`${info.method.padEnd(10)}${info.path.padEnd(20)}${info.handler}`);
    });
  }

  private registerErrorHandlers(notFoundHandler: Handler, errorHandler: ErrorRequestHandler) {
    this.app.use(notFoundHandler);
    this.app.use(errorHandler);
  }
}

Kiểu dữ liệu AppInitialize quy định các tham số dùng để khởi tạo server, chi tiết sẽ được nói kèm với hàm khởi tạo và các giá trị mặc định của App class.

Các thuộc tính của App class:

  • app: Có kiểu express.Application. Là đối tượng express application
  • port: Cổng mà ứng dụng sẽ sử dụng
  • basePath: Là base path của toàn bộ ứng dụng. VD: "/" hoặc "/api/v1"

Hàm khởi tạo – constructor. Hàm này nhận vào 1 tham số có kiểu Partial<AppInitialize>:

  • port: Giá trị cổng của ứng dụng. Mặc định sẽ là 8080
  • basePath: Giá trị sẽ gán cho thuộc tính basePath. Mặc định “\”
  • middleware: Mảng các middleware (Handler) của tầng application. Mặc định “[]”
  • controllers: Mảng các controller (BaseController) xử lý các request trong ứng dụng. Mặc định “[]”
  • notFoundHandler: Middleware xử lý lỗi route not found 404. Mặc định “NotFoundHandler” (đã nói ở phần trên)
  • errorHandler: Middleware xử lý lỗi trong ứng dụng (ErrorRequestHandler). Mặc định “ErrorHandler” (đã nói ở phần trên)

Hàm khởi tạo nhận các giá trị từ tham số truyền vào, set giá trị cho các thuộc tính port và basePath. Khởi tạo giá trị cho thuộc tính app.

Gọi lần lượt các phương thức: registerMiddleware, registerControllers, registerErrorHandlers. Vì chúng ta đang làm việc với express (sử dụng middleware pattern) nên thứ tự gọi các phương thức là vô cùng quan trọng.

  • registerMiddleware: Đăng ký các application middleware. VD: Logging, bodyparse…
  • registerControllers: Tạo ra các router tương ứng với các controller. Ý tưởng chung cho hàm này: Duyệt qua lần lượt các controller (có kiểu BaseController). Với mỗi controller, chúng ta lấy các danh sách route bằng phương thức initRoutes, duyệt qua các route để lấy thông tin xử lý.
    router[controllerRoute.method](controllerRoute.path, async (req, res, next) => {
          try {
            const result = await controllerRoute.handler.bind(controller)(req, res, next);
            if (!res.headersSent) {
              res.status(200).json(result);
            }
          } catch (e) {
            next(e);
          }
        });

    Ở đây chúng ta dùng try/catch khi thực hiện hàm handler, việc bắt lỗi sẽ được tập trung ở đây. Đây cũng là lý do các handler trong controller luôn trả lại một Promise. Chúng ta cũng kiểm tra res.headersSent để xử lý các trường hợp chưa gửi response trong handler (HomeController.index) hay đã gửi response trong handler (HomeController.hello).
    Biến routeInfo dùng cho việc in ra thông tin khi chúng ta khởi động ứng dụng. VD:

    Method    Path                Handler
    GET       /                   HomeController.index
    GET       /info               HomeController.hello
    
  • registerErrorHandlers: Phương thức này chỉ đơn giản là đăng ký các xử lý lỗi.

Lớp này public một phương thức với bên ngoài – start. Phương thức này sẽ được gọi để bắt đầu lắng nghe các request. Phương thức này trả lại lại một giá trị có kiểu là http.Server, giá trị này có thể được dùng cho những mục đích khác (VD: Khởi tạo socket-io instance).

Giờ chúng ta sẽ tạo một instance của App và gọi hàm start:

src/index.ts

import express from 'express';
import { ENVIRONMENTS } from './config';
import { HomeController } from './modules/home/HomeController';
import { App } from './server/App';

(async () => {
  const app = new App({
    port: ENVIRONMENTS.PORT,
    controllers: [
      new HomeController(),
    ],
    middleware: [
      express.json(),
      express.urlencoded({ extended: true }),
      (req, res, next) => {
        console.log('Request logged:', req.method, req.path);
        next();
      },
    ],
  });

  app.start();
})();

Chúng ta sử dụng biến môi trường ENVIRONMENTS.PORT để truyền giá trị cổng cho ứng dụng.

Chỉ có một controller – HomeController được truyền vào.

Danh sách middleware có 2 setting cơ bản cho việc đọc json body khi client gửi lên. Và có thêm một middleware dành cho việc logging, mỗi khi có một request tới,  thông tin về method và path của request sẽ được log ra.

Cuối cùng chúng ta gọi phương thức start.

Chạy lệnh npm run dev , nếu không gặp lỗi gì, chúng ta sẽ nhận được output ở terminal tương tự:

Method    Path                Handler
GET       /                   HomeController.index
GET       /info               HomeController.hello
Server is running on http://localhost:8080

Kết thúc phần 06

Phần 06 đã hoàn thành việc xây dựng các phần cơ bản của một http server với express. Trong bài viết dùng một số “thủ thuật” như “Truy cập phương thức theo tên bằng string”, “Bind scope”, giúp các bạn thấy được tầm quan trọng của việc hiểu biết JS “thuần”.

Việc xây dựng các phần theo hướng hướng đối tượng sẽ giúp chúng ta dễ dàng cho việc mở rộng sau này. Việc này cũng giúp chúng ta đạt được một trong những nguyên tắc cơ bản nhất trong SOLID – DI (dependency injection): Việc khởi tạo các controller với từ khóa new, giúp chúng ta có thể truyền các đối tượng phụ thuộc vào trong controller thông qua hàm khởi tạo (vd: new HomeController(homeService)).

Vẫn còn nhiều thứ phải làm để hoàn thiện một express server “hoàn chỉnh” như: middleware cho Controller, cho từng method, hay xây dựng auth controller …Bài viết này sẽ có thể là nền móng cho những việc đó.

Ở phần tiếp theo, chúng ta sẽ kết hợp với Movie repository đã tạo được ở các phần trước (Phần 01Phần 05), để tạo ra các api làm việc với đối tượng Movie trong cơ sở dữ liệu.

Đây là những gì được thêm vào, thay đổi so với Phần 05: Commit

Đây là project sau khi kết thúc phần 06: Github