iT邦幫忙

2021 iThome 鐵人賽

DAY 10
1
Modern Web

NestJS 帶你飛!系列 第 10 篇

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

  • 分享至 

  • xImage
  •  

本系列文已出版成書「NestJS 基礎必學實務指南:使用強大且易擴展的 Node.js 框架打造網頁應用程式」,感謝 iT 邦幫忙與博碩文化的協助。如果對 NestJS 有興趣、覺得這個系列文對你有幫助的話,歡迎前往購書,你的支持是我最大的寫作動力!

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

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

DTO 格式驗證

為了模擬驗證機制,這裡先產生一個 TodoModule 與 TodoController:

$ 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

關閉錯誤細項

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

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 的例子來說,可接受的參數為 title 與 description,假設今天客戶端傳送下方資訊:

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

可以發現傳了一個毫無關聯的 text,這時候想要快速過濾掉這種無效參數該怎麼做呢?透過 ValidationPipe 設置 whitelist 即可,當 whitelist 為 true 時,會 自動過濾掉於 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

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

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 進行測試,會在終端機看到下方結果,會發現 dto 為 CreateTodoDto 實例:

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.ts 在 dto 資料夾中,並讓它繼承 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 並讓它繼承 CreateTodoDto 的 title 欄位:

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,將 whitelist 與 forbidNonWhitelisted 設為 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,由於設置了 whitelist 與 forbidNonWhitelisted,所以無法通過驗證:
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

組合應用

上述的四個函式:PartialType、PickType、OmitType、IntersectionType 是可以透過組合的方式來使用的。下方的範例使用 OmitType 將 CreateTodoDto 的 title 欄位去除,並使用 IntersectionType 把 MockDto 與之合併 :

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

上一篇
[NestJS 帶你飛!] DAY09 - Pipe (上)
下一篇
[NestJS 帶你飛!] DAY11 - Middleware
系列文
NestJS 帶你飛! 共 32 篇
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中
0

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

HAO iT邦研究生 1 級 ‧ 2021-10-08 14:10:09 檢舉

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

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

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

HAO iT邦研究生 1 級 ‧ 2021-11-03 09:16:32 檢舉

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

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

0
dwyanelin
iT邦新手 5 級 ‧ 2022-10-17 12:40:32

您好,感謝教學,
想請問,我測試無法在local去更改global設定的值耶,
例如我在global設定transform: true
但在local(Controller)裡面想單獨改成transform: false
是無法成功的,想請問有辦法解決嗎?

因為如果local無法單獨修改,會大大降低global設定的可用性

HAO iT邦研究生 1 級 ‧ 2022-10-18 21:21:54 檢舉

據我所知,使用全域的方式配置確實無法單獨修改,原因是這個功能會對所有 Controller 底下的 Handler 起作用,而全域 Pipe 的優先級別會大於 Controller 的,所以資料會先被全域 Pipe 給轉換。

dwyanelin iT邦新手 5 級 ‧ 2022-10-19 11:12:28 檢舉

了解,感謝!
那可能就global開沒設定值的,
需要設定值再從local設定,
感謝啦!

我要留言

立即登入留言