управляемое введение
При разработке приложения в соответствии с традиционной архитектурой часто используется одна и та же модель данных для операций чтения и записи. Несмотря на то, что он может хорошо работать в небольших приложениях или в приложениях, которые полагаются на простую логику, когда у нас более сложная среда, нам в конечном итоге требуются более сложные решения.
В этой статье мы собираемся изучить основы шаблона проектирования 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 упрощает реализацию такого шаблона, позволяя создавать масштабируемые и эффективные приложения.
Надеюсь, это поможет! 😉