Представь: ты — шеф-повар на оживлённой кухне. Ты бы позволил своим официантам нарезать овощи, пока они принимают заказы? Конечно, нет! Именно поэтому нам нужен принцип разделения ответственности за команды и запросы (CQRS) в наших приложениях на Go. Давайте разберёмся в этой сложности с точностью мастера суши.
Мясная лавка CQRS: разделение чтения и записи
В традиционной модели CRUD наша кодовая база часто выглядит как холодильник в моей комнате в общежитии — всё перемешано в сомнительных сочетаниях. CQRS решает эту проблему, внося чёткое разделение:
type Command interface {
Validate() error
}
type Query interface {
Execute(ctx context.Context) (interface{}, error)
}
Наше первое архитектурное решение: команды изменяют состояние, но не возвращают ничего, кроме ошибок (например, сообщая кому-то, что он положил молоко перед хлопьями), а запросы возвращают данные, но оставляют состояние неизменным (например, вежливо спрашивая время).
Строительство кухни CQRS
Давайте настроим структуру нашего проекта:
/cmd
/pkg
/cqrs
command/
handler.go
bus.go
query/
handler.go
bus.go
/domain
user/
commands.go
queries.go
projections.go
Совет: если это кажется излишним для вашего небольшого проекта, помните — даже ракетные корабли начинаются с хорошего проектирования!
Сторона команд: где происходят изменения
Давайте реализуем регистрацию пользователя:
type RegisterUserCommand struct {
Email string
Password string
}
func (c *RegisterUserCommand) Validate() error {
if !strings.Contains(c.Email, "@") {
return errors.New("email must contain @")
}
return nil
}
type RegisterUserHandler struct {
repo UserRepository
}
func (h *RegisterUserHandler) Handle(ctx context.Context, cmd *RegisterUserCommand) error {
// Реализация магии здесь
return nil
}
Обратите внимание, как наша команда не возвращает созданного пользователя — это работа стороны запросов! Это как готовить стейк, не подавая его сразу на стол.
Сторона запросов: искусство извлечения данных
Теперь давайте получим некоторые данные:
type GetUserByIDQuery struct {
UserID uuid.UUID
}
func (q *GetUserByIDQuery) Execute(ctx context.Context) (*UserDTO, error) {
// Кэш? База данных? MongoDB? Да!
return &UserDTO{
ID: q.UserID,
Email: "[email protected]",
JoinDate: time.Now().Add(-24 * time.Hour),
}, nil
}
Прелесть здесь в том, что наша модель чтения может полностью отличаться от модели записи. Нужно объединить данные из трёх микросервисов? Пожалуйста!
Шина сообщений: почтовое отделение CQRS
Давайте соединим всё вместе с помощью шины сообщений:
Этот асинхронный поток позволяет нам независимо масштабировать операции чтения и записи — как иметь отдельные обеденное и вечернее обслуживание в ресторане.
Прогнозы: повара данных в реальном времени
Чтобы поддерживать актуальность моделей чтения, требуется магия прогнозирования:
func ProjectUserDetails(ctx context.Context, eventStream <-chan Event) {
for {
select {
case event := <-eventStream:
switch e := event.(type) {
case *Событие регистрации пользователя:
updateReadModel(e.UserID, e.Email)
case *Событие изменения электронной почты пользователя:
updateReadModel(e.UserID, e.NewEmail)
}
case <-ctx.Done():
return
}
}
}
Помните: согласованность в конечном счёте подобна хорошему сыру — со временем она становится только лучше!
Когда использовать этот шаблон
CQRS особенно полезен, когда: — Вам нужны разные масштабы для операций чтения и записи (соотношение 100:1 является обычным) — Необходимо отделить сложную бизнес-логику от отчётности — Требуются несколько представлений данных — Вы хотите реализовать источник событий Но будьте осторожны! Как и добавление хабанеро в блюдо, CQRS усложняет задачу. Начните с простого, а затем внедряйте эти шаблоны по мере необходимости.
Дзен-сад CQRS
Вот наш окончательный обзор архитектуры:
И помните: истинная мощь CQRS заключается в понимании того, что иногда лучший способ справиться со сложностью — это разделить и победить. Теперь идите и пишите (и читайте) ответственно!