CLOVER🍀

That was when it all began.

Node.jsのサーバーサイドフレームワーク、NestJSを試す

これは、なにをしたくて書いたもの?

Node.jsのサーバーサイドフレームワークといえば、ExpressやFastifyあたりが有名なのかなと思っていたのですが。

「The State of JS 2021」や「JavaScript Rizing Stars」を見ていて、Nest.jsというものを知ったのでちょっと試しておこうかな、と。

The State of JS 2021: Back-end Frameworks

The State of JS 2021: バックエンドフレームワーク

「The State of JS 2021」公開。最も使われているフロントエンドのライブラリはReact、バックエンドはExpress、ビルドツールはwebpackなど - Publickey

2021 JavaScript Rising Stars

NestJS

Nest(NestJS)は、サーバーサイドアプリケーションのためのフレームワークです。

Hello, nest!
A progressive Node.js framework for building efficient, reliable and scalable server-side applications.

NestJS - A progressive Node.js framework

ドキュメントはこちら。

Documentation | NestJS - A progressive Node.js framework

サンプルはこちら。

https://github.com/nestjs/nest/tree/master/sample

NestJSには、以下のような特徴があります。

  • TypeScriptのサポート
  • OOP、FP、FRPの組み合わせを使用
  • 内部の実行エンジンをExpress(デフォルト)とFastifyから選択可能

Controllerの書き方を見ていると、(Javaをよく使う人としては)どこかで見たことがあるようなことがあるような気がするのですが…。

Controllers | NestJS - A progressive Node.js framework

アーキテクチャーは、Angularに大きく影響を受けているとされています。

The architecture is heavily inspired by Angular.

実行環境は、Node.js 10.13.0以上であればよいみたいです。

Please make sure that Node.js (>= 10.13.0, except for v13) is installed on your operating system.

First steps / Prerequisites

とりあえず、今回は雰囲気を知るために簡単に動かしてみたいと思います。

こちらのFirst stepsに沿って進めてみましょう。

First steps | NestJS - A progressive Node.js framework

環境

今回の環境は、こちら。

$ node --version
v16.14.2


$ npm --version
8.5.0

NestJSプロジェクトを作成する

NestJSプロジェクトの作成方法は、Nest CLIを使う方法とstarterプロジェクトをクローンするかのどちらかになるようです。
※結果は同じだそうです

Introduction / Installation

Nest CLIを使う場合はnpm i -g @nestjs/cliでインストールするようなのですが、ちょっと微妙なのでnpxで実行する方法でいこうと思います。

追記)
こちらの方法も参照。

npxコマンドを使って、未インストールのコマンドを使った時の挙動を確認する - CLOVER🍀

とりあえず、プロジェクト用のディレクトリを作成

$ mkdir nestjs-getting-started
$ cd nestjs-getting-started

次に、NestJSのCLIをインストール。

$ npm i -D @nestjs/cli

インストールされたバージョンは、こちら。

$ npx nest --version
8.2.4

CLIのドキュメントは、こちら。

Overview - CLI | NestJS - A progressive Node.js framework

nest newまたはnest generateでプロジェクトを作成するようですが、どちらを使用するかでプロジェクトの構造が変わるようです。

CLI / Project structure

newで作成する場合は標準モード、generateで作成する場合はモノレポモード、となるようです。

今回は標準モードを使うことにします。

ヘルプは以下のようにCLI全体と

$ npx nest --help
Usage: nest <command> [options]

Options:
  -v, --version                                   Output the current version.
  -h, --help                                      Output usage information.

Commands:
  new|n [options] [name]                          Generate Nest application.
  build [options] [app]                           Build Nest application.
  start [options] [app]                           Run Nest application.
  info|i                                          Display Nest project details.
  update|u [options]                              Update Nest dependencies.
  add [options] <library>                         Adds support for an external library to your project.
  generate|g [options] <schematic> [name] [path]  Generate a Nest element.
    Schematics available on @nestjs/schematics collection:
      ┌───────────────┬─────────────┬──────────────────────────────────────────────┐
      │ name          │ alias       │ description                                  │
      │ application   │ application │ Generate a new application workspace         │
      │ class         │ cl          │ Generate a new class                         │
      │ configuration │ config      │ Generate a CLI configuration file            │
      │ controller    │ co          │ Generate a controller declaration            │
      │ decorator     │ d           │ Generate a custom decorator                  │
      │ filter        │ f           │ Generate a filter declaration                │
      │ gateway       │ ga          │ Generate a gateway declaration               │
      │ guard         │ gu          │ Generate a guard declaration                 │
      │ interceptor   │ in          │ Generate an interceptor declaration          │
      │ interface     │ interface   │ Generate an interface                        │
      │ middleware    │ mi          │ Generate a middleware declaration            │
      │ module        │ mo          │ Generate a module declaration                │
      │ pipe          │ pi          │ Generate a pipe declaration                  │
      │ provider      │ pr          │ Generate a provider declaration              │
      │ resolver      │ r           │ Generate a GraphQL resolver declaration      │
      │ service       │ s           │ Generate a service declaration               │
      │ library       │ lib         │ Generate a new library within a monorepo     │
      │ sub-app       │ app         │ Generate a new application within a monorepo │
      │ resource      │ res         │ Generate a new CRUD resource                 │
      └───────────────┴─────────────┴──────────────────────────────────────────────┘

各サブコマンドに対して見ることができます。

$ npx nest new --help
Usage: nest new|n [options] [name]

Generate Nest application.

Options:
  --directory [directory]                  Specify the destination directory
  -d, --dry-run                            Report actions that would be performed without writing out results.
  -g, --skip-git                           Skip git repository initialization.
  -s, --skip-install                       Skip package installation.
  -p, --package-manager [package-manager]  Specify package manager.
  -l, --language [language]                Programming language to be used (TypeScript or JavaScript).
  -c, --collection [collectionName]        Schematics collection to use.
  --strict                                 Enables strict mode in TypeScript.
  -h, --help                               Output usage information.

package.jsonが残ったままだと、newで失敗するので1度削除。

$ rm package*

残っていると、こうなります…。

$ npx nest new .
⚡  We will scaffold your app in a few seconds..

Error: A merge conflicted on path "/package.json".
    at /path/to/nestjs-getting-started/node_modules/@angular-devkit/schematics/src/tree/host-tree.js:141:35
    at Array.forEach (<anonymous>)
    at HostTree.merge (/path/to/nestjs-getting-started/node_modules/@angular-devkit/schematics/src/tree/host-tree.js:130:23)
    at MapSubscriber.project (/path/to/nestjs-getting-started/node_modules/@nestjs/schematics/node_modules/@angular-devkit/schematics/src/rules/base.js:54:103)
    at MapSubscriber._next (/path/to/nestjs-getting-started/node_modules/rxjs/internal/operators/map.js:49:35)
    at MapSubscriber.Subscriber.next (/path/to/nestjs-getting-started/node_modules/rxjs/internal/Subscriber.js:66:18)
    at TapSubscriber._next (/path/to/nestjs-getting-started/node_modules/rxjs/internal/operators/tap.js:65:26)
    at TapSubscriber.Subscriber.next (/path/to/nestjs-getting-started/node_modules/rxjs/internal/Subscriber.js:66:18)
    at ThrowIfEmptySubscriber._next (/path/to/nestjs-getting-started/node_modules/rxjs/internal/operators/throwIfEmpty.js:44:26)
    at ThrowIfEmptySubscriber.Subscriber.next (/path/to/nestjs-getting-started/node_modules/rxjs/internal/Subscriber.js:66:18)

Failed to execute command: node @nestjs/schematics:application --name=. --directory=undefined --no-dry-run --no-skip-git --no-strict --package-manager=undefined --language="ts" --collection="@nestjs/schematics"

通常は、nest new [アプリケーション名]で指定しますが、アプリケーション名を.にすることでカレントディレクトリを対象にできます。

$ npx nest new .

作成されるファイルが表示され、パッケージマネージャーの選択を求められます。

⚡  We will scaffold your app in a few seconds..

CREATE .eslintrc.js (631 bytes)
CREATE .prettierrc (51 bytes)
CREATE README.md (3339 bytes)
CREATE nest-cli.json (64 bytes)
CREATE package.json (2011 bytes)
CREATE tsconfig.build.json (97 bytes)
CREATE tsconfig.json (546 bytes)
CREATE src/app.controller.spec.ts (617 bytes)
CREATE src/app.controller.ts (274 bytes)
CREATE src/app.module.ts (249 bytes)
CREATE src/app.service.ts (142 bytes)
CREATE src/main.ts (208 bytes)
CREATE test/app.e2e-spec.ts (630 bytes)
CREATE test/jest-e2e.json (183 bytes)

? Which package manager would you ❤️  to use? (Use arrow keys)
❯ npm
  yarn
  pnpm

今回はnpmを選択。

しばらく待っていると、完了します。

✔ Installation in progress... ☕

🚀  Successfully created a new project
👉  Get started with the following commands:

$ cd .
$ npm run start


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


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

npm run startすればよいみたいですね。

ちなみに、対話形式ではなくコマンドラインオプションで指定したければ、以下のような感じになります。

$ npx nest new --language TypeScript --package-manager npm [アプリケーション名]

--languageは付与しなくてもデフォルトでTypeScriptなのですが、なんとなく…。

nest new後のディレクトリは、こんな感じです。

$ ll
合計 648
drwxrwxr-x   6 xxxxx xxxxx   4096  3月 22 23:59 ./
drwxrwxr-x   3 xxxxx xxxxx   4096  3月 22 23:54 ../
-rw-rw-r--   1 xxxxx xxxxx    631  3月 22 23:55 .eslintrc.js
drwxrwxr-x   7 xxxxx xxxxx   4096  3月 22 23:56 .git/
-rw-rw-r--   1 xxxxx xxxxx    391  3月 22 23:56 .gitignore
-rw-rw-r--   1 xxxxx xxxxx     51  3月 22 23:55 .prettierrc
-rw-rw-r--   1 xxxxx xxxxx   3339  3月 22 23:55 README.md
-rw-rw-r--   1 xxxxx xxxxx     64  3月 22 23:55 nest-cli.json
drwxrwxr-x 487 xxxxx xxxxx  20480  3月 22 23:56 node_modules/
-rw-rw-r--   1 xxxxx xxxxx 584349  3月 22 23:56 package-lock.json
-rw-rw-r--   1 xxxxx xxxxx   2011  3月 22 23:55 package.json
drwxrwxr-x   2 xxxxx xxxxx   4096  3月 22 23:55 src/
drwxrwxr-x   2 xxxxx xxxxx   4096  3月 22 23:55 test/
-rw-rw-r--   1 xxxxx xxxxx     97  3月 22 23:55 tsconfig.build.json
-rw-rw-r--   1 xxxxx xxxxx    546  3月 22 23:55 tsconfig.json

ソースコードおよびテストコードが配置されているディレクトリ。

$ tree src test
src
├── app.controller.spec.ts
├── app.controller.ts
├── app.module.ts
├── app.service.ts
└── main.ts
test
├── app.e2e-spec.ts
└── jest-e2e.json

0 directories, 7 files

設定ファイルも見てみましょう。

package.json

{
  "name": "nestjs-getting-started",
  "version": "0.0.1",
  "description": "",
  "author": "",
  "private": true,
  "license": "UNLICENSED",
  "scripts": {
    "prebuild": "rimraf dist",
    "build": "nest build",
    "format": "prettier --write \"src/**/*.ts\" \"test/**/*.ts\"",
    "start": "nest start",
    "start:dev": "nest start --watch",
    "start:debug": "nest start --debug --watch",
    "start:prod": "node dist/main",
    "lint": "eslint \"{src,apps,libs,test}/**/*.ts\" --fix",
    "test": "jest",
    "test:watch": "jest --watch",
    "test:cov": "jest --coverage",
    "test:debug": "node --inspect-brk -r tsconfig-paths/register -r ts-node/register node_modules/.bin/jest --runInBand",
    "test:e2e": "jest --config ./test/jest-e2e.json"
  },
  "dependencies": {
    "@nestjs/common": "^8.0.0",
    "@nestjs/core": "^8.0.0",
    "@nestjs/platform-express": "^8.0.0",
    "reflect-metadata": "^0.1.13",
    "rimraf": "^3.0.2",
    "rxjs": "^7.2.0"
  },
  "devDependencies": {
    "@nestjs/cli": "^8.0.0",
    "@nestjs/schematics": "^8.0.0",
    "@nestjs/testing": "^8.0.0",
    "@types/express": "^4.17.13",
    "@types/jest": "27.4.1",
    "@types/node": "^16.0.0",
    "@types/supertest": "^2.0.11",
    "@typescript-eslint/eslint-plugin": "^5.0.0",
    "@typescript-eslint/parser": "^5.0.0",
    "eslint": "^8.0.1",
    "eslint-config-prettier": "^8.3.0",
    "eslint-plugin-prettier": "^4.0.0",
    "jest": "^27.2.5",
    "prettier": "^2.3.2",
    "source-map-support": "^0.5.20",
    "supertest": "^6.1.3",
    "ts-jest": "^27.0.3",
    "ts-loader": "^9.2.3",
    "ts-node": "^10.0.0",
    "tsconfig-paths": "^3.10.1",
    "typescript": "^4.3.5"
  },
  "jest": {
    "moduleFileExtensions": [
      "js",
      "json",
      "ts"
    ],
    "rootDir": "src",
    "testRegex": ".*\\.spec\\.ts$",
    "transform": {
      "^.+\\.(t|j)s$": "ts-jest"
    },
    "collectCoverageFrom": [
      "**/*.(t|j)s"
    ],
    "coverageDirectory": "../coverage",
    "testEnvironment": "node"
  }
}

nest-cli.json

{
  "collection": "@nestjs/schematics",
  "sourceRoot": "src"
}

tsconfig.json

{
  "compilerOptions": {
    "module": "commonjs",
    "declaration": true,
    "removeComments": true,
    "emitDecoratorMetadata": true,
    "experimentalDecorators": true,
    "allowSyntheticDefaultImports": true,
    "target": "es2017",
    "sourceMap": true,
    "outDir": "./dist",
    "baseUrl": "./",
    "incremental": true,
    "skipLibCheck": true,
    "strictNullChecks": false,
    "noImplicitAny": false,
    "strictBindCallApply": false,
    "forceConsistentCasingInFileNames": false,
    "noFallthroughCasesInSwitch": false
  }
}

tsconfig.build.json

{
  "extends": "./tsconfig.json",
  "exclude": ["node_modules", "test", "dist", "**/*spec.ts"]
}

.prettierrc

{
  "singleQuote": true,
  "trailingComma": "all"
}

.eslintrc.js

module.exports = {
  parser: '@typescript-eslint/parser',
  parserOptions: {
    project: 'tsconfig.json',
    sourceType: 'module',
  },
  plugins: ['@typescript-eslint/eslint-plugin'],
  extends: [
    'plugin:@typescript-eslint/recommended',
    'plugin:prettier/recommended',
  ],
  root: true,
  env: {
    node: true,
    jest: true,
  },
  ignorePatterns: ['.eslintrc.js'],
  rules: {
    '@typescript-eslint/interface-name-prefix': 'off',
    '@typescript-eslint/explicit-function-return-type': 'off',
    '@typescript-eslint/explicit-module-boundary-types': 'off',
    '@typescript-eslint/no-explicit-any': 'off',
  },
};

ソースコードも見てみましょう。

srcディレクトリ内のファイルの役割は、以下に書かれています。

First steps / Setup

アプリケーションのエントリーポイント。

src/main.ts

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

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

アプリケーションのルートモジュール。

Modules | NestJS - A progressive Node.js framework

src/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 {}

サービス。

src/app.service.ts

import { Injectable } from '@nestjs/common';

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

コントローラー。

Controllers | NestJS - A progressive Node.js framework

src/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.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('root', () => {
    it('should return "Hello World!"', () => {
      expect(appController.getHello()).toBe('Hello World!');
    });
  });
});

こちらはユニットテストですね。

Testing / Unit testing

testディレクトリにあるのは、E2Eテストのようです。

Testing / End-to-end testing

test/app.e2e-spec.ts

import { Test, TestingModule } from '@nestjs/testing';
import { INestApplication } from '@nestjs/common';
import * as request from 'supertest';
import { AppModule } from './../src/app.module';

describe('AppController (e2e)', () => {
  let app: INestApplication;

  beforeEach(async () => {
    const moduleFixture: TestingModule = await Test.createTestingModule({
      imports: [AppModule],
    }).compile();

    app = moduleFixture.createNestApplication();
    await app.init();
  });

  it('/ (GET)', () => {
    return request(app.getHttpServer())
      .get('/')
      .expect(200)
      .expect('Hello World!');
  });
});

E2Eテストでの、Jestの設定ファイル。

test/jest-e2e.json

{
  "moduleFileExtensions": ["js", "json", "ts"],
  "rootDir": ".",
  "testEnvironment": "node",
  "testRegex": ".e2e-spec.ts$",
  "transform": {
    "^.+\\.(t|j)s$": "ts-jest"
  }
}

動かしてみる

とりあえず、この状態で起動してみましょうか。

$ npm start

起動しました。

> nestjs-getting-started@0.0.1 start
> nest start

[Nest] 61845  - 2022/03/23 0:22:49     LOG [NestFactory] Starting Nest application...
[Nest] 61845  - 2022/03/23 0:22:49     LOG [InstanceLoader] AppModule dependencies initialized +104ms
[Nest] 61845  - 2022/03/23 0:22:49     LOG [RoutesResolver] AppController {/}: +5ms
[Nest] 61845  - 2022/03/23 0:22:49     LOG [RouterExplorer] Mapped {/, GET} route +3ms
[Nest] 61845  - 2022/03/23 0:22:49     LOG [NestApplication] Nest application successfully started +2ms

こちらに従って、アクセスしてみます。

First steps / Running the application

$ curl localhost:3000
Hello World!

src/app.module.tsでは、3000ポートをリッスンするようになっていました。

ルーティングは、ログに定義が出力されていましたね。

[Nest] 61845  - 2022/03/23 0:22:49     LOG [RoutesResolver] AppController {/}: +5ms
[Nest] 61845  - 2022/03/23 0:22:49     LOG [RouterExplorer] Mapped {/, GET} route +3ms

コントーローラーのURLは、どのように見たらいいのでしょうか?

ルーティングの説明を見ると、@Controllerデコレーターでパスを指定するようですね。

Controllers / Routing

今回は特になにも指定されていなかったので、そのまま/(ルート)にマッピングされました、と。

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

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

@Getデコレーターにもパスを指定できるようです。

コントローラーを追加してみる

ここに、コントローラーを追加してみましょう。

$ npx nest g controller hello

gは、generateサブコマンドの短縮形です。

実行結果。2つのファイルが作成され、src/app.module.tsが更新されたようです。

CREATE src/hello/hello.controller.spec.ts (485 bytes)
CREATE src/hello/hello.controller.ts (99 bytes)
UPDATE src/app.module.ts (326 bytes)

生成されたファイルを見てみましょう。

sc/hello/hello.controller.ts

import { Controller } from '@nestjs/common';

@Controller('hello')
export class HelloController {}

src/hello/hello.controller.spec.ts

import { Test, TestingModule } from '@nestjs/testing';
import { HelloController } from './hello.controller';

describe('HelloController', () => {
  let controller: HelloController;

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

    controller = module.get<HelloController>(HelloController);
  });

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

src/app.module.tsは、以下のように生成したHelloControllerが追加されています。

src/app.module.ts

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

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

nest generateでは、このようにファイルの追加や変更もできるようです。どのような種類のファイルを生成できるかは、以下のページに
一覧表があります。

CLI command reference / nest generate

生成されたコントローラーを、ちょっと修正してみましょう。

src/hello/hello.controller.ts

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

@Controller('hello')
export class HelloController {
  @Get('get')
  async getMessage(@Query('message') message: string): Promise<string> {
    return `Hello ${message}!!`;
  }

  @Post('post')
  async postMessage(@Body() request: any): Promise<any> {
    return {
      responseMessage: `Hello ${request.message}!!`,
    };
  }
}

@Query@Bodyの使い方やasyncについては、以下あたりを参考に。

Controllers / Request object

Controllers / Asynchronicity

開発中は、start:devでアプリケーションを起動しておくと、ソースコードの修正に合わせてリロードしてくれそうです。

$ npm run start:dev

動作確認。

$ curl localhost:3000/hello/get?message=NestJS
Hello NestJS!!


$ curl -XPOST -H 'Content-Type: application/json' localhost:3000/hello/post -d '{"message": "NestJS" }'
{"responseMessage":"Hello NestJS!!"}

OKですね。

テストコードも修正しておきましょう。

src/hello/hello.controller.spec.ts

import { Test, TestingModule } from '@nestjs/testing';
import { HelloController } from './hello.controller';

describe('HelloController', () => {
  let controller: HelloController;

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

    controller = module.get<HelloController>(HelloController);
  });

  it('should return "Hello NestJS!!"', async () => {
    expect(await controller.getMessage('NestJS')).toBe('Hello NestJS!!');
  });

  it('should return "Hello NestJS!!" object', async () => {
    expect(await controller.postMessage({ message: 'NestJS' })).toEqual({
      responseMessage: 'Hello NestJS!!',
    });
  });
});

今回はここまでにしておきましょうか。

まとめ

NestJSを初めて使ってみました。

CLIでセットアップできて簡単に始められるのと、ドキュメントに沿って進めていくと基本的なことはすぐにできそうな気がしますね。

Node.jsでサーバーサイドアプリケーションを書く時には、ExpressよりもNestJSかな、とちょっと思い始めました。