Представь: ты — шеф-повар на оживлённой кухне. Ты бы позволил своим официантам нарезать овощи, пока они принимают заказы? Конечно, нет! Именно поэтому нам нужен принцип разделения ответственности за команды и запросы (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

Давайте соединим всё вместе с помощью шины сообщений:

sequenceDiagram participant Клиент participant Командная шина participant Обработчик команд participant Хранилище событий participant Шина запросов participant Обработчик запросов participant БД для чтения Клиент->>Командная шина: Команда регистрации пользователя Командная шина->>Обработчик команд: Обработать команду Обработчик команд->>Хранилище событий: Сохранить событие регистрации пользователя Хранилище событий-->>Обработчик команд: ACK Обработчик команд-->>Командная шина: Успешно Командная шина-->>Клиент: ОК Клиент->>Шина запросов: Запрос на получение пользователя Шина запросов->>Обработчик запросов: Выполнить запрос Обработчик запросов->>БД для чтения: ВЫБРАТЬ... БД для чтения-->>Обработчик запросов: Данные Обработчик запросов-->>Шина запросов: Результат Шина запросов-->>Клиент: UserDTO

Этот асинхронный поток позволяет нам независимо масштабировать операции чтения и записи — как иметь отдельные обеденное и вечернее обслуживание в ресторане.

Прогнозы: повара данных в реальном времени

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

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

Вот наш окончательный обзор архитектуры:

graph TD A[Клиент] -->|Команды| B[Командная шина] B --> C[Обработчик команд] C --> D[Хранилище событий] D --> E[Прогнозы] E --> F[Модель чтения] A -->|Запросы| G[Шина запросов] G --> H[Обработчик запросов] H --> F F -->|Результаты| A

И помните: истинная мощь CQRS заключается в понимании того, что иногда лучший способ справиться со сложностью — это разделить и победить. Теперь идите и пишите (и читайте) ответственно!