Так уж вышло, что я до сих пор не могу подобрать лучший способ для работы с транзакциями. Поэтому я решил написать эту обзорную статью. Я разберу все известные мне паттерны для работы с транзакциями и постараюсь выбрать лучший.
Вводные
Рассмотрим самый дефолтный проект с любой реляционной БД. У проекта есть две сущности — пользователи и заказы. Каждая сущность хранится в своей таблице. Мы пытаемся (насколько это возможно) не мешать слой БД со слоем бизнес-логики и стараемся писать поддерживаемый и расширяемый код.
В примерах кода специально упрощены сигнатуры функций, чтобы не переусложнять.
Столкнувшись с необходимостью работать с транзакциями, нам придётся выбрать один из этих вариантов:
1. Используем объект транзакции в слое бизнес-логики
Рассмотрим ситуацию, когда нам надо одновременно сохранить в БД и юзера, и заказ. Тогда можно написать примерно такой код:
package handlerimport ( "database/sql" "model" "context")type UsersRepository { Save(ctx context.Context, user model.User, tx *sql.Tx)}type OrdersRepository { Save(ctx context.Context, order model.Order, tx *sql.Tx)}type CreateHandler struct { usersDb UsersRepository ordersDb OrdersRepository db *sql.Db}func (h *CreateHandler) Create(user model.User, order model.Order) error { tx := h.db.Begin() defer tx.Rollback() h.usersDb.Save(user, tx) h.ordersDb.Save(order, tx) return tx.Commit()}
Плюс у такого подхода ровно один: это простой код, который поймёт разработчик любого уровня.
Но минусов намного больше:
-
Что делать, если мы в другом хендлере захотим переиспользовать метод
Saveиз репозитория без транзакции? -
В слой бизнес-логики протекла логика работы с БД
-
Если мы захотим переехать с
database/sqlна другую библиотеку для работы с БД, скорее всего придётся править много кода в слое бизнес-логики -
Велика вероятность ошибки, так как разработчик может забыть написать
tx.Commitилиtx.Rollback -
При появлении вложенных транзакций или необходимости указывать явно уровень изоляции код становится сильно менее читаемым
На самом деле примерно такой код я чаще всего и вижу в реальных проектах. И подозреваю, что в комментариях будет много защитников такого подхода, так как в сообществе go очень любят простоту.
2. Менеджер транзакций
Предыдущий способ, видимо, не понравился не только мне, поэтому разрабы из авито сделали свой менеджер транзакций. Он хранит транзакции в контексте и позволяет работать с ними примерно так:
package handlerimport ( "model" "context" "github.com/avito-tech/go-transaction-manager/trm/v2/manager")type UsersRepository { Save(ctx context.Context, user model.User)}type OrdersRepository { Save(ctx context.Context, order model.Order)}type CreateHandler struct { usersDb UsersRepository ordersDb OrdersRepository txManager manager.Manager}func (h *CreateHandler) Create(ctx context.Context, user model.User, order model.Order) error { // метод Do создаёт транзакцию и сохраняет её в контекст // если колбэк возвращает ошибку, транзакция откатывается return h.txManager.Do(ctx, func(ctx context.Context) error { h.usersDb.Save(ctx, user) h.ordersDb.Save(ctx, order) return nil })}
Вообще этот подход мне критиковать сложнее всего, хоть он и не очень мне нравится. Код всё ещё достаточно простой и читабельный, про откат транзакции думать не надо, можно переиспользовать методы репозитория внутри и вне транзакций. Но этот подход с хранением транзакции в контексте лично меня слишком отталкивает. В документации go по этому поводу сказано следующее:
Use context Values only for request-scoped data that transits processes and APIs, not for passing optional parameters to functions.
Формально транзакцию можно рассматривать как request-scoped data, но имхо это притягивание за уши.
Фактически разработчики просто заметили, что контекст, который всегда передаётся в любой метод репозитория, случайно имеет удобные методы для хранения в нём транзакции. Хотя изначально они использовали его для других целей.
3. Агрегаты
Мы все привыкли, что для каждой сущности создаётся свой репозиторий. Для клиентов создаётся UsersRepository, для заказов — OrdersRepository и т. д. Но вообще это не какое-то золотое правило, которое нельзя нарушать. Некоторые справедливо заметят, что репозитории надо делить не по таблицам в БД, а по бизнес-сущностям.
Если какой-то процесс подразумевает транзакционное создание и заказа, и юзера, то почему мы должны делить эту операцию на две? Мы можем выделить агрегат, состоящий из заказа и пользователя, и наш код будет выглядеть вот так:
package handlerimport ( "model" "context")type UsersRepository { Save(ctx context.Context, user model.UserWithOrder)}type CreateHandler struct { usersDb UsersRepository}func (h *CreateHandler) Create(ctx context.Context, user model.User, order model.Order) error { userWithOrder := model.NewUserWithOrder(user, order) h.usersDb.Save(userWithOrder) return nil}
Мне очень нравится такой подход. Я всегда стараюсь следовать именно ему, но тут тоже есть свои нюансы. Иногда всё же возможна ситуация, что в другом хендлере нам потребуется создать заказ отдельно от пользователя. И тогда нам придётся либо как-то шарить логику создания заказа между двумя репозиториями, либо делать новый слой, который знает про репозитории, но не знает про бизнес-логику. Выглядеть он будет примерно так:
package storageimport ( "database/sql" "model" "context")type UsersRepository { Save(ctx context.Context, user model.User, tx *sql.Tx) }type OrdersRepository { Save(ctx context.Context, order model.Order, tx *sql.Tx)}type Storage struct {}// из хендлеров будет вызываться этот метод// репозитории в нём больше не используются func (s *Storage) Create(ctx context.Context, user model.UserWithOrder) error { tx := h.db.Begin() defer tx.Rollback() h.usersDb.Save(user.User, tx) h.ordersDb.Save(user.Order, tx) return tx.Commit()}
4. Один репозиторий на всё
Сразу оговорюсь, что это экстремальный метод, который у многих вызовет отторжение. Но у него хватает своих плюсов, поэтому не могу не сказать о нём. Идея довольно простая: мы сделаем один репозиторий на все сущности проекта и добавим удобный метод для транзакций:
package handlerimport ( "model" "context")type Repository interface{ SaveUser(ctx context.Context, user model.User) SaveOrder(ctx context.Context, order model.Order) InTx(ctx context.Context, f func(ctx context.Context, tx Repository) error)}type CreateHandler struct { db Repository}func (h *CreateHandler) Create(ctx context.Context, user model.User, order model.Order) error { return h.db.InTx(ctx, func(ctx context.Context, tx Repository) error { tx.SaveUser(ctx, user) tx.SaveOrder(ctx, order) return nil })}
Да, многим такое не зайдёт. Наверняка у многих в голове сразу возникнут мысли про god object, big ball of mud (большой комок грязи), нарушение SOLID и пр. Но мне кажется, что такой подход не так уж и плох. Я легко пойму разработчиков, которые выберут именно этот вариант в своих проектах.
Кстати, подобный подход реализован в библиотеке ent, которая косит под entity framework.
5. CQRS (ver. 1)
Можно чуть усложнить предыдущий вариант и отказаться от паттерна репозиторий. Вместо этого мы разделим все наши запросы к БД на команды (command) и запросы (query), которые сможет обрабатывать один executor:
package cqrsimport ( "model" "context" "command" "database/sql")type Command interface {Handle(ctx context.Context, conn *sql.Conn) error}type Query[T any] interface {Handle(ctx context.Context, conn *sql.Conn) (T, error)}type Executor interface {RunCommand(ctx context.Context, command Command) errorRunQuery(ctx context.Context, query Query[any]) (any, error)InTx(ctx context.Context, f func(ctx context.Context, e Executor) error) error}
И тогда итоговый код будет выглядеть примерно так:
package handlerimport ( "model" "context" "command")type Executor interface{ RunCommand(ctx context.Context, command Command) error InTx(ctx context.Context, f func(ctx context.Context, e Executor) error) error}type CreateHandler struct { db Executor}func (h *CreateHandler) Create(ctx context.Context, user model.User, order model.Order) error { return h.db.InTx(ctx, func(ctx context.Context, tx Executor) error { tx.RunCommand(ctx, command.SaveUser{User: user}) tx.RunCommand(ctx, command.SaveOrder{Order: order})) return nil })}
Теперь у нас нет одного гигантского репозитория, где хранятся все методы для работы со всеми сущностями. Но наружу торчат методы Handle, где в сигнатуре наружу торчит тип sql.Tx. Да, формально никто не будет его вызывать напрямую, но чувство прекрасного всё равно задето.
В C# для таких вещей используют MediatR. В go не нашёл популярных аналогов. Что-то похожее делает sqlc. Имхо это одна из самых толковых либ для работы с БД, но многих пугает кодогенерация как явление.
6. CQRS (ver. 2)
Если в предыдущем варианте совсем не нравится метод Handle, то можно рассмотреть такой вариант:
package handlerimport ( "model" "context" "command")type Executor interface {RunCommand(ctx context.Context, command any) errorInTx(ctx context.Context, f func(ctx context.Context, e Executor) error) error}type CreateHandler struct { db Executor}func (h *CreateHandler) Create(ctx context.Context, user model.User, order model.Order) error { return h.db.InTx(ctx, func(ctx context.Context, tx Executor) error { tx.RunCommand(ctx, command.SaveUser{User: user}) tx.RunCommand(ctx, command.SaveOrder{Order: order})) return nil })}
Теперь у команды нет метода Handle с торчащим наружу типом sql.Tx. Вместо этого для каждой команды создаётся отдельный хендлер:
package cqrsimport ( "context" "command")type SaveUserHandler struct {}func (h *SaveUserHandler) Handle(ctx context.Context, query command.SaveUser) error { // тут логика сохранения юзера в БД}
Проблема начинается на этапе связывания команд и их хендлеров. Приходится либо подключать рефлексию, либо использовать кодогенерацию, либо вручную при сборе зависимостей указывать какой хендлер должен вызываться для каждой комманды. Ну и использование any в коде хочется избегать примерно всегда.
Из всех подходов этот мне, наверное, нравится меньше всех остальных.
7. UOW (unit of work)
Честно скажу, что я этим паттерном никогда не пользовался ни в go, ни в C#. В книгах про паттерны я тоже его не встречал (либо просто забыл об этом). Поэтому здесь я полагаюсь на чатгпт и мнение сообщества, которое почему-то не сходится в одном мнении.
На хабре в комментариях пользователь hello_my_name_is_dany пишет, что UoW придумали для шаринга одного коннекта между несколькими репозиториями, а удобство работы с транзакциями — это случайный приятный бонус.
Фаулер на своём сайте пишет следующее:
A Unit of Work keeps track of everything you do during a business transaction that can affect the database. When you’re done, it figures out everything that needs to be done to alter the database as a result of your work.
Имхо звучит немного расплывчато. Да и про транзакции вообще ничего не сказано. В любом случае не хочу превращать эту статью в ресёрч паттерна UoW, поэтому предлагаю реализацию, которая чаще всего приводится в других статьях на эту тему:
package handlerimport ( "context" "model")type UsersRepository { Save(ctx context.Context, user model.User)}type OrdersRepository { Save(ctx context.Context, order model.Order)}type UnitOfWork interface { func (u *UnitOfWork) GetUsersRepository() UsersRepository func (u *UnitOfWork) GetOrdersRepository() OrdersRepository func (u *UnitOfWork) Transaction(fn func() error)}type CreateHandler struct { usersDb UsersRepository ordersDb OrdersRepository uow UnitOfWork}func (h *CreateHandler) Create(ctx context.Context, user model.User, order model.Order) error { return h.uow.Transaction(func() { h.uow.GetUsersRepository().Save(ctx, user) h.uow.GetOrdersRepository().Save(ctx, order) return nil })}
Я не буду прикладывать конкретную реализацию интерфейса UnitOfWork, но чаще всего там приходится хранить все репозитории в одном месте примерно вот так:
package uowtype UoW struct { users UsersRepository // конкретная реализация репозитория, а не просто интерфейс orders OrdersRepository // конкретная реализация репозитория, а не просто интерфейс db *slx.Db}
Конкретный пример реализации можно посмотреть всё в том же комментарии от hello_my_name_is_dany.
По сути мы просто взяли подход номер 4, но вместо создания одного гигантского репозитория мы сделали адаптер UoW, который хранит в себе все репозитории. Минусы по сути такие же: god object, big ball of mud (большой комок грязи), нарушение SOLID и пр.
8. Callback
Предлагаю немного другую задачу. Теперь в хендлере нам надо запросить какого-то пользователя, залочить его, а потом деактивировать его, если он не совершил ни одной покупки. Тогда можно написать примерно такой код:
package handlerimport ( "context" "model")type UsersRepository interface { SelectForUpdate(ctx context.Context, userID int, func(u *model.User)) error}type UpdateHandler struct { users UsersRepository}func (h *CreateHandler) Create(ctx context.Context, userID int) error { return h.users.SelectForUpdate(ctx, userID, func(u *model.User) { if u.OrdersCount == 0 { // да, поле с количеством заказов лежит в таблице users, так уж вышло u.Active = false } })}
Метод SelectForUpdate сделает следующее:
-
выполнит
SELECT FOR UPDATEдля поиска юзера -
вызовет колбэк из нашего хендлера
-
сравнит, изменился ли наш юзер, и вызовет апдейт
Здесь хорошо почти всё, но на практике такой подход наиболее хрупкий из всех.
По мере добавления новых полей в model.User надо держать в голове, что где-то есть репозиторий с методом для обновления юзера, где надо эти изменения поддержать. Да, можно использовать кодогенерацию и даже навесить её на хук перед коммитом, но это ощущается как слишком большой оверхед.
К тому же этот подход плохо работает с большими агрегатами. Допустим, мы хотим запросить вот такой объект:
type UserWithOrder struct { User User Orders []Order}
Надо ли при этом лочить заказы? Если да, то до или после селекта юзера? А если нам в каких-то случаях надо изменить порядок локов? Ну и такой подход банально не работает для случая, когда нам надо создать какую-то другую сущность в этой же транзакции.
У меня был опыт работы в команде, где все микросервисы были настолько маленькие, что у каждого была ровно одна таблица. Там такой подход идеально бы зашёл. Но увы, это слишком узкий кейс.
Заключение
Наверняка какие-то подходы я пропустил, но все наиболее распространённые я вроде бы рассмотрел. Честно говоря, всё ещё не знаю, какой из них лучше других. Мне одновременно хочется читаемый и удобный вариант без протекания слоёв, но не похоже, что это возможно.
В статьях часто просят обратную связь ради трафика и подписчиков. Мне на это всё равно, но вот мнение сообщества мне действительно интересно. Буду рад прочитать ваши примеры, подходы и библиотеки для работы с транзакциями в go.
ссылка на оригинал статьи https://habr.com/ru/articles/1087238/