Pull to refresh
56
0
Ilya Kaznacheev @Color

Consulting Cloud Architect, GDE on Cloud

Send message

Очень круто, выглядит как и должен выглядеть современный технический музей!

Держу в курсе, она превращается в тыкву в другой стране. Просто перестает работать

Это тот премиум, который при выезде из страны покупки превращается в тыкву?

Никогда не использовал в продуктивном коде ни то, ни другое, по причине ненадобности и кажущегося значительного усложнения кода и его читабельности.

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

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

Знакомая проблема, тоже через это проходил.

Даже написал статью, правда, более широкого толка.

Спасибо за вопросы, попробую ответить на каждый.

Хотя, сюдя по вашему коду, транзакция у вас стратует в методе 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, но получилось действительно универсальное решение, разве что я не закладывал работу с несколькими бд одновременно или несколько транзакций в одном запросе одновременно, потому что первого не требовалось, а второе считаю неправильным подходом.

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

Именно так, поднимаем рядом testcontainers и гоняем интеграционные тесты на адаптер.

Мы просто мокаем все интерфейсы. Так как Transactor - интерфейс, мокаем и его.

Ниже подробнее расписал тулинг.

Это отличный вопрос, спасибо.

Мы делаем так: генерируем через mockery моки для всех интерфейсов (портов), а в тестах заряжаем моки данными. Непосредственно мок транзактора выглядит в тесте вот так:

transactor := new(mocks.Transactor)

transactor.On("WithinTransaction", any, any).
		Return(
			func(ctx context.Context, f func(context.Context) error) error {
				return f(ctx)
			},
		)

В целом получается тестировать так:

порты есть двух видов - входящие и исходящие. Входящие это хендлеры http, grpc серверов, консумер очереди и т.п. Исходящие - работа с БД, продьюсер очереди, клиенты http и grpc.

Исходящие порты мокаются, mockery позволяет нам не тратить на моки время.

Моки подсовываются через DI в ядро гексагона.

Методы входящих портов тестируются в тестах, в итоге ядро гексагона полностью изолировано.

Подробнее можно посмотреть тут.

но ведь транзакция БД и атомарная бизнес-операция - не одно и то же.

Не могу с этим спорить, но статья именно про транзакции БД, и я отвечал в этом контексте.

Есть разница.

Первое - действительно есть некие идеологические соображения. А именно то, что для слоя бизнес-логики транзакция выглядит как-то так:

type Transaction interface {
    Commit()
    Rollback()
}

А вот всякие QueryContext не имеют для бизнес логики никакого значения, потому что вызываться будет только в адаптере (репо и т.п.). Получается, что имплементация адаптера протекает в бизнес логику, то есть в противоположную сторону от направления зависимостей в гексагоне.

И не важно, что это интерфейс а не тип. Эти методы исключительно для работы с БД, о чем уровень логики не знает.

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

Это не говорит о том, что инвертированный подход (не репо внутри транзакции, а транзакция внутри репо) хуже, но для меня он выглядит более ограничительным по функционалу.

Хорошая альтернатива в любом случае, спасибо.

Да, тоже интересный подход. Но тут получается, что на уровне приложения нужно во-первых явно получить объект бд или транзакции (пусть и скрытый за интерфейсом QueryExecutor), а потом сконструировать на его основе инстанс CarRepository, который либо работает в транзакции, либо без нее.

Подход жизнеспособный, но кмк немного ограничивающий.

В моем случае логика управления транзакцией отделена от репозиториев, и я могу (при необходимости) делать часть запросов вне транзакции, а часть - в транзакции, используя один и тот же репозиторий.

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

Если быть точнее, слой приложения гексагона управляет транзакцией, а внутри он вызывает методы доменного слоя. А уже домен внутри дергает репозитории (спрятанные за интерфейсами-портами), при этом не зная ничего про транзакцию.

В этом случае, как писал выше, нужно будет протащить интерфейс со всеми методами (Database, QueryExecutor или еще что) в ядро гексагона, где он никак не нужен, да и реализует специфичную для адаптера логику.

Поэтому это делается через контекст, который в этом случае меняется, поэтому передается в функцию.

Нет, неудобно по двум причинам:

  1. контекст все равно нужен, он не только для транзакций используется, но еще много для чего - от прерывания задач по таймауту до телеметрии;

  2. если передавать копию Database, то придется протащить этот тип в интерфейсы ядра гексагона, а это нарушает принцип "зависимости от центра к периферии" - получается, что у нас бизнес логика знает о реализации адаптера для работы с БД, а этого быть не должно.

Работа через контекст позволяет сохранить изоляцию слоев и не нарушать направление зависимостей в гексагоне.

Спасибо. В целом слежу за тем, чтобы таких запросов не было, а где нельзя - пользуюсь блокировками БД. Таких ошибок не было, да и отлавливаются они быстро.

Но если рассматривать подход как фреймворк, который отдается в руки программистам, плохо владеющим SQL, ретраи могут пригодиться.

Зависит от контекста задачи - можно и прямо всю функцию-замыкание ретраить, а можно ретраить на уровне отправки запроса/чтения сообщения (мы пошли по второму пути).

Спасибо за пояснение.

У меня получается избегать этого через

  • написание неконфликтных запросов

  • пессимистичные блокировки там, где могут быть конфликты

Information

Rating
Does not participate
Registered
Activity