blog.euxn.me

NestJS の Module と DI を理解する

2019-12-02 Mon.

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

はじめに

昨日の記事ではアプリケーションを作って一通り動かすところまで説明されました。 この中では Module については、デフォルトで生成される AppModule のまま使用しておりますが、大規模になるにつれて Module を分割することになると思います。 この記事では、 Module の概要と、 Module を分割することによる DI への影響を説明します。 公式のドキュメントにも説明がありますので、合わせて読んでいただくことでより理解が深まると思います。

サンプルコードのリポジトリは以下になります。

https://github.com/nestjs-jp/advent-calendar-2019/tree/master/day02-understanting-module-and-di

なお、環境は執筆時点での Node.js の LTS である v12.13.1 を前提とします。

NestJS における Module とは

NestJS では任意の controller, provider(service など)をまとめた単位を Module といいます。 TypeScript の class 定義に対して、 @Module() Decorator を使用して定義します。この時、 class 定義は空でも問題ありません。

昨日の例では全て AppModule 内に定義しましたが、 AppController と AppService の実装を ItemsModule に移してみます。この場合、以下のように定義されます。

items/items.module.ts
1@Module({
2 controllers: [ItemsController],
3 providers: [ItemsService],
4})
5export class ItemsModule {}
6

また、 AppModule では import に以下のように定義します。

app.module.ts
1@Module({
2 imports: [ItemsModule],
3 controllers: [AppController],
4})
5export class AppModule {}

上記の controllers, providers, import の他に、Module に定義した provider を他の Module でも使用するための export があります。 export については後述します。

基本的には Module の内部で DI のスコープも完結します。これを試すため、以下で Comments Module を実装します。

cli を用いて CommentsModule を生成する

新たに Module を作成する場合、 @nestjs/cli を使用すると、 AppModule への反映も自動で行ってくれるため便利です。

1$ yarn add -D @nestjs/cli
2$ yarn nest g module comments

コマンドを実行すると、以下のようにファイル生成と更新が行われていることがわかります。

CREATE /src/comments/comments.module.ts (85 bytes)
UPDATE /src/app.module.ts (324 bytes)

AppModule の import に CommentsModule が追加されていますね。

app.module.ts
1@Module({
2 imports: [ItemsModule, CommentsModule],
3 controllers: [AppController],
4})
5export class AppModule {}

同様に controller と service も cli を使うことで生成すると共に該当する Module の controllers / providers に自動追記されます。

CommentsModule を実装し、動作を確認する

以下のように CommentsController と CommentsService を実装していきます。

comments/comments.controller.ts
1@Controller('comments')
2export class CommentsController {
3 constructor(private readonly commentsService: CommentsService) {}
4
5 @Get()
6 getCommentsByItemId(@Query() query: { itemId: string }): Comment[] {
7 return this.commentsService.getCommentsByItemId(+query.itemId);
8 }
9}
comments/comments.service.ts
1export interface Comment {
2 id: number;
3 itemId: number;
4 body: string;
5}
6
7const comments: Comment[] = [
8 {
9 id: 1,
10 itemId: 1,
11 body: 'Hello, I am Alice',
12 },
13 {
14 id: 2,
15 itemId: 1,
16 body: 'Hello, I am Beth',
17 },
18 {
19 id: 3,
20 itemId: 2,
21 body: 'That is also love.',
22 },
23];
24
25@Injectable()
26export class CommentsService {
27 getCommentsByItemId(itemId: number): Comment[] {
28 return comments.filter(comment => comment.itemId === itemId);
29 }
30}

curl コマンドで動作確認をします。

1$ curl localhost:3000/comments\?itemId=1
2[{"id":1,"itemId":1,"body":"Hello, I am Alice"},{"id":2,"itemId":1,"body":"Hello, I am Beth"}]

テストも追加していきます。

comments/comments.controller.spec.ts
1describe('Comments Controller', () => {
2 let commentsController: CommentsController;
3 let commentsService: CommentsService;
4
5 beforeEach(async () => {
6 commentsService = new CommentsService();
7 commentsController = new CommentsController(commentsService);
8 });
9
10 describe('/comments', () => {
11 it('should return comments', () => {
12 const comments: Comment[] = [
13 {
14 id: 1,
15 itemId: 1,
16 body: 'Mock Comment',
17 },
18 ];
19 jest
20 .spyOn(commentsService, 'getCommentsByItemId')
21 .mockImplementation(() => {
22 return comments;
23 });
24 expect(
25 commentsController.getCommentsByItemId({ itemId: '1' }),
26 ).toHaveLength(1);
27 });
28 });
29});
comments/comments.service.spec.ts
1describe('CommentsService', () => {
2 let commentsService: CommentsService;
3
4 beforeEach(async () => {
5 const module: TestingModule = await Test.createTestingModule({
6 providers: [CommentsService],
7 }).compile();
8
9 commentsService = module.get<CommentsService>(CommentsService);
10 });
11
12 it('should be defined', () => {
13 expect(commentsService).toBeDefined();
14 });
15
16 describe('getCommentsByItemId', () => {
17 it('should return comments if exist', () => {
18 const comments = commentsService.getCommentsByItemId(1);
19 expect(comments.length).toBeTruthy();
20 });
21
22 it('should return empty array if not exist', () => {
23 const comments = commentsService.getCommentsByItemId(0);
24 expect(comments).toHaveLength(0);
25 });
26 });
27});

なお、自明である内容をテストしている箇所があるため、今後はテストが必要であるところのみ、テストを記述します。

Module 間で DI のスコープが別れていることを確認する

Module をまたいだ DI は行えないため、 ItemsController で CommentsService を使用することはできません。 ItemsConbtroller に以下を実装し、確認します。

items/items.controller.ts
1interface GetItemWithCommentsResponseType {
2 item: PublicItem;
3 comments: Comment[];
4}
5
6@Controller()
7export class ItemsController {
8 constructor(
9 private readonly itemsService: ItemsService,
10 private readonly commentsService: CommentsService,
11 ) {}
12
13 @Get()
14 getItems(): PublicItem[] {
15 return this.itemsService.getPublicItems();
16 }
17
18 @Get(':id/comments')
19 getItemWithComments(@Param()
20 param: {
21 id: string;
22 }): GetItemWithCommentsResponseType {
23 const item = this.itemsService.getItemById(+param.id);
24 const comments = this.commentsService.getCommentsByItemId(+param.id);
25
26 return { item, comments };
27 }
28}

この状態で $ yarn start:dev で起動すると、 DI が解決できない旨のエラーが表示されます。

[ExceptionHandler] Nest can't resolve dependencies of the ItemsController (ItemsService, ?). Please make sure that the argument CommentsService at index [1] is available in the ItemsModule context.

Potential solutions:
- If CommentsService is a provider, is it part of the current ItemsModule?
- If CommentsService is exported from a separate @Module, is that module imported within ItemsModule?
  @Module({
    imports: [ /* the Module containing CommentsService */ ]
  })

別の Module の Service を使うために export する

CommentsService は別 Module にあるので、エラーメッセージに沿って以下のように修正します。

  • CommentsModule で CommentsService を export する
  • ItemsModule で CommentsModule を import する
comments/comments.module.ts
1@Module({
2 controllers: [CommentsController],
3 providers: [CommentsService],
4 exports: [CommentsService],
5})
6export class CommentsModule {}
items/items.module.ts
1@Module({
2 imports: [CommentsModule],
3 controllers: [ItemsController],
4 providers: [ItemsService],
5})
6export class ItemsModule {}

ここで重要なのは export が必要ということで、ただ CommentsModule を import するだけでは同様のエラーとなります。 export を使用してはじめて他の Module から参照可能になるということです。

同様にテストを追記し、 curl で動作確認を行います。

items/items.controller.spec.ts
1 describe('/items/1/comments', () => {
2 it('should return public item and comments', () => {
3 const item: PublicItem = {
4 id: 1,
5 title: 'Mock Title',
6 body: 'Mock Body',
7 };
8 const comments: Comment[] = [
9 {
10 id: 1,
11 itemId: 1,
12 body: 'Mock Comment',
13 },
14 ];
15 jest.spyOn(itemsService, 'getItemById').mockImplementation(() => {
16 return item;
17 });
18 jest
19 .spyOn(commentsService, 'getCommentsByItemId')
20 .mockImplementation(() => {
21 return comments;
22 });
23 expect(itemsController.getItemWithComments({ id: '1' })).toEqual({
24 item,
25 comments,
26 });
27 });
28 });
1$ curl localhost:3000/items/1/comments
2{"item":{"id":1,"title":"Item title","body":"Hello, World"},"comments":[{"id":1,"itemId":1,"body":"Hello, I am Alice"},{"id":2,"itemId":1,"body":"Hello, I am Beth"}]}

無事動作しましたので完成です。

おわりに

これで Module の概要と、 DI との関係性は伝えられたかと思います。 しかし今回は Module の基礎に留めたため、まだ紹介しきれていない機能もあるため、[公式ドキュメント](DTO と Request Validation)を読みながら試すのが早いかと思います。 また、今後アドベントカレンダーや Japan NestJS Users Group でテクニックを発信していく予定ですので、併せてご興味を持っていただけますと嬉しいです。

明日は @potato4d さんが DTO と Request Validation についてお話する予定です。