+56

Cách Request Lifecycle hoạt động trong NestJS 🧬🛠

Đây là bài viết nằm trong Series NestJS thực chiến, các bạn có thể xem toàn bộ bài viết ở link: https://viblo.asia/s/nestjs-thuc-chien-MkNLr3kaVgA


Trong Series lần trước mình đã giới thiệu đến các bạn Boilerplate cho dự án NextJS hôm nay chúng ta sẽ cùng phát triển song song Series về NestJS để các bạn có thể có cái nhìn tổng quan về quá trình phát triển một ứng dụng web. Series này sẽ bao gồm Boilerplate cho NestJS và các vấn đề cũng như cách giải quyết mà mình trải qua trong quá trình lập trình với dự án thực tế. Hy vọng có thể giúp ích cho các bạn trong quá trình học tập và làm việc với Javascript và các Framework của nó.

Đặt vấn đề 📜

NestJS cung cấp cho chúng ta nhiều thành phần hữu ích như Middleware, Guards, Interceptors, Pipe, Interceptors, Filter... Việc sử dụng các thành phần đó được NestJS tinh giản nên hầu như rất dễ sử dụng, vì thế đôi khi trong quá trình sử dụng chúng ta thường bỏ qua cách mà NestJS xử lý 1 request khi đi qua các thành phần trên. Để tận dụng tối đa sức mạnh của NestJS, chúng ta cần hiểu rõ về các thành phần đó và cách thức mà nó xử lý các request hay thứ tự mà các thành phần đó được gọi. Bằng cách tìm hiểu các thông tin trên, chúng ta có thể tối ưu hóa ứng dụng của mình và đạt được hiệu quả cao nhất cũng như tránh các lỗi không mong muốn trong quá trình lập trình. Hãy cùng mình khám phá và hiểu về thứ tự mà NestJS thực thi các bước trong lifecycle của một request để hiểu rõ hơn về framework này. Đồng thời chúng ta cũng sẽ tìm hiểu đôi nét về chức năng và cách ứng dụng các thành phần mà NestJS đã cung cấp.

  • Cho ví dụ như bên dưới, nếu thử chạy và xem kết quả ở console, các bạn sẽ thấy log được in ra không theo thứ tự mà chúng ta đã sắp xếp trong code:

image.png

  • Kết quả ở console cho thấy Middleware được gọi trước, sau đó đến Interceptors và cuối cùng nếu có lỗi sẽ đến Filters

image.png

Request Lifecycle 🧬

Trình tự xử lí request của NestJS sẽ theo thứ tự như hình bên dưới, bên trong các thành phần sẽ có thứ tự xử lí riêng tùy theo phạm vi ứng dụng các thành phần đó. Ví dụ với Middleware, các MiddlewareGlobal sẽ được xử lí trước, sau đó đến các Middleware được import bên trong các Module.

Để lấy ví dụ cho Series này mình sẽ sử dụng ý tưởng về dự án Học tiếng Anh với FlashCard, bên trong source code sẽ có module flash-cards dùng để CRUD thông tin các flashcard. Mình sẽ cố gắng nghĩ ra nhiều ví dụ nhất có thể để các bạn dễ hình dung chức năng. Tuy nhiên, vì đây là bài viết tổng quan nên mình sẽ chỉ nói đôi nét về chức năng của các thành phần trong lifecycle kèm với ví dụ chứ không đi sâu vào chi tiết các thành phần đó để bài viết không quá dài và dễ tiếp cận, chi tiết sẽ được chúng ta tìm hiểu rõ hơn ở các bài viết sau.

Các bạn có thể tải về toàn bộ source code ở đây

Các thành phần trong Request Lifecycle 🎛️

1. Middleware 🚧

image.png

Middleware được gọi đầu tiên khi request đến server, chúng ta thường dùng để xử lý và thay đổi thông tin request trước khi truyền đến route handler. Đây là thành phần đầu tiên được gọi nên thông thường khi cấu hình dự án chúng ta sẽ sử dụng chúng đầu tiên.

1.1. Global Bound Middleware 🌐🚧

Đúng như tên gọi, ở đây Middleware được đăng ký global trên toàn ứng dụng của chúng ta và sẽ được áp dụng cho tất cả các request được gửi đến. Chúng ta thường thấy khi sử dụng các package như cors, helmet, body-parser,... với cú pháp app.use().

Trong ví dụ của chúng ta, mình sẽ sử dụng helmet và một custom middleware để log ra thứ tự:

import { Logger } from '@nestjs/common';
import { NestFactory } from '@nestjs/core';
import { Request, Response } from 'express';
import helmet from 'helmet';
import { AppModule } from './app.module';

async function bootstrap() {
  const logger = new Logger(bootstrap.name);
  const app = await NestFactory.create(AppModule);
  // NOTICE: GLOBAL MIDDLEWARE
  app.use(helmet());
  app.use((req: Request, res: Response, next) => {
    logger.debug('===TRIGGER GLOBAL MIDDLEWARE===');
    next();
  });
  await app.listen(3000);
}
bootstrap();

Truy cập http://localhost:3000 và quay về console để xem kết quả:

image.png

1.2. Module Bound Middleware 📦️🚧

Middleware của phần này được sử dụng trong một module bất kỳ để thực hiện các chức năng riêng. Như trong series về LTI chúng ta đã sử dụng ltijs như một middleware để xử lý các logic liên quan đến LTI.

  • Lấy ví dụ trong quá trình phát triển ứng dụng, module flash-cards có một số yêu cầu update từ khách hàng làm thay đổi logic so với ban đầu. Tuy nhiên họ cũng không chắc những update này sẽ phù hợp với user nên muốn đưa ra version thử nghiệm để lấy ý kiến từ người dùng, nếu không ổn có thể quay về version trước đó.

  • Để làm được việc đó chúng ta sẽ tạo ra version 2.0.0 của module flash-cards để cho user dùng thử và yêu cầu phía FE gửi app version trong lúc call API. Phía FE cần gửi X-App-Version để xác định version và trả về dữ liệu chính xác với version đó, còn ở phía API chúng ta cũng phải kiểm tra xem FE có gửi X-App-Version gửi lên có tồn tại và thuộc version mà chúng ta support hay không. Để làm được việc đó mình sẽ tạo VersionMiddleware.

    import { Injectable, NestMiddleware } from '@nestjs/common';
    import { Request, Response, NextFunction } from 'express';
    
    @Injectable()
    export class VersionMiddleware implements NestMiddleware {
      logger = new Logger(VersionMiddleware.name);
      use(req: Request, res: Response, next: NextFunction) {
        // NOTICE: MODULE BOUND MIDDLEWARE
        this.logger.debug('===TRIGGER MODULE BOUND MIDDLEWARE===');
        const appVersion = req.headers['x-app-version'];
        if (!appVersion || appVersion !== '2.0.0')
          throw new BadRequestException('Invalid App Version');
        next();
      }
    }
    

Trong thực tế thì thường chúng ta sẽ cho thử nghiệm trên một tập user cụ thể trước để thu thập ý kiến của họ, sau đó nếu phản hồi tích cực mới thử nghiệm lần nữa trên toàn bộ user.

  • Sau đó chúng ta thêm vào AppModule để apply cho flash-cards routes:

    import { MiddlewareConsumer, Module, NestModule } from '@nestjs/common';
    import { AppController } from './app.controller';
    import { AppService } from './app.service';
    import { VersionMiddleware } from './middlewares/version.middleware';
    import { FlashCardsModule } from './modules/flash-cards/flash-cards.module';
    
    @Module({
      imports: [FlashCardsModule],
      controllers: [AppController],
      providers: [AppService],
    })
    export class AppModule implements NestModule {
      configure(consumer: MiddlewareConsumer) {
        consumer.apply(VersionMiddleware).forRoutes('flash-cards');
      }
    }
    
  • Truy cập http://localhost:3000http://localhost:3000/flash-cards để xem kết quả. Trong console có thể thấy request đi theo thứ tự từ Global middleware đến Module middleware

    image.png

  • Không có version hoặc version không phù hợp sẽ báo lỗi image.png

  • Đúng version:

    image.png

2. Guards 🔴💂🟢

image.png

Mục đích duy nhất của Guard là xác định xem có cho phép request được xử lý bởi route handler hay không tại run-time. Có thể các bạn sẽ có thắc mắc GuardMiddleware đều xử lý logic tương tự nhau, tuy nhiên về bản chất thì Middleware sau khi gọi hàm next() thì sẽ không biết handler nào sẽ được gọi sau đó. Ngược lại, Guard nhờ vào việc có thể truy cập vào ExcecutionContext instance nên có thể biết được handler nào tiếp theo sẽ được gọi sau khi gọi hàm next(). Việc sử dụng Guard giúp chúng ta đưa logic xử lý vào chu trình của ứng dụng một cách rõ ràng và dễ hiểu. Điều này giúp cho code của chúng ta trở nên ngắn gọn, dễ đọc và dễ bảo trì hơn, đồng thời giúp giảm thiểu các lặp lại trong code (DRY). Từ đó, ứng dụng có thể được phát triển và nâng cấp một cách dễ dàng và hiệu quả hơn.

Theo mình chúng ta nên dùng Middleware khi cần xử lý và thay đổi các thông tin yêu cầu, còn Guards thì sử dụng để bảo vệ tài nguyên của ứng dụng bằng cách kiểm tra các điều kiện nhất định.

2.1. Global guards 🌐💂

Ví dụ về Global guards là package @nestjs/throttler dùng để giới hạn request gọi đến một API nhất định, nếu truy cập vượt quá giới hạn sẽ trả về lỗi Too many requests. Cách sử dụng theo như docs như sau:

...
@Module({
  imports: [
    ThrottlerModule.forRoot({
      ttl: 60,
      limit: 10, // Giới hạn số request có thể truy cập trong thời gian ttl
    }),
    ...
  ],
})
export class AppModule {}
  • Để sử dụng cho global thì chúng ta dùng như bên dưới: image.png

  • Tuy nhiên để dễ theo dõi đường đi của request mình sẽ extends lại ThrottlerGuard từ package đó để log ra thông tin:

    import { ExecutionContext, Logger } from '@nestjs/common';
    import { ThrottlerGuard } from '@nestjs/throttler';
    
    export class CustomThrottlerGuard extends ThrottlerGuard {
      logger = new Logger(CustomThrottlerGuard.name);
      canActivate(context: ExecutionContext): Promise<boolean> {
        this.logger.log('===TRIGGER GLOBAL GUARD===');
        return super.canActivate(context);
      }
    }
    
  • Nội dung hoàn chỉnh của file app.module.ts sau khi thêm vào:

    ...
    @Module({
      imports: [
        ThrottlerModule.forRoot({
          ttl: 60,
          limit: 10,
        }),
        FlashCardsModule,
      ],
      controllers: [AppController],
      providers: [
        AppService,
        {
          provide: APP_GUARD,
          useClass: CustomThrottlerGuard,
        },
      ],
    })
    export class AppModule implements NestModule {
      configure(consumer: MiddlewareConsumer) {
        consumer.apply(VersionMiddleware).forRoutes('flash-cards');
      }
    }
    
  • Truy cập http://localhost:3000/flash-cards và xem log ở console. image.png

  • Response nếu truy cập quá 10 lần trong 60s

image.png

2.2. Controller Guards 🔀💂

Controller Guards thường được dùng với Jwt Authentication, nên chúng ta cũng sẽ lấy ví dụ dùng jwt để protect flash-cards route, chỉ những user login xong mới có thể truy cập vào. Tuy nhiên việc config jwt hơi dài dòng nên chúng ta sẽ thực hiện ở các bài viết tiếp theo trong series, mình sẽ chỉ viết tượng trưng để chúng ta thấy đường đi của request thôi.

import {
  Injectable,
  CanActivate,
  ExecutionContext,
  Logger,
} from '@nestjs/common';
import { Observable } from 'rxjs';

@Injectable()
export class JwtAuthorizationGuard implements CanActivate {
  logger = new Logger(JwtAuthorizationGuard.name);
  canActivate(
    context: ExecutionContext,
  ): boolean | Promise<boolean> | Observable<boolean> {
    // NOTICE: CONTROLLER GUARD
    this.logger.log('===TRIGGER CONTROLLER GUARD===');
    // IMPLEMENT JWT GUARD LOGIC HERE
    return true;
  }
}
  • Sử dụng Guard cho flash-cards controller
...
@UseGuards(JwtAuthorizationGuard)
@Controller('flash-cards')
export class FlashCardsController {
  constructor(private readonly flashCardsService: FlashCardsService) {}

  @Get()
  async findAll() {
    return await this.flashCardsService.findAll();
  }
  ...

image.png

2.3. Route guards 🔜💂

Sau khi đi qua Global guardsController guards sẽ đến Route guards, ở đây chúng ta thường dùng các guard có tính chất riêng. Ví dụ như user khi muốn sửa/xóa một flash-card thì cần phải là người tạo ra nó mới quyền sửa/xóa.

  • Chúng ta sẽ tạo ra OwnershipGuard để handle trường hợp này
import {
  Injectable,
  CanActivate,
  ExecutionContext,
  Logger,
} from '@nestjs/common';
import { Observable } from 'rxjs';

@Injectable()
export class OwnershipGuard implements CanActivate {
  logger = new Logger(OwnershipGuard.name);
  canActivate(
    context: ExecutionContext,
  ): boolean | Promise<boolean> | Observable<boolean> {
    // NOTICE: ROUTE GUARD
    this.logger.log('===TRIGGER ROUTE GUARD===');
    // IMPLEMENT QUERY FLASH-CARD DATA AND CHECK OWNERSHIP
    return true;
  }
}
  • Thêm vào cho method update trong flash-cards controller :

    ...
      @Patch(':id')
      @UseGuards(OwnershipGuard)
      update(
        @Param('id') id: string,
        @Body() updateFlashCardDto: UpdateFlashCardDto,
      ) {
        return this.flashCardsService.update(+id, updateFlashCardDto);
      }
      ```
    
  • Truy cập http://localhost:3000/flash-cards/1 với method PATCH để kiểm tra kết quả:

image.png

3. Interceptors 🔁

image.png

Nói sơ qua về Interceptors thì nó cho phép chúng ta xử lý các requestresponse trước khi chúng được xử lý bởi controller hoặc được trả về cho client. Vì thế chúng ta có thể chèn thêm custom logic vào quá trình xử lý request/response của ứng dụng. Interceptors thường được sử dụng cho các trường hợp sau đây:

  • Logging: Ghi lại thông tin requestresponse để giám sát và phân tích
  • Caching: Lưu cache của các response để giảm thiểu việc truy vấn database hoặc service bên ngoài
  • Transformation: Chuyển đổi request hoặc response để phù hợp với định dạng mong muốn
  • Error handling: Xử lý lỗi và trả về response phù hợp

Interceptors xử lý cả request lẫn response nên sẽ có 2 phần:

  • Pre: trước khi đến method handler của controller
  • Post: sau khi có response trả về từ method handler.

3.1. Global Interceptors 🌐🔁

  • Để lấy ví dụ mình sẽ tạo LoggingInterceptor để ghi lại thông tin user request đến API cũng như thời gian mà API phản hồi dữ liệu đến người dùng .
import {
  Injectable,
  NestInterceptor,
  ExecutionContext,
  CallHandler,
  Logger,
} from '@nestjs/common';
import { Observable } from 'rxjs';
import { tap } from 'rxjs/operators';

@Injectable()
export class LoggingInterceptor implements NestInterceptor {
  logger = new Logger(LoggingInterceptor.name);
  intercept(context: ExecutionContext, next: CallHandler): Observable<any> {
    // NOTICE: GLOBAL INTERCEPTOR
    this.logger.warn('===TRIGGER GLOBAL INTERCEPTOR (PRE)===');

    const now = Date.now();
    return next.handle().pipe(
      tap(() => {
        logger.log(`After... ${Date.now() - now}ms`);
        // NOTICE: GLOBAL INTERCEPTOR
        this.logger.warn('===TRIGGER GLOBAL INTERCEPTOR (POST)===');
      }),
    );
  }
}
  • Thêm Interceptors vừa tạo vào main.ts để áp dụng cho toàn ứng dụng

    import { LoggingInterceptor } from './interceptors/logging.interceptor';
    ...
    async function bootstrap() {
      const logger = new Logger(bootstrap.name);
      const app = await NestFactory.create(AppModule);
      // Thêm vào đây
      app.useGlobalInterceptors(new LoggingInterceptor());
      // NOTICE: GLOBAL MIDDLEWARE
      app.use(helmet());
      app.use((req: Request, res: Response, next) => {
        logger.debug('===TRIGGER GLOBAL MIDDLEWARE===');
        next();
      });
      await app.listen(3000);
    }
    bootstrap();
    
  • Thêm log ở flash-cards controller để kiểm tra:

    ...
    @UseGuards(JwtAuthorizationGuard)
    @Controller('flash-cards')
    export class FlashCardsController {
      private logger: Logger;
      constructor(private readonly flashCardsService: FlashCardsService) {
        this.logger = new Logger(FlashCardsController.name);
      }
    
      @Get()
      @UseGuards(OwnershipGuard)
      async findAll() {
        this.logger.log(`Method name: ${this.findAll.name}`);
        return await this.flashCardsService.findAll();
      }
    
  • Truy cập http://localhost:3000/flash-cards để xem kết quả:

image.png

  • Nhìn vào log ở trên các bạn có thể thấy, mặc dù chúng ta để lệnh app.useGlobalInterceptors(new LoggingInterceptor()) ở trên app.use((req: Request, res: Response, next) => { logger.debug('===TRIGGER GLOBAL MIDDLEWARE==='); next(); }) nhưng Middleware vẫn được gọi trước chứ không đi theo thứ tự từ trên xuống trong file code.
  • Interceptors thì PRE sẽ được gọi trước sau đó đến function findAll trong controller sau đó đến POST để hiển thị tổng thời gian request thực thi.

3.2. Controller Interceptors 🔀🔁

  • TimeoutInterceptor sẽ là ví dụ về Controller Interceptors, chúng ta có thể dùng để control response nếu request vượt quá thời gian định trước.

    import {
      Injectable,
      NestInterceptor,
      ExecutionContext,
      CallHandler,
      RequestTimeoutException,
      Logger,
    } from '@nestjs/common';
    import { Observable, throwError, TimeoutError } from 'rxjs';
    import { catchError, tap, timeout } from 'rxjs/operators';
    
    @Injectable()
    export class TimeoutInterceptor implements NestInterceptor {
      logger = new Logger(TimeoutInterceptor.name);
      intercept(context: ExecutionContext, next: CallHandler): Observable<any> {
        // NOTICE: CONTROLLER INTERCEPTOR
        this.logger.warn('===TRIGGER CONTROLLER INTERCEPTOR (PRE)===');
        return next.handle().pipe(
          tap(() => {
            // NOTICE: CONTROLLER INTERCEPTOR
            this.logger.warn('===TRIGGER CONTROLLER INTERCEPTOR (POST)===');
          }),
          timeout(5000),
          catchError((err) => {
            if (err instanceof TimeoutError) {
              return throwError(() => new RequestTimeoutException());
            }
            return throwError(() => err);
          }),
        );
      }
    }
    
  • Thêm vào flash-cards controller:

    ...
    import { TimeoutInterceptor } from 'src/interceptors/timeout.interceptor';
    
    @UseInterceptors(TimeoutInterceptor)
    @UseGuards(JwtAuthorizationGuard)
    @Controller('flash-cards')
    export class FlashCardsController {
    ...
    }
    
  • Truy cập http://localhost:3000/flash-cards để xem log ở conosle.

    image.png

  • Có thể thấy tương tự như trường hợp với Middleware, mặc dù chúng ta để TimeoutInterceptor phía trên JwtAuthorizationGuard nhưng nó chỉ được gọi sau khi JwtAuthorizationGuard xử lí xong logic của mình.

  • Lưu ý: thứ tự thực thi ở PREPOST của Interceptors sẽ ngược lại với nhau:

    • PRE: Global => Controller => Route
    • POST: Route => Controller => Global

3.3. Route Interceptors 🔜🔁

  • Interceptors thường thấy khi dùng với Route InterceptorsExcludeNull, giúp loại bỏ các trường null khỏi response trước khi trả về cho user.

    import {
      Injectable,
      NestInterceptor,
      ExecutionContext,
      CallHandler,
      Logger,
    } from '@nestjs/common';
    import { Observable } from 'rxjs';
    import { map, tap } from 'rxjs/operators';
    
    @Injectable()
    export class ExcludeNullInterceptor implements NestInterceptor {
      logger = new Logger(ExcludeNullInterceptor.name);
      intercept(context: ExecutionContext, next: CallHandler): Observable<any> {
        // NOTICE: ROUTE INTERCEPTOR
        this.logger.warn('===TRIGGER ROUTE INTERCEPTOR (PRE)===');
        return next.handle().pipe(
          map((value) => (value === null ? '' : value)),
          tap(() =>
            // NOTICE: ROUTE INTERCEPTOR
            this.logger.warn('===TRIGGER ROUTE INTERCEPTOR (POST)==='),
          ),
        );
      }
    }
    
  • Thêm vào cho function findAll trong flash-cards controller.

       ...
      @Get()
      @UseGuards(OwnershipGuard)
      @UseInterceptors(ExcludeNullInterceptor)
      async findAll() {
        this.logger.log(`Method name: ${this.findAll.name}`);
        return await this.flashCardsService.findAll();
      }
    
  • Truy cập http://localhost:3000/flash-cards để xem kết quả. Không ngoài mong đợi, kết quả trả về theo đúng thứ tự mà chúng ta đã nói ở trên.

    image.png

4. Pipes 🕳️

image.png

Mục đích chính của Pipe là để kiểm tra, chuyển đổi và/hoặc sàng lọc dữ liệu được gửi và nhận về từ client.

Các trường hợp khi nên sử dụng Pipe bao gồm:

  • Xác thực dữ liệu: Kiểm tra xem dữ liệu được gửi từ client có đúng định dạng và có hợp lệ hay không.
  • Chuyển đổi dữ liệu: Chuyển đổi định dạng dữ liệu được gửi từ client thành dạng dữ liệu mà server có thể hiểu được, hoặc ngược lại chuyển đổi định dạng dữ liệu gửi về cho client.
  • Sàng lọc dữ liệu: Lọc bỏ dữ liệu không cần thiết, nhạy cảm hoặc nguy hiểm.

4.1. Global Pipes 🌐🕳️

Chúng ta không còn xa lạ gì với class-validator khi dùng với NestJS, đó là 1 package thông dụng dùng Global Pipes mà chúng ta dùng trong hầu hết các trường hợp.

  • Mình cũng sẽ custom lại ValidationPipe của class-validator và thêm vào log.

    import { PipeTransform, Injectable, ArgumentMetadata, Logger, ValidationPipe } from '@nestjs/common';
    
    @Injectable()
    export class CustomValidationPipe extends ValidationPipe {
      logger: Logger;
      constructor() {
        super();
        this.logger = new Logger(CustomValidationPipe.name);
      }
      transform(value: any, metadata: ArgumentMetadata) {
        this.logger.debug('===TRIGGER GLOBAL PIPE===');
        return value;
      }
    }
    
  • Thêm vào main.ts

    import { CustomValidationPipe } from './pipes/custom-validation.pipe';
    ...
    async function bootstrap() {
      const logger = new Logger(bootstrap.name);
      const app = await NestFactory.create(AppModule);
     app.useGlobalPipes(new CustomValidationPipe()); 
     app.useGlobalInterceptors(new LoggingInterceptor());
      // NOTICE: GLOBAL MIDDLEWARE
      app.use(helmet());
      app.use((req: Request, res: Response, next) => {
        logger.debug('===TRIGGER GLOBAL MIDDLEWARE===');
        next();
      });
      await app.listen(3000);
    }
    bootstrap();
    
  • Tuy nhiên để trigger Pipes chúng ta cần thêm vào params ở route handler.

    ...
    @Get()
    @UseGuards(OwnershipGuard)
    @UseInterceptors(ExcludeNullInterceptor)
    async findAll(@Query('limit') limit) { // Có thể thay bằng @Body, @Params,...
    this.logger.log(`Method name: ${this.findAll.name}`);
    return await this.flashCardsService.findAll();
    }
    ...
    
  • Truy cập http://localhost:3000/flash-cards xem kết quả thu được. Log của CustomValidationPipe sẽ nằm trong cùng, chỉ trước khi method findAll trong controller được gọi bất kế có thứ tự như thế nào trong file main.ts

image.png

Lưu ý: ứng với mỗi params được gọi sẽ là 1 lần Pipes được trigger, đây là phần các bạn nên chú ý. Nếu xài Pipes không hợp lý sẽ làm cho nó gọi lại những Pipes không cần thiết. Ví dụ thêm @Query('limit') vào function findAll thì kết quả sẽ là: image.png

4.2. Controller Pipes 🔀🕳️

Controller PipesRoute Pipes thông thường chúng ta cũng sẽ dùng Validation Pipe tùy theo trường hợp vì thế 2 phần này mình sẽ tạo 2 Custom Pipe để log ra thông tin đường đi của request.

import {
  ArgumentMetadata,
  Injectable,
  Logger,
  PipeTransform,
} from '@nestjs/common';

@Injectable()
export class ParseControllerValidationPipe implements PipeTransform<string> {
  logger = new Logger(ParseControllerValidationPipe.name);
  transform(value: string, metadata: ArgumentMetadata): string {
    // NOTICE: CONTROLLER PIPE
    this.logger.verbose('===TRIGGER CONTROLLER PIPE===');
    return value;
  }
}
  • Thêm vào flash-cards controller
import { ParseControllerValidationPipe } from 'src/pipes/parse-custom-validation-controller.pipe';
...
@UseInterceptors(TimeoutInterceptor)
@UseGuards(JwtAuthorizationGuard)
@UsePipes(ParseControllerValidationPipe)
@Controller('flash-cards')
export class FlashCardsController {
...
}

4.3. Route Pipes 🔜🕳️

Tạo ParseRouteValidationPipe như đã nói ở trên:

import {
  ArgumentMetadata,
  Injectable,
  Logger,
  PipeTransform,
} from '@nestjs/common';

@Injectable()
export class ParseRouteValidationPipe implements PipeTransform<string> {
  logger = new Logger(ParseRouteValidationPipe.name);
  transform(value: string, metadata: ArgumentMetadata): string {
    // NOTICE: ROUTE PIPE
    this.logger.verbose('===TRIGGER ROUTE PIPE===');
    return value;
  }
}
  • Thêm vào flash-cards controller
import { ParseRouteValidationPipe } from 'src/pipes/parse-custom-route-validation.pipe';
...
@Get()
@UseGuards(OwnershipGuard)
@UseInterceptors(ExcludeNullInterceptor)
@UsePipes(ParseRouteValidationPipe)
async findAll(@Query('limit') limit) {
    this.logger.log(`Method name: ${this.findAll.name}`);
    return await this.flashCardsService.findAll();
}
...

image.png

4.4. Route Parameter Pipes

Các Pipe bắt đầu bằng prefix Parse* là các Pipe mà chúng ta thường dùng cho Route Parameter Pipes khi transfer dữ liệu input của user trong Query, Param hoặc Body từ String sang Number, Boolean, UUID...

  • Series này sẽ dùng MongoDB nên mình tạo một ParseMongoID pipe để transform ID mà FE gửi lên sang ObjectId của MongoDB đồng thời cũng trả về lỗi nếu ID không hợp lệ.

    import {
      ArgumentMetadata,
      BadRequestException,
      Injectable,
      Logger,
      PipeTransform,
    } from '@nestjs/common';
    import { isObjectIdOrHexString } from 'mongoose';
    
    @Injectable()
    export class ParseMongoIdPipe implements PipeTransform<string> {
      logger = new Logger(ParseMongoIdPipe.name);
      transform(value: string, metadata: ArgumentMetadata): string {
        // NOTICE: ROUTE PIPE
        this.logger.log('===TRIGGER ROUTE PARAMS PIPE===');
        if (!isObjectIdOrHexString(value)) {
          throw new BadRequestException('Invalid ID');
        }
        return value;
      }
    }
    
    • Thêm vào function findOne trong flash-cards controller
    import { ParseMongoIdPipe } from 'src/pipes/parse-mongo-id.pipe';
    import { ObjectId } from 'mongoose';
    ...
    @Get(':id')
    @UseInterceptors(ExcludeNullInterceptor)
    findOne(@Param('id', ParseMongoIdPipe) id: ObjectId) {
        return this.flashCardsService.findOne(id);
    }
      ...
    
  • Truy cập với ID không phải của MongoDB http://localhost:3000/flash-cards/1 để xem có gặp lỗi không.

image.png

image.png

  • Thông tin trong console:

image.png

5. Controller 🔀

Phần này thì không còn xa lạ gì với chúng ta, route handler xử lý logic chính của API được gọi tới. image.png

6. Service 🛞

Service là nơi mà Controller gọi tới để xử lý yêu cầu, hoặc cũng có thể không cần gọi tới nếu bản thân Controller có thể tự giải quyết được. Trong trường hợp các bạn áp dụng Repository Pattern thì có thể có thêm 1 tầng logic từ Service gọi tới Repository.

7. Exception Filter ⚠️

image.png

Khác với NodeJS thuần, khi gặp exceptions ứng dụng sẽ bị crash,Exception filter được NestJS tạo ra để xử lý các ngoại lệ (exceptions) trong ứng dụng. Nó giúp chúng ta kiểm soát và định hướng các ngoại lệ xảy ra trong ứng dụng và trả về một phản hồi thích hợp cho user. Nếu các exceptions không được chúng ta tự handle thì sẽ được chuyển đến cho Exception Filter xử lý.

  • Mình sẽ sử dụng ví dụ về HttpExceptionFilter từ docs của NestJS để xử lý các ngoại lệ từ HttpException class, đồng thời logs ra timestamppath.
import {
  ExceptionFilter,
  Catch,
  ArgumentsHost,
  HttpException,
  Logger,
} from '@nestjs/common';
import { Request, Response } from 'express';

@Catch(HttpException)
export class HttpExceptionFilter implements ExceptionFilter {
  logger = new Logger(HttpExceptionFilter.name);
  catch(exception: HttpException, host: ArgumentsHost) {
    const ctx = host.switchToHttp();
    const response = ctx.getResponse<Response>();
    const request = ctx.getRequest<Request>();
    const status = exception.getStatus();
    // NOTICE: GLOBAL FILTER
    this.logger.debug('===TRIGGER GLOBAL FILTER===');
    response.status(status).json({
      statusCode: status,
      message: exception.message,
      timestamp: new Date().toISOString(),
      path: request.url,
    });
  }
}
  • Tương tự với các thành phần trên, Exception Filter cũng có thể sử dụng ở các cấp độ: Global, ControllerRoute. Mình sẽ cho apply HttpExceptionFilter trên toàn ứng dụng.
import { HttpExceptionFilter } from './filters/http-exception.filter';
...
async function bootstrap() {
  const logger = new Logger(bootstrap.name);
  const app = await NestFactory.create(AppModule);
  app.useGlobalFilters(new HttpExceptionFilter()); // Thêm vào đây
  app.useGlobalInterceptors(new LoggingInterceptor());
  app.useGlobalPipes(new CustomValidationPipe());
  // NOTICE: GLOBAL MIDDLEWARE
  app.use(helmet());
  app.use((req: Request, res: Response, next) => {
    logger.debug('===TRIGGER GLOBAL MIDDLEWARE===');
    next();
  });
  ...

image.png

  • Có thể thấy response đã được cập nhật thêm timestamppath. Giờ quay lại console để xem request lifecycle. Từ logs trên hình cho ta biết khi validate ở Route params pipe thất bại, ngay lập tức request đến Exception Filter layer để response cho người dùng và kết thúc request.

image.png

Kết luận 📝

Trong bài viết này, chúng ta đã tìm hiểu về Request Lifecycle trong NestJS. Chúng ta đã bàn về các khái niệm cơ bản như Middleware, Guards, Interceptors, Pipes, và Exception Filters. Chúng ta cũng đã thảo luận về việc sử dụng các thành phần này trong ứng dụng của chúng ta để giải quyết các vấn đề khác nhau.

Chúng ta đã bàn về vai trò của MiddlewareGuards trong việc kiểm soát và bảo vệ các tài nguyên trong hệ thống, như cảnh báo truy cập trái phép hay kiểm soát quyền truy cập của người dùng. InterceptorsPipes được sử dụng để xử lý dữ liệu và định dạng dữ liệu trước khi nó được gửi đi hoặc sau khi nó được nhận về. Exception Filters giúp chúng ta xử lý các ngoại lệ xảy ra trong quá trình xử lý request.

Tóm lại, NestJS cung cấp một cơ chế mạnh mẽ và linh hoạt để xử lý Request Lifecycle của ứng dụng của bạn. Việc sử dụng các thành phần Middleware, Guards, Interceptors, Pipes, và Exception Filters có thể giúp bạn tạo ra một ứng dụng an toàn, bảo mật và dễ bảo trì hơn.


All rights reserved

Viblo
Hãy đăng ký một tài khoản Viblo để nhận được nhiều bài viết thú vị hơn.
Đăng kí