iT邦幫忙

2021 iThome 鐵人賽

DAY 10
0
Modern Web

NestJS 帶你飛!系列 第 10

[NestJS 帶你飛!] DAY10 - Pipe (下)

前一篇有提到如果遇到物件格式的資料要如何做驗證這個問題,事實上這個解法只需要使用 DTO、ValidationPipeclass-validator 以及 class-transformer ,這裡先完成簡單的前置作業,透過 npm 安裝 class-validatorclass-transformer

$ npm install --save class-validator class-transformer

DTO 格式驗證

為了模擬驗證機制,這裡先產生一個 TodoModuleTodoController

$ nest generate module features/todo
$ nest generate controller features/todo

接著,在 features/todo 下新增 dto 資料夾,並建立 create-todo.dto.ts
https://ithelp.ithome.com.tw/upload/images/20210330/201193386bVroj82VD.png

在驗證格式機制上,必須要採用 class 的形式建立 DTO,原因在Controller(下)這篇有提過,如果採用 interface 的方式在編譯成 JavaScript 時會被刪除,如此一來,Nest 便無法得知 DTO 的格式為何。這裡我們先簡單定義一下 create-todo.dto.ts 的內容:

export class CreateTodoDto {
  public readonly title: string;
  public readonly description?: string;
}

我希望 title 的規則如下:

  1. 為必填
  2. 必須是 String
  3. 最大長度為 20

description 的規則如下:

  1. 為選填
  2. 必須是 String

那要如何套用這些規則呢?非常簡單,透過 class-validator 就能辦到,主要是替這些屬性添加特定的裝飾器:

import { IsNotEmpty, IsOptional, IsString, MaxLength } from 'class-validator';


export class CreateTodoDto {

  @MaxLength(20)
  @IsString()
  @IsNotEmpty()
  public readonly title: string;

  @IsString()
  @IsOptional()
  public readonly description?: string;
}

提醒:詳細的裝飾器內容可以參考 class-validator

如此一來便完成了規則的定義,實在是太好用啦!接下來只需要在資源上透過 @UsePipes裝飾器套用 ValidationPipe 即可:

import { Body, Controller, Post, UsePipes, ValidationPipe } from '@nestjs/common';
import { CreateTodoDto } from './dto/create-todo.dto';

@Controller('todos')
export class TodoController {

  @Post()
  @UsePipes(ValidationPipe)
  create(@Body() dto: CreateTodoDto) {
    return {
      id: 1,
      ...dto
    };
  }

}

在 Controller 層級套用也可以,就會變成該 Controller 下的所有資源都支援驗證:

import { Body, Controller, Post, UsePipes, ValidationPipe } from '@nestjs/common';
import { CreateTodoDto } from './dto/create-todo.dto';

@Controller('todos')
@UsePipes(ValidationPipe)
export class TodoController {

  @Post()
  create(@Body() dto: CreateTodoDto) {
    return {
      id: 1,
      ...dto
    };
  }

}

透過 Postman 來測試,會發現順利報錯:
https://ithelp.ithome.com.tw/upload/images/20210327/201193382apLrluOBd.png

關閉錯誤細項

如果不想要回傳錯誤的項目,可以透過 ValidationPipedisableErrorMessages 來關閉:

import { Body, Controller, Post, UsePipes, ValidationPipe } from '@nestjs/common';
import { CreateTodoDto } from './dto/create-todo.dto';

@Controller('todos')
export class TodoController {

  @Post()
  @UsePipes(new ValidationPipe({ disableErrorMessages: true }))
  create(@Body() dto: CreateTodoDto) {
    return {
      id: 1,
      ...dto
    };
  }

}

透過 Postman 進行測試:
https://ithelp.ithome.com.tw/upload/images/20210327/20119338aRUDKawPPJ.png

自訂 Exception

與其他 Pipe 一樣可以透過 exceptionFactory 自訂 Exception:

import { Body, Controller, HttpStatus, NotAcceptableException, Post, UsePipes, ValidationPipe } from '@nestjs/common';
import { ValidationError } from 'class-validator';
import { CreateTodoDto } from './dto/create-todo.dto';

@Controller('todos')
export class TodoController {

  @Post()
  @UsePipes(
    new ValidationPipe({
      exceptionFactory: (errors: ValidationError[]) => {
        return new NotAcceptableException({
          code: HttpStatus.NOT_ACCEPTABLE,
          message: '格式錯誤',
          errors
        });
      }
    })
  )
  create(@Body() dto: CreateTodoDto) {
    return {
      id: 1,
      ...dto
    };
  }

}

透過 Postman 進行測試:
https://ithelp.ithome.com.tw/upload/images/20210327/20119338Kgg9sLVbo4.png

自動過濾屬性

以前面新增 Todo 的例子來說,可接受的參數為 titledescription,假設今天客戶端傳送下方資訊:

{
  "title": "Test",
  "text": "Hello."
}

可以發現傳了一個毫無關聯的 text,這時候想要快速過濾掉這種無效參數該怎麼做呢?透過 ValidationPipe 設置 whitelist 即可,當 whitelisttrue 時,會 自動過濾掉於 DTO 沒有任何裝飾器的屬性,也就是說,就算有該屬性但沒有添加 class-validator 的裝飾器也會被視為無效屬性。這裡我們簡單實驗一下 whitelist

import { Body, Controller, Post, UsePipes, ValidationPipe } from '@nestjs/common';
import { CreateTodoDto } from './dto/create-todo.dto';

@Controller('todos')
export class TodoController {

  @Post()
  @UsePipes(new ValidationPipe({ whitelist: true }))
  create(@Body() dto: CreateTodoDto) {
    return {
      id: 1,
      ...dto
    };
  }

}

透過 Postman 進行測試:
https://ithelp.ithome.com.tw/upload/images/20210327/2011933846uvWl8O5Z.png

如果想要傳送無效參數時直接報錯的話,則是同時使用 whitelistforbidNonWhitelisted

import { Body, Controller, Post, UsePipes, ValidationPipe } from '@nestjs/common';
import { CreateTodoDto } from './dto/create-todo.dto';

@Controller('todos')
export class TodoController {

  @Post()
  @UsePipes(new ValidationPipe({ whitelist: true, forbidNonWhitelisted: true }))
  create(@Body() dto: CreateTodoDto) {
    return {
      id: 1,
      ...dto
    };
  }

}

透過 Postman 進行測試:
https://ithelp.ithome.com.tw/upload/images/20210327/201193388kPqruSHT6.png

自動轉換

ValidationPipe 還提供 transform 參數來轉換傳入的物件,將其實例化為對應的 DTO:

import { Body, Controller, Post, UsePipes, ValidationPipe } from '@nestjs/common';
import { CreateTodoDto } from './dto/create-todo.dto';

@Controller('todos')
export class TodoController {

  @Post()
  @UsePipes(new ValidationPipe({ transform: true }))
  create(@Body() dto: CreateTodoDto) {
    console.log(dto);
    return dto;
  }

}

透過 Postman 進行測試,會在終端機看到下方結果,會發現 dtoCreateTodoDto 實例:

CreateTodoDto { title: 'Test' }

transform 還有一個很厲害的功能,還記得如何取得路由參數嗎?假設路由參數要取得 id,這個 id 型別是 number,但正常來說路由參數收到的時候都會是 string,透過 transform Nest 會嘗試去轉換成我們指定的型別:

import { Controller, Get, Param, UsePipes, ValidationPipe } from '@nestjs/common';

@Controller('todos')
export class TodoController {

  @Get(':id')
  @UsePipes(new ValidationPipe({ transform: true }))
  get(@Param('id')id : number) {
    console.log(typeof id);
    return '';
  }

}

透過瀏覽器存取 http://localhost:3000/1,會在終端機看到型別確實轉換成 number 了:

number

檢測陣列 DTO

如果傳入的物件為陣列格式,不能使用 ValidationPipe,要使用 ParseArrayPipe,並在 items 帶入其 DTO:

import { Body, Controller, ParseArrayPipe, Post } from '@nestjs/common';
import { CreateTodoDto } from './dto/create-todo.dto';

@Controller('todos')
export class TodoController {

  @Post()
  create(
    @Body(new ParseArrayPipe({ items: CreateTodoDto }))
    dtos: CreateTodoDto[]
  ) {
    return dtos;
  }

}

透過 Postman 進行測試:
https://ithelp.ithome.com.tw/upload/images/20210330/20119338kOolhua1iv.png

解析查詢參數

ParseArrayPipe 還可以用來解析查詢參數,假設查詢參數為 ?ids=1,2,3,此時就可以善用此方法來解析出各個 id,只需要添加 separator 去判斷以什麼作為分界點:

import { Controller, Get, ParseArrayPipe, Query } from '@nestjs/common';

@Controller('todos')
export class TodoController {
  @Get()
  get(
    @Query('ids', new ParseArrayPipe({ items: Number, separator: ',' }))
    ids: number[]
  ) {
    return ids;
  }
}

透過 Postman 進行測試:
https://ithelp.ithome.com.tw/upload/images/20210330/201193381s3cN9Kr9d.png

DTO 技巧

當系統越來越龐大的時候,DTO 的數量也會隨之增加,有許多的 DTO 會有重複的屬性,例如:相同資源下的 CRUD DTO,這時候就會變得較難維護,還好 Nest 有提供良好的解決方案,運用特殊的繼承方式來處理:

局部性套用 (Partial)

局部性套用的意思是將既有的 DTO 所有欄位都取用,只是全部轉換為非必要屬性,需要使用到 PartialType 這個函式來把要取用的 DTO 帶進去,並給新的 DTO 繼承。這邊我們先建立一個 update-todo.dto.tsdto 資料夾中,並讓它繼承 CreateTodoDto 的欄位:

import { PartialType } from '@nestjs/mapped-types';
import { CreateTodoDto } from './create-todo.dto';

export class UpdateTodoDto extends PartialType(CreateTodoDto) {
}

其效果相當於:

import { IsNotEmpty, IsOptional, IsString, MaxLength } from 'class-validator';

export class UpdateTodoDto {
  @MaxLength(20)
  @IsString()
  @IsNotEmpty()
  @IsOptional()
  public readonly title?: string;

  @IsString()
  @IsOptional()
  public readonly description?: string;
}

接著來修改 todo.controller.ts

import { Body, Controller, Param, Patch, UsePipes, ValidationPipe } from '@nestjs/common';
import { UpdateTodoDto } from './dto/update-todo.dto';

@Controller('todos')
export class TodoController {
    @Patch(':id')
    @UsePipes(ValidationPipe)
    update(
        @Param('id') id: number,
        @Body() dto: UpdateTodoDto
    ) {
        return {
            id,
            ...dto
        };
    }
}

透過 Postman 進行測試,這邊我不帶任何值去存取 PATCH /todos/:id,會發現可以通過驗證:
https://ithelp.ithome.com.tw/upload/images/20210331/20119338VFWblhWwgx.png

選擇性套用 (Pick)

選擇性套用的意思是用既有的 DTO 去選擇哪些是會用到的屬性,需要使用到 PickType 這個函式來把要取用的 DTO 帶進去以及指定要用的屬性名稱,並給新的 DTO 繼承。這邊我們沿用 UpdateTodoDto 並讓它繼承 CreateTodoDtotitle 欄位:

import { PickType } from '@nestjs/mapped-types';
import { CreateTodoDto } from './create-todo.dto';

export class UpdateTodoDto extends PickType(CreateTodoDto,  ['title']) {
}

其效果等同於:

import { IsNotEmpty, IsString, MaxLength } from 'class-validator';

export class UpdateTodoDto {
  @MaxLength(20)
  @IsString()
  @IsNotEmpty()
  public readonly title: string;
}

todo.controller.ts 沿用前面的範例:

import { Body, Controller, Param, Patch, UsePipes, ValidationPipe } from '@nestjs/common';
import { UpdateTodoDto } from './dto/update-todo.dto';

@Controller('todos')
export class TodoController {
    @Patch(':id')
    @UsePipes(ValidationPipe)
    update(
        @Param('id') id: number,
        @Body() dto: UpdateTodoDto
    ) {
        return {
            id,
            ...dto
        };
    }
}

透過 Postman 進行測試,這邊我不帶任何值去存取 PATCH /todos/:id,會發現無法通過驗證:
https://ithelp.ithome.com.tw/upload/images/20210331/20119338LHx2pVlIIr.png

忽略套用 (Omit)

忽略套用的意思是用既有的 DTO 但忽略不會用到的屬性,需要使用到 OmitType 這個函式來把要取用的 DTO 帶進去以及指定要忽略的屬性名稱,並給新的 DTO 繼承。這邊我們沿用 UpdateTodoDto 並讓它繼承 CreateTodoDto 的欄位,但忽略 title 屬性:

import { OmitType } from '@nestjs/mapped-types';
import { CreateTodoDto } from './create-todo.dto';

export class UpdateTodoDto extends OmitType(CreateTodoDto,  ['title']) {
}

其效果等同於:

import { IsOptional, IsString } from 'class-validator';

export class UpdateTodoDto {
  @IsString()
  @IsOptional()
  public readonly description?: string;
}

這裡稍微調整一下 todo.controller.ts,將 whitelistforbidNonWhitelisted 設為 true

import { Body, Controller, Param, Patch, UsePipes, ValidationPipe } from '@nestjs/common';
import { UpdateTodoDto } from './dto/update-todo.dto';

@Controller('todos')
export class TodoController {
    @Patch(':id')
    @UsePipes(new ValidationPipe({ whitelist: true, forbidNonWhitelisted: true }))
    update(
        @Param('id') id: number,
        @Body() dto: UpdateTodoDto
    ) {
        return {
            id,
            ...dto
        };
    }
}

透過 Postman 進行測試,這邊我刻意帶 title 去存取 PATCH /todos/:id,由於設置了 whitelistforbidNonWhitelisted,所以無法通過驗證:
https://ithelp.ithome.com.tw/upload/images/20210331/20119338Zl2bUEEZbE.png

合併套用 (Intersection)

合併套用的意思是用既有的兩個 DTO 來合併屬性,需要使用到 IntersectionType 這個函式來把要取用的兩個 DTO 帶進去,並給新的 DTO 繼承。這邊我們沿用 CreateTodoDto 並在 update-todo.dto.ts 新增一個 MockDto,再讓 UpdateTodoDto 去繼承這兩個的欄位:

import { IntersectionType } from '@nestjs/mapped-types';
import { IsNotEmpty, IsString } from 'class-validator';
import { CreateTodoDto } from './create-todo.dto';

export class MockDto {
    @IsString()
    @IsNotEmpty()
    public readonly information: string;
}

export class UpdateTodoDto extends IntersectionType(CreateTodoDto, MockDto) {
}

其效果等同於:

import { IsNotEmpty, IsOptional, IsString, MaxLength } from 'class-validator';

export class UpdateTodoDto {
  @MaxLength(20)
  @IsString()
  @IsNotEmpty()
  public readonly title: string;

  @IsString()
  @IsOptional()
  public readonly description?: string;
  
  @IsString()
  @IsNotEmpty()
  public readonly information: string;
}

這裡調整一下 todo.controller.ts

import { Body, Controller, Param, Patch, UsePipes, ValidationPipe } from '@nestjs/common';
import { UpdateTodoDto } from './dto/update-todo.dto';

@Controller('todos')
export class TodoController {
    @Patch(':id')
    @UsePipes(ValidationPipe)
    update(
        @Param('id') id: number,
        @Body() dto: UpdateTodoDto
    ) {
        return {
            id,
            ...dto
        };
    }
}

透過 Postman 進行測試,這邊我刻意不帶 information 去存取 PATCH /todos/:id,所以無法通過驗證:
https://ithelp.ithome.com.tw/upload/images/20210331/20119338CSib3iRbem.png

組合應用

上述的四個函式:PartialTypePickTypeOmitTypeIntersectionType 是可以透過組合的方式來使用的。下方的範例使用 OmitTypeCreateTodoDtotitle 欄位去除,並使用 IntersectionTypeMockDto 與之合併 :

import { IntersectionType, OmitType } from '@nestjs/mapped-types';
import { IsNotEmpty, IsString } from 'class-validator';
import { CreateTodoDto } from './create-todo.dto';

export class MockDto {
    @IsString()
    @IsNotEmpty()
    public readonly information: string;
}

export class UpdateTodoDto extends IntersectionType(
    OmitType(CreateTodoDto, ['title']), MockDto
) {
}

其效果等同於:

import { IsNotEmpty, IsOptional, IsString } from 'class-validator';

export class UpdateTodoDto {
  @IsString()
  @IsOptional()
  public readonly description?: string;
  
  @IsString()
  @IsNotEmpty()
  public readonly information: string;
}

todo.controller.ts 保持本來的樣子:

import { Body, Controller, Param, Patch, UsePipes, ValidationPipe } from '@nestjs/common';
import { UpdateTodoDto } from './dto/update-todo.dto';

@Controller('todos')
export class TodoController {
    @Patch(':id')
    @UsePipes(ValidationPipe)
    update(
        @Param('id') id: number,
        @Body() dto: UpdateTodoDto
    ) {
        return {
            id,
            ...dto
        };
    }
}

透過 Postman 進行測試,這邊我不帶任何值去存取 PATCH /todos/:id,會發現無法通過驗證:
https://ithelp.ithome.com.tw/upload/images/20210401/201193382aLm99F5LW.png

全域 Pipe

ValidationPipe 算是一個蠻常用的功能,因為大多數的情況都會使用到 DTO 的概念,如此一來便可以使用 DTO 驗證的方式去檢查資料的正確性,所以可以直接將 ValidationPipe 配置在全域,僅需要修改 main.ts 即可:

import { ValidationPipe } from '@nestjs/common';
import { NestFactory } from '@nestjs/core';
import { AppModule } from './app.module';

async function bootstrap() {
  const app = await NestFactory.create(AppModule);
  app.useGlobalPipes(new ValidationPipe());
  await app.listen(3000);
}
bootstrap();

透過 useGlobalPipes 使 ValidationPipe 適用於全域,實在是非常方便!

依賴注入實作全域 Pipe

上面的方法是透過模組外部完成全域配置的,與 Exception filter 一樣可以用依賴注入的方式,透過指定 Provider 的 tokenAPP_PIPE 來實現,這裡是用 useClass 來指定要建立實例的類別:

import { Module, ValidationPipe } from '@nestjs/common';
import { APP_PIPE } from '@nestjs/core';
import { AppController } from './app.controller';
import { AppService } from './app.service';
import { TodoModule } from './features/todo/todo.module';

@Module({
  imports: [TodoModule],
  controllers: [AppController],
  providers: [
    AppService,
    {
      provide: APP_PIPE,
      useClass: ValidationPipe
    }
  ],
})
export class AppModule {}

小結

ValidationPipe 與 DTO 的驗證機制十分好用且重要,任何的 API 都需要做好完善的資料檢查,才能夠降低帶來的風險。這裡附上今天的懶人包:

  1. ValidationPipe 需要安裝 class-validatorclass-transformer
  2. 透過 ValidationPipe 可以實現 DTO 格式驗證。
  3. ValidationPipe 可以透過 disableErrorMessages 關閉錯誤細項。
  4. ValidationPipe 一樣可以透過 exceptionFactory 自訂 Exception。
  5. ValidationPipe 可以透過 whitelist 來過濾無效參數,如果接收到無效參數想要回傳錯誤的話,還需要額外啟用 forbidNonWhitelisted
  6. ValidationPipe 可以透過 transform 來達到自動轉換型別的效果。
  7. ParseArrayPipe 解析陣列 DTO 以及查詢參數。
  8. DTO 可以透過 PartialTypePickTypeOmitTypeIntersectionType 這四個函式來重用 DTO 的欄位。
  9. 全域 Pipe 可以透過依賴注入的方式實作。

上一篇
[NestJS 帶你飛!] DAY09 - Pipe (上)
下一篇
[NestJS 帶你飛!] DAY11 - Middleware
系列文
NestJS 帶你飛!32

2 則留言

0

非常感谢,在大陆很少看到nestjs适合自己的系列文章,官网翻译又比较生硬,现在轻松入门nestjs了,感激之情,无以言表

HAO iT邦新手 3 級 ‧ 2021-10-08 14:10:09 檢舉

能夠幫助大家入門 NestJS 是我的榮幸,謝謝你的讚賞!

0
mihuartuanr
iT邦新手 5 級 ‧ 2021-11-02 22:18:33

您好,想咨询一下,若页面和服务端都是用typescript研发,那DTO的结构是共用的,在与服务端分离的场景下,如何共用DTO呢?

HAO iT邦新手 3 級 ‧ 2021-11-03 09:16:32 檢舉

你好,如果說不介意採用 monorepo 架構的話,可以研究一下 Nx

好的,谢谢,我研究一下。

我要留言

立即登入留言