Никогда не использовал в продуктивном коде ни то, ни другое, по причине ненадобности и кажущегося значительного усложнения кода и его читабельности.
Технически это несложно реализовать, тем не менее. Но современные архитектурные практики склоняются к тому, чтобы работать с каждым агрегатом в отдельной транзакции, а между ними коммуницировать через очередь или вроде того.
Опыт работы над несколькими агрегатами в одной транзакции показывает, что идея с брокером действительно лучше.
Хотя, сюдя по вашему коду, транзакция у вас стратует в методе Transactor.WithinTransaction , который вызывается прямо в домене.
Это было сделано из желания показать семантику вызова метода, не превращая статью в листинг кода. Поэтому я и написал комментарий, что на деле это делается не в слое домена, а в слое приложения. Я не хотел давать слишком много абстракций на случай, если читатель плохо знаком с концепцией гексагона.
На деле управление транзакцией и вызовы методов репозиторев
выглядят примерно так
Здесь транзакцией управляет непосредственно слой приложения, а домен внутри работает с репозиториями.
Может ли домен работать с портами адаптеров БД напрямую, или должна быть какая-то прокладка от уровня приложения? Я считаю, что работать напрямую с портом можно, и не видел утверждения обратного в концепции гексагона. Если не согласны - приведите ссылку на первоисточник, где утверждается обратное.
Передавать только один объект Conn , который присутствует в стандартной библиотеке database/sql
Только передавать не sql.Conn, а общий интерфейс между sql.Conn и sql.Tx, потому что создание транзакции происходит до упаковки в контекст, а не после.
В моем случае я использую go-pg, которая не использует database/sql, а реализует собственный драйвер, поэтому я использую соответствующий интерфейс из библиотеки go-pg. Смысл тот же - это интерфейс, в котором есть методы исполнения запроса, которые есть и в коннекте, и в транзакции.
Если вы захотите применять блокировки на уровне базы postgresql, то у вас не будет зависимости в очередности применения транзакции и блокировки.
У меня нет таких зависимостей. Поскольку в контекст передается именно объект транзакции (а не коннект к бд), я всегда явно работаю с определенной транзакцией без жонглирования пулом коннектов.
Проблема, описанная в вашей статье, как мне кажется от того, что вы используете отдельную транзакцию для блокировок, и отдельную для остальной логики. Мне абсолютно непонятно, для чего так делать, когда блокировки как раз и работают в рамках одной транзакции. Я делаю блокировки в рамках одной транзакции силами инструментов БД (блокировки БД), и не имею никаких проблем. Операций записи где нужны блокировки мало по сравнению с операциями чтения, где они не нужны (привет, CQRS), поэтому БД отлично справляется. Если перестанет справляться - будем оптимизировать.
В целом поднимать за один запрос несколько транзакций БД кажется потенциальным выстрелом в ногу, что ваша статья, кажется, подтверждает.
Ещё спорным является решение положить в Context объект Transaction
Решение действительно спорное. В моем случае оно позволяет реализовать очень компактные и чистые с точки зрения изоляции транзакции, поэтому я на него пошел. Впрочем, идея так передавать транзакции не моя, и в целом она используется в индустрии. Но утверждать, что все смогут с этим смириться, не готов.
К тому же вы не контролируете кто владеет транзакцией. Так код, что находится по дереву выполнения ниже может закрыть транзакцию, а она доступна всем по дереву выполнения выше из контекста, сущности выше могут попытаться выполнить SQL-запрос на уже закрытой транзакции.
Нет, транзакцией владеет только метод WithinTransaction. Выше по стеку в контексте в принципе нет транзакции, а ниже (внутри замыкания) доступен интерфейс для осуществления запросов, а не управления транзакцией.
Да, можно выполнить COMMIT через Exec. Но давайте быть серьезными, это уже откровенный саботаж проекта, и для борьбы с этим есть подходящие инструменты - code review и процесс собеседований, в рамках которых саботажники отсеиваются.
Context в домене. Объект Context в своём поле Value переносит неизвестную домену сущность interface{} и получается домен начинает зависеть от неопределенной сущности, а значит нарушается целостность домена!
Нет, не начинает. Домен в принципе не имеет доступа к этой сущности, к ней доступ имеет только адаптер БД, т.к. ключ неэкспортируемый.
Можно за уши притянуть то, что для правильной работы транзакций нужно пропагировать контекст от метода-замыкания до вызовов методов порта (репо). Да, нужно. Но так как мы и так это делаем как для graceful shutdown, так для телеметрии, это не является проблемой. Это конечно накладывает некоторые ограничения, но это явно описано в godoc интерфейса Transactor, и в целом является меньшей болью из всех возможных.
Надеюсь смог достаточно полно ответить на вопросы. Еще раз повторюсь - передо мной не стояло задачи универсального механизма транзакций для любой абстрактной ACID-совместимой БД. Я делал решение именно под Postgres и именно с учетом возможностей go-pg, но получилось действительно универсальное решение, разве что я не закладывал работу с несколькими бд одновременно или несколько транзакций в одном запросе одновременно, потому что первого не требовалось, а второе считаю неправильным подходом.
Передача транзакции в контексте действительно спорная, однако в случае гексогона дает решает больше проблем, чем создает, и отвечает на больше вопросов, чем задает. Дискуссию об использовании контекста для тех или иных данных я оставляю пуристам, я же решил инженерную задачу, и как мне кажется, решил с минимально возможной болью.
Мы делаем так: генерируем через mockery моки для всех интерфейсов (портов), а в тестах заряжаем моки данными. Непосредственно мок транзактора выглядит в тесте вот так:
порты есть двух видов - входящие и исходящие. Входящие это хендлеры http, grpc серверов, консумер очереди и т.п. Исходящие - работа с БД, продьюсер очереди, клиенты http и grpc.
Исходящие порты мокаются, mockery позволяет нам не тратить на моки время.
Моки подсовываются через DI в ядро гексагона.
Методы входящих портов тестируются в тестах, в итоге ядро гексагона полностью изолировано.
Первое - действительно есть некие идеологические соображения. А именно то, что для слоя бизнес-логики транзакция выглядит как-то так:
type Transaction interface {
Commit()
Rollback()
}
А вот всякие QueryContext не имеют для бизнес логики никакого значения, потому что вызываться будет только в адаптере (репо и т.п.). Получается, что имплементация адаптера протекает в бизнес логику, то есть в противоположную сторону от направления зависимостей в гексагоне.
И не важно, что это интерфейс а не тип. Эти методы исключительно для работы с БД, о чем уровень логики не знает.
Второе - в решении, которое я сделал, транзакция и запросы к репо разделены, и могут быть реализованы на разных уровнях. В гексагоне я выполняю транзакцию на слое приложения, а сами методы репо вызываю внутри слоя доменов. При этом я волен как одним репо пользоваться и в транзакции и вне ее явно, так и в одной транзакции пользоваться несколькими репо без лишних манипуляций.
Это не говорит о том, что инвертированный подход (не репо внутри транзакции, а транзакция внутри репо) хуже, но для меня он выглядит более ограничительным по функционалу.
Да, тоже интересный подход. Но тут получается, что на уровне приложения нужно во-первых явно получить объект бд или транзакции (пусть и скрытый за интерфейсом QueryExecutor), а потом сконструировать на его основе инстанс CarRepository, который либо работает в транзакции, либо без нее.
Подход жизнеспособный, но кмк немного ограничивающий.
В моем случае логика управления транзакцией отделена от репозиториев, и я могу (при необходимости) делать часть запросов вне транзакции, а часть - в транзакции, используя один и тот же репозиторий.
Плюс чисто технически я делаю инициализацию зависимостей и пробрасывание их через DI в момент старта приложения, поэтому при обработке запроса у меня уже есть все необходимые репо. Такой код получается немного чище, т.к. инициализация репо вынесена далеко от бизнес-логики, а в самой бизнес логике происходят вызовы методов репо, управление транзакцией ну и сама логика.
Если быть точнее, слой приложения гексагона управляет транзакцией, а внутри он вызывает методы доменного слоя. А уже домен внутри дергает репозитории (спрятанные за интерфейсами-портами), при этом не зная ничего про транзакцию.
В этом случае, как писал выше, нужно будет протащить интерфейс со всеми методами (Database, QueryExecutor или еще что) в ядро гексагона, где он никак не нужен, да и реализует специфичную для адаптера логику.
Поэтому это делается через контекст, который в этом случае меняется, поэтому передается в функцию.
контекст все равно нужен, он не только для транзакций используется, но еще много для чего - от прерывания задач по таймауту до телеметрии;
если передавать копию Database, то придется протащить этот тип в интерфейсы ядра гексагона, а это нарушает принцип "зависимости от центра к периферии" - получается, что у нас бизнес логика знает о реализации адаптера для работы с БД, а этого быть не должно.
Работа через контекст позволяет сохранить изоляцию слоев и не нарушать направление зависимостей в гексагоне.
Спасибо. В целом слежу за тем, чтобы таких запросов не было, а где нельзя - пользуюсь блокировками БД. Таких ошибок не было, да и отлавливаются они быстро.
Но если рассматривать подход как фреймворк, который отдается в руки программистам, плохо владеющим SQL, ретраи могут пригодиться.
Зависит от контекста задачи - можно и прямо всю функцию-замыкание ретраить, а можно ретраить на уровне отправки запроса/чтения сообщения (мы пошли по второму пути).
Очень круто, выглядит как и должен выглядеть современный технический музей!
Телевизор же смотрят, в чем разница?
Держу в курсе, она превращается в тыкву в другой стране. Просто перестает работать
Это тот премиум, который при выезде из страны покупки превращается в тыкву?
Скриншотов бы
Никогда не использовал в продуктивном коде ни то, ни другое, по причине ненадобности и кажущегося значительного усложнения кода и его читабельности.
Технически это несложно реализовать, тем не менее. Но современные архитектурные практики склоняются к тому, чтобы работать с каждым агрегатом в отдельной транзакции, а между ними коммуницировать через очередь или вроде того.
Опыт работы над несколькими агрегатами в одной транзакции показывает, что идея с брокером действительно лучше.
Знакомая проблема, тоже через это проходил.
Даже написал статью, правда, более широкого толка.
Кажется, вы зачем-то переизобрели транзакции.
Спасибо за вопросы, попробую ответить на каждый.
Это было сделано из желания показать семантику вызова метода, не превращая статью в листинг кода. Поэтому я и написал комментарий, что на деле это делается не в слое домена, а в слое приложения. Я не хотел давать слишком много абстракций на случай, если читатель плохо знаком с концепцией гексагона.
На деле управление транзакцией и вызовы методов репозиторев
выглядят примерно так
Здесь транзакцией управляет непосредственно слой приложения, а домен внутри работает с репозиториями.
Может ли домен работать с портами адаптеров БД напрямую, или должна быть какая-то прокладка от уровня приложения? Я считаю, что работать напрямую с портом можно, и не видел утверждения обратного в концепции гексагона. Если не согласны - приведите ссылку на первоисточник, где утверждается обратное.
Только передавать не
sql.Conn
, а общий интерфейс междуsql.Conn
иsql.Tx
, потому что создание транзакции происходит до упаковки в контекст, а не после.В моем случае я использую go-pg, которая не использует
database/sql
, а реализует собственный драйвер, поэтому я использую соответствующий интерфейс из библиотеки go-pg. Смысл тот же - это интерфейс, в котором есть методы исполнения запроса, которые есть и в коннекте, и в транзакции.У меня нет таких зависимостей. Поскольку в контекст передается именно объект транзакции (а не коннект к бд), я всегда явно работаю с определенной транзакцией без жонглирования пулом коннектов.
Проблема, описанная в вашей статье, как мне кажется от того, что вы используете отдельную транзакцию для блокировок, и отдельную для остальной логики. Мне абсолютно непонятно, для чего так делать, когда блокировки как раз и работают в рамках одной транзакции. Я делаю блокировки в рамках одной транзакции силами инструментов БД (блокировки БД), и не имею никаких проблем. Операций записи где нужны блокировки мало по сравнению с операциями чтения, где они не нужны (привет, CQRS), поэтому БД отлично справляется. Если перестанет справляться - будем оптимизировать.
В целом поднимать за один запрос несколько транзакций БД кажется потенциальным выстрелом в ногу, что ваша статья, кажется, подтверждает.
Решение действительно спорное. В моем случае оно позволяет реализовать очень компактные и чистые с точки зрения изоляции транзакции, поэтому я на него пошел. Впрочем, идея так передавать транзакции не моя, и в целом она используется в индустрии. Но утверждать, что все смогут с этим смириться, не готов.
Нет, транзакцией владеет только метод
WithinTransaction
. Выше по стеку в контексте в принципе нет транзакции, а ниже (внутри замыкания) доступен интерфейс для осуществления запросов, а не управления транзакцией.Да, можно выполнить
COMMIT
черезExec
. Но давайте быть серьезными, это уже откровенный саботаж проекта, и для борьбы с этим есть подходящие инструменты - code review и процесс собеседований, в рамках которых саботажники отсеиваются.Нет, не начинает. Домен в принципе не имеет доступа к этой сущности, к ней доступ имеет только адаптер БД, т.к. ключ неэкспортируемый.
Можно за уши притянуть то, что для правильной работы транзакций нужно пропагировать контекст от метода-замыкания до вызовов методов порта (репо). Да, нужно. Но так как мы и так это делаем как для graceful shutdown, так для телеметрии, это не является проблемой. Это конечно накладывает некоторые ограничения, но это явно описано в godoc интерфейса
Transactor
, и в целом является меньшей болью из всех возможных.Надеюсь смог достаточно полно ответить на вопросы. Еще раз повторюсь - передо мной не стояло задачи универсального механизма транзакций для любой абстрактной ACID-совместимой БД. Я делал решение именно под Postgres и именно с учетом возможностей go-pg, но получилось действительно универсальное решение, разве что я не закладывал работу с несколькими бд одновременно или несколько транзакций в одном запросе одновременно, потому что первого не требовалось, а второе считаю неправильным подходом.
Передача транзакции в контексте действительно спорная, однако в случае гексогона дает решает больше проблем, чем создает, и отвечает на больше вопросов, чем задает. Дискуссию об использовании контекста для тех или иных данных я оставляю пуристам, я же решил инженерную задачу, и как мне кажется, решил с минимально возможной болью.
Именно так, поднимаем рядом testcontainers и гоняем интеграционные тесты на адаптер.
Мы просто мокаем все интерфейсы. Так как
Transactor
- интерфейс, мокаем и его.Ниже подробнее расписал тулинг.
Это отличный вопрос, спасибо.
Мы делаем так: генерируем через mockery моки для всех интерфейсов (портов), а в тестах заряжаем моки данными. Непосредственно мок транзактора выглядит в тесте вот так:
В целом получается тестировать так:
порты есть двух видов - входящие и исходящие. Входящие это хендлеры http, grpc серверов, консумер очереди и т.п. Исходящие - работа с БД, продьюсер очереди, клиенты http и grpc.
Исходящие порты мокаются, mockery позволяет нам не тратить на моки время.
Моки подсовываются через DI в ядро гексагона.
Методы входящих портов тестируются в тестах, в итоге ядро гексагона полностью изолировано.
Подробнее можно посмотреть тут.
Не могу с этим спорить, но статья именно про транзакции БД, и я отвечал в этом контексте.
Есть разница.
Первое - действительно есть некие идеологические соображения. А именно то, что для слоя бизнес-логики транзакция выглядит как-то так:
А вот всякие
QueryContext
не имеют для бизнес логики никакого значения, потому что вызываться будет только в адаптере (репо и т.п.). Получается, что имплементация адаптера протекает в бизнес логику, то есть в противоположную сторону от направления зависимостей в гексагоне.И не важно, что это интерфейс а не тип. Эти методы исключительно для работы с БД, о чем уровень логики не знает.
Второе - в решении, которое я сделал, транзакция и запросы к репо разделены, и могут быть реализованы на разных уровнях. В гексагоне я выполняю транзакцию на слое приложения, а сами методы репо вызываю внутри слоя доменов. При этом я волен как одним репо пользоваться и в транзакции и вне ее явно, так и в одной транзакции пользоваться несколькими репо без лишних манипуляций.
Это не говорит о том, что инвертированный подход (не репо внутри транзакции, а транзакция внутри репо) хуже, но для меня он выглядит более ограничительным по функционалу.
Хорошая альтернатива в любом случае, спасибо.
Да, тоже интересный подход. Но тут получается, что на уровне приложения нужно во-первых явно получить объект бд или транзакции (пусть и скрытый за интерфейсом QueryExecutor), а потом сконструировать на его основе инстанс CarRepository, который либо работает в транзакции, либо без нее.
Подход жизнеспособный, но кмк немного ограничивающий.
В моем случае логика управления транзакцией отделена от репозиториев, и я могу (при необходимости) делать часть запросов вне транзакции, а часть - в транзакции, используя один и тот же репозиторий.
Плюс чисто технически я делаю инициализацию зависимостей и пробрасывание их через DI в момент старта приложения, поэтому при обработке запроса у меня уже есть все необходимые репо. Такой код получается немного чище, т.к. инициализация репо вынесена далеко от бизнес-логики, а в самой бизнес логике происходят вызовы методов репо, управление транзакцией ну и сама логика.
Если быть точнее, слой приложения гексагона управляет транзакцией, а внутри он вызывает методы доменного слоя. А уже домен внутри дергает репозитории (спрятанные за интерфейсами-портами), при этом не зная ничего про транзакцию.
В этом случае, как писал выше, нужно будет протащить интерфейс со всеми методами (Database, QueryExecutor или еще что) в ядро гексагона, где он никак не нужен, да и реализует специфичную для адаптера логику.
Поэтому это делается через контекст, который в этом случае меняется, поэтому передается в функцию.
Нет, неудобно по двум причинам:
контекст все равно нужен, он не только для транзакций используется, но еще много для чего - от прерывания задач по таймауту до телеметрии;
если передавать копию Database, то придется протащить этот тип в интерфейсы ядра гексагона, а это нарушает принцип "зависимости от центра к периферии" - получается, что у нас бизнес логика знает о реализации адаптера для работы с БД, а этого быть не должно.
Работа через контекст позволяет сохранить изоляцию слоев и не нарушать направление зависимостей в гексагоне.
Спасибо. В целом слежу за тем, чтобы таких запросов не было, а где нельзя - пользуюсь блокировками БД. Таких ошибок не было, да и отлавливаются они быстро.
Но если рассматривать подход как фреймворк, который отдается в руки программистам, плохо владеющим SQL, ретраи могут пригодиться.
Зависит от контекста задачи - можно и прямо всю функцию-замыкание ретраить, а можно ретраить на уровне отправки запроса/чтения сообщения (мы пошли по второму пути).
Спасибо за пояснение.
У меня получается избегать этого через
написание неконфликтных запросов
пессимистичные блокировки там, где могут быть конфликты