19
6

More than 1 year has passed since last update.

【NestJS】これから学習する人向けの基本内容まとめ

Posted at

はじめに

最近転職が決まりました。
転職先ではフルスタックではなくフロントエンド専業で働くことになるので、プライベートではバックエンドに磨きをかけることにしました。
これまでPHP(Laravel)を仕事で使ってきたのですが、新しい言語やフレームワークを学んでみたかったのでNestJSを勉強してみることにしてみました。
流行りに乗ってGolangを勉強してみようとも思ったのですが、TypeScriptでバックエンドを書くという点に魅力を感じたのでNestJSを選びました。

基本内容

NestJSはTypeScriptでバックエンドを書けるNode.jsのフレームワークです。
Webサーバの実装にExpressやFastifyを使うことができ、@nestjs/platform-expressなどのモジュールをいれてあげるだけで連携することができます。

モジュール

NestJSを使うにあたって必須となるモジュールには以下のものがあります。

  • @nestjs/core, @nestjs/common: Nestの実装で必須の関数やクラスを使うために必要
  • @nestjs/platform-express: NestでExpressを使う(HTTPリクエストのハンドリングなど)ために必要
  • reflect-metadata: デコレータの処理を助けるために必要
  • typescript: TypeScriptでNestを書くために必要

ツール

HTTPリクエストを受けたとき、Nestはサーバサイドの各処理の段階におけるツールをもっています。
それらは以下の順序で使われます。

  • Pipe: リクエストデータのバリデーション
  • Guard: ユーザが認証済であるかどうかの確認
  • Controller: 特定の関数へのリクエストのルーティング
  • Service: ビジネスロジックの実行
  • Repository: データベースへのアクセス

この他にもさまざまなツールがあります。

  • Modules: コードをグループ化
  • Filters: リクエストハンドリング中に起こったエラーの処理
  • Interceptors: リクエストやレスポンスに追加のロジックを付加

Laravelでいうと、PipeはRequest、InterceptorsはMiddlewareのイメージです。

Nest CLI

Nest CLIを使うことでベースとなるプロジェクトを簡単に作成することができます。

$ npm i -g @nestjs/cli
$ nest new [プロジェクト名]

モジュールやコントローラなどのツールもコマンドで作成することができます。

$ nest generate module [モジュール名]
$ nest generate controller [コントローラ名]

また、ローカルサーバの起動コマンドnpm run startもデフォルトで用意されているので、npx ts-node-dev src/main.tsと打つ必要がありません。

実装

JSONファイルにメッセージを保存したり取り出すような実装をしてみます。

メッセージの保存(POST)と全メッセージの取得(GET)のリクエストは、localhost:3000/messagesへ行い、個別メッセージの取得(GET)のリクエストはlocalhost:3000/messages/:idへ行います。

簡単のため、Guardは省き、Controller、Service、Repositoryを実装します。
メッセージ保存(POST)のときのみ、重複したIDのメッセージが作成されないようなバリデーションを行うために、Pipeを実装します。

Controller、Service、Repository、Pipeはすべて1つのModuleにまとめます。

プロジェクト作成

プロジェクトを新規で作成します。

$ nest new messages

src/messages以下にmessages.module.tsというモジュールを作成します。

$ nest generate module messages

Controllerの作成

Controllerを作成します。

$ nest generate controller messages/messages --flat

messages/messagesとすることで、messagesディレクトリ以下にmessages.controller.tsが作成されます。
また、--flatとすることで、controllerディレクトリが作成されないようにすることができます。

クラスには@Controller()というデコレータを付加します。
@Controller('messages')とすることで、localhost:3000/messagesにリクエストが送られるようになります。
クラスのメソッドを3つ作成するのですが、このとき@Get() @Post()というデコレータを付加します。

createMessage()はPostするリクエストボディを引数にもつため、@Bodyというデコレータを付加します。

getMessage()では個別のメッセージを取得するため、引数にリクエストパラメータをもつことを@Param('id')を付加することで示します。
@Get('/:id')とすることで、localhost:3000/messages/:idにリクエストが送られるようにします。

messages.conroller.ts
import { Controller, Get, Post, Body, Param } from '@nestjs/common';

@Controller('messages')
export class MessagesController {
  constructor() {}

  @Get()
  listMessages() {}

  @Post()
  createMessage(@Body() body: any) {}

  @Get('/:id')
  getMessage(@Param('id') id: string) {}

バリデーションの追加(Pipeの作成)

前述した通り、バリデーションはPipeで行います。
Pipeはリクエストがコントローラに届くまでの間で実行されます。

バリデーションを実行する手順は以下のようになります。
1は1回のみで十分で、2-4についてはバリデーションを追加するたびに必要です。

  1. Nestにglobal validationを使用することを伝える
  2. Data Transfer Object(DTO)を作成しClassベースでリクエストボディの型定義を行う
  3. class-validatorで作成したClass(DTO)にバリデーションルールを追加する
  4. リクエストハンドラ(コントローラ)にDTOを適用する

1については、main.tsapp.useGlobalPipes(new ValidationPipe()) 加えるだけで完了します。

main.ts
import { NestFactory } from '@nestjs/core';
import { ValidationPipe } from '@nestjs/common';
import { MessagesModule } from './messages/messages.module';

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

2以降を実装するにあたり、以下のモジュールを追加します。

npm install class-validator class-transformer

作成するDTOは以下のようになります。
@IsString()というデコレータを加えることで、リクエストボディのcontentがstring以外だったらエラーを返すようになります。

create-message.dto.ts
import { IsString } from 'class-validator';

export class CreateMessageDto {
  @IsString()
  content: string;
}

コントローラのメソッドの引数にDTOを注釈することでバリデーションルールが適用されます。
ちなみに、@Body()デコレータはbodyがリクエストボディであることを示します。

  @Post()
  createMessage(@Body() body: CreateMessageDto) {
    console.log(body);
  }

ここで疑問となるのが、「なんでリクエストボディであるbodyにDTOを注釈できるのか?」という点です。
これは、リクエストボディがValidation Pipeにわたったとき、先にインストールしたclass-transformerによって、JSオブジェクトからClassのインスタンスに変換されているためです。

ServiceとRepositoryの作成

役割が曖昧なところですが、基本的にServiceにはビジネスロジック、RepositoryにはDBに近い部分のロジックを記述します。

Controller内におけるServiceの呼び出し、Service内におけるRepositoryの呼び出しにおいては、依存関係逆転の原則に反さないように気をつける必要があります。
つまり、Contollerクラス内でServiceクラスのインスタンスを作成しまうなどのように、ハイレベルのモジュールがローレベルのモジュールにもろに依存するような実装は避けなければなりません。

直接依存を防ぐために、インターフェースの実装や、呼び出し側のconstructorでインスタンスを読み込む(依存性の注入)などといった実装が必要となります。
このような実装にすることで、Serviceで読み込むRepositoryを、本番DB操作用MessagesRepository、テストDB操作用FakeRepositoryなどに簡単に切り替えられるようになります。

依存性の注入(DI)の実装

DIを実装するにあたり、DIコンテナを作成します。
DIコンテナというのは、どのServiceクラスからRepositoryクラスを呼び出しているのかといった関係性やこれらのクラスのインスタンスを登録して、必要なときに利用できるようにまとめたリストのようなものです。

例えば、DIコンテナがControllerのインスタンスを作成するフローは以下のようになります。

  1. appを起動したとき、Controller以外のクラスおよびそのクラスとdependency(呼び出されるクラス)の関係性をDIコンテナに登録する
  2. リクエストに備えてControllerのインスタンスを作成しようとする
  3. Controllerのインスタンス作成にあたり、DIコンテナはControllerで呼び出すServiceクラスが必要だと判断する
  4. まずはServiceで呼び出しているRepositoryクラスのインスタンスを作成してDIコンテナに登録する
  5. Serviceクラスのインスタンスを作成してDIコンテナに登録する
  6. ここまでにDIコンテナに登録したクラスやインスタンスからControllerのインスタンスを作成する

一見複雑な処理に思えますが、Nestだとシンプルな実装になります。
1については、@Injectable()というデコレータをクラスに付加して、Moduleのprovidersに追加するだけで完了します。
1を実装していれば、2以降はNestが勝手にやってくれます。

messages.service.ts
import { Injectable } from '@nestjs/common';
import { MessagesRepository } from './messages.repository';

@Injectable()
export class MessagesService {
  constructor(public messagesRepo: MessagesRepository) {}

  findOne(id: string) {
    return this.messagesRepo.findOne(id);
  }

  findAll() {
    return this.messagesRepo.findAll();
  }

  create(content: string) {
    return this.messagesRepo.create(content);
  }
}
messages.repository.ts
import { Injectable } from '@nestjs/common';
import { promises } from 'fs';

const { readFile, writeFile } = promises;

@Injectable()
export class MessagesRepository {
  async findOne(id: string) {
    const contents = await readFile('messages.json', 'utf8');
    const messages = JSON.parse(contents);

    return messages[id];
  }

  async findAll() {
    const contents = await readFile('messages.json', 'utf8');
    const messages = JSON.parse(contents);

    return messages;
  }

  async create(content: string) {
    const contents = await readFile('messages.json', 'utf8');
    const messages = JSON.parse(contents);

    const id = Math.floor(Math.random() * 999);

    messages[id] = { id, content };

    await writeFile('messages.json', JSON.stringify(messages));
  }
}
messages.module.ts
import { Module } from '@nestjs/common';
import { MessagesController } from './messages.controller';
import { MessagesService } from './messages.service';
import { MessagesRepository } from './messages.repository';

@Module({
  controllers: [MessagesController],
  providers: [MessagesService, MessagesRepository],
})
export class MessagesModule {}

また、最終的なControllerの中身は以下のようになります。
getMessage()で指定したIDのメッセージが見つからなかったのときのエラー処理NotFoundExceptionを追加しています。

messages.conroller.ts
import {
  Controller,
  Get,
  Post,
  Body,
  Param,
  NotFoundException,
} from '@nestjs/common';
import { CreateMessageDto } from './dtos/create-message.dto';
import { MessagesService } from './messages.service';

@Controller('messages')
export class MessagesController {
  constructor(public messagesService: MessagesService) {}

  @Get()
  listMessages() {
    return this.messagesService.findAll();
  }

  @Post()
  createMessage(@Body() body: CreateMessageDto) {
    return this.messagesService.create(body.content);
  }

  @Get('/:id')
  async getMessage(@Param('id') id: string) {
    const message = await this.messagesService.findOne(id);

    if (!message) {
      throw new NotFoundException('message not found');
    }

    return message;
  }
}

参考資料

19
6
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
19
6