Typescript: Tự viết router cho ứng dụng express sử dụng decorators

!!! Bài viết mặc định người đọc có hiểu biết về việc xây dựng một ứng dụng web xử dụng express, và người đọc có hiểu biết cơ bản đối với Typescript.

Decorators là một công cụ rất hữu ích khi viết các ứng dụng bằng Typescript. Nếu bạn đã từng làm việc các ngôn ngữ hay framework như Java hay APS.NET MVC sẽ thấy quen thuộc với cú pháp decorators này. Một trong những cách tôi sử dụng chúng trong các ứng dụng web với mô hình MVC là dùng chúng để cấu hình routing cho ứng dụng. Dung decorators để tạo ra một cái gì đó tương tự như thế này:

@Controller('/cats')
class UserController {
    @Route('/')
    public index() {
        // return proper response
    }
    
    @Route('/:id')
    public details() {
        // return proper response
    }
}

Khá dễ hiểu, chúng ta có một router với tiền tố là /cats , khi đó với API GET /cats chúng ta sẽ danh sách các đối tượng “mèo” và GET /cats/:id chúng ta sẽ có chi tiết thông tin của một đối tượng mèo. Ý tưởng này có thể làm bạn liên tưởng tới  Symfony Routing hoặc NestJS Controllers.

Giới thiệu

Trong bài viết này tôi sẽ xây dựng một ứng dụng web bằng  Express framework sử dụng ngôn ngữ là Typescript, tôi cũng sử dụng ts-node (thư viện tiện ích giúp chúng ta thực thi các file .ts mà không cần biên dịch chúng).

Các bạn có thể áp dụng với những framework khác ngoài express, ví dụ koahapi  hoặc đơn giản là những server http mà bạn tự triển khai. Điểm khác nhau là ở thời điểm chúng ta đăng ký các router vào ứng dụng, còn những phần khác thì tương tự nhau. Lý do để tôi chọn express vì tôi có nhiều kinh nghiệm với fw này nhất – Nhưng các bạn thì cứ thoải mái chọn những gì mà bạn thấy quen thuộc nhất.

Kiến trúc

Có nhiều cách khác nhau để chúng ta tạo ra các router cho ứng dụng web. Nhưng trước khi đi vào triển khai code ứng dụng có một vài thứ chúng ta phải nhớ:

Điều quan trọng nhất chúng ta cần nhớ ở đây là:

Decorators sẽ được thực thi khi class được khai báo, không phải khi class được khởi tạo.

Nên khi chúng ta can thiệp vào các phương thức của class bằng các decorators, chúng ta sẽ không có một đối đã được khởi tạo để làm việc bên trong decorators. Thay vào đó chúng ta chỉ có một class đã được khai báo để sử dụng. Chúng ta có thể đọc thêm chi tiết   tại đây.

Vì decorators cũng đơn giản chỉ là những function nên chúng cũng có những scope của riêng chúng. Điều này dẫn tới một vấn đề nhỏ, đó là việc đăng ký các router cho ứng express sẽ diễn ra bên ngoài scope của các decorators:

Có một cách để lấy được router của chúng ta từ các decorator để đăng ký với ứng dụng express là tạo ra các thuộc tính mới trong các class trong các decorator, các thuộc tính này sẽ được dùng khi đăng ký router với ứng dụng express.

Nhưng chúng ta có một cách đơn giản hơn, sử dụng thư viện  reflect-metadata (thư viện mà bạn có thể đã nghe thấy hoặc dùng khi làm việc với decorators). Thay vì đăng ký các thông tin của router vào các thuộc tính riêng biệt, chúng ta có thể đơn giản hóa việc đó bằng việc đăng ký thông tin đó vào metadata của class Controller:

Chúng ta chỉ đơn giản là lưu thông tin router vào metadata của class controller. Sau đó, khi đăng ký các router cho ứng dụng express, các class controller đã được khởi tạo và tại đây chúng ta dễ dàng đọc được các thông tin về router của chúng ta được lưu sẵn trong metadata, khi đó chúng ta sẽ đăng ký các router tương ứng với những phương thức của các controller đã được khởi tạo.

Tạm hiểu tất cả những điều trên, hãy bắt đầu đi xây dựng ứng dụng của chúng ta với các decorators!

Express

(Vì bài viết được khuyên cho những người có kinh nghiệm làm việc với Typescript + Nodejs nên những bước đơn giản sẽ được bỏ qua)

Đầu tiên chúng ta cần khởi tạo ứng dụng express, lúc khởi tạo chúng ta chỉ cần một router đơn giản để kiểm tra mọi thứ đã hoạt động:

// index.ts
import 'reflect-metadata';
import express from 'express';
import {Request, Response} from 'express';

const app = express();

app.get('/', (req: Request, res: Response) => {
  res.send('Meooo!');
});

app.listen(3000, () => {
  console.log('Started express on port 3000');
});

reflect-metadatachỉ nên được import một lần trong chương trình, đây là nơi tốt nhất để làm việc đó.

Khởi động server với câu lệnh ts-node index.ts và dùng trình duyệt truy cập vào đường dẫn http://localhost:3000/ , nếu bạn thấy dòng chữ “Meooo!” nghĩa là mọi thứ đã hoạt động.

Controller decorators

Decorator này sẽ được dùng ở các class controller, nó sẽ quy định prefix path cho controller này:

// decorators/Controller.decorators.ts
export const Controller = (prefix: string): ClassDecorator => {
  return (target) => {
    Reflect.defineMetadata('prefix', prefix, target);

    // Nếu bạn có một controller mà không có router nào(hầu như không thể), chúng ta sẽ tự gán giá trị routers là một mảng rỗng
    if (!Reflect.hasMetadata('routes', target)) {
      Reflect.defineMetadata('routes', [], target);
    }
  }
};



Một decorator đơn giản, nó sẽ thêm vào metadata của class thông tin về prefix , trong trường hợp thông tin routers không được tìm thấy trong metadata, chúng ta sẽ set chúng là một mảng rỗng. Như tôi đã comment trong code, gần như giá trị routes không bao giờ là undefined, ngoại trừ chúng ta có một controller mà không có decorators cho phương thức (sẽ được nói ở phần dưới).

Route decorator

Tối muốn định nghĩa các phương thức http một cách thật thuận tiện, ví dụ @Get, @Post,…Để lấy ví dụ đơn giản, tôi sẽ triển khai viết decorator cho phương thức Http GET:

// decorators/Get.decorator.ts
import { IRouteDefinition } from "../models/Route.definition";

export const Get = (path: string): MethodDecorator => {
  // `target` là class của chúng ta - Controller, `propertyKey` tương ứng với tên phương thức - để xử lý request
  return (target, propertyKey) => {
    // Trong trường hợp đây là lần đầu `routes` được đăng ký, thông tin `routes` sẽ là undefined.
    // Để các bước sau có thể hoạt động, đơn giản set giá trị cho `routes` là một mảng rỗng.
    if (!Reflect.hasMetadata('routes', target.constructor)) {
      Reflect.defineMetadata('routes', [], target.constructor);
    }

    // Lấy giá trị routes đã được lưu trước đó, thêm vào một route mới và set lại vào metadata.
    const routes: IRouteDefinition[] = Reflect.getMetadata('routes', target.constructor);
    routes.push({
      requestMethod: 'get',
      path,
      methodName: propertyKey,
    });

    Reflect.defineMetadata('routes', routes, target.constructor);
  };
};

Chú ý quan trọng: Chúng ta sử dụng targettarget.constructor thay vì target (như ở class decorators) để xử lý metadata.

Một lần nữa, chúng ta có một interface để định nghĩa một route RouteDefinition:

// models/Route.definition.ts
export type RequestMethod = 'get' | 'post' | 'delete' | 'options' | 'put';

export interface IRouteDefinition {
  // Path cho route
  path: string;
  // Phương thức http
  requestMethod: RequestMethod;
  // Tên phương thức của class controller để xử lý request
  methodName: string | symbol;
}

Tip: Trong trường hợp bạn muốn cài đặt các middleware cho các phương thức thì đây là nơi bạn nên thiết lập chúng – IRouteDefinition.

Bây giờ chúng ta đã có đủ các decorators cần thiết, chúng ta sẽ quay lại ứng dụng express và tiến hành khởi tạo và đăng ký các route cho ứng dụng.

Đăng ký routes

Trước khi đăng ký các routes với ứng dụng express, chúng ta sẽ định nghĩa controller của chúng ta sử dụng những decorators tôi đã viết ở phía trên:

import { Request, Response } from "express";
import { Controller } from "../decorators/Controller.decorator";
import { Get } from "../decorators/Get.decorator";

interface ICatDetailRequest extends Request {
  params: {
    id: string,
  };
}

@Controller('/cats')
export default class CatController {

  @Get('/')
  public index(req: Request, res: Response) {
    return res.json({
      description: 'List of cats',
      cats: [/**/],
    });
  }

  @Get('/:id')
  public details(req: ICatDetailRequest, res: Response) {
    return res.json({
      description: `You are looking at the profile of ${req.params.id} cat`,
      cat: {/**/},
    });
  }
}

Chúng ta có các routes GET / sẽ trả về danh sách các con mèo, và GET /:id sẽ trả về thông tin chi tiết của một con mèo theo ID.

Nhưng trước tiên để class controller này có thể làm việc được, chúng ta cần phải nói cho express biết chúng ta có những routes nào, quay lại với file index.ts:

/* tslint:disable:ordered-imports */
import 'reflect-metadata';
import express, { Request, Response } from 'express';
import CatController from './controllers/Cat.controller';
import { IRouteDefinition } from "./models/Route.definition";

const app = express();

app.get('/', (req: Request, res: Response) => {
  res.send('Meooo!');
});

// Duyệt qua tất cả các controller để đăng ký các routes
[
  CatController,
].forEach((controller) => {
  // Khởi tạo đối tượng controller
  const instance = new controller();

  // Lấy thông tin của prefix, chúng ta đã lưu chúng trong metadata của class controller
  const prefix = Reflect.getMetadata('prefix', controller);

  // Tương tự, lất ra tất cả các `routes`
  const routes: IRouteDefinition[] = Reflect.getMetadata('routes', controller);

  // Duyệt qua tất cả các routes và đăng ký chúng với express
  routes.forEach((route) => {
    // Ở đây, tốt nhất là dùng `switch/case` để đảm bảo chúng ta sử dụng đúng phương thức của express(.get, .post(), ...)
    // Nhưng để đơn giản thì như thế này là đủ
    app[route.requestMethod](prefix + route.path, (req: Request, res: Response) => {
      // Thực thi phương thức xử lý request, truyền vào là request và response
      (instance as any)[route.methodName](req, res);
    });
  });
});

app.listen(3000, () => {
  console.log('Started express on port 3000');
});

Giờ chúng ta khởi động lại ứng dụng express và thử truy cập vào các route: GET /cats, GET /cats/1. Wohoo!

Nâng cao

Ở trên tôi đã mô tả một ví dụ khá cơ bản, nếu bạn muốn áp dụng nó vào một sản phẩm “chạy thực” thì còn khá nhiều chỗ phải cải tiến khi bạn triển khai.

Khởi tạo controller

Trong ví dụ, controller của chúng ta được khởi tạo một cách rất đơn giản new controller(). Nhưng trong thực tế thì hàm constructor sẽ nhận vào rất nhiều các tham số (nếu bạn tuân theo nguyên lý SOLID).

Sẽ là hoàn hảo nếu các bạn áp dụng các thư viện (hoặc tự dựng) để giúp đỡ việc triển khai Dependency injection (tôi sẽ viết ở một bài viết khác).

Giá trị response

Tôi không thích lúc nào cũng sử dụng cú pháp res.send() hay res.json() trong controller, thay vào đó sẽ tốt hơn nếu chúng trả lại luôn kết quả sau khi xử lý (ví dụ: return new JsonResponse(/* ... */)), như vậy controller sẽ minh bạch hơn dễ dàng cho việc thực hiện test. Điều này khá dễ dàng để thực hiện, các phương thức trong controller sẽ trả lại một đối tượng chung (ví dụ: JsonResponse) và tại nơi bạn đăng ký các route chúng ta sẽ gọi res.send() một lần duy nhất:

app[route.requestMethod](prefix + route.path, (req: express.Request, res: express.Response) => {
  const response = instance[route.methodName](req, res);
  res.send(response.getContent()); // `getContent()` sẽ trả lại nội dung mà bạn muốn gửi tới client
});

Điều này cho phép bạn thêm các logic cần thiết (validate…) trước khi trả dữ liệu xuống phía client. Đặc biệt với cách này, chúng ta có thể loại bỏ được việc quên gọi phương thức next() của express (phương thức next() cũng được “gói” trong phương thức send(), nếu bạn không dùng phương thức send() bạn phải gọi phương thức next() “bằng tay”).

Kết luận

Bài viết đã trình bày một cách đơn giản để xử lý các route trong ứng dụng express bằng cách sử dụng TypeScript decorators, với không quá nhiều “bí thuật” hay phải sử dụng các framework, các thư viện bổ xung.

Bạn có thể tìm thấy rất nhiều thư viện hỗ trợ cho việc này, nhưng việc tự thực hiện sẽ giúp bạn hiểu rõ hơn vấn đề, và trong nhiều trường hợp nó sẽ giúp bạn quản lý mọi thứ tốt hơn.

Toàn bộ mã được cung cấp tại Github