NestJSについて軽く調べてまとめた
特徴
- TypeScriptで開発されたバックエンドのフレームワーク。デフォルトでTypeScriptをサポート
- デフォルトでExpressを活用。Fastifyも活用できる
- Expressのミドルウェアをそのまま転用できる
- OOP(オブジェクト指向プログラミング)、FP(関数型プログラミング)、FRP(関数型リアクティブプログラミング)の要素をフル活用できる
- DIコンテナをデフォルトで活用できる
- テストが簡単で疎結合であり、スケーラブル。専用のテストフレームワークが用意されている
- モジュール構造により、プロジェクトを個別のブロックに分割できる。これによって、プロジェクト内で外部のライブラリを簡単に活用できる
- すぐに利用を開始できるCLIツールがある。コマンド一つで開発に必要な雛形を簡単に作れる
- マイクロサービスを開発しやすい
- GraphQLとREST APIの開発を両方ともサポートしている
- 拡張性が高い
- TypeORM、Mongoose、GraphQL、Logging、Validation、Caching、WebSocketsなどNest固有のモジュールのサポートを提供
基本的な概念
軽く紹介
- Controllers
- Providers
- Modules
- Middleware
- Exception filters
- Pipes
- Guards
- Interceptors
Controllers、Providers、Modules は、クライアントからのリクエストに対するルーティングやビジネスロジック、それらをまとめる機能を提供
Controllers
送られてくるリクエストを処理してレスポンスをクライアントに返す枠割を担ってる
Controllerの目的は、アプリケーション内の特定のリクエストを受け取ることで、ルーティングはどのControllerがどのリクエストを受信するのかを制御している
import { Body, Controller, Delete, Get, Param, ParseUUIDPipe, Patch, Post } from '@nestjs/common';
import { Item } from './item.model';
import { ItemsService } from './items.service';
import { CreateItemDto } from './dto/create-item.dto';
@Controller('items')
export class ItemsController {
constructor(private readonly itemsService: ItemsService) {}
@Get()
findAll(): Item[] {
return this.itemsService.findAll();
}
@Get(':id')
findById(@Param('id', ParseUUIDPipe) id: string): Item {
return this.itemsService.findById(id);
}
@Post()
create(@Body() createItemDto: CreateItemDto): Item {
return this.itemsService.create(createItemDto);
}
@Patch(':id')
updateStatus(@Param('id', ParseUUIDPipe) id: string): Item {
return this.itemsService.updateStatus(id);
}
@Delete(':id')
delete(@Param('id', ParseUUIDPipe) id: string) {
return this.itemsService.delete(id);
}
}
Providers
NestJSの基本的なクラスの多くはProviderとして扱われることが多い。
Providerの基本的な考えは依存関係として注入できること。言い換えるとオブジェクトはお互いに様々な関係を作れる。
Controllerから複雑なタスクを依頼される
ビジネスロジックを定義するサービスクラスとか
import { Injectable, NotFoundException } from '@nestjs/common';
import { ItemStatus } from './item-status.enum';
import { Item } from './item.model';
import { CreateItemDto } from './dto/create-item.dto';
import { v4 as uuid } from 'uuid';
@Injectable()
export class ItemsService {
private items: Item[] = [];
findAll() {
return this.items;
}
findById(id: string): Item {
const found = this.items.find((item) => item.id === id);
if (!found) {
// スタンダード
// throw new HttpException(
// { reason: 'this can be a human readable reason' },
// HttpStatus.BAD_REQUEST,
// );
// HttpExceptionを継承している用意されてるやつ
throw new NotFoundException();
}
return found;
}
create(createItemDto: CreateItemDto) {
const item: Item = {
id: uuid(),
...createItemDto,
status: ItemStatus.ON_SALE,
};
this.items.push(item);
return item;
}
updateStatus(id: string): Item {
const item = this.findById(id);
item.status = ItemStatus.SOLD_OUT;
return item;
}
delete(id: string): void {
this.items = this.items.filter((item) => item.id !== id);
}
}
Modules
@Module()デコレータでアノテーションされたクラスを意味する。@Module()デコレータは、NestJSがアプリケーションの構造を整理するために利用するメタデータを提供。
以下の要素から構成される:
- providers: Nest injector によりインスタンス化される Provider で、Module 内でシェアされる
- controllers: Module で定義される Controller
- imports: Module で使用する Provider をエクスポートしている他の Module
- exports: Module からエクスポートされる Provider で、Module の public interface といえる
少なくとも一つのModule(Root module) を必要とし、これと他のインポートされたModuleの連鎖であるapplication graph によって構成される
特定の役割に応じて一つの Module が構成されるべきである(1機能1Module)
import { Module } from '@nestjs/common';
import { ItemsController } from './items.controller';
import { ItemsService } from './items.service';
@Module({
controllers: [ItemsController], // Controller の登録
providers: [ItemsService], // Service の登録
exports: [ItemsService], // エクスポートする Provider の登録
})
export class ItemsModule {}
pipe
- ハンドラーがリクエストを受け取る前にリクエストに対して処理を行う
- データの変換とバリデーションが可能
- 処理を行なった後のデータをハンドラーに渡す
- Pipeの処理中に例外を返すことも可能
組み込むのPipeが用意されている
- ValidationPipe
- ParseIntPipe
- ParseBoolPipe
- ParseArrayPipe
- ParseUUIDPipe
- DefaultValuePipe
1.ハンドラに適用
2.パラメータごとに適用
3.グローバルに適用
@Get(':id')
findById(@Param('id', ParseUUIDPipe) id: string): Item {
return this.itemsService.findById(id);
}
カスタムのパイプも割と簡単に作れそう
DTO
- NestJSのDTOはRequestPayload(body)の型定義を行うためのもの
- 一般的な DAO/DTO の文脈とは少し違った印象を受けるイメージ
- 型定義と同時にバリデーションまで含むことができる
class-validator および class-transformer パッケージを利用することでDTO定義の中でバリデーションできる
Controller に処理が渡ってくる時点で正しいデータであることが保証される
import { Type } from 'class-transformer';
import { IsInt, IsNotEmpty, IsString, MaxLength, Min } from 'class-validator';
export class CreateItemDto {
@IsString()
@IsNotEmpty()
@MaxLength(40)
name: string;
@IsInt()
@Min(1)
@Type(() => Number)
price: number;
@IsString()
@IsNotEmpty()
description: string;
}
Exception filters
未処理のすべての例外を処理できる例外フィルタがデフォルトで組み込まれている
アプリのコードで処理されない例外が発生した場合、このフィルタが例外をキャッチしてレスポンスを自動送信してくれる
組み込みで色々な例外が用意されている
例外フィルタを完全に制御したい場合もある
下記はHttpExceptionクラスのインスタンスである例外をキャッチし、その例外に対するカスタムのレスポンスのロジックを実装するための例外フィルタ
import { ExceptionFilter, Catch, ArgumentsHost, HttpException } from '@nestjs/common';
import { Request, Response } from 'express';
@Catch(HttpException)
export class HttpExceptionFilter implements ExceptionFilter {
catch(exception: HttpException, host: ArgumentsHost) {
const ctx = host.switchToHttp();
const response = ctx.getResponse<Response>();
const request = ctx.getRequest<Request>();
const status = exception.getStatus();
response
.status(status)
.json({
statusCode: status,
timestamp: new Date().toISOString(),
path: request.url,
});
}
}
使うときはコントローラーで下記のようにする
Exception filters は、メソッドのレベル、コントローラのレベル、グローバルなレベルで使用することができる
@Post()
@UseFilters(HttpExceptionFilter) // Exception filter を登録
async create(@Body() createCatDto: CreateCatDto) {
// ...
}
Guards
ガードが持つ責任は一つ。ランタイムに存在する特定の条件(パーミッション、ロール、ACL等)に依存して、与えられたリクエストがルートハンドラによって処理されるかどうかを決定する事.。これは認可と呼ばれる。
認可に使用するGuard
特定のルートは呼び出し側(通常は特定の認証済みユーザー)が十分なパーミッションを持っている場合にのみ利用可能であるべき。
下記のAuthGuardは、認証されたユーザーを想定している(リクエストヘッダにトークンが添付されていることを想定)。トークンを抽出して検証し、抽出された情報を使って リクエストを続行できるかどうかを決定。
import { Injectable, CanActivate, ExecutionContext } from '@nestjs/common';
import { Observable } from 'rxjs';
@Injectable()
export class AuthGuard implements CanActivate {
canActivate(
context: ExecutionContext,
): boolean | Promise<boolean> | Observable<boolean> {
const request = context.switchToHttp().getRequest();
return validateRequest(request);
}
}
trueを返す場合は、リクエストがそのまま進行されます。
falseを返す場合は、リクエストを拒否します。
Interceptors
以下のことなどが可能:
- メソッドの実行の前後において追加のロジックをバインドする
- 関数の返り値を変換する
- 関数から送出された例外を変換する
- 関数の振る舞いを拡張する
(たとえばキャッシュを目的として) 特定の条件に応じて関数をオーバーライドする
インターセプターを使用してユーザとのやり取りを記録する場合です。シンプルなLoggingInterceptorの例
import { Injectable, NestInterceptor, ExecutionContext, CallHandler } from '@nestjs/common';
import { Observable } from 'rxjs';
import { tap } from 'rxjs/operators';
@Injectable()
export class LoggingInterceptor implements NestInterceptor {
intercept(context: ExecutionContext, next: CallHandler): Observable<any> {
console.log('Before...');
const now = Date.now();
return next
.handle()
.pipe(
tap(() => console.log(`After... ${Date.now() - now}ms`)),
);
}
}
バインドする
@UseInterceptors(LoggingInterceptor)
export class CatsController {}
※GuardsとInterceptorsはまだしっかり触れてません
メリット
DIコンテナをデフォルトで活用できる
- あまり意識せずにDIの実装できる
- NestJSを使うことで自然と依存関係を意識したコートが作れそう
- テストがしやすい
OpinionatedArchitecture(こだわりの強いアーキテクチャ)
- Diを前提としたServiceとそれを利用するProvider / Module
- 自然とレイヤとユースケースについて考慮されるようになる
- 他のフレームワークにあるが本質的でないものは無関心でコアにRDBやNoSQLへの機能は存在しない(追加はできる)
- 必要なものを選択するだけで、自然とNestらしくなる利点がある
「convention over configuration」というパラダイムを採用していて開発者が考えることを減らそうとしてる
逆にExpressはシンプルで自由度が高い分、自分達で責任を持って設計しないと大変
豊富なエコシステム
- NestJSにはコアにはないがオフィシャルのパッケージがたくさんある
- TypeORM,Sequalize / Mongoose
- GraphQL, grpc
- OpenAPI
Expressの知見も活かせる
- ExpressがベースなのでNestが用意していない機能が必要になった場合は、Expressのmiddlewareの知見が活かせる
- Fastifyを選ぶとFastifyのライブラリが充実していなかったりもするらしい
DXの観点
- 好み分かれるかもだがデコレータで色々指定できるのは楽だった
- コード量は減るが増えると煩雑になりそう。
- ExceptionFiltersやDTO,Pipesといった機能を使用してのバリデーションが便利だった
- Interceptors,Guardsはまだ触れてないが認証部分はGuardsを使用するといい感じに実装できる
- Nest CLIでファイル作成とかできる
デメリット・不安点
- Expressのような柔軟性がない。ディレクトリの構造がファイル単位で予め決まっているので、自由なアーキテクチャでの開発には向かないかも
- Rails、LaravelやDjangoと比較してユーザが少なく、情報量も少ない
- 日本語での情報がちょい少ない
- AngularやTypeScriptに慣れていれば習得はそこまで難しくないが、慣れていない場合は学習コストが多少かかるかも
- 階層化を重ねるとディレクトリの構造が複雑になり、Moduleの依存関係を把握しづらくなる
- デコレータの設定が煩雑
Discussion