управляемое введение

При разработке приложения в соответствии с традиционной архитектурой часто используется одна и та же модель данных для операций чтения и записи. Несмотря на то, что он может хорошо работать в небольших приложениях или в приложениях, которые полагаются на простую логику, когда у нас более сложная среда, нам в конечном итоге требуются более сложные решения.

В этой статье мы собираемся изучить основы шаблона проектирования CQRS, его преимущества и недостатки. Также мы собираемся погрузиться в то, как мы можем реализовать такой шаблон, используя NestJS Framwork для Node.js.

obs: Если вы еще не проверили NestJS Framework, я полностью рекомендую его. Вдохновленный архитектурой Angular, это простой, эффективный и масштабируемый фреймворк Node.js.

Шаблон CQRS

«В большинстве случаев нет причин усложнять приложения малого и среднего размера. Однако иногда этого недостаточно, и когда наши потребности становятся более сложными, мы хотим иметь масштабируемые системы с прямым потоком данных ».
NestJS на CQRS

При запросе данных с сервера приложение может выполнять несколько запросов и использовать DTO (объекты передачи данных) в соответствии с запрошенными данными. Также, когда данные необходимо изменить или добавить в базу данных, несколько операций могут быть выполнены с использованием DTO.

Сопоставление этих объектов и понимание их различных форматов может стать настоящей проблемой в более крупных приложениях. Когда выполняется операция записи, вы также можете получить сложные модели, которые реализуют несколько проверок и бизнес-логику.

Слишком часто существует несоответствие между представлением данных между операциями чтения и записи, что также затрудняет контроль того, какие данные отображаются в каждом контексте. Следовательно, такой подход может отрицательно сказаться на производительности, сложности и безопасности приложений.

Адреса шаблона CQRS разделяют операции чтения и записи на отдельные модели с использованием команд для обновления данных и запросов для чтения данных.

Команды должны быть ориентированы на задачи, которые они должны выполнять.
Команды могут быть помещены в очередь для асинхронной обработки.

Запросы никогда не изменяют базу данных.
Запросы возвращают DTO без знания предметной области.

ЗА

Разделение. Разделение задач - операций чтения (запросов) и записи (команд) - помогает иметь более гибкие модели, ускоряет разработку и упрощает обслуживание.

Масштабируемость. Это позволяет вашим запросам и командам масштабироваться независимо, что может привести к меньшему количеству конфликтов блокировки.

Оптимизированные схемы. Схемы для операций записи и чтения оптимизированы для своих целей, а также их легче понять и поддерживать.

Безопасность. Упрощает проверку того, что только нужные сущности в правильном контексте могут иметь доступ для операций чтения и записи данных.

МИНУСЫ

Сложность. Основная проблема CQRS заключается в том, что, хотя основы CQRS просты, его реализация может быть сложной и дорогостоящей. Следовательно, его следует использовать только тогда, когда это имеет смысл; когда масштабируемость является важной проблемой приложения, когда вы работаете со сложными моделями и бизнес-логикой и т. д. В противном случае вместо упрощения вы фактически вносите в приложение ненужную большую сложность.

Согласованность. Имея разные модели для ваших запросов и команд, может стать труднее поддерживать согласованность между ними, особенно при обработке сложных данных.

Реализация шаблона CQRS в NestJS

Хорошо, теперь давайте погрузимся в то, как мы можем реализовать этот шаблон, чтобы лучше понять, как он работает. Мы собираемся сделать это с помощью NestJS Framework. NestJS предоставляет нам предварительно созданный модуль CQRS, который значительно упростит нашу жизнь при использовании этого шаблона с NestJS.

Во-первых, давайте установим интерфейс командной строки NestJS, создадим с ним новый проект и добавим в наш проект модуль Nest CQRS.

npm install -g @nestjs/cli
nest new project-cqrs
cd project-cqrs
npm install @nestjs/cqrs --save
npm run start

Для демонстрации мы собираемся реализовать простую функцию Users CRUD. Мы собираемся создать пользовательский контроллер, в котором мы собираемся использовать шаблон CQRS. Давайте разделим пользователей как функцию, создадим для нее модуль, сам контроллер и две папки: commands и запросы. Затем мы собираемся создать каждый из необходимых нам запросов и команд.

Ниже представлена ​​структура и файлы нашего проекта.
Мы собираемся углубиться во все функции users.

Запросы

Давайте построим наш ListUsersQuery шаг за шагом в качестве примера.
Сначала мы определяем класс как наш Query, который будет содержать информацию, необходимую его Обработчик. Поскольку мы собираемся перечислить пользователей, важно знать номер запрашиваемой страницы и размер страницы.

export class ListUsersQuery {
  constructor(
    public page: number = 1,
    public pageSize: number = 10
  ) { }
}

Затем мы создаем обработчик, который будет запускаться, когда этот Query вызывается в QueryBus.

QueryBus - это поток запросов. По запросу он делегирует запросы своим эквивалентным обработчикам. У каждого запроса должен быть соответствующий обработчик. Эта связь достигается с помощью декоратора @QueryHandler.

@QueryHandler(ListUsersQuery)
export class ListHandler implements IQueryHandler<ListUsersQuery> {}

Мы создали ListHandler, который будет обрабатывать ранее созданный запрос ListUsersQuery. Позже мы увидим, как этот Query может быть вызван с помощью QueryBus.

Теперь нам нужно разместить логику внутри нашего Handler. Когда вызывается Query, он вызывает метод execute обработчика Handler. Здесь будет находиться наша логика Handler.

@QueryHandler(ListUsersQuery)
export class ListHandler implements IQueryHandler<ListUsersQuery> {
  constructor(
    // Here we would inject what is necessary to retrieve our data
  ) { }
  public async execute(query: ListUsersQuery): Promise<User[]> {
    // Here we are going to have any necessary logic related
    // to that Query and return the requested information
    // such as a service method call
  }
}

Итак, вот как будет выглядеть наш ListUsersQuery:

export class ListQuery {
  constructor(
    public page: number = 1,
    public pageSize: number = 10
  ) { }
}
@QueryHandler(ListUsersQuery)
export class ListHandler implements IQueryHandler<ListUsersQuery> {
  constructor(
    // Here we would inject what is necessary to retrieve our data
  ) { }
  public async execute(query: ListUsersQuery): Promise<User[]> {
    // Here we are going to have any necessary logic related
    // to that Query and return the request information
    // such as a service method call
  }
}

А ниже показано, как будет выглядеть другой наш запрос, GetUserById, ; теперь добавляем пример кода доступ к репозиторию с помощью TypeORM для извлечения данных.

// All we need is the id of the user we want to retrieve the data
export class GetUserByIdQuery {
  constructor(
    public id: number
  ) { }
}
@QueryHandler(GetUserByIdQuery)
export class GetUserByIdHandler implements
  IQueryHandler<GetUserByIdQuery> {
  // We inject our TypeORM repository to fetch the user data
  constructor(
    @InjectRepository(User)
    private readonly _repository: Repository<User>
  ) { }
  public async execute(query: GetUserByIdQuery): Promise<User> {
    // We fetch user data and return it on the execute method       
    return await this._repository.findOne(query.id);
  }
}

Команды

Давайте создадим наш AddUserCommand шаг за шагом в качестве примера.
Все это очень похоже на то, как мы создавали наши запросы.
Сначала мы определяем class в качестве нашей Command, который будет содержать информацию, необходимую его Handler. Здесь нам потребуется добавить новую информацию о пользователе.

export class AddUserCommand {
  constructor(
    public name: string,
    public email: string,
    public birthdate: Date
  ) { }
}

Затем мы создаем наш Handler, который будет запускаться, когда эта Command вызывается в CommandBus.

CommandBus - это поток команд. По запросу он делегирует команды своим эквивалентным обработчикам. У каждой команды должен быть соответствующий обработчик. Эта ассоциация достигается с помощью декоратора @CommandHandler.

@CommandHandler(AddUserCommand)
export class AddUserHandler implements IQueryHandler<AddUserCommand> {}

Теперь мы создали AddUserHandler, который будет обрабатывать команду AddUserCommand, которую мы создали ранее. Позже мы увидим, как эта Command может быть вызвана с помощью CommandBus.

Теперь нам нужно разместить логику внутри нашего Handler. Когда вызывается Command, она вызывает метод execute обработчика . Здесь будет находиться наша логика Handler.

@CommandHandler(AddUserCommand)
export class AddUserHandler implements IQueryHandler<AddUserCommand> {
    constructor(
      // Here we would inject what is necessary to persist our data
    ) { }
    public async execute(query: ListUsersQuery): Promise<User> {
      // Here we are going to have any necessary logic related
      // to that Command and do any change operations
    }
}

Итак, вот как будет выглядеть наш AddUserCommand:

export class AddUserCommand {
  constructor(
    public name: string,
    public email: string,
    public birthdate: Date
  ) { }
}
@CommandHandler(AddUserCommand)
export class AddUserHandler implements IQueryHandler<AddUserCommand> {
    constructor(
      // Here we would inject what is necessary to persist our data
    ) { }
    public async execute(query: ListUsersQuery): Promise<User> {
      // Here we are going to have any necessary logic related
      // to that Command and do any change operations
    }
}

А ниже показано, как будут выглядеть другие наши Commands, UpdateUser и DeleteUser,, теперь добавляем пример кода для доступа к репозиторию с использованием TypeORM для внесения изменений в базу данных .

UpdateUserCommand

export class UpdateUserCommand {
  constructor(
    public id: number,
    public name?: string,
    public email?: string,
    public birthdate?: Date
  ) { }
}
@CommandHandler(UpdateUserCommand)
export class UpdateUserHandler implements IQueryHandler<UpdateUserCommand>
{
    constructor(
      @InjectRepository(User)
      private readonly _repository: Repository<User>
    ) { }
    public async execute(request: UpdateUserCommand): Promise<User>
    {
      const user = await this._repository.findOne(request.id);
      if (!user)
        throw new NotFoundException('User does not exist');
      user.name = request.name || user.name;
      user.email = request.email || user.email;
      user.birthdate = request.birthdate|| user.birthdate;
      return await this._repository.save( user );
    }
}

DeleteUserCommand

export class DeleteUserCommand {
  constructor(
    public id: number
  ) { }
}
@CommandHandler(DeleteUserCommand)
export class DeleteUserHandler implements IQueryHandler<DeleteUserCommand>
{
    constructor(
      @InjectRepository(User)
      private readonly _repository: Repository<User>
    ) { }
    public async execute(request: DeleteUserCommand):
      Promise<DeleteResult>
    {
      return await this._repository.delete({
        'id': request.id
      });
    }
}

Контроллер

Теперь, когда у нас созданы наши запросы и команды, нам нужно собрать все это воедино и создать наш UserController для конечных точек User CRUD.

Прежде всего, мы создаем наш класс UserController, используя декоратор @Controller и указав префикс маршрута 'user' для нашего контроллера. Затем мы предоставляем ему QueryBus и CommandBus в конструкторе.

@Controller('user')
export class UserController {
  constructor(
    private readonly commandBus: CommandBus,
    private readonly queryBus: QueryBus
  ) { }
}

Теперь мы можем создавать наши конечные точки, которые будут вызывать QueryBus и CommandBus соответственно.

@Controller('user')
export class UserController {
  constructor(
    private readonly commandBus: CommandBus,
    private readonly queryBus: QueryBus
  ) { }
  @Get()
  public async getAllOngs(
    @Query() request: ListUsersQuery,
    @Res() response
  ) {
  
    const users = await this.queryBus.execute(
      new ListUsersQuery(
        request.page,
        request.pageSize
      )
    );
    response.status(HttpStatus.OK).json(users);
  }
}

Мы определили декораторы @Get, @Post, @Put и @Delete, чтобы указать желаемый метод HTTP-запроса. Кроме того, мы использовали декоратор @Query для получения параметров запроса из запроса, декоратор @Param для получения маршрута Params из запроса и декоратор @Body для получения тела запроса.

Затем все, что нам нужно было сделать, это вызвать QueryBus или CommandBus в соответствии с целью конечной точки, вызвав ее метод execute, предоставив желаемый запрос или команду и передачу необходимой информации. С результатом операции мы можем затем вернуть ответ с результатом операции и любыми необходимыми данными. Готово!

Модуль

Теперь все, что осталось сделать, это зарегистрировать все это в UserModule. Сначала мы создаем индексный файл для команд и еще один для запросов. Это упростит его визуализацию и предоставление в модуле. Файлы index будут выглядеть следующим образом:

// commands > _index.ts
export const CommandHandlers = [
  AddUserHandler,
  UpdateUserHandler,
  DeleteUserHandler
];
// queries > _index.ts
export const QueryHandlers = [
  ListHandler,
  GetUserByIdHandler
];

Теперь мы можем создать наш UserModule, объявить наш UserController и предоставить наши запросы и команды. Затем мы импортируем его в наш AppModule, и все готово!

@Module({
  imports: [
    CqrsModule,
    ...
    // Here might be included other Modules, such as
    // the TypeOrmModule
  ],
  controllers: [
    UserController
  ],
  providers: [
    ...QueryHandlers,
    ...CommandHandlers
  ]
})
export class UserModule { }

Поиск событий и CQRS

Некоторые реализации CQRS используют паттерн Event Sourcing. При использовании этого шаблона состояние приложения сохраняется как последовательность событий, в которой каждое событие представляет набор изменений данных. Используя поток событий, он предотвращает конфликты обновлений и максимизирует производительность и масштабируемость. Тем не менее, Event Sourcing добавляет даже большей сложности к дизайну приложения, который и без того сложен в этом шаблоне.

Важно отметить, что приложения, основанные на шаблоне источника событий, являются только в конечном итоге согласованными, поскольку существует некоторая задержка между возникновением события и хранилище данных, которое обновляется. Кроме того, постоянная обработка событий для определенных сущностей или наборов сущностей может потребовать значительного времени обработки и использования ресурсов.

Шаблоны проектирования, такие как CQRS, при правильном применении могут помочь нам писать и поддерживать качественный код в наших приложениях. Это очень полезный и используемый шаблон, основы которого важно знать. Кроме того, как мы уже выяснили, NestJS упрощает реализацию такого шаблона, позволяя создавать масштабируемые и эффективные приложения.

Надеюсь, это поможет! 😉

Использованная литература: