Реализация CQRS на Go: практическое руководство по масштабируемой архитектуре

Реализация CQRS на Go без лишних сложностей

Содержимое страницы

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

Полезная версия гораздо проще: раздели код, изменяющий состояние, от кода, считывающего состояние, и позволь каждой стороне развиваться под свои задачи. Мартин Фаулер описывает CQRS как использование одной модели для обновления информации и другой — для чтения, при этом предупреждая, что для большинства систем это добавляет рискованную сложность. Microsoft делает тот же основной вывод, но в более операционных терминах: разделите модели чтения и записи, чтобы каждую из них можно было оптимизировать независимо.

CQRS в Go — команды и запросы как отдельные пути через узел gopher

Если вы работаете на Go, эта идея необычно хорошо ложится на язык. Go хорош в явных границах, маленьких интерфейсах, скучных типах данных и пакетах, ориентированных на use-case. Это делает базовый CQRS в Go гораздо менее театральным, чем он часто выглядит на слайдах конференций. Вам не нужно event sourcing, Kafka или три базы данных, чтобы начать. На самом деле, как руководство Microsoft по CQRS, так и примеры Go от Three Dots Labs показывают, что простая реализация может использовать одно и то же хранилище, с отдельными обработчиками команд и запросов, добавленными первыми, а более сложная инфраструктура вводится только тогда, когда проблема действительно того требует.

Что CQRS означает на самом деле

В основе CQRS проводится жесткая линия между командами и запросами. Запрос считывает данные и не должен изменять состояние системы. Команда изменяет состояние и не должна возвращать доменные данные в качестве основного результата. Three Dots Labs формулируют это в практических терминах Go: запросы возвращают данные, а команды вносят изменения, при этом ошибки являются нормальным результатом команды. Это базовый ход. Все остальное — опционально.

Распространенное заблуждение заключается в том, что CQRS автоматически подразумевает отдельные базы данных, асинхронные проекции или event sourcing. Это не так. Руководство по паттернам Microsoft явно относит отдельные хранилища данных к более продвинутой форме, а не к умолчанию, а Three Dots Labs демонстрируют реализацию на Go, где запросы читают из той же базы данных, что и записи, потому что этого достаточно для данной системы. Если ваша статья учит чему-то одному четко, пусть это будет следующее: CQRS — это в первую очередь выбор моделирования и структуры приложения, а не обязательный пакет для распределенных систем.

Другой важный момент — это именование. Команды должны моделировать бизнес-намерения, а не мутации хранения. Пример Microsoft контрастирует «Забронировать номер в отеле» с «Установить ReservationStatus на Reserved», а Three Dots Labs рекомендуют имена, близкие к тому, как говорят эксперты по предметной области, например, «ScheduleTraining» или «CancelTraining», а не общие глаголы «Create» и «Delete». В Go эта дисциплина именования окупается, потому что имена команд часто становятся именами типов, именами обработчиков и границами пакетов.

Почему команды обращаются к нему

CQRS становится привлекательным, когда одна модель CRUD начинает плохо справляться со слишком многими задачами. В руководстве Microsoft перечислены типичные точки давления: представления данных для чтения и записи расходятся, конкурентные обновления создают конфликты блокировок, производительность чтения страдает от сложности запросов, а общие сущности превращают правила безопасности в путаницу. Иными словами, проблема не в том, что CRUD морально плох. Проблема в том, что одна модель вынуждена удовлетворять несовместимые требования одновременно.

Это особенно характерно для технических продуктов. Записи обычно заботятся о валидации, инвариантах, транзакциях и бизнес-правилах. Чтение обычно заботится о фильтрах, соединениях, агрегации, кэшировании, сортировке и подаче именно той формы данных, которая нужна странице или API. CQRS позволяет стороне записи оставаться строгой и ориентированной на домен, в то время как сторона чтения остается прагматичной и ориентированной на DTO. Microsoft явно рекомендует модель записи, сфокусированную на валидации и согласованности, и модель чтения, сфокусированную на DTO или проекциях, оптимизированных для представления и отзывчивости.

Также есть выгода на уровне команды. Three Dots Labs утверждают, что разделение команд и запросов улучшает расцепление, делает поток выполнения яснее и ускоряет адаптацию, потому что разработчики могут проверить небольшой список доступных команд и запросов, вместо того чтобы гоняться за логикой через случайные слои сервисов. Microsoft также отмечает, что CQRS особенно полезен в средах совместной работы, где несколько пользователей обновляют одни и те же данные, и команды должны иметь достаточную гранулярность, чтобы предотвращать или разрешать конфликты.

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

Преимущества и цена

Базовый CQRS имеет реальные преимущества, даже прежде чем вы добавите любую маршрутизацию сообщений или отдельные хранилища. Он дает вам меньшие модели команд, меньшие модели запросов, более четкие use-case и более очевидные места для применения сквозных аспектов, таких как логирование и инструментирование. Three Dots Labs явно называют лучшую организацию кода, расцепление и более простые модели немедленными выгодами, в то время как Microservices.io выделяет более простые модели команд и запросов, а также поддержку денормализованных, масштабируемых представлений для чтения.

Как только проблема этого требует, CQRS также открывает дверь к более сильной оптимизации стороны чтения. Руководство Microsoft отмечает, что отдельные модели чтения могут использовать DTO, проекции, реплики только для чтения или даже совершенно другую технологию хранения. Оно также указывает на материализованные представления как на способ избежать тяжелых соединений и путей запросов, перегруженных ORM. Если вы оцениваете, какой слой доступа к данным использовать на стороне записи, Сравнение ORM для Go для PostgreSQL описывает компромиссы между GORM, Ent, Bun и sqlc в практических терминах. Именно здесь CQRS начинает приносить операционную, а не только структурную выгоду.

Цена также реальна. Предупреждение Фаулера остается правильной отправной точкой: для большинства систем CQRS добавляет рискованную сложность. Microsoft перечисляет увеличенную сложность и eventual consistency как основные соображения, в то время как Microservices.io добавляет потенциальное дублирование кода и задержку репликации в представлениях для чтения. Если вы разделите хранилища, вы также наследуете задачу по их синхронизации, обычно через события, не полагаясь на аккуратную распределенную транзакцию между вашей базой данных и брокером.

Event sourcing не отменяет этот счет; он меняет его форму. Руководство Microsoft по CQRS говорит, что event sourcing может сделать хранилище событий единственным источником истины и позволить вам перестроить материализованные представления, проиграв историю, в то время как Event Horizon указывает на отслеживаемость и аудит-логирование как на основные преимущества. Но Microsoft также предупреждает, что генерация представлений, воспроизведение и обработка событий добавляют больше сложности проектирования, и предлагает снимки для уменьшения затрат на воспроизведение. Вот почему я предпочитаю объяснять event sourcing как «CQRS плюс второе трудное решение», а не как входной билет.

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

Простая реализация CQRS в Go

Разумный первый шаг в Go — сохранить одну базу данных и разделить только слой приложения. Команды владеют бизнес-правилами и персистентностью. Запросы возвращают модели чтения, сформированные для вызывающих. Это именно тот вид базового CQRS, который Three Dots Labs рекомендуют, прежде чем обращаться к асинхронным шинам или отдельным хранилищам для чтения.

Начните с команд

package blog

import (
	"context"
	"errors"
	"time"
)

type PublishPostCommand struct {
	Title   string
	Slug    string
	BodyMD  string
	Author  string
}

type PostRepository interface {
	NextID(ctx context.Context) (string, error)
	Save(ctx context.Context, post Post) error
}

type Post struct {
	ID          string
	Title       string
	Slug        string
	BodyMD      string
	Author      string
	PublishedAt time.Time
}

type PublishPostHandler struct {
	Repo  PostRepository
	Now   func() time.Time
}

func (h PublishPostHandler) Handle(ctx context.Context, cmd PublishPostCommand) error {
	if cmd.Title == "" || cmd.Slug == "" || cmd.BodyMD == "" {
		return errors.New("title, slug, and body are required")
	}

	id, err := h.Repo.NextID(ctx)
	if err != nil {
		return err
	}

	post := Post{
		ID:          id,
		Title:       cmd.Title,
		Slug:        cmd.Slug,
		BodyMD:      cmd.BodyMD,
		Author:      cmd.Author,
		PublishedAt: h.Now(),
	}

	return h.Repo.Save(ctx, post)
}

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

Добавьте запросы

package blog

import "context"

type PostView struct {
	ID          string
	Title       string
	Slug        string
	Author      string
	PublishedAt string
	Excerpt     string
}

type LatestPostsQuery struct {
	Limit int
}

type PostReadModel interface {
	Latest(ctx context.Context, limit int) ([]PostView, error)
	BySlug(ctx context.Context, slug string) (PostView, error)
}

type LatestPostsHandler struct {
	ReadModel PostReadModel
}

func (h LatestPostsHandler) Handle(ctx context.Context, q LatestPostsQuery) ([]PostView, error) {
	limit := q.Limit
	if limit <= 0 {
		limit = 10
	}
	return h.ReadModel.Latest(ctx, limit)
}

type GetPostBySlugQuery struct {
	Slug string
}

type GetPostBySlugHandler struct {
	ReadModel PostReadModel
}

func (h GetPostBySlugHandler) Handle(ctx context.Context, q GetPostBySlugQuery) (PostView, error) {
	return h.ReadModel.BySlug(ctx, q.Slug)
}

Обратите внимание, что сторона чтения возвращает PostView, а не модель записи. Это отражает рекомендацию Microsoft, что модель чтения должна быть оптимизирована для DTO и представления, в то время как модель записи настроена на транзакционную целостность и доменные правила.

Подключите это как приложение Go, а не как святилище

package app

import "your/module/internal/blog"

type Application struct {
	Commands Commands
	Queries  Queries
}

type Commands struct {
	PublishPost blog.PublishPostHandler
}

type Queries struct {
	LatestPosts   blog.LatestPostsHandler
	GetPostBySlug blog.GetPostBySlugHandler
}

Эта форма не случайна. Three Dots Labs используют очень похожий паттерн в Wild Workouts: тип Application, экспонирующий Commands и Queries, с конкретными обработчиками, подключенными из отдельных пакетов app/command и app/query. Их код композиции сервисов импортирует эти пакеты отдельно и конструирует из них единый объект приложения. Это чистый, go-шный способ сделать границу очевидной без драмы фреймворков. Если ваш граф зависимостей становится сложным по мере умножения обработчиков, Dependency Injection in Go охватывает паттерны Wire, Dig и инъекции конструкторов, которые естественно сочетаются с этой структурой на основе обработчиков.

Если позже вам понадобятся асинхронные команды, межсервисные события или денормализованный поисковый индекс, вы можете добавить их из этой базовой линии. Three Dots Labs явно представляют асинхронные шины команд и отдельные базы данных запросов как более поздние оптимизации, а не как отправную точку.

Библиотеки Go, которые стоит знать

Экосистема Go CQRS уже, чем .NET, что, честно говоря, является благословением. Вы можете осмотреть реальные варианты за полдня и избежать принятия трех абстракций, которые вам не нужны.

Watermill

Watermill — самый ясный современный выбор, когда вам нужен CQRS плюс месседжинг. Его компонент CQRS — это высокоуровневый API, который позволяет работать с структурами Go, а не с сырыми сообщениями, и его строительные блоки включают EventBus, EventProcessor, CommandBus и CommandProcessor. Документация также охватывает группы обработчиков событий для упорядоченной обработки на общих темах, пример модели чтения и пользовательские метаданные маршалинга. За пределами слоя CQRS Watermill поддерживает широкий спектр бэкендов pub/sub, включая RabbitMQ, Kafka, NATS Jetstream, Redis Streams, Google Cloud Pub/Sub, SQL, HTTP и другие. Pkg.go.dev помечает Watermill как готовый к производству со стабильным публичным API с версии v1.0.0, и текущая опубликованная версия модуля — v1.5.2, с GitHub, указывающим этот релиз на 13 мая.

commandBus, err := cqrs.NewCommandBusWithConfig(pub, cfg)
eventBus, err := cqrs.NewEventBusWithConfig(pub, cfg)
commandProcessor, err := cqrs.NewCommandProcessorWithConfig(router, cfg)
eventProcessor, err := cqrs.NewEventProcessorWithConfig(router, cfg)

Используйте Watermill, когда команды и события должны пересекать границы процессов, когда вы хотите, чтобы семантика повторных попыток и повторной доставки были первоклассными, или когда вы знаете, что ваш «простой» сервис уже на полпути к событийно-ориентированной реальности. Недостаток в том, что теперь у вас есть разговоры о брокере, темах, упорядочивании и идемпотентности независимо от того, хотели вы этого или нет. Это не недостаток Watermill. Это цена пространства проблем.

Event Horizon

Event Horizon — это набор инструментов CQRS и event sourcing для Go. Его сопровождающие описывают его как используемый в производственных системах, но также отмечают, что API не финален. Набор инструментов предоставляет помощники для регистрации агрегатов, команд и событий, официальные реализации хранилищ событий для вариантов памяти и MongoDB, поддержку проекций и репозиториев, а также примеры, включающие приложение на основе паттерна outbox. Поток релизов все еще активен, с GitHub, показывающим v0.17.0 на 16 июня, и более ранними релизами, добавляющими такие функции, как снимки, повторяемые проекции, постоянный планирование команд и паттерн outbox.

eh.RegisterAggregate(func(id uuid.UUID) eh.Aggregate {
	return &InvoiceAggregate{ID: id}
})

eh.RegisterCommand(func() eh.Command {
	return &CreateInvoiceCommand{}
})

Event Horizon имеет наибольший смысл, когда event sourcing — это суть, а не необязательное будущее расширение. Если вам нужны потоки, дружественные к аудиту, воспроизводимая история, проекции и модель, ориентированная на хранилище событий, это серьезный вариант. Если вам нужны только более чистые сервисы приложения в монолите, это, вероятно, больше механизмов, чем вам нужно. Примечание «API не финален» также означает, что вам следует заложить немного больше адаптации со временем, чем с Watermill.

Go-MediatR

Go-MediatR — это не полный фреймворк CQRS, но он полезен для внутрипроцессного CQRS. Его README описывает его как реализацию паттерна медиатора, используемого с CQRS, с диспетчеризацией запрос/ответ для команд и запросов, диспетчеризацией уведомлений для событий и поведенческими конвейерами для сквозных аспектов. Проект также имеет теги релизов, с GitHub, указывающим v1.4.0 как последний релиз и выделяющим потокобезопасную регистрацию обработчиков и улучшения, связанные с конкурентностью.

resp, err := mediatr.Send[*CreateProductCommand, *CreateProductResponse](ctx, cmd)
post, err := mediatr.Send[*GetPostBySlugQuery, *PostView](ctx, query)

Это хороший вариант, если вам нужны команды и запросы на основе обработчиков, но не брокер, движок проекций или хранилище событий. Он особенно дружелюбен для команд, приходящих из MediatR в .NET. Компромисс также ясен: вам все еще нужно проектировать собственную персистентность, стратегию обновления модели чтения и историю интеграции вне процесса. Иными словами, он дает вам границу приложения, а не всю архитектуру.

Старые фреймворки и справочные материалы

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

jetbasrawi/go.cqrs описывает себя как референсную реализацию Go CQRS с примерами приложений на основе принципов Грегга Янга. Однако pkg.go.dev показывает отсутствие действительного go.mod, отсутствие тегированной версии и отсутствие стабильной версии, в то время как GitHub показывает отсутствие релизов, и метаданные пакета были опубликованы 7,4 года назад. Это полезная история, а не сильный сигнал для нового производственного внедрения в 2026 году.

andrewwebber/cqrs подобен: он предоставляет event sourcing, выпуск и обработку команд, публикацию событий и генерацию моделей чтения из опубликованных событий, но метаданные пакета также были опубликованы 7,4 года назад. Я бы определенно прочитал его, если хотите понять, как более ранние библиотеки Go CQRS подходили к проблеме. Я бы был осторожен с тем, чтобы сделать его основой новой кодовой базы, если только вы не готовы стать частичным сопровождающим собственного стека архитектуры.

Практическая структура проекта Go

Типичная структура Go CQRS должна делать use-case очевидными, а не прятать их под общими абстракциями. Wild Workouts — хороший ориентир здесь. Репозиторий разделяет ограниченные контексты под internal, держит команды и запросы в отдельных пакетах приложения и подключает их к типу Application, экспонирующему Commands и Queries. Композиция сервисов собирает адаптеры, обработчики и зависимости явно. Паттерны, описанные здесь, согласуются с более широким руководством в Go Project Structure: Practices & Patterns, который охватывает более широкий набор решений по компоновке, с которыми сталкиваются команды по мере роста кодовых баз Go.

Прагматичная структура выглядит так:

internal/
  blog/
    app/
      app.go
      command/
        publish_post.go
        unpublish_post.go
      query/
        get_post_by_slug.go
        latest_posts.go
    domain/
      post.go
      slug.go
    adapters/
      postgres/
        post_repository.go
        post_read_model.go
    ports/
      http/
        handler.go
    service/
      application.go

У этой структуры есть несколько преимуществ.

Во-первых, обработчики команд и запросов живут близко к use-case, которые они реализуют. Это затрудняет скрытие бизнес-поведения в репозиториях или обработчиках, названных в честь транспортных слоев. Three Dots Labs делают это напрямую в Wild Workouts, где app/command и app/query — это отдельные пакеты, и верхний уровень Application группирует обработчики по ответственности.

Во-вторых, пакет домена может оставаться сосредоточенным на инвариантах и поведении, в то время как сторона запроса свободна возвращать DTO и проекции. Это согласуется с руководством Microsoft по моделям записи и чтения и избегает распространенного антипаттерна CQRS, где сторона запроса вынуждена проходить обратно через доменные объекты только ради идеологической чистоты.

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

Когда CQRS подходит, а когда нет

CQRS имеет смысл, когда чтение и запись — это по-настоящему разные проблемы. Microsoft рекомендует его для рабочих нагрузок, где модели чтения и записи нуждаются в независимой оптимизации, где несколько пользователей сотрудничают с одними и теми же данными, и где четкое разделение помогает с производительностью, масштабируемостью и безопасностью. Microservices.io добавляет еще один классический вариант: денормализованные, высокопроизводительные представления, построенные из доменных событий или материализованных проекций. Three Dots Labs также указывают на сложную бизнес-логику, поддерживаемость и будущее расширение в сторону асинхронных команд или специализированных хранилищ для чтения как сильные причины для его внедрения в Go.

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

CQRS не имеет смысла, когда ваш сервис — это прямой редактор данных. Фаулер говорит прямо, что для большинства систем CQRS добавляет рискованную сложность, а Three Dots Labs говорят, что простые сервисы CRUD, которые получают и возвращают по сути одни и те же данные, не являются хорошим вариантом. В их собственном примере Wild Workouts, более простой сервис пользователей не использует Чистую Архитектуру и CQRS, потому что паттерны не окупятся там.

Это та часть, которую стоит сказать прямо в техническом блоге: CQRS — это не бейдж зрелости, а преднамеренный компромисс, и он имеет смысл только тогда, когда вам действительно нужно то, что он дает. Если ваша панель администратора записывает строки и читает те же строки обратно, не разделяйте модель просто потому, что можете. Если ваши обработчики команд в основном «установить поле X на записи Y», у вас нет проблемы CQRS. У вас нормальное приложение, и это совершенно уважаемое программное обеспечение.

Заключительные мысли

Лучший способ реализовать CQRS в Go — начать со скучной версии. Разделите обработчики команд от обработчиков запросов. Позвольте командам моделировать бизнес-намерения. Позвольте запросам возвращать модели чтения. Сохраните ту же базу данных, если это все, что вам нужно. Затем, только когда система вынудит вашу руку, добавьте асинхронные шины, проекции, отдельные хранилища или event sourcing. Эта прогрессия согласуется с предупреждением Фаулера о сложности, поэтапным руководством Microsoft по CQRS и прагматичными примерами Go от Three Dots Labs.

Если вам нужна библиотека, Watermill — самый сильный универсальный выбор для событийно-ориентированного CQRS в Go, Event Horizon привлекателен, когда event sourcing — центр притяжения, а Go-MediatR — хороший легкий штрих, когда вам нужна только внутрипроцессная диспетчеризация команд и запросов. Все остальное должно зарабатывать свое место очень осторожно. Для более широкой карты структуры кода, интеграции и паттернов доступа к данным в производственных системах Go, App Architecture guide — полезный компаньон.

Это, в конце концов, самый go-подобный ответ на CQRS: используйте паттерн, а не костюм.

Подписаться

Получайте новые материалы про системы, инфраструктуру и AI engineering.