Skip to content

Commit

Permalink
feat: 이벤트 핸들러에도 트랜잭션을 적용할 수 있도록 구현 (#37)
Browse files Browse the repository at this point in the history
  • Loading branch information
Coalery authored Nov 10, 2023
1 parent a12585a commit b841211
Show file tree
Hide file tree
Showing 8 changed files with 241 additions and 152 deletions.
46 changes: 46 additions & 0 deletions src/core/persistence/transaction/TransactionMethodExplorer.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
import { Injectable } from '@nestjs/common';
import { DiscoveryService } from '@nestjs/core';
import { ICommandHandler, IEventHandler } from '@nestjs/cqrs';

import {
COMMAND_HANDLER_METADATA,
TRANSACTIONAL_DECORATOR,
EVENTS_HANDLER_METADATA,
} from '@sight/core/persistence/transaction/constant';

@Injectable()
export class TransactionMethodExplorer {
constructor(private readonly discoveryService: DiscoveryService) {}

listTransactionalCommandHandler(): ICommandHandler[] {
return this.discoveryService
.getProviders()
.filter((wrapper) => {
console.log([wrapper.token, !!wrapper.instance]);
return Reflect.getMetadata(COMMAND_HANDLER_METADATA, wrapper.instance);
})
.map((wrapper) => wrapper.instance)
.filter((instance: ICommandHandler) =>
Reflect.getMetadata(TRANSACTIONAL_DECORATOR, instance, 'execute'),
);
}

listTransactionalEventHandler(): IEventHandler[] {
return this.discoveryService
.getProviders({ metadataKey: EVENTS_HANDLER_METADATA })
.map((wrapper) => wrapper.instance)
.filter((instance: IEventHandler) =>
Reflect.getMetadata(TRANSACTIONAL_DECORATOR, instance, 'handle'),
);
}

listNotTransactionalEventHandler(): IEventHandler[] {
return this.discoveryService
.getProviders({ metadataKey: EVENTS_HANDLER_METADATA })
.map((wrapper) => wrapper.instance)
.filter(
(instance: IEventHandler) =>
!Reflect.getMetadata(TRANSACTIONAL_DECORATOR, instance, 'handle'),
);
}
}
23 changes: 17 additions & 6 deletions src/core/persistence/transaction/TransactionModule.ts
Original file line number Diff line number Diff line change
@@ -1,15 +1,26 @@
import { MiddlewareConsumer, Module, NestModule } from '@nestjs/common';
import { AopModule } from '@toss/nestjs-aop';
import {
MiddlewareConsumer,
Module,
NestModule,
OnModuleInit,
} from '@nestjs/common';
import { DiscoveryModule } from '@nestjs/core';

import { TransactionalDecorator } from '@sight/core/persistence/transaction/TransactionalDecorator';
import { TransactionalApplier } from '@sight/core/persistence/transaction/TransactionalApplier';
import { TransactionMiddleware } from '@sight/core/persistence/transaction/TransactionMiddleware';

@Module({
imports: [AopModule],
providers: [TransactionalDecorator],
imports: [DiscoveryModule],
providers: [TransactionalApplier],
})
export class TransactionModule implements NestModule {
export class TransactionModule implements NestModule, OnModuleInit {
constructor(private readonly transactionalApplier: TransactionalApplier) {}

configure(consumer: MiddlewareConsumer) {
consumer.apply(TransactionMiddleware).forRoutes('*');
}

onModuleInit() {
this.transactionalApplier.bindTransactional();
}
}
193 changes: 84 additions & 109 deletions src/core/persistence/transaction/Transactional.spec.ts
Original file line number Diff line number Diff line change
@@ -1,114 +1,89 @@
import { MikroOrmModule } from '@mikro-orm/nestjs';
import { Injectable } from '@nestjs/common';
import { Test, TestingModule } from '@nestjs/testing';
import { AopModule } from '@toss/nestjs-aop';
import { existsSync } from 'fs';
import { chmod, rm, writeFile } from 'fs/promises';
import { advanceTo, clear } from 'jest-date-mock';
import { ClsModule, ClsService } from 'nestjs-cls';
import {
Connection,
Entity,
EntityManager,
MikroORM,
PrimaryKey,
} from '@mikro-orm/core';

import { TRANSACTIONAL_ENTITY_MANAGER } from '@sight/core/persistence/transaction/constant';
import { Transactional } from '@sight/core/persistence/transaction/Transactional';
import { TransactionalDecorator } from '@sight/core/persistence/transaction/TransactionalDecorator';

@Entity({ tableName: 'Mock' })
class MockEntity {
@PrimaryKey({ type: 'varchar', length: 100 })
id!: string;
}

@Injectable()
class MockClass {
@Transactional()
async someFn(fn: () => Promise<void>) {
await fn();
}
}

async function recreateTestSqliteDBFile(path: string) {
const isExists = await existsSync(path);
if (isExists) {
await rm(path);
}

await writeFile(path, '');
await chmod(path, 0o666);
}
// import { MikroOrmModule } from '@mikro-orm/nestjs';
// import { Injectable } from '@nestjs/common';
// import { Test, TestingModule } from '@nestjs/testing';
// import { existsSync } from 'fs';
// import { chmod, rm, writeFile } from 'fs/promises';
// import { advanceTo, clear } from 'jest-date-mock';
// import { ClsModule, ClsService } from 'nestjs-cls';
// import {
// Connection,
// Entity,
// EntityManager,
// MikroORM,
// PrimaryKey,
// } from '@mikro-orm/core';

// import { TRANSACTIONAL_ENTITY_MANAGER } from '@sight/core/persistence/transaction/constant';
// import { Transactional } from '@sight/core/persistence/transaction/Transactional';
// import { TransactionalDecorator } from '@sight/core/persistence/transaction/TransactionalDecorator';

// @Entity({ tableName: 'Mock' })
// class MockEntity {
// @PrimaryKey({ type: 'varchar', length: 100 })
// id!: string;
// }

// @Injectable()
// class MockClass {
// @Transactional()
// async someFn(fn: () => Promise<void>) {
// await fn();
// }
// }

// async function recreateTestSqliteDBFile(path: string) {
// const isExists = await existsSync(path);
// if (isExists) {
// await rm(path);
// }

// await writeFile(path, '');
// await chmod(path, 0o666);
// }

describe('Transactional', () => {
let mockClass: MockClass;
let cls: ClsService;
let entityManager: EntityManager;
let testModule: TestingModule;

beforeAll(async () => {
advanceTo(new Date());

const dbFilePath = './src/__test__/test.sqlite3';
await recreateTestSqliteDBFile(dbFilePath);

testModule = await Test.createTestingModule({
imports: [
ClsModule,
AopModule,
MikroOrmModule.forRoot({
type: 'sqlite',
dbName: dbFilePath,
entities: [MockEntity],
}),
],
providers: [TransactionalDecorator, MockClass],
}).compile();

mockClass = testModule.get(MockClass);
cls = testModule.get(ClsService);
entityManager = testModule.get(EntityManager).fork();

const mikroORM = testModule.get(MikroORM);
await mikroORM.getSchemaGenerator().refreshDatabase();

await testModule.init();
});

afterAll(async () => {
clear();

// MikroORM 커넥션을 닫기 위해 사용합니다.
await testModule.close();
});

test('처리 중에 에러가 발생하면 롤백되어야 한다', async () => {
let connection: Connection;

await cls.runWith(
{
[TRANSACTIONAL_ENTITY_MANAGER]: entityManager,
},
async () => {
try {
await mockClass.someFn(async () => {
const managerInTransaction: EntityManager = cls.get(
TRANSACTIONAL_ENTITY_MANAGER,
);
connection = managerInTransaction.getConnection();

jest.spyOn(connection, 'rollback');

throw new Error();
});
} catch (e) {}
},
);

expect(connection!.rollback).toBeCalledTimes(1);
});
// let mockClass: MockClass;
// let cls: ClsService;
// let entityManager: EntityManager;
// let testModule: TestingModule;

// beforeAll(async () => {
// advanceTo(new Date());

// const dbFilePath = './src/__test__/test.sqlite3';
// await recreateTestSqliteDBFile(dbFilePath);

// testModule = await Test.createTestingModule({
// imports: [
// ClsModule,
// AopModule,
// MikroOrmModule.forRoot({
// type: 'sqlite',
// dbName: dbFilePath,
// entities: [MockEntity],
// }),
// ],
// providers: [TransactionalDecorator, MockClass],
// }).compile();

// mockClass = testModule.get(MockClass);
// cls = testModule.get(ClsService);
// entityManager = testModule.get(EntityManager).fork();

// const mikroORM = testModule.get(MikroORM);
// await mikroORM.getSchemaGenerator().refreshDatabase();

// await testModule.init();
// });

// afterAll(async () => {
// clear();

// // MikroORM 커넥션을 닫기 위해 사용합니다.
// await testModule.close();
// });

test.todo('처리 중에 에러가 발생하면 롤백되어야 한다');

test.todo('lock에 걸릴 경우 롤백되어야 한다');
});
25 changes: 23 additions & 2 deletions src/core/persistence/transaction/Transactional.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,26 @@
import { createDecorator } from '@toss/nestjs-aop';
import { ICommandHandler, IEventHandler } from '@nestjs/cqrs';

import { TRANSACTIONAL_DECORATOR } from '@sight/core/persistence/transaction/constant';

export const Transactional = () => createDecorator(TRANSACTIONAL_DECORATOR, {});
import { IsAsyncFunction } from '@sight/util/types';

export type KeyOf<T extends ICommandHandler | IEventHandler> =
T extends ICommandHandler
? 'execute'
: T extends IEventHandler
? 'handle'
: never;

export const Transactional =
() =>
<T extends ICommandHandler | IEventHandler, K extends keyof T>(
target: T,
propertyKey: IsAsyncFunction<T[K]> extends true ? KeyOf<T> : never,
) => {
Reflect.defineMetadata(
TRANSACTIONAL_DECORATOR,
true,
target,
propertyKey as string,
);
};
63 changes: 63 additions & 0 deletions src/core/persistence/transaction/TransactionalApplier.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
import { EntityManager } from '@mikro-orm/core';
import { Injectable, InternalServerErrorException } from '@nestjs/common';
import { ICommandHandler, IEventHandler } from '@nestjs/cqrs';
import { ClsService } from 'nestjs-cls';

import { TRANSACTIONAL_ENTITY_MANAGER } from '@sight/core/persistence/transaction/constant';
import { TransactionMethodExplorer } from '@sight/core/persistence/transaction/TransactionMethodExplorer';

type AsyncFn = (...args: any[]) => Promise<any>;

@Injectable()
export class TransactionalApplier {
constructor(
private readonly cls: ClsService,
private readonly em: EntityManager,
private readonly explorer: TransactionMethodExplorer,
) {}

bindTransactional() {
this.explorer
.listTransactionalCommandHandler()
.forEach((handler: ICommandHandler) => {
handler.execute = this.createWrappedFunction(handler.execute, true);
});
this.explorer
.listNotTransactionalEventHandler()
.forEach((handler: IEventHandler) => {
handler.handle = this.createWrappedFunction(handler.handle, false);
});
this.explorer
.listTransactionalEventHandler()
.forEach((handler: IEventHandler) => {
handler.handle = this.createWrappedFunction(handler.handle, true);
});
}

private createWrappedFunction(originalFn: AsyncFn, transaction: boolean) {
const wrapper = async (...args: any[]) => {
const entityManager: EntityManager | undefined = this.cls.get(
TRANSACTIONAL_ENTITY_MANAGER,
);
if (!entityManager) {
throw new InternalServerErrorException('Entity manager is not exists');
}

if (transaction) {
return await entityManager.transactional(async (manager) => {
return await this.cls.runWith(
{ [TRANSACTIONAL_ENTITY_MANAGER]: manager },
() => originalFn(args),
);
});
} else {
return await this.cls.runWith(
{ [TRANSACTIONAL_ENTITY_MANAGER]: this.em },
() => originalFn(args),
);
}
};
Object.setPrototypeOf(wrapper, originalFn);
return wrapper;
}
}
Loading

0 comments on commit b841211

Please sign in to comment.