Comments 32
Я правильно понимаю что на языке который намеренно создан чтобы не быть как в Java, философия которого не быть как Java, многие разработчики в который приходят потому что устали писать как в Java, и вообще одна из киллерфич и смыслов которого не быть как Java,
Вы пишете статью о том как на Go писать как в Java?
Поправьте конечно если я не прав, я не эксперт в Go, но из своего опыта я не понимаю зачем идти поперек языка? Вот к примеру зачем на каждый чих городить столько файлов? Ну в Java то для этого причина есть, там в файле не более одного публичного класса может быть. В Go нет никаких классов, как и в Rust философия модульно-пакетная, где концентрация логики идет по пакетам а не по отдельным файликам. Да и в целом язык очень ограниченный и призван брать простой и нужно использовать сильные стороны инструмента. А с таким подходом это будет пародия на Java так как в Go даже нет инструментов поддержки такой навороченности.
У меня основной опыт тоже в Java/Kotlin, и сейчас пишу немного на Go. Я наоборот пришел к тому, что пакетная видимость очень удобна для дробления файлов.
Когда я вижу пакет, я мысленно рассматриваю его как один "обобщенный" файл. Т.е. я могу хоть по функции в файл дробить, но за счет того, что они в одном пакете - все "приватные" методы доступны. Если же мне надо что-то инкапсулировать (а ля создать новый Class), я запихиваю это в отдельный пакет.
Т.е. условно получается, что один class-file из java становится пакетом+файлом(пакетом+файлами) в Go...
Сейчас подумываю о создании базовой структуры с методом toString, hash, equals (joke)
Неправильно. Хватит демонизировать джаву. Го не создавали, чтобы он был НЕ КАК джава. Го скорее создавали для других типов задач, вроде инфраструктуры, где он себя кстати отлично чувствует (докер, кубер).
Но на го начали писать энтерпрайз приложения, а они обладают большой сложностю, которая продиктованна сложностью предметной области. И чтобы с этой сложностью совладать можно использовать разные паттерны, которые до этого использовались в джаве, как раз с целью совладания со сложностью!
Более абсурдным выглядит писать энтерпрайз приложения, где много бизнес логики и не использовать ничего из этого. В этом случае вы столкнетесь лицом к лицу с этой сложностью. Ее можно конечно отрицать и это почему-то популярно, но всё же приятнее, когда все упорядочено и не надо всё держать в голове.
А на файлы разделять за тем же, за чем и просто что-то большое разбивать на что-то мелкое - декомпозиция сложности. Все паттерны, все солиды, все эти DRY, KISS, GRASP и т.д. про совладание со сложностью.
Неправильно. Хватит демонизировать джаву. Го не создавали, чтобы он был НЕ КАК джава. Го скорее создавали для других типов задач, вроде инфраструктуры, где он себя кстати отлично чувствует (докер, кубер).
Гугл создал себе целый язык общего назначения, со сборщиком мусора, горутинами и стандартной библиотекой полной компонентов для создания веб сервисом чтобы куберы писать? Интересно.
Но на го начали писать энтерпрайз приложения, а они обладают большой сложностю, которая продиктованна сложностью предметной области.
Большей чем чем Кубер в котором больше 2 миллионов строк кода?
И чтобы с этой сложностью совладать можно использовать разные паттерны, которые до этого использовались в джаве, как раз с целью совладания со сложностью!
И как, совладали? Или переложили сложность из одной категории в другую? Раньше сложно было разобраться из-за отсутствие структуры а сейчас невозможно разобраться но уже из-за тотального оверинженерига и десятков слоев абстракций. Однозначно победа. Давайте повторим.
Более абсурдным выглядит писать энтерпрайз приложения, где много бизнес логики и не использовать ничего из этого. В этом случае вы столкнетесь лицом к лицу с этой сложностью. Ее можно конечно отрицать и это почему-то популярно, но всё же приятнее, когда все упорядочено и не надо всё держать в голове.
Мир не крутиться вокруг Java и ее проблем а также вокруг проблем ООП языков. Не нужно если у Вас в руках молоток видеть все вокруг как гвозди. В мире есть не только ООП парадигма и проекты пишут в разных стилях и как то борются с этой вашей сложностью.
А на файлы разделять за тем же, за чем и просто что-то большое разбивать на что-то мелкое - декомпозиция сложности.
В Java это ограничение языка потому так и делали и так вошло в мейнстрим. Натягивать сову на глобус на другие языки где этой проблемы нет и где философия другая, такое себе занятие. Не нужно считать автором языков которые так намеренно сделали тупыми, что они не догадались сделать как в Java.
Все паттерны, все солиды, все эти DRY, KISS, GRASP и т.д. про совладание со сложностью.
И еще раз, совладали то со сложностью по итогу? Например с той сложностью что SOLID уже несколько десятилетий пытаются объяснить в сотнях статей. Или ООП которым как выясняется за несколько десятков лет так никто толком пользоваться и не научился. Хороший же результат, что может пойти не так если такой подход внедрить туда где от этого решили отказаться и пойти другим путем?
К сожалению, вы понимаете это неверно. Фраза «не как Java» не означает, что любое сходство с Java кодом автоматически является злом или дурным тоном. Она указывает на ряд фундаментальных различий, заложенных в сам язык, — например, в обработке ошибок или модели конкурентности.
Однако архитектура - это не про Java. Многие архитектурные принципы вообще не зависят от конкретного языка программирования. Речь идёт о системности, аккуратности и, если угодно, чистом коде.
Не стоит бояться изучать паттерны и архитектурные подходы - особенно если вы работаете над продуктовым приложением, а не пишете инфраструктурный тул, где уместна крайняя минимализация. В большинстве случаев как раз требуется системный подход.
И самое главное (постскриптум для начинающих разработчиков) - не стоит использовать фразу «Go не Java» как оправдание отсутствия архитектуры и незнания принципов проектирования.
К сожалению, вы понимаете это неверно. Фраза «не как Java» не означает, что любое сходство с Java кодом автоматически является злом или дурным тоном. Она указывает на ряд фундаментальных различий, заложенных в сам язык, — например, в обработке ошибок или модели конкурентности.
Позвольте немного разверну ответ.
Фраза «не как Java» действительно не означает что Java это очень плохо и не нужно таким быть.
Вопрос в природе решений и их сильных и слабых сторонах. Go - это типичное асимметричное решение. Вместо того чтобы делать очередной более навороченный язык авторы принесли в жертву функциональность в обмен на минималистичность. Получился довольно топорный и не всегда удобный язык но который за счет этого был дико минималистичным а это значит что его очень быстро можно выучить и сильно ограниченным что значит что разработчики сильно ограничены в фантазиях по использованию языка. Из этого сложились сильные и слабые стороны языка: силен там где можно сделать просто и понятно и слаб там где к примеру нужно реализовывать DSL подобный код или строить сложные абстракции. И логично предположить что нужно развивать проекты в направлении сильных сторон языка.
Более того, это язык с другой философией и структурой. Если Java из времен где модно было ООП и не существует ничего вне классов, а классы связаны с файлами то Go это другая история скорее ближе к помеси C с современными языками, в нем нет никаких классов и построения логики вокруг них, есть пакеты, есть обычные функции и обычные структуры которые можно и нужно использовать не как ООП.
Когда проект пишется на Go в стиле Java то теряются многие сильные стороны языка потому как он для этого не предназначен и теряются сильные стороны, получается недо-Java 2.0 на минималках. По мне так это не очень хорошая история.
Захотелось привести хороший пример из истории. На заре Первой мировой войны, английский лорд Джон Фишер придумал идею линейных крейсеров т.е. кораблей с орудиями от линкоров но обладающих огромной скоростью, разумеется для этого пришлось пожертвовать броней. Как говаривал лорд - "скорость — лучшая броня ". Разумеется никто не подразумевал участие таких кораблей в линейном бою с линкорами противника. Это было типичное асимметричное решение - разменять броню на скорость и использовать согласно данным преимуществам. Но вот незадача, типичному адмиралу может прийти в голову мысль - "как можно не использовать корабли с такими большими пушками в линейном бою". Через определенное время так и случилось, в Ютландском сражении где адмиралы таки бросили линейные крейсера в бой и те начала гореть как спички и тонуть, Дэвидом Битти была сказана знаменитая фраза " There seems to be something wrong with our bloody ships today."
Просто Go и микросервис это круто, а PHP и монолит это отстой.
«Нельзя добавить более десяти пунктов в список дел» — небольшой вариант оптимизации
make([]TodoListItem, 0, 10)
Правда, с подобной структурой проекта это «оптимизация» ни на что не повлияет, так, просто «хороший тон».
На Хабре в последнее время каждую неделю по DDD статье. Это отлично, но что за хайп я пропустил?
Отличная статья, спасибо. Статей на эти темы много, но зачастую они бестолковые и с уймой ошибок. Тут же видно, что автор изучал то, о чем пишет.
Большая статья, но если коротко, почему вы вначале рисуете зависимости снаружи во внутрь? Почему домену нельзя пользоваться своей же оберткой? Для чего вообще тогда слои? CQS, на сколько я понимаю относится к методам класса, а не архитектуре приложения, и вы, по всей видимости, путаете его с паттерном ООП команда. И почему у людей в гексогональной архитектуре порты только в 1 месте всегда? А слои как между собой общаются?
Доброго времени суток!
Стрелки для меня - это `import`. В своих проектах делаю импорты направленнми снаружи во внутрь. В статье я делюсь тем, что делаю, поэтому и нарисовл так.
Не совсем понял что вы имеете в виду под оберткой. Приведите, пожалуйста, пример.
Импорт типов из пакета
internal/infra
в пакетinternal/domain
запрещен, поскольку такой стрелки нет на рисунке.
Ну и стрелку я отчетливо вижу) Нет?
У вас "стрелочки" из домена идут внаружу только для общих пакетов, что вы сами и пишите:
В пакете
internal/shared
можно хранить, например, строковые константы, используемые во всех слоях или единый перечень ошибок (var Err...), используемых повсеместно в приложении:
internal/shared/strconst
internal/shared/errors
В
internal/pkg
можно поместить middleware fiber с вашей собственной реализацией CORS. Напротив, если в middleware fiber обрабатываются ошибки из пакетаinternal/domain
, то такой код нужно оставить в слое инфраструктуры. В демо-приложении вinternal/pkg
размещен пакетpgxtx
, который отвечает за передачу транзакции БД вcontext.Context
. Однако содержимоеpgxtx
можно было бы оставить и в слое инфраструктуры, поскольку эта функциональность используется только там.
И причем тут CORS? У вас реализация корс в каком-то отдельном пакете? Чем глубже читаешь, тем больше понимаешь что лайки заслужены конечно)
CQS использую как повод отделить функции, изменяющие состояние БД от функций, считывющих состояние БД. Command здесь - это просто название dto. Здесь нет паттернов, только организация структур и функций в слое приложения.
Спасибо за статью. Многие решения откликаются во мне.
Предлагаю обсудить интерфейсы.
Почему интерфейсы репозиториев в домене?
Их необходимость ещё стоит обосновать (https://enterprisecraftsmanship.com/posts/ocp-vs-yagni/)
Но если они все-таки нужны... Мне кажется, что подход, при котором интерфейсы находятся в слое домена, взят из Джавы и прочих ООП языков, в которых в реализации явно указывается, что она реализовывает интерфейс.
В го типизация утиная и такой необходимости нет. Так зачем размазывать знание о хранении модели в слое модели?
Я предпочитаю определять интерфейсы по месту использования. В данном случае это слой приложения.
Доброго времени суток!
Для меня репозиторий - это тоже доменная модель. Модель коллекции агрегатов. Репозитории используются многими command handles и domain services, поэтому я расцениваю репозиторий в своем роде как общий код. Пока удается сохранять небольшой список функций репозитория (add, update, delete, get) храню его рядом с агрегатами.
Однако часто в репозиториях появляются функции вроде `getByName`, `getBySomething`, `updateName` и т.д. Тогда это уже не модель предметной области, а набор функциональных интерфейсов, например `todoListGetterByName`.
В работе я активно использую интерфейсы, объявленные в месте использования (в слое приложения в основном), и активно комбинирую этот подход с репозиториями. Просто в статье не нашел место это показать.
Я обратил внимание, что у вас в доменных службах используется 2 репозитория.
Я пришел к выводу, что нужно быть аккуратным в этом вопросе, особенно в случаях, когда в рамках сервиса происходит изменение нескольких агрегатов. Если вы допускаете такое в своем домене, то такого рода изменение обязательно произойдёт (закон Мерфи).
Первая причина - это ограничивает масштабируемость приложения, происходит завязывание этих модулей друг на друга и разбить их например на микросервисы станет невозможным без изменения домена. Для вашего выдуманного сервиса это может быть актуально, т.к. using очевидно гораздо более demanding контекст, чем editing.
Вторая причина - проблемы с консистентностью. Агрегат подразумевает собой границу консистентности данных, но при попытке изменить несколько агрегатов в транзакции эти границы неконтролируемо раздвигаются. В целом можно изменять несколько агрегатов в одной транзакции, но только если они в одном контексте и разработчик четко отдает себе отчёт, что такая транзакция не имеет шансов нарушить какие либо инварианты или случайно перезаписать какие-то данные в БД, внесённые в параллельной транзакции. Тут тоже все по закону Мерфи.
Как правило в своих приложениях в рамках одной команды я обновляю только один агрегат. Если мне нужно информация типа состояния из editing контекста, то я передаю эту информацию на вход команды using контекста, при этом работая по контрактам using контекста; таким образом оба этих контекста работают независимо, и внесение изменений в один из них не означает внесение изменений в другой. Да, появляются "дубликаты" кода между контекстами, но по факту это не совсем дубликаты, и чем больше развивается приложение, тем более очевидным это становится.
Для композиции query из editing и вызова command из using, я использую слой оркестрации. Это application слой, который манипулирует доступными commands & queries. Оркестраторов может быть несколько, в зависимости от сложности бизнеса (кор бизнес, аналитика, отчёты, биллинг). Оркестратор управляет машиной состояний заложенной в некий процесс. Примером оркестратора может служить AWS Step Functions, или Mass Transit. Таким образом и слой домена и слой оркестрации содержат бизнес логику. Слой оркестрации содержит логику оператора, это собственно и есть то, что мы пытаемся автоматизировать. Слой домена предлагает кирпичики и ручки, с которыми работает оператор. Другая метафора оркестратора это блоки управления в авто. Есть агрегаты - ДВС, АКПП. БУ делает условный query в ДВС, далее БУ рассчитывает следующую передачу и на основе этого отправляет command в АКПП на переключение передачи. Точно так же есть отдельный БУ для системы мультимедиа и тд.
Доброго времени суток!
Спасибо за комментарий!
Если мне нужно информация типа состояния из editing контекста, то я передаю эту информацию на вход команды using контекста, при этом работая по контрактам using контекста
Подскажите, я правильно понял, что в соответствии с этим подходом клиент приложения сначала получает editing todo list с помощью query, а затем передает его в command контексту using?
Или, например, контекст editing публикует доменное событие `TodoListPublished`, а контекст using создает у себя модель PublishedTodoList, к которому потом можно направить команду TakeTodoList в контекст using?
Варианты реализации:
Опция 1 - Позволить фронтенду (FE) выполнить оркестрацию: процесс начинается с запроса (query) в контексте редактирования для отображения списка задач (todo). Пользователь в любом случсе должен просмотреть список задач, который он копирует, чтобы убедиться, что он ему подходит. Далее фронтенд отправляет команду на бэкенд (BE) в контекст using.
Плюсы: Простота и универсальность (открывает возможность создания пользовательских списков без необходимости взаимодействия со списками, созданными редакторами). Минусы: Это решение может не полностью соответствовать концепциям бизнеса. Например, если бы вы использовали звонок по телефону в качестве интерфейса вместо API, вряд ли вы бы хотели, чтобы оператор прослушивал все 100 пунктов списка. Скорее, оператор хотел бы получить идентификатор списка задач созданного редактором из контекста редактирования.
Опция 2 - Вовлечение оркестратора: пользователь отправляет команду CreateTakeToDoListRequestCommand
в контекст using. В результате из контекста генерируется событие TakeToDoListRequestCreatedEvent
. Оркестратор перехватывает это событие, выполняет запрос GetEditorToDoListQuery
в контекст editing и формирует команду CreateToDoListCommand(UserId, EditingListId, TakeToDoListRequestId и т.д.)
. После успешного выполнения, в том же обработчике события или в обработчике события UserToDoListCreatedEvent
отправляется команда CompleteTakeToDoListRequestCommand
.
Этот процесс является наиболее ориентированным на бизнес и наиболее репрезентативным.
Плюсы: Все действия происходят в рамках бизнес-домена. Агрегаты представляют собой согласованные элементы-кирпичики, а оркестратор (можно назвать "Оператор") координирует процесс, реализуя бизнес-логику. Это фактически процесс, описанный на языке бизнеса, без упоминания API, контроллеров, фронтенда, бэкенда и т.д.
Минусы: Внедрение такого подхода с первого раза может быть сложным (ничего действительно ценного не дается легко), и для его реализации необходимы разработчики соответствующего уровня, ориентированные на решение бизнес-задач. Однако в данный момент рынок переполнен специалистами, которые больше сосредоточены на решении задач с литкод, прыжками между компаниями для повышения зп и вайб-кодинге, чем на реальных бизнес-проблемах (и я их не виню, сам бизнес это культивирует).
Кроме того, Опция 2 является наиболее масштабируемым вариантом. Модуль editing может функционировать на одном инстансе и не самом производительном инстансе базы данных. Модуль using как значительно более нагруженный, в свою очередь, может быть развернут на нескольких инстансах и может быть применено партиционирование базы данных.
Брокеры сообщений, такие как Kafka, способны обрабатывать миллионы сообщений в секунду.
Количество оркестраторов может варьироваться: их может быть как один, так и несколько. Эффективно оценивать необходимость их увеличения можно по динамике задержки в обработке сообщений в брокере. Если какой-либо процесс занимает значительное время у оркестратора, что приводит к задержкам в выполнении других процессов, имеет смысл выделить этот нагруженный процесс в отдельный оркестратор и масштабировать его независимо. Это также следует обсудить с бизнесом, поскольку такое решение может развеять туман войны для бизнеса и помочь осознать, что для управления этим процессом требуются отдельный вид операторов (именно так бы решалась эта проблема в доцифровые времена).
Что касается других аспектов, таких как необходимость в API Gateway, возможно, вам не потребуется самостоятельно управлять этой инфраструктурой. Рассмотрите возможность использования serverless-решений, таких как AWS API Gateway или Apigee, которые могут значительно упростить управление API.
расскажите у вас работают транзакции? я правильно понимаю, что пишете ее в контекст?
Да, транзакция передается в контексте. Вот production ready решение https://github.com/avito-tech/go-transaction-manager.
В слое инфраструктуры есть функция, которая в качестве аргумента принимает анонимную функцию. Эта ананонимная функция создется в слое приложения и описывает алгоритм взаимодействия с БД. Перед вызовом анонимной функции открывается транзакция. После выполнения анонимой функции транзакция коммитится. Если анонимная функция вернула ошибку, то транзакция откатывается. Каждая функция, которая делает sql-запрос должна проверять context.Context на наличие транзакции. Если транзакция есть, то в качетве объекта то sql-запрос посылается в рамках транзакции. Если транзакции в контексте нет, то sql-запрос посылается через одно из соединений в пуле соединений с БД.
разве запись в контекст не является плохой практикой? https://www.reddit.com/r/golang/comments/1awp5af/is_passing_database_transactions_as_via_context/
Я бы сказал, что у такого подхода есть свои минусы и свои плюсы.
На мой взгляд основной минус - это неочевидность того, что транзакция находится в контексте.
К счастью реализаций абстрактных транзкакций много. Есть из чего выбрать.
можете поделиться примерами реализаций, которые на ваш взгляд наиболее удачные?
Первый вариант - сделать то же самое, что и в статье, только транзакцию передавать явно.
Чтобы не было типа pgx в слое приложения, нужно:
- переименовать type Tx interface в TxExecutor
- рядом с интерфейсом объявить type Tx any
- использовать Tx any во всех пакетах, кроме postgres. В пакете postgres понадобится делать явное приведение типа any к pgx.Tx
Я в работе использую этот первый вариант. С ним нормально. Есть еще у меня проект, где транзакция в контексте. Тоже нормально.
Второй и третий варианты - https://www.angus-morrison.com/blog/atomic-go-repositories
Второй - это создавать транзакцию вызовом Begin у интерфейса репозитория. В слое приложения придется обрабатывать commit и rollback.
Третий - type AtomicOperation func(context.Context, Repository) error из статьи. Я экспериментировал с таким подходом. Если много репозиториев нужно, то будет расти количество аргументов type AtomicOperation func(context.Context, Repository1, Repository2, ..., RepositoryN)
Все остальное, что я видел - это уже, на мой взгляд, вариации этих трех вариантов.
Попробуйте поискать статьи по фразе 'golang clean transaction', будет много вариантов с примерами.
У всех подходов есть плюсы и минусы. В go я не нашел такого подхода, который можно было бы считать эталоном.
Еще один вариант структуры go-приложения