本文へ移動
🤔

NestJS と Express、Hono の違い: モジュールと依存性の注入

29分
もくじ

Express は書けるのに、NestJS を初めて触ったら、最初の機能を1つ作るところで止まった。サービスのクラスを import して、エディタに赤い波線も出ていないのに、起動すると英語のエラーが出て動かない。知りたかったのは、なぜ import しただけでは動かないのかと、このエラーのどこを読めば直す場所が分かるのかだ。

Express、Hono、NestJS は、どれもバックエンド(サーバーの側で動いて、求められたデータを用意するプログラム)を書くための Web フレームワークだ。API は、バックエンドのうち、アプリやブラウザーからの求めを受け取って、データを返す部分だ。サービスは、データを扱うコードをまとめたクラスを指す。赤い波線は、TypeScript の型検査(コードを動かす前に、型の誤りを調べること)などで見つかった誤りの場所に、エディタが引く線だ。

止まった原因は、モジュール、依存性の注入、controllers と providers の書き分けという NestJS の基本を、僕が知らないまま書き始めたことだった。Express や Hono では、部品をどこでつなぐかを、僕は自分でいかようにでも決めてきた。NestJS では、部品を登録する場所が決まっている。動かなかった理由は、この違いにあった。

前提: Web フレームワークは、パスと処理を結び付ける

Web フレームワークは、アプリやブラウザーから届いたリクエスト(求め)を受け取り、パスに対応する処理を呼んで、結果を返す。パスは、GET /books の /books の部分だ。たとえば、GET /books というリクエストが届いたら、本の一覧を返す関数を呼ぶ。

処理が増えてくると、リクエストを受け取るコードと、データを扱うコードを分けて書くことが多い。受け取る側を コントローラ、データを扱う側を サービス と呼ぶ。たとえば、GET /books を受け取るのがコントローラで、本の配列から一覧を取り出すのがサービスだ。分けたからには、コントローラがサービスを呼べるように、だれかが2つをつながなければならない。

GET /books ここの話 アプリやブラウザー Web フレームワークパスと処理を結び付ける コントローラリクエストを受け取る サービスデータを扱う

ここで扱うのは、コントローラとサービスの間だ。コントローラにサービスを渡すコードを、だれが、どこに書くのか。3つのフレームワークは、ここが違う。

3つの公式の説明は、こうだ。

  • Express の公式サイトは、Express を「Fast, unopinionated, minimalist web framework for Node.js」と説明している。unopinionated は、決まったやり方を押し付けない、という意味だ
  • Hono の公式ドキュメントは、Hono を「a small, simple, and ultrafast web framework built on Web Standards」と説明している。小さく、単純で、速い、という説明である
  • NestJS の公式ドキュメントは、Node.js のライブラリや道具は数多くあるが、どれもアーキテクチャ(コードを役目ごとにどう分けて、どこに何を書くかの決め方)という主な問題を解いていない、と述べている。そのうえで、NestJS はすぐに使えるアーキテクチャを提供する、と説明している

NestJS は、内部では既定で Express を使う。既定のままなら、NestJS は Express の上に、どこに何を書くかの決まりを足していると言える。

困りごと: import したのに、起動で止まる

本の一覧を返す API を、NestJS で書く。GET /books に、本の番号と題名を返すだけの API だ。

src/
├── main.ts                   起動する
├── app.module.ts             入口のモジュール
└── books/
    ├── books.service.ts      サービス。本の配列を持つ
    ├── books.controller.ts   コントローラ。GET /books を受け取る
    └── books.module.ts       本の機能のモジュール

サービスは、本の配列を持ち、全件を返す。

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

@Injectable()
export class BooksService {
  private readonly books = [
    { id: '1', title: '吾輩は猫である' },
    { id: '2', title: '注文の多い料理店' },
  ];

  findAll() {
    return this.books;
  }
}

@ で始まる行は デコレータ で、クラスやメソッドに印を付ける。印を付けるだけで、印を読んで動くのは NestJS だ。@Injectable() は、このクラスを NestJS が管理できる、という印である。

コントローラは、GET /books を受け取って、サービスの findAll() を呼ぶ。

// src/books/books.controller.ts
import { Controller, Get } from '@nestjs/common';
import { BooksService } from './books.service.js';

@Controller('books')
export class BooksController {
  constructor(private readonly booksService: BooksService) {}

  @Get()
  findAll() {
    return this.booksService.findAll();
  }
}

@Controller('books') と @Get() は、GET /books が届いたら findAll() を呼ぶ、という印だ。constructor の行(コンストラクタ。クラスから実体を作るときに呼ばれる部分)は、BooksService を引数で受け取る、と書いている。実体は、new で作った物を指す。このファイルのどこにも、new BooksService() は無い。

悪い例: providers が空のモジュール

モジュールは、本の機能を作っている部品の一覧だ。

// src/books/books.module.ts
import { Module } from '@nestjs/common';
import { BooksController } from './books.controller.js';
import { BooksService } from './books.service.js';

@Module({
  controllers: [BooksController],
  providers: [],
})
export class BooksModule {}

controllers にはコントローラを並べた。providers は空のままだ。BooksService は、コントローラのファイルでも、このファイルでも、先頭で import してある。

入口のモジュールには、本の機能のモジュールを並べる。

// src/app.module.ts
import { Module } from '@nestjs/common';
import { BooksModule } from './books/books.module.js';

@Module({
  imports: [BooksModule],
})
export class AppModule {}

型検査のコマンドを実行しても、エラーは1件も出ない。ところが起動すると、次のエラーが出て止まる。エラーの出力のうち、原因を述べている1文だ。

Nest can't resolve dependencies of the BooksController (?). Please make sure that the argument BooksService at index [0] is available in the BooksModule module.

僕は、この文が英語のままでは読めなかった。どのアプリでも出そうな文に見えて、自分のコードのどこが悪いのかが分からない。僕のエディタに、赤い波線は出ていなかったのに、である。

結論: NestJS では、import に加えてモジュールへの登録が要る

NestJS では、サービスを import しただけでは受け取れない。上の例では、BooksService を BooksModule の providers に並べると受け取れる。入口のモジュールの imports はそのままにして、providers: [BooksService] と書けば、この例は動く。

理由は、コントローラにサービスを渡す作業を、NestJS が引き受けているからだ。Express と Hono では、つなぐコードを自分で書く。NestJS では、自分は「どの部品があるか」の一覧を書き、渡す作業は NestJS に任せる。この例では、BooksService を BooksModule の providers に登録しないと、NestJS はコントローラに渡せない。ファイルの先頭の import は、そのファイルでクラスを使えるようにする。モジュールの providers に登録する働きは無い。

Express では、作って渡すコードを自分で書く

同じ API を Express で書く。コントローラとサービスは、印の付いていない普通のクラスだ。

import express from 'express';

class BooksService {
  private readonly books = [
    { id: '1', title: '吾輩は猫である' },
    { id: '2', title: '注文の多い料理店' },
  ];

  findAll() {
    return this.books;
  }
}

class BooksController {
  constructor(private readonly booksService: BooksService) {}

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

const booksService = new BooksService();
const booksController = new BooksController(booksService);

const app = express();
app.get('/books', (req, res) => {
  res.json(booksController.findAll());
});

new BooksService() でサービスの実体を作り、new BooksController(booksService) でコントローラに渡している。この2行が、つなぐコードだ。app.get() は、パスと処理を結び付ける。

どの例も、起動したあとに自分で GET /books を呼び、HTTP の番号(200 や 404 のような、応答の結果を表す番号)と応答の本文を表示する数行を足して実行した。その数行は載せていない。

200 [{"id":"1","title":"吾輩は猫である"},{"id":"2","title":"注文の多い料理店"}]

サービスを渡し忘れて new BooksController() と書くと、型検査が次のエラーを出す。

error TS2554: Expected 1 arguments, but got 0.

引数が1つ要るのに0個だった、という意味だ。型検査で見つかる誤りなので、エディタが赤い波線を引く対象になる。この例では、コンストラクタが引数を1つ必ず受け取ると書いてあるので、渡し忘れは TypeScript の目に入る。

Hono では、分けたルートを自分でつなぐ

Hono の公式ドキュメントの「Best Practices」は、Ruby on Rails(Ruby という言語の Web フレームワーク)のようなコントローラを、できれば作らないよう勧めている。コントローラに分けると、複雑な型の指定を書かない限り、パスに含まれる値(/books/:id の id の部分)の型を TypeScript が推論できないためだ。代わりに、パスの定義のすぐ後に処理を書き、アプリが大きくなったらファイルごとに分けて app.route() でつなぐ、と書いてある。それでもコントローラの形にしたいときのために、型の推論が働く書き方も、同じページに載っている。

その勧めに沿って、同じ API を書く。パスと処理の組を、ルートと呼ぶ。

// src/books.ts
import { Hono } from 'hono';

const books = new Hono();

books.get('/', (c) => {
  return c.json([
    { id: '1', title: '吾輩は猫である' },
    { id: '2', title: '注文の多い料理店' },
  ]);
});

export default books;
// src/index.ts
import { Hono } from 'hono';
import books from './books.js';

const app = new Hono();
app.route('/books', books);

app.route('/books', books) が、つなぐコードだ。本のルートを、/books というパスにつないでいる。

200 [{"id":"1","title":"吾輩は猫である"},{"id":"2","title":"注文の多い料理店"}]

app.route() の1行を書き忘れても、型検査は通り、エラーも出ずに動く。GET /books を呼んだときに、はじめて分かる。

404 404 Not Found

先頭の 404 が HTTP の番号で、その後ろが応答の本文だ。books を import してあっても、つないでいなければ、そのパスは無いものとして扱われる。import しただけでは動かないのは、Hono も同じである。違うのは、つなぐコードを書くのが自分だという点だ。

コントローラとサービスのクラスに分けたときに、Hono でサービスをどう受け渡すかは、ここでは確かめていない。

NestJS では、モジュールに登録して、渡す作業を任せる

良い例: providers にサービスを並べたモジュール

// src/books/books.module.ts
import { Module } from '@nestjs/common';
import { BooksController } from './books.controller.js';
import { BooksService } from './books.service.js';

@Module({
  controllers: [BooksController],
  providers: [BooksService],
})
export class BooksModule {}

変えたのは providers の1行だけだ。

200 [{"id":"1","title":"吾輩は猫である"},{"id":"2","title":"注文の多い料理店"}]

自分が書く場所は、2か所ある。

  • 「欲しい」と書く場所は、コントローラのコンストラクタの引数だ。constructor(private readonly booksService: BooksService) {} が、BooksService が欲しい、と言っている
  • 「渡してよい」と書く場所は、モジュールの providers だ。providers: [BooksService] が、BooksService を用意して渡してよい、と言っている

2か所がそろって、はじめて動く。サービスに @Injectable() の印を付けただけでは、一覧に載ったことにはならない。NestJS は、起動してコントローラを作るときに、コンストラクタの引数に書かれたクラスを手掛かりにして、渡してよい部品を探す。この例で探す先は、BooksModule の providers だ。

ある 無い NestJS が起動する AppModule の imports からBooksModule を見つける BooksController を作ろうとする 引数の BooksService はBooksModule のproviders にあるか BooksService の実体をBooksController に渡す エラーを出して起動をやめる

Express で自分が書いた2行を、NestJS が代わりに実行している。

const booksService = new BooksService();
const booksController = new BooksController(booksService);

僕は、この置き換えでやっと腑に落ちた。NestJS のコードに new が無いのは、だれも実行していないからではない。NestJS が実行しているからだ。

ここまでに出てきた物には、名前がある。

  • モジュール:ひとつの機能を作っている部品を一覧にして、NestJS に伝えるクラス。@Module() を付けて書く
  • プロバイダ:NestJS が用意して、必要とするクラスへ渡す部品。この例ではサービスがプロバイダで、モジュールの providers に並べる
  • 依存性の注入:クラスが必要な部品を自分で new せず、外から受け取る書き方。この例では、コンストラクタの引数で受け取っている。必要な部品を「依存性」、外から渡すことを「注入」と呼んでいる

部品が2つなら、自分で new して渡しても困らない。部品が増えると、どれを先に作り、どこへ渡すかを、自分で順番どおりに書き続けることになる。NestJS は、モジュールに登録された部品と、コンストラクタの引数から、作る順番と渡す先を決める。

import と imports は別の物

紛らわしいのは、似た名前が3つあることだ。

書く物読むのは働き
ファイルの先頭の importTypeScriptほかのファイルのクラスを、そのファイルで使えるようにする
@Module() の importsNestJSこのモジュールが使う、ほかのモジュールを並べる
@Module() の providersNestJS用意して渡してよい部品を並べる
@Module() の controllersNestJSこのモジュールのコントローラを並べる

ファイルの先頭の import で BooksService を使えるようにしても、NestJS の一覧には載らない。NestJS が読むのは、@Module() の中だ。

受け取れる範囲も決まっている。NestJS の公式ドキュメントの「Modules」によると、通常のモジュールで受け取れるのは、同じモジュールのプロバイダか、imports に並べたモジュールが外へ出すと決めたプロバイダだけだ。

型検査は通るのに、起動で止まるエラーの読み方

この例の providers の書き忘れは、型検査に出ない。僕のエディタにも、赤い波線は出なかった。TypeScript から見ると、BooksService は正しく import されていて、コンストラクタの引数の型も合っている。providers: [] も、空の配列として正しい。BooksService が providers に登録されているかまでは、型検査は調べない。NestJS が起動して providers を読むときに、はじめて調べられる。TypeScript が黙っているのは、問題が無いからではない。TypeScript が調べる範囲の外だからだ。

起動で出るエラーには、続きがある。

Nest can't resolve dependencies of the BooksController (?). Please make sure that the argument BooksService at index [0] is available in the BooksModule module.

Potential solutions:
- Is BooksModule a valid NestJS module?
- If BooksService is a provider, is it part of the current BooksModule?
- If BooksService is exported from a separate @Module, is that module imported within BooksModule?

最初の1文を頭から日本語にすると、「NestJS は、BooksController が必要とする部品を用意できない。0番目の引数の BooksService が、BooksModule の中で使える状態になっているかを確かめてほしい」となる。

読む所は3つで、どれもクラスの名前だ。

エラーの中の部分分かることこの例では
of the BooksController (?)受け取れなかったクラス。カッコの中はコンストラクタの引数を順に並べた物で、用意できなかった引数が ? になるBooksController
argument BooksService at index [0]足りない物の名前と、何番目の引数か。番号は0から数えるBooksService
in the BooksModule moduleNestJS が探したモジュールBooksModule

3つを並べると、直す場所が決まる。BooksModule の providers に、BooksService を足せばよい。「Potential solutions」の2行目も、BooksService がプロバイダなら、今の BooksModule に入っているか、と同じことを尋ねている。

Express の書き方に置き換えると、new BooksController(?) になる。渡す物が見つからなかった、というだけの話である。Express なら自分で書く行なので、この例の渡し忘れは型検査で見つかる。NestJS では NestJS がこの行を実行するので、実行する時、つまり起動の時に分かる。

NestJS の公式ドキュメントの「Common errors」は、同じ形の文を載せている。いちばん多い原因は、足りない物がそのモジュールの providers に無く、imports に並べたモジュールからも外へ出されていないことだ、と書いてある。

この例で imports から外すと、エラーは出ずに 404 になる

間違える場所を変えると、結果も変わる。BooksModule の providers は正しく書き、入口のモジュールの imports から BooksModule を外す。

// src/app.module.ts
import { Module } from '@nestjs/common';
import { BooksModule } from './books/books.module.js';

@Module({
  imports: [],
})
export class AppModule {}

今度は、型検査も通り、起動もエラーなしで終わる。GET /books を呼ぶと、こうなる。

404 {"message":"Cannot GET /books","error":"Not Found","statusCode":404}

この例では、BooksModule が AppModule の imports に無いと、NestJS は BooksModule を読まない。BooksController も作られないので、BooksService を欲しがるクラスがいない。足りない物が無いので、エラーも出ない。/books というパスが登録されないだけだ。

書き忘れた場所型検査起動GET /books
BooksModule の providers通るエラーを出して止まる起動していないので送れない
AppModule の imports通るエラーなしで終わる404

Express、Hono、NestJS で違う所

下の表のうち、書き忘れに気づく時の行は、上で試した書き忘れだけの結果だ。ほかの間違い方までは確かめていない。

比べる点ExpressHonoNestJS
部品をつなぐのは自分。new して渡す自分。app.route() でつなぐNestJS。モジュールの一覧を読んで渡す
この例で自分が書いた物new して渡す2行app.route() の1行モジュールの providers と、コンストラクタの引数
上で試した書き忘れに気づく時型検査の時(引数の渡し忘れ)リクエストを送った時(404)起動した時(providers の書き忘れ)か、リクエストを送った時(imports の書き忘れ。404)

上の3つの例で書いたファイルは、Express が1つ、Hono が2つ、NestJS が5つだ。NestJS は、モジュールのファイルが増え、登録する場所を覚える手間が先に来る。僕が最初の機能で止まったのは、その手間をまだ払っていなかったからだ。どちらが優れているという話ではない。つなぐ作業を自分で持つか、フレームワークに預けるかが違う。

まとめ: NestJS では、import に加えてモジュールへの登録が要る

Express と Hono では、部品をつなぐコードを自分で書く。NestJS では、モジュールに部品を登録して、つなぐ作業を任せる。だから NestJS では、import しただけのサービスは受け取れない。

providers への登録を忘れると、型検査は通り、起動で止まる。そのときのエラーには、受け取れなかったクラス、足りない物、探したモジュールの3つの名前が入っている。どのアプリでも出そうに見えた文は、自分のコードのクラスの名前を3つ並べた文だった。

おわり😊

RELATED

つくることで、見える景色がある。