Как-то раз я сидел в баре с давним приятелем, с которым раньше мне довелось поработать на поза-поза-позапрошлой работе. Он из сомневающихся по поводу перехода на Go, ярый приверженец своего нынешнего языка. Хочу сказать, что он делает действительно классные вещи, пишет безупречный код, у него есть, чему поучиться. Но к Go у него отношение не слишком позитивное. Как он сказал: “Go — это *****код (плохой код)”. И в качестве одного из аргументов привел то, насколько, по его мнению, криво в Go реализована обработка ошибок. В чем-то он прав — в моем текущем не самом большом проекте на Go конструкция “if err != nil” встречается 1132 раза.
Этот мой приятель, не побоюсь этого слова — адепт DDD (domain driven design). Все, что не относится к DDD, — это, по его мнению, антипаттерн, ад и хаос. Когда я ему рассказал, что у меня есть довольно успешный опыт проектирования по DDD в Go-проектах, он округлил глаза. Да, ответил я, с определенной серией оговорок и компромиссов это работает, и неплохо.
Привет, меня зовут Толя и я ведущий разработчик платежного сервиса в Константе. Мой опыт разработки 15 лет, я разрабатывал на PHP, на чистом C, временами на C++, а последние 2 года разрабатываю на Go.
Мне кажется, что почти все гоферы когда-то мигрировали в мир Go из других миров. В основном это миры хардкорного ООП. Тех, кто начал свой путь в IT именно с языка Go — крайне мало, я таких даже и не встречал. Оно и понятно: язык появился сравнительно недавно, а хайпанул вообще как будто вчера.
Тем не менее, есть определенная категория разработчиков, которые подсознательно хотели бы окунуться в Go, но в силу своих собственных убеждений этого пока не делают. Или делают что-то на Go частично, какие-то небольшие кусочки логики, которую их уютные PHP/Python/etc по каким-то причинам тянут не так хорошо. В общем, “Go — не для всего”, говорят они.
Мне повезло: при переходе в мир Go я попал в команду, помешанную на DDD и экспериментах, связанных с привнесением лучших практик из других языков. В каком-то смысле удалось получить огромное удовольствие от соединения сильных сторон самого Go и того положительного опыта, который ранее я приобрел в других языках программирования.
С DDD у вас все элементы и кусочки логики строго на своих местах, при этом вы мыслите больше в терминах бизнеса, а не только технических особенностей. Вы получаете хорошо поддерживаемый, расширяемый и красиво написанный проект. Но из-за особенностей языка Go требуется повышенная дисциплина разработки. Такая дисциплина, в общем-то, и в других языках требуется. Пока еще не придумали язык DDD++, на 100% заточенный под всё, что есть в DDD, и делающий невозможным отступление от определенных правил.
Я уже молчу про то, что в Go для реализации своих domain driven амбиций местами может потребоваться отступить от официальных стайлгайдов создателей Go. Помните: творец здесь вы, а язык программирования — это всего лишь инструмент.
Пример: разделение на слои и их изоляция друг от друга. Мой опыт
В интернете уже есть статьи о том, как в Go-проектах разложить файлы по папочкам так, чтобы получилось DDD. Я хочу немного поделиться своим опытом, тем, как это получилось у меня, на нескольких примерах.
Предположим, мы делаем приложение — платежный сервис. Пусть приложение будет иметь следующие слои:
- прикладной (application);
- предметной области (domain);
- инфраструктурный (infrastructure).
Раскладываем все наши объекты по одноименным Go-пакетам. А дальше дилемма: как бы нам так все организовать, чтобы детали реализации каждого слоя не торчали наружу? Чаще всего рекомендуют непубличные объекты называть с маленькой буквы. Но мне понравилась другая идея: все структуры, скрывающие детали реализации, складывать в подпакет internal
. Это такой пакет, содержимое которого доступно соседним пакетам и вышестоящему пакету, но не всем остальным. В самом же пакете слоя пусть лежат интерфейсы, а также те типы объектов, которые скрывать мы не будем (например, сущности, объекты-значения и т.д.). Отсюда следует идея, что фабричные функции (которые NewFooBar()
) спрятанных в internal
сервисов можно вынести в свой подпакет factory
.
Вот как это может выглядеть на примере слоя domain
:
- domain/
- factory/
user_repository_factory.go
transaction_repository_factory.go
- entity/
user.go
transaction.go
- value/
money.go
- internal/
user_repository.go
transaction_repository.go
user_repository_interface.go
transaction_repository_interface.go
Итак, в нашем домене 2 сущности: User
и Transaction
. За вытаскивание из БД и сохранение в БД (или в какой-то другой тип хранилища) отвечают соответственно UserRepository
и TransactionRepository
. Опишем интерфейсы этих репозиториев и положим в соответствующие файлы в корне слоя-пакета domain
:
type UserRepositoryInterface interface {
Get(id int64) (entity.User, error)
FindByEmail(email string) (*entity.User, error)
Save(user entity.User) error
}
type TransactionRepositoryInterface interface {
Get(id int64) (entity.Transaction, error)
FindByUserId(userId int64) (*entity.Transaction, error)
Save(transaction entity.Transaction) error
}
Соответственно, в factory
будут такие фабрики (для простоты иллюстрации опустим проброс зависимостей и прочие вещи):
func NewUserRepository() domain.UserRepositoryInterface {
return &internal.UserRepository{}
}
func NewTransactionRepository() domain.TransactionRepositoryInterface {
return &internal.TransactionRepository{}
}
В internal
будут лежать реализации UserRepository
и TransactionRepository
.
В случае, если у нас не доменный слой, а, например, сервисный, то в его internal
ушли бы реализации сервисов.
Но вернёмся к доменному слою. Пытливый читатель может возразить, мол, домен не должен иметь никаких зависимостей, а значит конкретные реализации репозиториев должны находиться в инфраструктурном слое. Что ж, без проблем, подвинем две папки и получим такую структуру пакетов:
- infrastructure/
- factory/
user_repository_factory.go
transaction_repository_factory.go
- internal/
user_repository.go
transaction_repository.go
- domain/
- entity/
user.go
transaction.go
- value/
money.go
user_repository_interface.go
transaction_repository_interface.go
Пример: поведение сущностей
В случае с сущностями дела обстоят несколько хуже, но все равно приемлемо. Дело в том, что сущность — это не просто структура с полями, которые можно сеттить сколько и когда душе угодно. При работе с сущностями важно обеспечивать соблюдение инвариантов — таких бизнес-правил, которые форсируют возможные состояния сущности, и которые не могут быть соблюдены только лишь техническими ограничениями в виде строгой типизации и т.д.
Например, мы в нашем платежном сервисе имеем сущность под названием Transaction
со следуюшими полями (оставлю только те, которые нужны в этом примере):
type Transaction struct {
...
Status string
ProcessedAt *time.Time
}
Status
— текущий статус транзакции (может принимать значения Created
, Processing
, Success
, Failed
). А ProcessedAt
— время проведения транзакции на стороне внешней платежной системы, может быть nil
, если транзакция ещё не проведена (имеет статус, отличный от Success
). Если транзакция в статусе Success
, то поле ProcessedAt
обязательно должно иметь какое-то значение (т.е. не nil
).
Получается, если мы позволим в поля транзакции записывать значения, как хотим, то инвариант с ProcessedAt
и Status
может быть не соблюден в какой-то момент времени — то есть в какой-то момент времени сущность Transaction
может оказаться в невалидном состоянии.
Выходит, нам всё равно придется что-то придумать, чтобы менять состояние строго через вызовы методов, в которых инкапсулируется логика проверки возможности этого изменения.
Давайте так и сделаем:
func (t *Transaction) SetSuccess(processedAt time.Time) error {
if t.Status != "Processing" {
return fmt.Errorf("cannot set success status after %v", t.Status)
}
t.Status = "Success"
t.ProcessedAt = &processedAt
return nil
}
В данном случае метод SetSuccess
гарантирует нам правильный переход из статуса в статус, а также гарантирует, что ProcessedAt
будет задан одновременно с установкой успешного статуса.
Окей, наделали красивых методов. Но поля-то всё ещё публичные… И тут я вам предлагаю на выбор 3 варианта, что можно сделать:
- Сделать поля "приватными". Но в этом случае они внутри пакета всё равно будут видны из других объектов, да и придётся наплодить кучу геттеров, что, оказывается, не go way.
- Каждую отдельную сущность положить в свой отдельный пакет внутри
entity
. Получить в итоге "пакет с пакетами" и чрезмерно усложнённое дерево папок в проекте. - Забить и договориться всей командой, что поля напрямую не сеттим, состояние меняем строго через методы, а нарушителей такого правопорядка на код-ревью бьём по рукам.
Декларативный стиль описания бизнес-логики
Декларативное описание бизнес-правил в Go у меня в целом получилось приемлемым, aka паттерн "спецификации", хотя в моём случае и не на 100% его книжный вариант. Здесь покажу один из возможных примеров реализации, и я уверен, что у вас получится лучше, красивее и каноничнее.
Давайте представим: в нашем платежном сервисе возникла необходимость завести небольшой и поначалу не очень сложный компонент под названием "антифрод". Этот компонент должен разрешать или запрещать разным пользователям операции пополнения баланса или вывода средств по определенным правилам. Для каждой поддерживаемой нами платежной системы этот набор правил свой; также набор правил меняется в зависимости от того, в какой юрисдикции действует наш сервис. Поначалу таких правил немного, но у бизнеса аппетит приходит во время еды, и появляются все новые и новые требования, возникает необходимость комбинировать правила между собой. И очень важно: наш код при этом не должен превращаться в лапшу.
Итак, зададим интерфейс, который должно будет реализовывать каждое правило нашего антифрода:
type AntifraudRule interface {
IsDepositAllowed(user User, wallet Wallet, amount Money) (bool, error)
IsPayoutAllowed(user User, wallet Wallet, amount Money) (bool, error)
And(other AntifraudRule) AntifraudRule
Or(other AntifraudRule) AntifraudRule
AndNot(other AntifraudRule) AntifraudRule
}
Первые два метода должны выполнять проверку, стоит ли разрешить пользователю user
пополнить/вывести amount
денег на/с кошелька wallet
во внешней платежной системе. А методы And()
, Or()
, AndNot()
— это методы-операторы, благодаря которым мы можем выстраивать наши правила в уникальные комбинации.
Вот пример реализации одного из правил. Допустим, мы хотим разрешать всем делать пополнения только в растущую луну, а выплаты в полнолуние. Тогда напишем следующий код:
type MoonRule struct {}
func NewMoonRule() AntifraudRule {
return &MoonRule{}
}
func (r MoonRule) IsDepositAllowed(user User, wallet Wallet, amount Money) (bool, error) {
if IsRisingMoon() {
return true, nil
}
return false, nil
}
func (r MoonRule) IsPayoutAllowed(user User, wallet Wallet, amount Money) (bool, error) {
if IsFullMoon() {
return true, nil
}
return false, nil
}
func (r MoonRule) And(other AntifraudRule) AntifraudRule {
return NewAndRule(r, other)
}
func (r MoonRule) Or(other AntifraudRule) AntifraudRule {
return NewOrRule(r, other)
}
func (r MoonRule) AndNot(other AntifraudRule) AntifraudRule {
return NewAndNotRule(r, other)
}
В последних трёх методах видим создание экземпляров AndRule
, OrRule
и AndNotRule
. Вот как выглядит, например, реализация AndRule
:
type AndRule struct {
left AntifraudRule
right AntifraudRule
}
func NewAndRule(left AntifraudRule, right AntifraudRule) AntifraudRule {
return &AndRule{
left: left,
right: right,
}
}
func (r AndRule) IsDepositAllowed(user User, wallet Wallet, amount Money) (bool, error) {
leftResult, err := r.left.IsDepositAllowed(user, wallet, money)
if err != nil {
return false, err
}
rightResult, err := r.right.IsDepositAllowed(user, wallet, money)
if err != nil {
return false, err
}
return leftResult && rightResult, nil
}
func (r AndRule) IsPayoutAllowed(user User, wallet Wallet, amount Money) (bool, error) {
leftResult, err := r.left.IsPayoutAllowed(user, wallet, money)
if err != nil {
return false, err
}
rightResult, err := r.right.IsPayoutAllowed(user, wallet, money)
if err != nil {
return false, err
}
return leftResult && rightResult, nil
}
func (r AndRule) And(other AntifraudRule) AntifraudRule {
return NewAndRule(r, other)
}
func (r AndRule) Or(other AntifraudRule) AntifraudRule {
return NewOrRule(r, other)
}
func (r AndRule) AndNot(other AntifraudRule) AntifraudRule {
return NewAndNotRule(r, other)
}
Аналогичным образом реализуются OrRule
и AndNotRule
.
И наконец о том, как всем этим пользоваться. Допустим, у нас 3 правила: MoonRule
, SunRule
и RetrogradeMercuryRule
. Определимся, что в текущей версии нашего сервиса мы хотим разрешать платежи людям тогда, когда нам благоприятствуют: Луна И Солнце ИЛИ Ретроградный Меркурий. Давайте напишем сборку нашего антифрода с этими условиями:
func NewAntifraud() AntifraudRule {
moon := NewMoonRule()
sun := NewSunRule()
retrogradeMercury := NewRetrogradeMercuryRule()
return moon.And(sun).Or(retrogradeMercury)
}
Как видим, вроде получилось. И вроде даже Go нам палки в колеса особо не ставил, и даже назойливые if err != nil
почти не путались под ногами. А если всё это дело додумать, причесать, ух… Как говорится, нет предела совершенству.
Вместо заключения
Несмотря на то, что DDD в Go внедряется с определённым количеством компромиссов, всё же я увидел от такого внедрения больше плюсов, чем минусов. Да, где-то придётся на что-то закрыть глаза, где-то извернуться. Но даже в таком виде оно того стоит. И уж точно "натягиванием совы на глобус" я бы это не назвал.