はじめに
エアークローゼット アドベントカレンダー13日目の記事です。
概念
最初は概念からいきましょう。概念が把握した上で、次にNestJSのDIとCQRSの仕組みを説明します。
-
NestJS
NestJSは、Node.jsのためのフレームワークであります。結構有名なフレームワークなので、ご存知の方も多いと思います。
以下にNestJSの主な特徴と利点をまとめます。- TypeScriptサポート
- モジュールベースのアーキテクチャ
- プロバイダと依存性注入(DI)
- ミドルウェアとフィルタリング
- ルーティングとコントローラ
- テストサポート
- プラグインと拡張性
-
DIPとDI
DIP(Dependency Inversion Principle)
Dependency Inversion Principleは、高レベルのモジュールは低レベルのモジュールに依存すべきではない、という原則です。
この原則により、モジュールを具体的な実装から切り離すことで、柔軟性と拡張性のあるコードを実現することができます。
例えば、以下のようなコードを考えてみましょう。class A { private b: B; constructor() { this.b = new B(); } } class B { public doSomething() { console.log('do something'); } }
このコードは、AクラスがBクラスに依存しています。このコードは、AクラスがBクラスの具体的な実装に依存しているため、Aクラスを拡張する際にBクラスの実装を変更する必要があります。
このコードをDIPに従って書き換えると、以下のようになります。interface IB { doSomething(): void; } class A { private b: IB; constructor(b: IB) { this.b = b; } }
このコードは、AクラスがBクラスの具体的な実装に依存していないため、Aクラスを拡張する際にBクラスの実装を変更する必要がありません。
このように、DIPに従って書かれたコードは、拡張性が高くなります。DI(Dependency Injection)
Dependency Injectionは、依存性注入と訳されます。DIは、DIPを実現するための手段です。
DIは、以下のようなメリットがあります。- テスト容易性(モックを利用したテストが可能)
- 柔軟性(モジュールの切り替えが可能、同じインタフェースであれば)
- 再利用性(モジュールの再利用が可能、別のシナリオで同じモジュール利用可)
- ロジックの集中化(高レベルのモジュールは展開したいロジックだけに集中)
- コードの可読性
-
CQRS
CQRSは、Command Query Responsibility Segregationの略です。CQRSは、コマンドとクエリを分離することで、柔軟性と拡張性のあるコードを実現するためのパターンです。
イメージとしては、以下のような感じです。
CQRSを採用することによって、以下のようなメリットがあります。- テスト容易性(コマンドとクエリの分離によって期待値が明確に)
- 柔軟性(コマンドとクエリの分離によって、コマンドとクエリの処理を別のモジュールに分けることが可能)
- 再利用性(コマンドとクエリの分離によって、コマンドとクエリの処理を別のシナリオで再利用可能)
- ロジックの集中化(コマンドは更新ロジックだけに、クエリは取得データだけに集中)
- コードの可読性
- パフォーマンスの向上(Caching利用、クエリ)
- スケーラビリティの向上(一番イメージしやすい例は、コマンドはMasterDB、クエリはSlaveDBに接続することで、スケーラビリティを向上させることができる)
NestJSのDI
NestJSのサーバは、モジュールで構成されています。モジュールは、以下のように定義されます。
@Module({
imports: [TypeOrmModule.forFeature([User])],
providers: [UserService],
exports: [UserService],
})
export class UserModule {
}
@Injectable()
export class UserService {
constructor(
@InjectRepository(User)
private readonly userRepository: Repository<User>,
) {
}
async findById(id: number): Promise<User> {
return await this.userRepository.findOne(id);
}
}
コードがモジュール化されているため、モジュール内で利用したい部分があれば、モジュールのインポートして利用することができます。
例えば、上記のモジュールでUserServiceを利用したい場合は、UserServiceをこのようにインポートします。
@Module({
imports: [UserModule],
providers: [UserService],
exports: [UserQueryHandler],
})
export class ApplicationModule {
}
@QueryHandler(GetUserQuery)
export class UserQueryHandler implements IQueryHandler<GetUserQuery, User> {
constructor(
private readonly userService: UserService,
) {
}
async execute(query: GetUserQuery): Promise<User> {
return await this.userService.findById(query.id);
}
}
フレームワークが全部サポートしてくれているので、使用は簡単です。
NestJSのCQRS仕組み
NestJSのCQRSはバスを利用して、コマンドとクエリを分離しています。バスは、以下のように定義されます。
@Module({
imports: [CqrsModule],
providers: [
UserQueryHandler,
UserCommandHandler,
UserRepository,
],
exports: [UserService],
})
export class UserModule {
}
@Injectable()
export class UserService {
constructor(
private readonly commandBus: CommandBus,
private readonly queryBus: QueryBus,
) {
}
async findUserWithId(id: number): Promise<User> {
const query = new GetUserQuery(id);
return await this.queryBus.execute(query);
}
async createNewUser(firstName: string, lastName: string, dateOfBirth: Date): Promise<void> {
const command = new CreateUserCommand(firstName, lastName, dateOfBirth);
await this.commandBus.execute(command);
}
}
QueryBusとCommandBusが挿入されます。コマンドはCommandBusで実行され、クエリはQueryBusで実行されます。
その裏では、以下のようにコマンドハンドラとクエリハンドラが対応しています。
@QueryHandler(GetUserQuery)
export class UserQueryHandler implements IQueryHandler<GetUserQuery, User> {
constructor(
private readonly userRepository: UserRepository,
) {
}
async execute(query: GetUserQuery): Promise<User> {
return await this.userRepository.findById(query.id);
}
}
@CommandHandler(CreateUserCommand)
export class UserCommandHandler implements ICommandHandler<CreateUserCommand> {
constructor(
private readonly userRepository: UserRepository,
) {
}
async execute(command: CreateUserCommand): Promise<void> {
const user = new User(command.firstName, command.lastName, command.dateOfBirth);
await this.userRepository.save(user);
}
}
まとめ
以上、NestJSのDIとCQRSの仕組みを説明しました。
NestJSのDIは、コードをモジュール化にされ、再利用したい部分をインポートします。
NestJSのCQRSは、NestJSのCQRSはバスを利用して、コマンドとクエリを分離しています。
NestJSのDIとCQRSの仕組みを理解することで、柔軟性と拡張性のあるコードを実現することができます。
おまけ
NestJSのDIはSCOPEできます。SCOPEすることで、インスタンスのライフサイクルを制御することも可能です。
例えば、以下のようにSCOPEすることができます。
@Module({
imports: [TypeOrmModule.forFeature([User])],
providers: [UserService],
exports: [UserService],
})
export class UserModule {
}
@Injectable({scope: Scope.REQUEST})
export class UserService {
constructor(
@InjectRepository(User)
private readonly userRepository: Repository<User>,
) {
}
async findById(id: number): Promise<User> {
return await this.userRepository.findOne(id);
}
}
SCOPEの種類は、以下のようになっています。
- REQUEST
- TRANSIENT
- DEFAULT
SCOPEの種類によって、インスタンスのライフサイクルが変わります。
REQUESTの場合は、リクエストごとにインスタンスが生成されます。リクエストが終了すると、インスタンスは破棄されます。
TRANSIENTの場合は、インスタンスが生成されるたびにインスタンスが生成されます。インスタンスは、生成された場所で破棄されます。
DEFAULTの場合は、インスタンスが生成されるたびにインスタンスが生成されます。インスタンスは、アプリケーションが終了するまで破棄されません。Singletonパータンと同じです。
じゃNestJSのCQRSはどのようなSCOPEか気になりますね。SCOPEできるのかな?
ということで、NestJSのCQRSのバスのSCOPEを調べてみました。
結論から言うと、バスはSCOPEできません。バスは、アプリケーションが終了するまで破棄されません。
以下のコードで確認できます。
@Module({
imports: [CqrsModule],
providers: [
{provide: UserQueryHandler, scope: Scope.REQUEST},
{provide: UserCommandHandler, scope: Scope.REQUEST},
UserRepository,
],
exports: [UserService],
})
export class UserModule {
}
@Injectable({scope: Scope.REQUEST})
export class UserService {
constructor(
private readonly commandBus: CommandBus,
private readonly queryBus: QueryBus,
) {
}
async findUserWithId(id: number): Promise<User> {
const query = new GetUserQuery(id);
return await this.queryBus.execute(query);
}
async createNewUser(firstName: string, lastName: string, dateOfBirth: Date): Promise<void> {
const command = new CreateUserCommand(firstName, lastName, dateOfBirth);
await this.commandBus.execute(command);
}
}
@QueryHandler(GetUserQuery)
export class UserQueryHandler implements IQueryHandler<GetUserQuery, User> {
constructor(
private readonly userRepository: UserRepository,
) {
}
async execute(query: GetUserQuery): Promise<User> {
return await this.userRepository.findById(query.id);
}
}
@CommandHandler(CreateUserCommand)
export class UserCommandHandler implements ICommandHandler<CreateUserCommand> {
constructor(
private readonly userRepository: UserRepository,
) {
}
async execute(command: CreateUserCommand): Promise<void> {
const user = new User(command.firstName, command.lastName, command.dateOfBirth);
await this.userRepository.save(user);
}
}
で、実行すると、以下のようになります。
Error: UserQueryHandler is marked as a scoped provider. Request and transient-scoped providers can't be used in combination with "get()" method. Please, use "resolve()" instead.
残念ながら、バスはSCOPEできません。バスは、アプリケーションが終了するまで破棄されません。
じゃTransaction的のコマンドはどうする? 私もこのところはまだ調査中です。
是非、皆さんが何かわかったら教えてください。