All streams
Search
Write a publication
Pull to refresh
86
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, ретраи могут пригодиться.

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

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

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

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

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

Насколько помню, локи отрабатывают для всех уровней изоляции одинаково хорошо (если речь не про фантомы, конечно, но зачем нам локи на фантомы?).

Касательно того, что называют здесь "сбоями сериализации" - видимо имеется в виду Lost updates и Non-repeatable reads (ну и фантомы), но этого можно избежать, просто грамотно продумывая запросы, а не бездумно полагаясь на повышение уровня сериализации.

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

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

Никак не буду делать ретрай, при локе первая транзакция проставит лок, а вторая будет ждать синхронно, пока лок будет отпущен.

Синхронных операций по 20 минут в одной транзакции принципиально нет, все отрабатывает за 20мс в худшем случае, поэтому необходимости в каких-то ретраях нету.

С этим особых проблем нет, просто делаю в репо метод вида GetCarWithLock(ctx context.Context, id string) (*model.Car, error), внутри которого SELECT FOR UPDATE.

Сбоев сериализации пока не было, но при необходимости можно добавить в WithinTransaction функциональных опций с передачей уровня изоляции, например.

Американские авторы такие американские... скажите спасибо, что статься не начинается с рекламы статьи и как читатель сможет лихо вертеть флагами к концу прочтения

Проблемы, как всегда, решаются запретами.

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

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

В итоге получили бы решение не через запреты, а через возможности. Но нет, зачем же.

Information

Rating
Does not participate
Registered
Activity