この記事は NestJS アドベントカレンダー 2 日目の記事です。
はじめに
昨日の記事ではアプリケーションを作って一通り動かすところまで説明されました。
この中では Module については、デフォルトで生成される AppModule のまま使用しておりますが、大規模になるにつれて Module を分割することになると思います。
この記事では、 Module の概要と、 Module を分割することによる DI への影響を説明します。
公式のドキュメントにも説明がありますので、合わせて読んでいただくことでより理解が深まると思います。
サンプルコードのリポジトリは以下になります。
https://github.com/nestjs-jp/advent-calendar-2019/tree/master/day2-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 に移してみます。この場合、以下のように定義されます。
@Module({controllers:[ItemsController],providers:[ItemsService],})exportclassItemsModule{}
また、 AppModule では import に以下のように定義します。
@Module({imports:[ItemsModule],controllers:[AppController],})exportclassAppModule{}
上記の controllers
, providers
, import
の他に、Module に定義した provider を他の Module でも使用するための export
があります。 export
については後述します。
基本的には Module の内部で DI のスコープも完結します。これを試すため、以下で Comments Module を実装します。
cli を用いて CommentsModule を生成する
新たに Module を作成する場合、 @nestjs/cli
を使用すると、 AppModule への反映も自動で行ってくれるため便利です。
$ yarn add -D @nestjs/cli
$ yarn nest g module comments
コマンドを実行すると、以下のようにファイル生成と更新が行われていることがわかります。
CREATE /src/comments/comments.module.ts (85 bytes)
UPDATE /src/app.module.ts (324 bytes)
AppModule の import に CommentsModule が追加されていますね。
@Module({imports:[ItemsModule,CommentsModule],controllers:[AppController],})exportclassAppModule{}
同様に controller と service も cli を使うことで生成すると共に該当する Module の controllers
/ providers
に自動追記されます。
CommentsModule を実装し、動作を確認する
以下のように CommentsController と CommentsService を実装していきます。
@Controller('comments')exportclassCommentsController{constructor(privatereadonlycommentsService:CommentsService){}@Get()getCommentsByItemId(@Query()query:{itemId:string}):Comment[]{returnthis.commentsService.getCommentsByItemId(+query.itemId);}}
exportinterfaceComment{id:number;itemId:number;body:string;}constcomments:Comment[]=[{id:1,itemId:1,body:'Hello, I am Alice',},{id:2,itemId:1,body:'Hello, I am Beth',},{id:3,itemId:2,body:'That is also love.',},];@Injectable()exportclassCommentsService{getCommentsByItemId(itemId:number):Comment[]{returncomments.filter(comment=>comment.itemId===itemId);}}
curl コマンドで動作確認をします。
$ curl localhost:3000/comments\?itemId=1
[{"id":1,"itemId":1,"body":"Hello, I am Alice"},{"id":2,"itemId":1,"body":"Hello, I am Beth"}]
テストも追加していきます。
describe('Comments Controller',()=>{letcommentsController:CommentsController;letcommentsService:CommentsService;beforeEach(async()=>{commentsService=newCommentsService();commentsController=newCommentsController(commentsService);});describe('/comments',()=>{it('should return comments',()=>{constcomments:Comment[]=[{id:1,itemId:1,body:'Mock Comment',},];jest.spyOn(commentsService,'getCommentsByItemId').mockImplementation(()=>{returncomments;});expect(commentsController.getCommentsByItemId({itemId:'1'}),).toHaveLength(1);});});});
describe('CommentsService',()=>{letcommentsService:CommentsService;beforeEach(async()=>{constmodule:TestingModule=awaitTest.createTestingModule({providers:[CommentsService],}).compile();commentsService=module.get<CommentsService>(CommentsService);});it('should be defined',()=>{expect(commentsService).toBeDefined();});describe('getCommentsByItemId',()=>{it('should return comments if exist',()=>{constcomments=commentsService.getCommentsByItemId(1);expect(comments.length).toBeTruthy();});it('should return empty array if not exist',()=>{constcomments=commentsService.getCommentsByItemId(0);expect(comments).toHaveLength(0);});});});
なお、自明である内容をテストしている箇所があるため、今後はテストが必要であるところのみ、テストを記述します。
Module 間で DI のスコープが別れていることを確認する
Module をまたいだ DI は行えないため、 ItemsController で CommentsService を使用することはできません。
ItemsConbtroller に以下を実装し、確認します。
interfaceGetItemWithCommentsResponseType{item:PublicItem;comments:Comment[];}@Controller()exportclassItemsController{constructor(privatereadonlyitemsService:ItemsService,privatereadonlycommentsService:CommentsService,){}@Get()getItems():PublicItem[]{returnthis.itemsService.getPublicItems();}@Get(':id/comments')getItemWithComments(@Param()param:{id:string;}):GetItemWithCommentsResponseType{constitem=this.itemsService.getItemById(+param.id);constcomments=this.commentsService.getCommentsByItemId(+param.id);return{item,comments};}}
この状態で $ 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
する
@Module({controllers:[CommentsController],providers:[CommentsService],exports:[CommentsService],})exportclassCommentsModule{}
@Module({imports:[CommentsModule],controllers:[ItemsController],providers:[ItemsService],})exportclassItemsModule{}
ここで重要なのは export
が必要ということで、ただ CommentsModule を import
するだけでは同様のエラーとなります。
export を使用してはじめて他の Module から参照可能になるということです。
同様にテストを追記し、 curl で動作確認を行います。
describe('/items/1/comments',()=>{it('should return public item and comments',()=>{constitem:PublicItem={id:1,title:'Mock Title',body:'Mock Body',};constcomments:Comment[]=[{id:1,itemId:1,body:'Mock Comment',},];jest.spyOn(itemsService,'getItemById').mockImplementation(()=>{returnitem;});jest.spyOn(commentsService,'getCommentsByItemId').mockImplementation(()=>{returncomments;});expect(itemsController.getItemWithComments({id:'1'})).toEqual({item,comments,});});});
$ curl localhost:3000/items/1/comments
{"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 の基礎に留めたため、まだ紹介しきれていない機能もあるため、公式ドキュメントを読みながら試すのが早いかと思います。
また、今後アドベントカレンダーや Japan NestJS Users Group でテクニックを発信していく予定ですので、併せてご興味を持っていただけますと嬉しいです。
明日は @potato4dさんが DTO と Request Validation についてお話する予定です。