blog.euxn.me

NestJS で循環参照を解決する

2019-12-17 Tue.

この記事は NestJS アドベントカレンダー 2019 10 日目の記事です。 寝込んでいたため遅くなり申し訳ありません。

はじめに

この記事ではいつの間にか生まれがちな循環参照の原因と回避策について説明します。

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

https://github.com/nestjs-jp/advent-calendar-2019/tree/master/day10-resolve-circular-reference

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

循環参照を観測する

循環参照が発生する例として、以下のようなサンプルコードを用意しました。 なお、循環参照を小さい規模で意図的に起こしているため、あまり良い設計ではないです。

src/users/users.module.ts
1import { Module } from '@nestjs/common';
2import { UsersService } from './users.service';
3import { AuthService } from '../auth/auth.service';
4import { AuthModule } from '../auth/auth.module';
5
6@Module({
7 imports: [AuthModule],
8 providers: [UsersService],
9 exports: [AuthService],
10})
11export class UsersModule {}
src/users/users.service.ts
1import { Injectable } from '@nestjs/common';
2import { AuthService } from '../auth/auth.service';
3import { User } from '../types';
4
5@Injectable()
6export class UsersService {
7 constructor(private readonly authService: AuthService) {}
8
9 findUserById(_id: string): User {
10 // ...
11 return { id: 'foo', hashedPassword: 'bar' };
12 }
13
14 getUserConfig(sessionToken: string) {
15 const user = this.authService.getUser(sessionToken);
16
17 return this.getConfig(user);
18 }
19
20 private getConfig(_user: User) {
21 // ...
22
23 return { name: 'alice' };
24 }
25}
26
src/auth/auth.module.ts
1import { Module } from '@nestjs/common';
2import { AuthService } from './auth.service';
3import { UsersModule } from '../users/users.module';
4
5@Module({
6 imports: [UsersModule],
7 providers: [AuthService],
8 exports: [AuthService],
9})
10export class AuthModule {}
src/auth/auth.service.ts
1import { Injectable } from '@nestjs/common';
2import { UsersService } from '../users/users.service';
3import { User } from '../types';
4
5@Injectable()
6export class AuthService {
7 constructor(private readonly usersService: UsersService) {}
8
9 getUser(_sessionToken: string): User {
10 // ...
11 return { id: 'foo', hashedPassword: 'bar' };
12 }
13
14 login(userId, password) {
15 const user = this.usersService.findUserById(userId);
16
17 return this.authenticateUser(user, password);
18 }
19
20 private authenticateUser(_user: User, _password: string): string {
21 // ...
22
23 return 'hoge4js'
24 }
25}

さすがにここまで直接的ではなくとも、似たような依存関係になってしまうことはあるかと思います。

この状態でアプリケーションを起動すると、以下のようなエラーが出ます。

1[Nest] 61340 - 12/16/2019, 5:56:10 PM [NestFactory] Starting Nest application...
2[Nest] 61340 - 12/16/2019, 5:56:10 PM [ExceptionHandler] Nest cannot create the module instance. Often, this is because of a circular dependency between modules. Use forwardRef() to avoid it.
3
4(Read more: https://docs.nestjs.com/fundamentals/circular-dependency)
5Scope [AppModule -> UsersModule -> AuthModule]
6 +5ms
7Error: Nest cannot create the module instance. Often, this is because of a circular dependency between modules. Use forwardRef() to avoid it.
8
9(Read more: https://docs.nestjs.com/fundamentals/circular-dependency)
10Scope [AppModule -> UsersModule -> AuthModule]

NestJS の場合は循環参照が発生している場合、まず起動できません。

なぜ起動できなくなるか

NestJS は bootstrap 時に Module の Provider であり @Injectable() なものをインスタンス生成し、 DI コンテナを生成します。

この時、 A には B が、 B には C が、と依存している場合、依存を解決し、 C -> B -> A という順で初期化しています。

このとき、循環参照があると依存が解決できず、 Provider のインスタンス生成が失敗します。例えば、 A には B の インスタンス が必要であり、 B には A の インスタンス が必要であるので、どちらかが先にインスタンス生成されていないといけないのです。

forwardRef を使用し依存を解消する

NestJS ではこのような循環参照を回避する方法として、 @Inject()forwardRef(() => { ... }) が用意されています。

forwardRef では、依存先をまだインスタンス生成されていないものに対して未来に依存することを約束し、型のみ合わせて初期化を進めます。

まずは Module の循環参照を解決します。

src/users/users.module.ts
1
2@Module({
3 imports: [forwardRef(() => AuthModule)],
4 providers: [UsersService],
5 exports: [AuthService],
6})
7export class UsersModule {}
src/auth/auth.module.ts
1@Module({
2 imports: [forwardRef(() => UsersModule)],
3 providers: [AuthService],
4 exports: [AuthService],
5})
6export class AuthModule {}

理屈上は片方のみの循環参照の解決で良いのですが、後述する Service 間の依存に影響が出てしまうため、双方ともに forwardRef するのが良いでしょう。

次に、 Service の循環参照を解決します。

src/users/users.service.ts
1import { forwardRef, Inject, Injectable } from '@nestjs/common';
2import { AuthService } from '../auth/auth.service';
3import { User } from '../types';
4
5@Injectable()
6export class UsersService {
7 constructor(
8 @Inject(forwardRef(() => AuthService))
9 private readonly authService: AuthService,
10 ) {}
11
12 findUserById(_id: string): User {
13 // ...
14 return { id: 'foo', hashedPassword: 'bar' };
15 }
16
17 getUserConfig(sessionToken: string) {
18 const user = this.authService.getUser(sessionToken);
19
20 return this.getConfig(user);
21 }
22
23 private getConfig(_user: User) {
24 // ...
25
26 return { name: 'alice' };
27 }
28}
src/auth/auth.service.ts
1import { forwardRef, Inject, Injectable } from '@nestjs/common';
2import { UsersService } from '../users/users.service';
3import { User } from '../types';
4
5@Injectable()
6export class AuthService {
7 constructor(
8 @Inject(forwardRef(() => UsersService))
9 private readonly usersService: UsersService,
10 ) {}
11
12 getUser(_sessionToken: string): User {
13 // ...
14 return { id: 'foo', hashedPassword: 'bar' };
15 }
16
17 login(userId, password) {
18 const user = this.usersService.findUserById(userId);
19
20 return this.authenticateUser(user, password);
21 }
22
23 private authenticateUser(_user: User, _password: string): string {
24 // ...
25
26 return 'hoge4js'
27 }
28}

修正を加えた状態でアプリケーションを起動するとうまく動きます。

正しく動くことを確認するために、 AppController に以下の変更を加えて動作させてみます。

src/app.controller.ts
1@Controller()
2export class AppController {
3 constructor(
4 private readonly usersService: UsersService,
5 private readonly authService: AuthService,
6 ) {}
7
8 @Get('config')
9 getConfig() {
10 return this.usersService.getUserConfig('hoge4js');
11 }
12
13 @Get('login')
14 login() {
15 return this.authService.login('foo', 'baz')
16 }
17}
1$ curl localhost:3000/login
2hoge4js
3
4$ curl localhost:3000/config
5{"name":"alice"}

無事アプリケーションも動いています。

おわりに

この記事ではいつの間にか生まれがちな循環参照の原因と回避策について説明しました。 特に Service <-> Service では複雑な依存が生まれがちなので、気をつけるようにしてください。 forwardRef 自体に副作用や危険な要素があるわけではないようなので、起動時間をチューニングする必要がない環境では極力定義しておくと良いのではないでしょうか。

明日は @ci7lus さんの NestJS Response ヘッダー 変え方 です(確定した未来)。