image

В последнее время на хабре, и не только, можно наблюдать интерес GO сообщества к луковой/чистой архитектуре, энтерпрайз паттернам и прочему DDD. Читая статьи на данную тему и разбирая примеры кода, постоянно замечаю один момент — когда дело доходит до хранения сущностей предметной области — начинается изобретение своих велосипедов, которые зачастую еле едут. Код вроде бы состоит из набора паттернов: сущности, репозитории, value object’ы и так далее, но кажется, что они для того там “чтобы были”, а не для решения поставленных задач.
В данной статье я бы хотел не только показать, что, по моему мнению, не так с типичными DDD-примерами на GO, но также продемонстрировать собственную ORM для реализации персистентности доменных сущностей.


Дисклеймер


Прежде чем приступить к теме статьи, есть несколько моментов, которые необходимо осветить:


  • Данная статья о том, как писать приложения с богатой бизнес логикой. Сервисы на GO зачастую такими не являются, не нужно применять к ним DDD’шные подходы.
  • Исходя из того, что я не являюсь ярым фанатом ORM, считаю, что зачастую использование этой технологии попросту излишне. Кроме того, необходимо брать ее лишь в том случае, когда вы отдаете себе отчет в ее целесообразном использовании в проекте, иначе вы попросту используете инструмент для галочки, “для того, чтоб был”.
  • Оппонировать я буду подходам из этой статьи и (раз, два) примерам проектов.
  • Я буду иллюстрировать свои мысли на примере типичного приложения — wish list.

А теперь — можно начинать.


Энтерпрайз паттерны в GO и что с ними не так


Речь здесь пойдет о таких паттернах как: репозиторий, сущность, агрегат и способах их приготовления. Для начала, давайте разберемся, что же это за паттерны такие. Я не буду придумывать определения в стиле “от себя”, а буду использовать слова признанных мастеров: Ерика Эванса и Мартина Фаулера.


Сущность


Начнем с сущности. По Эвансу:


Entity: Objects that have a distinct identity that runs through time and different representations. You also hear these called "reference objects".

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


type Wish struct {
    id       sql.NullInt64
    content  string
    createAt time.Time
}

Агрегат


А вот про этот шаблон как то незаслуженно забывают, особенно в контексте GO. А забывают, между прочим, абсолютно зря. Чуть позже мы разберем почему агрегаты намеренно не используются в различных примерах DDD проектов на GO. Итак, определение по Эвансу:


Cluster the entities and value objects into aggregates and define boundaries around each. Choose one entity to be the root of each aggregate and control all access to the objects inside the boundary through the root

Рассмотрим пример aggregate root:


type User struct {
    id      sql.NullInt64     
    name    string            
    email   Email              
    wishes  []*Wish 
    friends []*User 
}

Тут у нас агрегат “пользователь”, который включает в себя сущность User, а также набор желаний этого пользователя и набор друзей.


Ну пока все ок, скажете вы, разве есть какие-то проблемы с реализацией? Я считаю — есть, перейдем к репозиториям.


Репозиторий


A Repository mediates between the domain and data mapping layers, acting like an in-memory domain object collection. Client objects construct query specifications declaratively and submit them to Repository for satisfaction. Objects can be added to and removed from the Repository, as they can from a simple collection of objects, and the mapping code encapsulated by the Repository will carry out the appropriate operations behind the scenes. Conceptually, a Repository encapsulates the set of objects persisted in a data store and the operations performed over them, providing a more object-oriented view of the persistence layer. Repository also supports the objective of achieving a clean separation and one-way dependency between the domain and data mapping layers.

Определение емкое, поэтому выделю основные моменты:


  • Репозиторий абстрагирует конкретное хранилище — ну обычно на этом в GO проектах все и заканчивается. Да, конечно, это важно, например, при написании юнит тестов, но это далеко не вся суть репозиториев.
  • Репозитории создаются только для aggregate root. Это исходит из определения агрегата, потому как все, что мы делаем в доменном слое, должно быть сделано через корень агрегата.
  • Репозиторий предоставляет интерфейс схожий с интерфейсом коллекции.

Давайте рассмотрим типичный для GO пример репозитория и как он используется:


type UserRepository interface {
    Save(*User)
    Update(*User)
    FindById(*User, error)
}

user1 := &User{}
userRepo.Save(user1) // Save

user2, _ := userRepo.FindById(1) // FindById

user2.Name = “new user”
userRepo.Update(user2 ) // Update

Вопросы, которые сразу же возникают для таких репозиториев:


  • должен ли FindById загружать коллекции друзей и желаний (что ведет к расходам на дополнительные запросы)? Что, если для решения конкретной бизнес задачи эти коллекции мне не нужны?
  • Должен ли Update каждый раз проверять список друзей и желаний — не изменилось ли там что-то? Как мне отслеживать эти изменения?
  • Как быть с транзакционностью? В одном кейсе я хочу сделать Save одного пользователя, а в другом кейсе я хочу, чтобы в транзакции было два Save’a. Очевидно, в таком случае управление транзакцией должно быть вне метода Save. Как в данном случае избежать протечки инфраструктурной логики в домен?

Обычно в примерах GO кода такие вопросы принято “обходить” всеми возможными способами:


  • делаем репозитории и для агрегатов и для сущностей, ведь чем меньше (и проще) структура — тем проще ее сохранить
  • сознательно избегаем кейсов, где наша реализация начинает протекать в бизнес логику, так же поступаем с кейсами, реализация которых будет выглядеть крайне громоздкой
  • берем базу данных в которую можно положить агрегат целиком (например mongo) и делаем вид, что других хранилищ не бывает и транзакции нам не нужны

А как насчет схожести интерфейса репозитория к интерфейсу GO-коллекций? Ниже представлен пример работы с коллекцией пользователей реализованной через slice:


var users []*User

user1 := &User{}
users = append(users, user1) // Save
user2 = users[1] // FindById
user2.Name = “new user” // Update

Как видите, эквивалент методу Update для слайса users просто не требуется, потому, что изменения внесенные в агрегат User применяются сразу же.


Обобщим проблемы, которые не дают DDD-like GO коду быть достаточно выразительным, тестируемым и вообще классным:


  • Типичные GO-репозитории создаются для всего подряд, агрегат, сущность может value object — who cares? Причина — нет ORM или других инструментов позволяющая “грамотно” работать сразу с графом объектов.
  • Типичные GO-репозитории не стараются походить на коллекции. В результате страдает выразительность и тестируемость кода. Знание о базе данных может протечь в бизнес логику. Причина — вновь упираемся в отсутствие подходящей ORM. Можно, опять же, все делать руками, но как показывает практика — это слишком неудобно.

D3 ORM. Зачем оно мне?


Хм, похоже что написать свою ORM не самая плохая идея, что я и сделал. Рассмотрим как же она помогает решить описанные выше проблемы. Для начала, как выглядит сущность Wish и агрегат User:


//d3:entity
//d3_table:lw_wish
type Wish struct {
    id       sql.NullInt64 `d3:"pk:auto"`
    content  string
    createAt time.Time
}

//d3:entity
//d3_table:lw_user
type User struct {
    id      sql.NullInt64      `d3:"pk:auto"`
    name    string             `d3:"column:name"`
    email   Email              `d3:"column:email"`
    wishes  *entity.Collection `d3:"one_to_many:<target_entity:Wish,join_on:user_id,delete:cascade>"`
    friends *entity.Collection `d3:"many_to_many:<target_entity:User,join_on:u1_id,reference_on:u2_id,join_table:lw_friend>"`
}

Как видите изменений не много, но они есть. Во первых — появились аннотации, с помощью которых описывается мета-информация (имя таблицы в БД, маппинг полей структуры на поля в БД, индексы). Во вторых — вместо обычных для GO коллекций — slice’ов D3 ORM накладывает требования на использование своих коллекций. Данное требование исходит из желания иметь фичу lazy/eager loading. Можно сказать, что, если не брать в расчет кастомные коллекции, то описание бизнес сущностей делается полностью нативными средствами.


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


userRepo, _:= d3orm.MakeRepository(&domain.User{})

userRepo.Persists(ctx, user1) // Save
user2, _ := userRepo.FindOne(ctx, userRepo.Select().AndWhere("id", "=", 1)) // FindById
user2.Name = “new user” // Update

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


orm.Session(ctx).Flush()

Если вы работали с такими инструментами как: hybernate или doctrine то, для вас это не будет неожиданностью. Так же для вас не должно быть неожиданностью то, что вся работа выполняется в рамках логических транзакций — сессий. Для удобства работы с сессиями в D3 ORM есть ряд функций, которые позволяют положить и вынуть их из контекста.


Разберем еще некоторые примеры кода для демонстрации тех или иных фич:


  • lazy loading, в данном примере запрос на извлечение из БД желаний пользователя будет создан и выполнен в момент непосредственного обращения к коллекции (в последней строке)

u, _ := userRepo.FindOne(ctx, userRepo.Select().AndWhere("id", "=", 1)) // будет сгенерирован запрос только для таблицы lw_user

wishes := u.wishes.ToSlice() // cгенерируется запрос для таблицы lw_wish

  • transactions — D3 ORM использует концепцию UnitOfWork или другими словами транзакции на уровне приложения. Все изменения накапливаются пока не будет вызван Flush(). Кроме того транзакцией можно управлять вручную, объединяя несколько Flush’ей в одну транзакцию

userRepo.Persists(ctx, user1)
userRepo.Persists(ctx, user2)

orm.Session(ctx).Flush() // стандартное поведение - при вызове Flush создается физическая транзакция, в рамках которой выполняется два insert’a

session := orm.Session(ctx)
session.BeginTx() // переводим в ручной режим управления транзакцией

userRepo.Persists(ctx, user1)
userRepo.Persists(ctx, user2)
session.Flush() // в ручном режиме тут не будет сгенерировано запросов к базе

userRepo.Persists(ctx, user3)
session.Flush()

session.CommitTx() // на этой строчке будет сгенерирована транзакция в рамках которой выполняется три insert’a 

  • при вызове Persists сохраняются все объекты от корневого (то есть граф объектов). При этом запросы в базу данных на вставку/обновление генерируются только для тех, которые действительно изменились

Подробно о том, как работать с ORM, есть документация, а также демо проект. Краткий список фич:


  • кодогенерация вместо рефлексии
  • автогенерация схемы базы данных на основе сущностей
  • «один к одному», «один ко многим» и «многие ко многим» связи между сущностями
  • lazy/eager загрузка связей
  • query builder
  • загрузка связей в одном запросе к базе (используется join)
  • кэш сущностей
  • каскадное удаление и обновление связанных сущностей
  • application-level transactions (UnitOfWork)
  • DB transactions
  • поддерживается UUID

А зачем оно вам?


Резюмируя, чем вам может быть полезна D3 ORM:


  • у вас много бизнес логики и вы хотите: чтобы ваш код был как можно ближе к языку доменной области и чтобы все инварианты и вообще весь доменный слой был покрыт юнит тестами

В противном случае не могу советовать использовать D3 ORM.
А еще бы хотел описать случаи, где, по моему мнению, использовать любую ORM плохая идея:


  • если вам действительно важна производительность и вы боритесь за каждую аллокацию
  • если ваше приложение выполняет в основном READ операции. Ну право дело, для этого у нас есть отличный инструмент — SQL, зачем нам что-то другое?
  • если ваше приложение тонкий клиент к базе данных. Зачем вводить ненужные абстракции?

Заключение


Надеюсь данной статьей мне удалось хотя бы немного поставить под сомнение типичный GO-style написания бизнес логики. Кроме того, я постарался показать и альтернативу этому подходу. В любом случае решать, как писать код, вам, ну что ж, удачи в этом нелегком деле!