Всем привет! Я Илья Сергунин, веб-разработчик из продуктовой команды Авито. Мы пишем на Go сервис для выкупа мобильных телефонов, про MLP которого уже писали в блоге. В качестве примеров я буду использовать всем знакомый интернет-магазин, чтобы показать, как создать репозиторий через менеджер транзакций.  Важное замечание: в Go нужно явно прописывать обработку каждой ошибки. В некоторых примерах я скрываю эту часть кода, чтобы они легче читались. Паттерн Repository впервые был предложен Мартином Фаулером в книге «Шаблоны корпоративных приложений». Я буду использовать альтернативное понимание с методом сохранения данных. Его предложил Эрик Эванс в книге «Предметно-ориентированное проектирование (DDD). Структуризация сложных программных систем».  Repository конвертирует модель в представление для базы данных для сохранения, а при получении данных — обратно. Каждая реализация паттерна работает только с одной моделью. Repository в большинстве случаев не содержит внутри себя валидацию бизнес-правил.  По умолчанию у него очень простой интерфейс с двумя методами: для получения и сохранения данных. Допустим, у нас есть репозиторий для пользователя: User может включать в себя никнейм, пароль, имя, фамилию, настройки уведомлений и т.д. Все эти данные принадлежат нашему пользователю, однако могут быть представлены в разном виде.  Имя и фамилия могут быть вынесены в отдельную таблицу, если в системе пользователями могут быть люди и компании. Настройки уведомлений могут быть представлены в виде json-объекта. Их нужно конвертировать в структуры. Repository позволяет сконцентрировать эти преобразования в одном месте и сосредоточиться на модели с бизнес-правилами, а не на работе с базой данных. Давайте посмотрим на примеры: В методах  toModel и toRow находится маппинг данных из представления для базы данных в модель и обратно. Мы хотим, чтобы при любых изменениях и доработках продукта интерфейс паттерна оставался таким же простым.  Каждого нового пользователя нужно зарегистрировать: Мы хотим сообщить пользователю о том, что он зарегистрировался, поэтому добавляем отправку уведомления через очередь сообщений: Мы предполагаем, что в очереди сообщений может произойти сбой, и тогда уведомление о регистрации не дойдет до пользователя. Чтобы этого избежать, добавим транзакцию и явно передадим её в репозиторий и в очередь: Для очереди можно использовать паттерн transaction outbox, чтобы отправлять события атомарно с сохранением. И также нужно дописать функцию сохранения, чтобы репозиторий знал о существовании транзакции: Мы получили транзакционность операций, но взамен усложнили интерфейс репозитория:  У нашего пользователя есть возможность сделать заказ. Для этого сценария нам нужен второй репозиторий: Прописываем сценарий заказа и сразу предусматриваем транзакционность: Для увеличения продаж дадим пользователю возможность совершить покупку одновременно с регистрацией: Register и Purchase являются вложенными сценариями в FastPurchase. Давайте сделаем быструю покупку в одной транзакции: Напомню, что в сценариях регистрации пользователя и покупки тоже нужно учесть транзакции. Доработаем их, чтобы транзакция работала с вложенными сценариями корректно.  Регистрация пользователя: Что в итоге получилось: у нас есть транзакционность и вложенные сценарии. Но при этом ещё и много дополнительного кода. Если мы захотим перейти на другую базу данных, сделать это будет сложно. Чтобы улучшить решение, добавим замыкание в репозитории пользователя:  Но теперь пользовательский репозиторий знает о заказах. Нам это не нужно: со временем продукт улучшается и растёт, моделей и репозиториев становится всё больше. Сценарии тоже расширяются и усложняются, и репозиторий пользователя узнаёт о других моделях.  Знание усложняет работу с репозиторием и его модификацию. В итоге он потеряет свою изначальную функцию — работа с данными пользователя.  Решим проблему просто: вынесем замыкание в отдельную функцию на более высокий уровень. Получим новую функцию WithTransaction:  Благодаря этому мы можем сократить сценарий регистрации пользователя, чтобы была видна его суть, а не инфраструктура:  Что получается при таком решении: мы спрятали работу с транзакциями в отдельную функцию, но все еще передаём их явно в интерфейсе репозитория.  У нас получилось довольно неплохое решение, но это пока не менеджер транзакций. Транзакция как аргумент привязывает нас к определенной базе данных на разных уровнях приложения. Давайте попробуем подсмотреть решение в других языках — как они избавляются от прорастания базы данных во все части приложения. В разных языках эта проблема решается по-разному:  в Python (SQLAlchemy) транзакция передаётся явно, как аргумент, но нам хочется от этого отказаться; в Python (Django) и однопоточных языках PHP и JavaScript используется глобальная переменная для хранения транзакции; в C#, Java транзакцию можно привязать к потоку.  Среди этих решений нам может подойти последнее: в Go есть Goroutine, которые немного похожи на потоки. Но это не одно и то же, поэтому решение нужно модифицировать. Варианты хранения транзакции в Go: Передать транзакцию как аргумент, что мы и делаем. Но хотим отказаться от этого подхода. Привязать транзакцию к Goroutine по id, но это нестабильное решение. Сохранить в local-storage, но его реализация привязана к конкретным версиям Go, не развивается и поэтому не подходит нам. Передать как context. Внутри этот пакет устроен как список, а значит, у него высокая сложность поиска O(N).  Получается, идеального решения нет, но мы всё-таки остановимся на context. Это единственный способ хранить транзакцию в Go и не опасаться, что решение сломается при обновлении языка на новую версию. Транзакцию можно хранить в context, если они создаются и завершаются в рамках одного запроса. У нас есть пять пожеланий к решению:  простой интерфейс репозитория, транзакционность операций, поддержка вложенных сценариев, скрытая работа транзакций, поддержка разных баз данных.  В решении, на котором мы остановились, не закрыты только два из них:  простой интерфейс репозитория; поддержка разных баз данных. При этом у нас уже есть два готовых элемента: функция WithTransaction и хранилище на основе context. Расширим функцию WithTransaction до полноценного менеджера транзакций: В менеджере добавим интерфейс с возможностью донастройки текущей транзакции. Общие настройки включают параметры, которые подходят для разных баз данных. Также можно добавить специализированные — для конкретной базы. Теперь мы можем убрать из сценария регистрации пользователя существенную часть инфраструктуры:  Новую версию кода намного легче читать.  Также нужно переписать с использованием context сценарий создания заказа:  И быструю покупку: Теперь займёмся хранилищем транзакций и посмотрим, поможет ли оно сделать репозиторий идеальным. Добавим интерфейс для хранилища транзакций, чтобы получать транзакцию из контекста по ключу или ключу по умолчанию. Наличие метода получения по ключу нужно, чтобы поддерживать подключение к нескольким базам данных. Теперь можем доработать функцию сохранения в репозиторий. Теперь функцию проще читать и в ней меньше кода: Еще хочется, чтобы решение легко мигрировало с одной базы данных на другую. Для этого создадим абстракцию над транзакцией на основе Spring в Java и System Transaction из C#:  Наконец, научим хранилище транзакций работать с абстракцией и делать локальный менеджер транзакций для конкретного драйвера: В итоге мы выполнили все пожелания: Чтобы проверить влияние менеджера на скорость работы кода, я написал бенчмарк на sqlmock, который мы используем для тестирования. В результате разрыв в производительности с менеджером транзакций и без него получился всего около 3,5%. Но при этом меня удивило, что результаты теста оказались в миллисекундах, поэтому я переписал бенчмарк на SQLite для базы данных в памяти. Новый тест показал, что с менеджером транзакций производительность падает на 18%. В реальном времени это всего 5 микросекунд.  Я решил протестировать кейс, который встречается чаще: когда база данных лежит в файле. На этот раз разницы практически не было. Это связано с большими издержками при работе файловой системы.  Ознакомиться с бенчмарками можно по ссылке. Работает только на Go 1.13 и выше. При этом Go обновляется минорными версиями, поэтому перейти на нужную достаточно просто.  Мало адаптеров для драйверов, баз данных и ORM. Пока есть только database/sql, jmoiron/sqlx, mongo-go-driver, gorm, go-redis). Но вы можете написать свой адаптер, на это понадобится всего около 60 строк кода. Снижает производительность. Но нужно учесть, что этот разрыв заметен в ситуации, когда база данных хранится в памяти. В реальности база обычно хранится в файле или вообще на отдельном сервере. В этом случае нужно добавлять издержки на работу файловой системы и сетевое взаимодействие. Поэтому в большинстве реальных задач разница в скорости работы с файловым менеджером и без него будет практически незаметной.  Нужно постоянно передавать в функции context. Правда, если вы пишете веб-приложение на Go, то скорее всего, уже носите его с собой. Это полезная штука, которая часто используется. Например, чтобы не делать лишние вычисления, если пользователь отменил запрос. Также context может хранить данные авторизованного пользователя или другие данные для логирования. Можно возразить, что транзакция — слишком сложный объект для контекста. Однако ряд статей (1, 2) из wiki GoLang и описание самого пакета context разрешает хранить транзакцию, если она открывается и закрывается в рамках одного запроса. Не поддерживает долгие транзакции. Если у вас есть сетевой вызов к отдельному серверу, он не будет работать. Например, проверку по номеру телефона нужно будет вынести из транзакции. Пожалуй, это самый большой минус, который лечится с помощью паттерна UnitOfWork, но это отдельная тема.  Для нас менеджер транзакций оказался очень полезен. Раньше репозитории были жёстко привязаны к конкретной базе данных через транзакцию, а теперь мы передаем её в context. То есть можем переключаться на другую базу с минимальным количеством изменений в коде сценариев. Дополнительно скрыли дублирования работы с транзакциями до унифицированного интерфейса. Код сценариев стал понятнее, потому что мы убрали из них инфраструктурные элементы. Нужен ли вам менеджер транзакций в вашем проекте? Это можно понять по количеству вложенных сценариев. Это важно, потому что если их хотя бы больше двух, то количество строк кода начинает быстро расти. Сам менеджер транзакции добавляет к коду всего 5 строк на инициализацию, и ещё несколько — на каждое использование. Без менеджера нам нужно каждый раз писать по 8 строчек кода. Кстати, ещё я советую учесть когнитивную нагрузку на разработчика, который создаёт и поддерживает код с множеством вложенных сценариев. Чем кода больше, тем выше вероятность ошибки. При таких условиях менеджер транзакций очень помогает справляться с поддержкой лучше.  Весь код решения можно посмотреть на Github, как и примеры. И приходите в комментарии — обсудить, стоит ли внедрять это решение, чтобы упростить код и уменьшить вероятность ошибок. Предыдущая статья: Ультимативный гайд по HTTP. Структура запроса и ответа