CQRS

CQRS

가장 간단한 CRUD 응용 프로그램의 흐름은 다음 단계를 사용하여 설명할 수 있습니다.

  1. 컨트롤러 계층은 HTTP 요청을 처리하고 작업을 서비스에 위임합니다.

  2. 서비스 계층은 대부분의 비즈니스 로직이 수행되는 장소입니다.

  3. 서비스 는 저장소 /DAO를 사용하여 엔티티를 변경/지속합니다.

  4. 엔티티는 setter 및 getter와 함께 값의 컨테이너 역할을합니다.

대부분의 경우 중소 규모의 응용 프로그램을 더 복잡하게 만들 이유가 없습니다. 그러나 때로는 충분하지 않으며 우리의 요구가 더 정교 해지고있을 때 데이터 흐름이 간단한 확장 가능한 시스템을 원합니다.

따라서 아래에서 설명하는 요소인 경량 CQRS 모듈을 제공합니다.

Commands

응용 프로그램을 이해하기 쉽게하려면 각 변경 사항 앞에 Command 가 있어야합니다. 명령이 전달될 때 응용 프로그램이 응답해야 합니다. 명령은 서비스 (또는 컨트롤러/게이트웨이에서 직접)에서 발송되고 해당하는 명령 처리기에서 사용될 수 있습니다.

@@filename(heroes-game.service)
@Injectable()
export class HeroesGameService {
  constructor(private readonly commandBus: CommandBus) {}

  async killDragon(heroId: string, killDragonDto: KillDragonDto) {
    return this.commandBus.execute(
      new KillDragonCommand(heroId, killDragonDto.dragonId)
    );
  }
}
@@switch
@Injectable()
@Dependencies(CommandBus)
export class HeroesGameService {
  constructor(commandBus) {
    this.commandBus = commandBus;
  }

  async killDragon(heroId, killDragonDto) {
    return this.commandBus.execute(
      new KillDragonCommand(heroId, killDragonDto.dragonId)
    );
  }
}

다음은 KillDragonCommand를 전달하는 샘플 서비스입니다. 명령이 어떻게 보이는지 봅시다:

@@filename(kill-dragon.command)
export class KillDragonCommand {
  constructor(
    public readonly heroId: string,
    public readonly dragonId: string,
  ) {}
}
@@switch
export class KillDragonCommand {
  constructor(heroId, dragonId) {
    this.heroId = heroId;
    this.dragonId = dragonId;
  }
}

CommandBus스트림 명령입니다. 동등한 핸들러에 명령을 위임합니다. 각 명령에는 해당 명령 처리기가 있어야합니다.

@@filename(kill-dragon.handler)
@CommandHandler(KillDragonCommand)
export class KillDragonHandler implements ICommandHandler<KillDragonCommand> {
  constructor(private readonly repository: HeroRepository) {}

  async execute(command: KillDragonCommand) {
    const { heroId, dragonId } = command;
    const hero = this.repository.findOneById(+heroId);

    hero.killEnemy(dragonId);
    await this.repository.persist(hero);
  }
}
@@switch
@CommandHandler(KillDragonCommand)
@Dependencies(HeroRepository)
export class KillDragonHandler {
  constructor(repository) {
    this.repository = repository;
  }

  async execute(command) {
    const { heroId, dragonId } = command;
    const hero = this.repository.findOneById(+heroId);

    hero.killEnemy(dragonId);
    await this.repository.persist(hero);
  }
}

이제 모든 애플리케이션 상태 변경은 Command 발생의 결과입니다. 로직은 핸들러에 캡슐화됩니다. 원하는 경우 여기에 로깅을 추가하거나 그 이상을 추가할 수 있으며 데이터베이스에 명령을 유지할 수 있습니다 (예: 진단 목적).

Events

처리기에 명령을 캡슐화했기 때문에 명령 구조 간의 상호 작용을 막을 수 있습니다. 응용 프로그램 구조는 응답 이 아니라 유연하지 않습니다. 해결책은 이벤트를 사용하는 것입니다.

@@filename(hero-killed-dragon.event)
export class HeroKilledDragonEvent {
  constructor(
    public readonly heroId: string,
    public readonly dragonId: string,
  ) {}
}
@@switch
export class HeroKilledDragonEvent {
  constructor(heroId, dragonId) {
    this.heroId = heroId;
    this.dragonId = dragonId;
  }
}

이벤트는 비동기적입니다. models 또는 EventBus를 사용하여 직접 발송됩니다. 이벤트를 전달하려면 모델이 AggregateRoot 클래스를 확장해야 합니다.

@@filename(hero.model)
export class Hero extends AggregateRoot {
  constructor(private readonly id: string) {
    super();
  }

  killEnemy(enemyId: string) {
    // logic
    this.apply(new HeroKilledDragonEvent(this.id, enemyId));
  }
}
@@switch
export class Hero extends AggregateRoot {
  constructor(id) {
    super();
    this.id = id;
  }

  killEnemy(enemyId) {
    // logic
    this.apply(new HeroKilledDragonEvent(this.id, enemyId));
  }
}

apply()메소드는 모델과 EventPublisher 클래스 사이에 관계가 없기 때문에 아직 이벤트를 전달하지 않습니다. 모델과 게시자를 연결하는 방법은 무엇입니까? 커맨드 핸들러내에서 퍼블리셔 mergeObjectContext()메소드를 사용해야합니다.

@@filename(kill-dragon.handler)
@CommandHandler(KillDragonCommand)
export class KillDragonHandler implements ICommandHandler<KillDragonCommand> {
  constructor(
    private readonly repository: HeroRepository,
    private readonly publisher: EventPublisher,
  ) {}

  async execute(command: KillDragonCommand) {
    const { heroId, dragonId } = command;
    const hero = this.publisher.mergeObjectContext(
      await this.repository.findOneById(+heroId),
    );
    hero.killEnemy(dragonId);
    hero.commit();
  }
}
@@switch
@CommandHandler(KillDragonCommand)
@Dependencies(HeroRepository, EventPublisher)
export class KillDragonHandler {
  constructor(repository, publisher) {
    this.repository = repository;
    this.publisher = publisher;
  }

  async execute(command) {
    const { heroId, dragonId } = command;
    const hero = this.publisher.mergeObjectContext(
      await this.repository.findOneById(+heroId),
    );
    hero.killEnemy(dragonId);
    hero.commit();
  }
}

이제 모든 것이 예상대로 작동합니다. 이벤트가 즉시 전달되지 않으므로 commit()이벤트가 필요합니다. 분명히, 객체는 선재할 필요가 없습니다. 타입 컨텍스트도 쉽게 병합할 수 있습니다 :

const HeroModel = this.publisher.mergeContext(Hero);
new HeroModel('id');

그게 전부입니다. 모델은 이제 이벤트를 게시할 수 있습니다. 그리고 우리는 그것들을 처리해야 합니다. 또한, 우리는 EventBus를 사용하여 수동으로 이벤트를 생성할 수 있습니다 :

this.eventBus.publish(new HeroKilledDragonEvent());

info 힌트 이벤트 버스는 주입 가능한 클래스입니다.

각 이벤트에는 여러 개의 이벤트 핸들러가 있을 수 있습니다.

@@filename(hero-killed-dragon.handler)
@EventsHandler(HeroKilledDragonEvent)
export class HeroKilledDragonHandler implements IEventHandler<HeroKilledDragonEvent> {
  constructor(private readonly repository: HeroRepository) {}

  handle(event: HeroKilledDragonEvent) {
    // logic
  }
}

이제 write logic을 이벤트 핸들러로 옮길 수 있습니다.

Sagas

이 유형의 이벤트 중심 아키텍처는 응용 프로그램 응답성 및 확장성을 향상시킵니다. 이제 이벤트가 있을 때 다양한 방식으로 간단하게 반응할 수 있습니다. Sagas는 아키텍처 관점에서 마지막 빌딩 블록입니다.

Sagas는 엄청나게 강력한 기능입니다. Single saga는 1..* 이벤트를 수신 할 수 있습니다. [... ] 이벤트 스트림을 결합, 병합, 필터링 할 수 있습니다. RxJS 라이브러리는 마법의 근원입니다. 간단히 말해서, 각 saga는 명령이 포함된 Observable을 반환해야 합니다. 이 명령은 비동기적으로 전달됩니다.

@@filename(heroes-game.saga)
@Injectable()
export class HeroesGameSagas {
  @Saga()
  dragonKilled = (events$: Observable<any>): Observable<ICommand> => {
    return events$.pipe(
      ofType(HeroKilledDragonEvent),
      map((event) => new DropAncientItemCommand(event.heroId, fakeItemID)),
    );
  }
}
@@switch
@Injectable()
export class HeroesGameSagas {
  @Saga()
  dragonKilled = (events$) => {
    return events$.pipe(
      ofType(HeroKilledDragonEvent),
      map((event) => new DropAncientItemCommand(event.heroId, fakeItemID)),
    );
  }
}

info 힌트 ofType 연산자는@nestjs/cqrs 패키지에서 가져옵니다.

우리는 규칙을 선언했습니다 - 어떤 영웅이 용을 죽이면 고대 아이템이 떨어집니다. 그런 다음 적절한 처리기가 DropAncientItemCommand를 전달하고 처리합니다.

Queries

CqrsModule은 쿼리 처리에도 유용할 수 있습니다. QueryBusCommandsBus와 동일하게 작동합니다. 또한 쿼리 핸들러는 IQueryHandler 인터페이스를 구현하고 @QueryHandler()데코레이터로 표시해야 합니다.

Setup

우리가 마지막으로 돌봐야 할 것은 전체 메커니즘을 설정하는 것입니다.

@@filename(heroes-game.module)
export const CommandHandlers = [KillDragonHandler, DropAncientItemHandler];
export const EventHandlers =  [HeroKilledDragonHandler, HeroFoundItemHandler];

@Module({
  imports: [CqrsModule],
  controllers: [HeroesGameController],
  providers: [
    HeroesGameService,
    HeroesGameSagas,
    ...CommandHandlers,
    ...EventHandlers,
    HeroRepository,
  ]
})
export class HeroesGameModule {}

Summary

CommandBus, QueryBusEventBus관찰 가능(Observables)입니다. 이벤트 소싱을 통해 전체 스트림을 쉽게 구독하고 응용 프로그램을 보강할 수 있습니다.

실제 사례는 여기에서 확인할 수 있습니다.

Last updated