Чистые транзакции в гексагональном Go
В современной микросервисной разработке очень популярна чистая архитектура (она же луковая). Этот подход ясно отвечает на много архитектурных вопросов, а также хорошо подходит для сервисов с небольшой кодовой базой. Другая приятная особенность чистой архитектуры состоит в том, что она отлично сочетается с Domain Driven Development — они отлично дополняют друг друга.
Одной из прикладных реализаций чистой архитектуры является гексагональная архитектура — подход, явно выделяющей слои, адаптеры и прочее. Данный подход заслуженно сыскал любовь среди разработчиков на Go — он не требует сложных абстракций или зубодробительных паттернов, а также почти ни в чем не противоречит сложной идиоматике языка — так называемому Go way.
Но есть проблема, которую я часто вижу во многих командах, адаптирующих гексагоны, и с которой я сам столкнулся и успешно решил — реализация транзакций базы данных в рамках DDD и пресловутого гексагона. Что у меня вышло я и расскажу в этой заметке.
Статья оригинально размещена в моем блоге.
Проблема высоких абстракций
Гексагональная архитектура предполагает инверсию зависимостей следующим образом: в центре всего находится модель данных, вокруг нее строится (и зависит от нее) доменная логика, на нее накладывается слой логики сервиса, а дальше идут адаптеры, скрытые за интерфейсами, называемыми портами. Это может варьироваться, но основная идея в том, что зависимости расходятся от центра к периферии, остается.
Для примера на минуточку представим, что мы делаем микросервис, реализующий продажу б/у автомобилей.
Представим, что одним из адаптеров является модуль взаимодействия с базой данных. Но не какой-нибудь случайной, а базой, поддерживающей ACID транзакции. Взаимодействие с базой с точки зрения кода реализовать довольно легко — оборачиваем доменные модели в репозитории, каждый репозиторий прячем за интерфейс (порт), а внутри адаптера его реализуем. Выглядеть такой порт может как-то так:
package port
import ...
// CarRepository car persistence repository
type CarRepository interface {
UpsertCar(ctx context.Context, car *model.Car) error
GetCar(ctx context.Context, id string) (*model.Car, error)
GetCarsBatch(ctx context.Context, ids []string) ([]model.Car, error)
GetCarsByTimePeriod(ctx context.Context, from, to time.Time) ([]model.Car, error)
GetCarsByModel(ctx context.Context, model string) ([]model.Car, error)
DeleteCar(ctx context.Context, id string) error
}
Со стороны доменной логики этот адаптер будет передаваться как DI через интерфейс.
package domain
import ...
type Car struct {
carRepo port.CarRepository
}
func NewCar(carRepo port.CarRepository) &Car {
return &Car{
carRepo: carRepo,
}
}
Для примера логика поиска авто по году выпуска будет такой.
func (c *Car) GetNewCars(ctx context.Context, from time.Time) ([]model.Car, error) {
if err := c.someValidation(); err := nil {
return nil, fmt.Errorf("invalid request: %v", err)
}
// read latest cars
cars, err := c.carRepo.GetCarsByTimePeriod(ctx, from, time.Now())
if err := nil {
return nil, fmt.Errorf("newest cars read: %v", err)
}
return c.filterCars(cars), nil
}
Это довольно хорошо работает с простыми атомарными операциями, как создание, удаление или чтение. Но довольно часто возникает необходимость выполнить сложную логику в рамках одной транзакции БД. Я не буду расписывать примеры, вы и так их отлично знаете.
Проблема тут в том, что с точки зрения архитектуры транзакция является частью адаптера по работе с базой данных — она открывается и закрывается определенными командами (BEGIN
, COMMIT
или ROLLBACK
в SQL), и имеет привязку к порожденной сущности — транзакции. Транзакция сама по себе обычно тоже не витает в облаках глобального скоупа программы, а явно привязана к сессии подключения к базе данных поверх TCP соединения. Поэтому мы не можем (да и не хотим) абстрактно объявить «начало транзакции» и «конец транзакции» в бизнесовом коде — внутри домена. При открытии транзакции у нас появляется некая сущность — транзакция — которую в дальнейшем нужно передать в адаптер для выполнения операций БД непосредственно в этой транзакции.
Здесь возникает проблема курицы и яйца. С одной стороны, адаптер требует, чтобы для каждого запроса в рамках транзакции была передана информация об этой самой транзакции — обычно используемая библиотека реализует транзакцию как некий объект, через который можно делать запросы. С другой стороны, слой домена или сервиса не может знать про реализацию адаптера в парадигме гексагона. Можно завернуть транзакцию в какой-нибудь интерфейс, этот интерфейс окажется монструозно огромным (с методами вроде Select
, Insert
, Delete
и прочими — откройте любимую SQL библиотеку и посмотрите сколько там методов). Причем он не будет иметь никакого смысла для домена — эти методы будут использоваться внутри адаптера, где есть доступ к «незаабстракченой» транзакции.
Можно пойти иначе, и передать транзакцию как interface{}
, а потом в адаптере через рефлексию привести к нужному типу, но я считаю такой подход несерьезным и негодным для продуктивного кода. Кроме того, очень не хочется замусоривать сигнатуру методов передачей дополнительно транзакции — ведь она не имеет прямого отношения к самому методу, а указывает на особенности всего процесса работы с бд в рамках операции. Что же делать?
Решение предметных реализаций
Теперь пару слов о контексте нашего решения. В поиске элегантной реализации я несколько раз сталкивался с решениями вроде UnitOfWork
, представляющих транзакцию как некоторую бизнес-сущность (о которой знает ядро гексагона с бизнес-логикой). Действительно, транзакцию можно представить как некую бизнес-сущность — ведь бизнес логика может требовать атомарного и неконкурентного выполнения операции. Но проблема элегантных идей в неэлегантной реализации — абстрактные фабрики, рефлексия и некрасивая работа с методами самого адаптера.
Часто эти изощрения продиктованы желанием работать с несколькими базами данных, или иметь возможность переключаться с одной БД на другую, изменив лишь код адаптера (и не меняя бизнес логики).
Поняв, что это слишком «абстрактно», да и в целом не отвечает go way, я вывел несколько ограничений нашего проекта, которые должны были упростить эту задачу.
Сервис работает только с одной БД
И это PosgreSQL. Ну действительно, часто ли вы переключаетесь между БД? Многие стараются писать некий обобщенный код для работы с generic SQL базой данных, однако есть ли в этом смысл? Практика показывает, что переход с одной SQL базы данных на другую все равно заставит вас перелопатить весь проект, а про переход с SQL на NoSQL или наоборот даже говорить не приходится.
Мы используем конкретную библиотеку для работы с БД
И это go-pg. Лично мне она очень нравится, как билдер запросов (нежели как ORM), и отличается хорошей производительностью. У нее есть одна особенность, о которой я скажу дальше, без которой мне пришлось бы повозиться для реализации задуманного. Но такой функционал есть и в других библиотеках, так что не спешите переписывать свой код.
К чему пришли в итоге
Поэтому я с чистым сердцем взял за основу транзакции в go-pg. Мне хотелось с одной стороны оставить сигнатуры методов репозитория чистыми (только контекст и параметры вызова метода), но при этом сделать решение идиоматичным с точки зрения Go.
В го есть прекрасный инструмент, который позволяет передавать утилитарные данные, которые не касаются вызова конкретного метода, но касаются контекста операции — context.Context
. Часто туда попадает телеметрия, логгеры, идентификаторы идемпотентности и прочее. С моей точки зрения информация о транзакции отлично подходит под определение «утилитарных данных» — это некий модификатор процесса, который не влияет на логику напрямую, но оказывает косвенное влияние. От слов — к делу!
package postgres
import ...
type txKey struct{}
// injectTx injects transaction to context
func injectTx(ctx context.Context, tx *pg.Tx) context.Context {
return context.WithValue(ctx, txKey{}, tx)
}
// extractTx extracts transaction from context
func extractTx(ctx context.Context) *pg.Tx {
if tx, ok := ctx.Value(txKey{}).(*pg.Tx); ok {
return tx
}
return nil
}
Первый шаг — добавляем методы для добавления транзакции в контекст и извлечения транзакции из контекста. Методы неэкспортируемые, то есть вызывать их можно только внутри адаптера. Обратите внимание — здесь используется транзакция из пакета go-pg безо всяких оберток или абстракций. Можем себе позволить это внутри адаптера!
Далее, нам нужно научить сам адаптер (репозиторий) работать с транзакцией. И вот тут нам понадобится возможность, которая есть в go-pg, но нет в некоторых других библиотеках, например, в sqlx. Это — единый интерфейс для методов запросов, выполняемых библиотекой как в транзакции, так и без нее. Это Select
, Insert
, Delete
и прочие — у них должна быть одинаковая сигнатура для транзакции и без, чтобы можно было вынести за интерфейс. Если нет — придется написать обертку. В случае go-pg и у объекта подключения к БД, и у транзакции есть метод ModelContext(c context.Context, model ...interface{}) *Query
, который мы и использовали.
Получилась небольшая оберточка, которая проверяет, есть ли в контексте транзакция. Если есть — возвращает Query
из транзакции, а если нет — возвращает Query
из коннекта к БД.
package postgres
import ...
// model returns query model with context with or without transaction extracted from context
func (db *Database) model(ctx context.Context, model ...interface{}) *orm.Query {
tx := extractTx(ctx)
if tx != nil {
return tx.ModelContext(ctx, model...)
}
return db.conn.ModelContext(ctx, model...)
}
Здесь Database — это непосредственно структура, реализующая CarRepository
, в методах которой содержатся SQL запросы к PostgreSQL, а также коннект (пул конектов) к базе данных. Она может реализовывать и больше репозиториев, если у вас их много.
В итоге реализация метода, читающего машины из БД, будет выглядеть так:
package postgres
import ...
func (db *Database) GetCarsByTimePeriod(ctx context.Context, from, to time.Time) ([]model.Car, error) {
var m []model.Car
err := db.model(ctx, &m).
Where("manufacture_date BETWEEN ? AND ?", from, to).
Order("model").
Select()
if err != nil {
return nil, err
}
return m, nil
}
При этом метод можно использовать как в транзакции, так и без нее — ни сигнатура, ни сам метод от этого не меняется. Причем решение, использовать транзакцию, или нет, принимает именно часть сервиса с бизнес-логикой. Давайте же посмотрим, как это сделано.
Транзакции в бизнесе и бизнес в транзакциях
Дело осталось за малым — реализовать метод создания транзакции внутри адаптера, который будет возвращать «заряженный» транзакцией контекст, добавить этот метод в интерфейс, вызывать его в бизнес логике и передавать во все вызовы репозитория, а в конце делать коммит или роллбэк.
Звучит логично, но как-то некрасиво. Может быть, в Go есть более элегантный инструмент?
И он есть! Это — замыкание. Реализуем метод, который позволит нам реализовать всю транзакцию, не отходя от кассы:
package postgres
import ...
// WithinTransaction runs function within transaction
//
// The transaction commits when function were finished without error
func (db *Database) WithinTransaction(ctx context.Context, tFunc func(ctx context.Context) error) error {
// begin transaction
tx, err := db.conn.Begin()
if err != nil {
return fmt.Errorf("begin transaction: %w", err)
}
// finalize transaction on panic, etc.
defer tx.Close()
// run callback
err = tFunc(injectTx(ctx, tx))
if err != nil {
// if error, rollback
tx.Rollback()
return err
}
// if no error, commit
tx.Commit()
return nil
}
Для читабельности убрал обработку ошибок при коммите и роллбеке
Метод принимает контекст и функцию, которую нужно выполнить в транзакции. На основе контекста создается контекст с транзакцией, и передается в функцию. Это позволяет также прервать выполнение функции при отмене родительского контекста — например, при graceful shutdown.
Далее если функция выполнена без ошибок, выполняется commit, в противном случае выполняется rollback, а ошибка возвращается из метода.
Этот метод выведем в отдельный порт — изоляцию нужно соблюдать!
package port
import ...
// Transactor runs logic inside a single database transaction
type Transactor interface {
// WithinTransaction runs a function within a database transaction.
//
// Transaction is propagated in the context,
// so it is important to propagate it to underlying repositories.
// Function commits if error is nil, and rollbacks if not.
// It returns the same error.
WithinTransaction(context.Context, func(ctx context.Context) error) error
}
Добавим его через DI в домен:
package domain
import ...
type Car struct {
carRepo port.CarRepository
transactor port.Transactor
}
func NewCar(transactor port.Transactor, carRepo port.CarRepository) &Car {
return &Car{
carRepo: carRepo,
transactor: transactor,
}
}
Это позволяет нам совсем не заморачиваться по поводу транзакций внутри бизнес логики, и упростить транзакционные операции до следующего:
package domain
import ...
func (c *Car) BuyCar(ctx context.Context, id string, price int, owner model.Owner) error {
if err := c.validateBuyer(ctx, price, owner); err != nil {
return err
}
return c.transactor.WithinTransaction(ctx, func(txCtx context.Context) error {
car, err := c.carRepo.GetCar(txCtx, id)
if err != nil {
return err
}
if err := c.validatePurchase(txCtx, car, owner); err != nil {
return err
}
car.Owner = owner
car.SellPrice = price
car.SellDate = time.Now()
if err := c.carRepo.UpsertCar(txCtx, car); err != nil {
return err
}
log.Printf("car %s model % sold to %s for %d",
car.Id, car.Model, owner.Name, price)
return nil
})
}
В примере я несколько упростил код для лучшего понимания. На практике в гексагоне управление транзакцией должно происходить не в слое домена (domain service), а в слое приложения (application service), что я и делаю в реальной жизни.
При этом домен ничего не знает про транзакцию, да ему и не нужно. А слой приложения управляет как транзакциями, так и другими «бизнесовыми» сущностями, выходящими за рамки домена (как пример — идемпотентность команд в CQRS).
Утешительные итоги
В результате мы получили:
- простой с точки зрения слоя бизнес-логики механизм выполнения операций в транзакции;
- изоляция уровней, абстракции не протекают;
- отсутствие рефлексии, вся работа с транзакцией типизирована и отказоустойчива;
- чистые методы репозиториев, нет нужды пробрасывать транзакцию в сигнатуру;
- методы с запросами агностичны к наличию транзакции — если она есть, выполнятся в ней, если нет — напрямую в БД;
- commit и rollback выполняются автоматически по результату выполнения функции. Никаких defer.
- при панике выполнится rollback внутри
tx.Close()
.
Этот подход применим к любой базе данных, поддерживающий ACID транзакции, при условии общего интерфейса для запросов как в транзакции, так и без него. При желании можно дописать свою обертку, если в любимой библиотеке этого нет.
Этот подход не применим в ситуации, когда вы работаете с несколькими БД в одном сервисе, и вам нужно связать две транзакции в одну. В этом случае я вам не завидую.
Возможно, где-то я отошел от принципов DDD или пренебрег концепциями гексагональной архитектуры, однако результат вышел простым, красивым и читабельным.
А как бы сделали вы? Приглашаю в комментарии для обсуждения идей и критики!