234
139

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 3 years have passed since last update.

触って覚える NestJS のアーキテクチャの基本

Last updated at Posted at 2019-12-01

この記事は NestJS アドベントカレンダー一日目の記事です。

記念すべき一記事目ということで、今回は NestJS の基本的な構造について、概念を理解し、実際に触ってみるまでを紹介したいと思います。

NestJS の概念的な部分においての概要は下記スライドをご参照ください。前提知識として一読いただいた上で、手を動かしていただけると

はじめに

実際の開発に入る前に、いくつか共有事項があります。

検証環境について

macOS 上で、執筆時点での Node.js の LTS である v12.13.1 を前提とします。

この記事で学ぶことについて

NestJS は非常に多くの機能を有しているので、今回は核となる一部の機能と実装例のみを紹介します。

具体的には、 CLI で初期化時に生まれる Service / Module / Controller だけで簡単な GET リクエストを返却する アプリケーション開発をゴールとしたいと思います。

具体的には何かしらの掲示板を対象とし、タイトル・本文・削除用パスワードがあるようなものの GET 処理を実装したいと思います。具体的には、こんなデータを配列にして返すようなもので良いかなと思います。

なお、今回は機能の実装から、デフォルトでついてくるテストの修正まで行います。

types.ts
type Item = {
  id: number;
  title: string;
  body: string;
  deletePassword: string;
}

完成品のレポジトリ

なお、今回の完成コードが全て動くサンプルを GitHub に用意しています。今回のアドベントカレンダーのコードは、可能な限りこのレポジトリに集約するため、 Star / Watch しておくと様子をみることができて便利です。

プロジェクトの初期化

概念を理解するためにも、まずは NestJS でプロジェクトを作ってみます。NestJS は、オフィシャルにスキャフォールディングなどを行ってくれる CLI ツールを提供しています。

初めて開発する人は、 CLI から初めることをオススメいたします。今回は npx 経由で利用します。

$ npx -p @nestjs/cli nest new nest-project-day1

質問には好きに答えたあと、最終的にこの結果が出たら OK です。


$ cd nest-project-day1
$ yarn run start


                          Thanks for installing Nest 🙏
                 Please consider donating to our open collective
                        to help us maintain this package.


               🍷  Donate: https://opencollective.com/nest

ここまでいったら、プロジェクトを起動します。プロジェクトの起動コマンドは yarn start:dev です。

> yarn start:dev
yarn run v1.15.2
$ tsc-watch -p tsconfig.build.json --onSuccess "node dist/main.js"

22:28:40 - Starting compilation in watch mode...
22:28:45 - Found 0 errors. Watching for file changes.
[Nest] 56715   - 2019-11-30 22:28:46   [NestFactory] Starting Nest application...
[Nest] 56715   - 2019-11-30 22:28:46   [InstanceLoader] AppModule dependencies initialized +28ms
[Nest] 56715   - 2019-11-30 22:28:46   [RoutesResolver] AppController {/}: +13ms
[Nest] 56715   - 2019-11-30 22:28:46   [RouterExplorer] Mapped {/, GET} route +8ms
[Nest] 56715   - 2019-11-30 22:28:46   [NestApplication] Nest application successfully started +3ms

特に何も出ないので分かりづらいですが、 localhost:3000 にてサーバーが立ち上がっています。試しに curl で叩いてみます。

> curl -L -v http://localhost:3000
* Rebuilt URL to: http://localhost:3000/
*   Trying ::1...
* TCP_NODELAY set
* Connected to localhost (::1) port 3000 (#0)
> GET / HTTP/1.1
> Host: localhost:3000
> User-Agent: curl/7.54.0
> Accept: */*
>
< HTTP/1.1 200 OK
< X-Powered-By: Express
< Content-Type: text/html; charset=utf-8
< Content-Length: 12
< ETag: W/"c-Lve95gjOVATpfV8EL5X4nxwjKHE"
< Date: Sat, 30 Nov 2019 13:31:13 GMT
< Connection: keep-alive
<
* Connection #0 to host localhost left intact
Hello World!⏎

Hello World! と書かれているので問題なさそうですね。これでプロジェクトの作成は完了です。

なお、この時点でテストも用意されているので、 yarn test も実行できます。通常のテストの場合、 Unit Test だけが実行されるようになっています。

> yarn test
yarn run v1.15.2
$ jest
 PASS  src/app.controller.spec.ts
  AppController
    root
      ✓ should return "Hello World!" (13ms)

Test Suites: 1 passed, 1 total
Tests:       1 passed, 1 total
Snapshots:   0 total
Time:        3.81s
Ran all test suites.
✨  Done in 5.84s.

プロジェクトの構成について

さて、動くことがわかったので、さっそくプロジェクトを見てみます。プロジェクトを立ち上げると、以下のような構成となっているはずです。

スクリーンショット 2019-11-30 22.55.12.png

パット見でわかることは、 Service / Module / Controller の概念があること、ユニットテストと E2E テスト両方の環境が整備されていること。くらいでしょうか。

tsconfig.json や tslint.json、 package.json など、見慣れたファイルも多いので、今回は Nest 特有のものについてのみご紹介いたします。

src/app.service.ts

いわゆる「サービスクラス」を定義するためのファイルです。

NestJS は、 Web アプリケーションのサーバーとしての機能を提供するために作られているため、MVC や MVP といった画一的な開発手法を提供するわけではありません。平たく言うといわゆる「モデル」がはじめはないですし、View も実質的にありません。

この設計方法は却ってシンプルで、 Service 同士のつながりとしてアプリケーションが構築され、 Controller へと実体に即した粒度で機能が提供されるためです。

ややこしいことを考えず、はじめは「メインロジックを書くところ」と考えてもらっても構いません。初期化したばかりのプロジェクトでは、Hello World を出力する関数が定義されています。

app.service.ts
import { Injectable } from '@nestjs/common';

@Injectable()
export class AppService {
  getHello(): string {
    return 'Hello World!';
  }
}

通常のクラスである他に、 @Injectable というデコレーターが定義されています。これは、このクラスが依存関係の解決において注入可能なオブジェクトであることを示しており、このデコレーターを定義することで、後述する Module において、自動的な依存関係の解決を行ってくれます。

src/app.controller.ts

Controller はそのまま Controller です。薄く保ち、リクエストに関するレスポンス返却への関心を持っておくところです。

特徴的なのはコンストラクタ。 NestJS では、フレームワークの機能により、コンストラクタにてクラスの型をもったインスタンスを引数に定義すると、自動的にそのインスタンスを this に格納してくれます。この場合、 this.appServiceAppService が生えてきます。

これをコンストラクタインジェクションと呼びます。
「Service を勝手に new してくれるやつ」くらいの認識でも大丈夫です。

app.controller.ts
import { Controller, Get } from '@nestjs/common';
import { AppService } from './app.service';

@Controller()
export class AppController {
  constructor(private readonly appService: AppService) {}

  @Get()
  getHello(): string {
    return this.appService.getHello();
  }
}

src/app.module.ts

最後がモジュールです。とりあえず使う分には、「Controller と Service をまとめるところ」と認識してもらえれば OK です。実際にはそれ以外も関わってきますが、依存関係の解消で出てくるという認識で OK です。

app.module.ts
import { Module } from '@nestjs/common';
import { AppController } from './app.controller';
import { AppService } from './app.service';

@Module({
  imports: [],
  controllers: [AppController],
  providers: [AppService],
})
export class AppModule {}

先程 Service に対して @Injectable を定義するという説明をしましたが、これは Module から利用されることによって価値を生み出します。

NestJS は DI を基本概念としていますが、 @Injectable を定義した Service は、 Module の Provider として定義されることによって、 Controller への依存オブジェクトの注入を実行します。これによって Service と Controller が密結合であることが解消され、再利用しやすくクリーンなコードとなります。

つまりは、NestJS には Controller かどうか、Module かどうか、そうでないものは Service として実装し、 Provider として提供されるという世界観であることを認識していただければ OK です。一番汎用的な枠が Provider となります。

最後に、実際のアプリケーションでは、 app ではなく、例えば投稿データなら item.module.ts など、それぞれのドメイン概念と結びついた命名を行うことが一般的です。そして切り出したモジュールは、 そのモジュール単位で読み込みが可能 ということです。 item に関するものをまとめてモジュールとして切り出して、 AppModule からは ItemModule を読み込むのみ。という実装も可能となります。なお、今回は割愛します。

Service にデータストアへのアクセスと型定義を実装する

それではいよいよ開発をはじめます。
今回は冒頭で紹介した、以下のような Item を返却したいとします。

types.ts
type Item = {
  id: number;
  title: string;
  body: string;
  deletePassword: string;
}

これはこれで良いですが、流石に API に投稿の削除用パスワードがあるのはまずそうです。ですので今回は、 Item のほかに PublicItem 型を付与してみます。

Service にはロジックを書いて良いので、ガリガリ書いていきましょう。アプリケーションを作る場合、実際は RDB や NoSQL にデータを格納するかと思いますが、今回は GET だけなので、オンメモリで固定のデータを定義しておきます。

実際に書くとこんな感じでしょうか。

app.service.ts
import { Injectable } from '@nestjs/common';

export interface Item {
  id: number;
  title: string;
  body: string;
  deletePassword: string;
}

export type PublicItem = Omit<Item, 'deletePassword'>;

const items: Item[] = [
  {
    id: 1,
    title: 'Item title',
    body: 'Hello, World',
    deletePassword: '1234',
  },
];

@Injectable()
export class AppService {

  getAllItems(): Item[] {
    return [...items];
  }

  getPublicItems(): PublicItem[] {
    return this.getAllItems().map((item) => {
      const publicItem = {...item};
      delete publicItem.deletePassword;
      return publicItem;
    });
  }
}

getAllItems が private でも良さそうな気がしますが、ロジック上必要なことも多そうなので両方口は開けておきます。

これで Service は完成です。Service に関しては、特に悩ましいところはないかと思います。

Controller から Service へとアクセスし、リクエストを定義する

次に、 Controller から Service の機能を呼び出しています。既に Service のインスタンスはコンストラクタインジェクションにて this へとわたってきているため、 API コールするだけです。

折角なので @Get デコレーターの内容を書き換えて、 /items でアクセスするようにしておきました。

app.controller.ts
import { Controller, Get } from '@nestjs/common';
import { AppService, PublicItem } from './app.service';

@Controller()
export class AppController {
  constructor(private readonly appService: AppService) {}

  @Get('items')
  getItems(): PublicItem[] {
    return this.appService.getPublicItems();
  }
}

これだけで実装は完了です。実際のインスタンスを生成するコードを書いているわけでもないのに呼び出せてしまうのは、不思議な人もいるかもしれませんが、これが Module の効果です。

curl / ブラウザから動作確認する

実装できたら、動作確認してみましょう。はじめと同じく、 curl でリクエストを飛ばしてみます。
エンドポイントを変更したので、次の対象は /items で。

>
curl -L -v http://localhost:3000/items
*   Trying ::1...
* TCP_NODELAY set
* Connected to localhost (::1) port 3000 (#0)
> GET /items HTTP/1.1
> Host: localhost:3000
> User-Agent: curl/7.54.0
> Accept: */*
>
< HTTP/1.1 200 OK
< X-Powered-By: Express
< Content-Type: application/json; charset=utf-8
< Content-Length: 53
< ETag: W/"35-VJo6Zdsd0lG6FctIGAHbOh4z/mI"
< Date: Sat, 30 Nov 2019 16:14:26 GMT
< Connection: keep-alive
<
* Connection #0 to host localhost left intact
[{"id":1,"title":"Item title","body":"Hello, World"}]⏎

問題なく帰ってきていれば OK です。これで Service の内容をもとに、Controller からレスポンスを出力することができました。勿論ブラウザからも確認できます。ブラウザでも http://localhost:3000/items にてアクセス可能です。

スクリーンショット 2019-12-01 1.14.51.png

NestJS では、このように関数の戻り値をベースにレスポンスを定義できます。Express などでは Response オブジェクトや Context オブジェクトに対してのメソッド呼び出しで行っていたものが、関数の実行結果ベースになり、より直感的な動作となったと言えます。

また、異常系のレスポンスについては例外を発生させることで実現しますが、こちらは 10 日の記事で紹介予定です。早く知りたいというかたは、以下のオフィシャルのドキュメントを参考にしてください。

Controller のテストを修正する

さて、ここまでで NestJS で Service コードを定義し、 Controller から呼び出すまでは体験できました。が、これで完成とは言えません。

なぜなら、はじめに動作させた yarn test が、現状では動かなくなっているためです。折角テスティングまで含めてお膳立てされているので、今回はテストの修正も行ってみたいと思います。

まずは簡単な例として、 describe('/root'describe('/items' に変えてテストを修正してみました。

app.controller.spec.ts
import { Test, TestingModule } from '@nestjs/testing';
import { AppController } from './app.controller';
import { AppService } from './app.service';

describe('AppController', () => {
  let appController: AppController;

  beforeEach(async () => {
    const app: TestingModule = await Test.createTestingModule({
      controllers: [AppController],
      providers: [AppService],
    }).compile();

    appController = app.get<AppController>(AppController);
  });

  describe('/items', () => {
    it('should return public items', () => {
      expect(appController.getItems()).toHaveLength(2);
    });
  });
});

Controller から Service の機能が呼ばれ、 2 つのデータが帰ってきていることがわかります。これで実際に Controller でページネーションの処理が出てきたときなんかも、データの数を通して Controller 側の仕事がわかりやすそうですね。

Jest のモックを利用して Service に依存しない Controller Spec を定義する

さて、 Controller のテストをひとまずは修正できましたが、実際のアプリケーション開発の場合、Serviceには RDB や NoSQL データストアに依存したコードが出てくるはずです。

今回はハードコーディングした項目であるため問題なかったですが、Controller のテストに RDB の文脈が出てくることや、 Service の実装に強く依存するのは、現状だと Controller の単体テストとなりえていません。

今回は AppModule しか登場しませんが、例えば Qiita のようなサービスであれば、 User や Item、Like がそれぞれ密接に紐づくはずであり、そういった場合は適切にモックを用意したくなるはずです。

こうした場合、NestJS がコンストラクタインジェクションを行っている利点を活かすことができます。

NestJS の DI 機能は、 Module にて依存関係が暗黙的に解決されるだけで、クラスの初期化時にそれぞれの Service へのインスタンスを要求しています。

そのため、リフレクションやダーティな機能を利用せずとも、外部から Service を簡単に定義でき、その上で注入 Service に mock を仕込むなど、柔軟な取り扱いが可能となります。

実際にコードを書いてみましょう。app.controller.spec.ts を、以下のように変更します。

app.controller.spec.ts
import { Test, TestingModule } from '@nestjs/testing';
import { AppController } from './app.controller';
import { AppService, PublicItem } from './app.service';

describe('AppController', () => {
  let appController: AppController;
  let appService: AppService;

  beforeEach(async () => {
    appService = new AppService();
    appController = new AppController(appService);
  });

  describe('/items', () => {
    it('should return public items', () => {
      jest.spyOn(appService, 'getPublicItems').mockImplementation(() => {
        const item: PublicItem = {
          id: 1,
          title: 'Mock Title',
          body: 'Mock Body',
        };
        return [item];
      });
      expect(appController.getItems()).toHaveLength(1);
    });
  });
});

変わった点としては、 appController = new AppController(appService); の形で Controller を生成するようにしたことです。これまではモジュールを作ってモジュール単位で解決していましたが、そこを自身でインジェクションターゲットを設定する形で実装しました。

こうすることで、実際の Service にはアクセスすることがなく、Controller の記述ロジックだけをテストすることが可能です。

気をつけておいてもらいたいのは、依存を一部でも切るということは、その部分のテストは別途個別でユニットテストを書く必要があるということです。今回であれば、 getItems は Service の Spec としてテストが書かれている必要があります。

Jest のモックを利用したテストについてより詳細に知りたいかたは、今後の記事もしくは公式の Testing を参考にしてください。

Service の Unit Test を記述する

最後に、前述のモック化によってテストされないコードが生まれた Service に対して Unit Test を記述して終わります。

こちらは Controller とは違って特別なコード記述を必要としない分シンプルですが、デフォルトではテストコードが作られていません。

そのため、以下のコードを参考に、 app.service.spec.ts を作成し、実行してください。

app.service.spec.ts
import { Test, TestingModule } from '@nestjs/testing';
import { AppService } from './app.service';

describe('AppService', () => {
  let service: AppService;

  beforeEach(async () => {
    const module: TestingModule = await Test.createTestingModule({
      providers: [AppService],
    }).compile();

    service = module.get<AppService>(AppService);
  });

  it('should be defined', () => {
    expect(service).toBeDefined();
  });

  describe('getPublicItems', () => {
    it('should have deletePassword removed', () => {
      const publicItems = service.getPublicItems();
      expect(publicItems.every((item) => !('deletePassword' in item))).toBe(true);
    });
  });
});

テストを実行し、両方 PASS していれば OK です。これでひとまず NestJS でのはじめてのエンドポイント作成が完了しました。

> yarn test
yarn run v1.15.2
$ jest
 PASS  src/app.controller.spec.ts
 PASS  src/app.service.spec.ts

Test Suites: 2 passed, 2 total
Tests:       3 passed, 3 total
Snapshots:   0 total
Time:        4.738s
Ran all test suites.
✨  Done in 6.67s.

おわりに

今回は初日ということで、一番基本的なモジュール構造を利用した機能開発から、デフォルトで提供されているテストの修正までを行っていました。

もちろん NestJS にはこれだけではない非常に多くの機能が存在します。例えばリクエストの受け付けだけでも、リクエストのデータ構造を定義する DTO、そのデータ構造にバリデーションをかけるバリデータ、リクエスト時に認証を行う Guard などの機能は初日では紹介していません。

NestJS の膨大な機能を自分でキャッチアップするのは骨が折れる作業です。そのため、それぞれの機能の詳細については、今後のアドベントカレンダーで徐々に紹介していくことができればと思います。

明日は @euxn23 さんが Module と DI について詳しく紹介する予定です。

234
139
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
234
139

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?