2 ноября 2015 в 13:21

Чистая архитектура в Go-приложении. Часть 1

От переводчика: данная статья написана Manuel Kiessling в сентябре 2012 года, как реализация статьи Дядюшки Боба о чистой архитектуре с учетом Go-специфики.



Перед этой статьей я перевел ее прообраз — смотреть здесь. Поскольку в рамках этой статьи будет активно использоваться описанное в статье Дядюшки Боба, то лучше начать с нее… если Вы, конечно, ее еще не читали.

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

В данной части будет описана общая концепция и работа с внутренним слоем.

По моему мнению Правило Зависимостей является одним из самых важных правил из тех, что должно быть применено при разработке ПО, если вы хотите получить систему которая легко тестируется, не зависит от фреймворка, UI или БД. Следование этому правилу в конце концов ведет к слабой связности компонентов системы и четкому разделению ответственности.

Слабосвязанные системы


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

Чтобы сделать это мы реализуем простое, но при этом полноценное Go-приложение попутно рассуждая когда и как должны быть применены понятия «Чистой Архитектуры»

Это приложение — минимально возможная версия магазина, позволяющая получать посредством HTTP-запросов список товаров, относящихся к заказу.

Исходный код, включая некоторое покрытие тестами, можно найти на гитхабе.

Для того, чтобы сохранить исходный код легким для понимания, мы не будем реализовывать другие сценарии как то: просмотр товаров, оформление заказа или оплаты. Кроме того, я сосредоточился на реализации тех частей кода, которые помогают объяснить суть архитектуры — таким образом, в коде не хватает много других вещей, как, например, полноценной обработки ошибок. Он также содержит много избыточности, но он позволяет прочитать код сверху вниз, без необходимости прыгать туда-сюда.

Архитектура приложения


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

Домен, или бизнес, нашего приложения — люди приходят в магазин за покупками или в более формальном виде: клиент добавляет товары к заказу. Мы должны представить эти бизнес-объекты и их правила, как код внутреннего слоя, слоя домена.

Что куда поставить и почему


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

Не удивительно, что как разработчики ПО мы привыкли смотреть на вещи с программно-формальной точки зрения. Таким образом, рассуждая об архитектуре, я, во избежание ошибок, из-за некоторых неприятных тонкостей, стараюсь избегать метафор касающихся компьютеров. Что если мы представим, что бизнес-Домен не часть программы, а часть, скажем, настольной игры?

Представьте, что мы должны реализовать eBay или Amazon как настольную игру. eBay-сайту и eBay-настольной игре нужны покупатели, продавцы, товары и ставки — но только eBay-сайту нужны пользователи, сессии, куки, авторизация и т.д.

Это достаточно тонкое различие, поскольку когда ваша программа мала, то решение, что пользователи и клиенты — это одно и то же, не кажется большим делом. Но это одна из тех вещей, которые в последствии будет сложно исправить. Причина в том, что для 99% ситуаций допущение, что пользователи и клиенты — это одно и то же ровно ничего не значит, до тех пор пока не вступает в игру оставшийся 1%. Наше приложение проиллюстрирует эту ситуацию.

Итак, в то время как заказы и товары принадлежат к слою Домена, пользователи, принадлежат к следующему слою — Сценариям.

Что еще принадлежит к слою Сценариев? Сценарии — это слой, где реализуются случаи использования, которые возникают из-за того, что пользователям приложения необходимо что-то «делать» с субъектами базового Домена. Примером варианта использования может быть «клиент добавляет товар к заказу». Для реализации этого и других случаев использования необходимо реализовать методы, которые «приводят в движение» бизнес-сущности.

Хотя это и может быть реализовано в слое домена, я рекомендую делать это в Сценариях. Основная причина этого в том, что Сценарии являются приложение-специфичными, в то время как сущности Домена — нет. Представьте себе два приложения: одно из них позволяет клиентам просматривать и покупать вещи, и второе, которым пользуются администраторы для управления и выполнения размещенных заказов. В то время как сущности Домена остаются одними и теми же для обоих приложений Сценарии использования очень разные: «добавить товар в заказ» против «пометить заказ как доставленный». Домен и слой Сценариев вместе образуют ядро нашего приложения, представляя реалии бизнеса с которым мы работаем. Все остальное — детали реализации, которые не связаны с особенностями нашего бизнеса. Наш магазин может быть реализован как веб-сайт или десктопное приложение — до тех пор пока мы не трогаем доменные сущности и сценарии их использования — это все то же бизнес-ориентированное приложение.

Мы можем переключить веб-сервис с HTTP на SPDY или БД с MySQL на Oracle — это не изменит того факта, что у нас есть магазин с клиентами, которые делают заказы состоящие из товаров (Домен) и клиентов, которым доступно создание заказа, изменение количества товаров и возможность оплаты (Сценарии)

В то же время это лакмусовая бумажка для наших внутренних слоев — должны ли мы изменить хоть одну строчку кода при переключении с MySQL на Oracle?

Если ответ — да, то мы нарушили Правило Зависимостей, сделав так, что какой-то из наших внутренних слоев зависит от деталей в наружных слоях.

Также найдется местечко для кода, который работает с БД или обработкой HTTP-запросов или внешними сервисами. Это место — слой интерфейсов.

Например, если наше приложение становится доступным в качестве веб-сайта, контроллеры, которые обрабатывают входящий HTTP-запрос имеют свое место в слое интерфейсов, так как они образуют интерфейс между HTTP-сервером и прикладным уровнем. Эти события из внешнего мира — вызванные HTTP-запросы, клики мышкой в UI или удаленные вызовы процедур — создают активность в нашем приложении. Без них наше методы Сценариев и сущности Домена были бы просто мертвым грузом. Но, поскольку, элементы из внутренних слоев не могут взаимодействовать (и вообще знать) как либо с внешним миром, интерфейсы необходимы для преобразования события из внешнего мира в действия во внутренних слоях.

Если мы хотим сохранять данные магазина (товары, заказы и пользователи в БД), то нам так же понадобится интерфейс к БД. Это то место приложения, где применение Правила Зависимостей становится особенно интересным: если код работы с SQL живет в слое Интерфейсов и при этом ничто из внутреннего слоя не может вызвать наружный слой, при этом инициализация сохранения происходит на уровне Сценария, то как мы можем избежать нарушения Правила Зависимостей? Мы будем разбирать это подробно ближе к реализации кода.

Последний слой называется Инфраструктура. Разделение того, что принадлежит Инфраструктуре, а что Интерфейсам не всегда просто. Например, оба слоя содержат код который взаимодействует с внешним миром, как, например, код который общается с БД. Определяющим фактором тут является то, что код Интерфейса специфичен для вашей программы, а код Инфраструктуры — нет и может быть использован в совершенно разных приложениях. Например, функции обрабатывающие HTTP-запросы в нашему приложения имеют смысл только в рамках этого приложения, а стандартная библиотека Go для работы с HTTP является кодом общего назначения и может быть использована в любом другом приложении. В этом смысле большая часть стандартной библиотеки Go будет концептуально находиться в слое Инфраструктуры.

Подведем черту для создания списка всех слоев и частей нашего приложения:

Домен:
  • Сущность Клиент
  • Сущность Товар
  • Сущность Заказ

Сценарии:
  • Сущность Пользователь
  • Сценарий: Добавить товар в заказ
  • Сценарий: Получить список товаров заказа
  • Сценарий: Администратор добавляет товар в заказ

Интерфейсы:
  • Веб-сервис для обработки товара / заказа
  • Репозиторий для Сценариев и Доменных Сущностей

Инфраструктура:
  • База данных
  • Код, который обрабатывает подключения к БД
  • Сервер HTTP
  • Стандартные библиотеки Go

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

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

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

Чем дальше мы сдвигаемся влево, тем более низкоуровневым становится код («передать поток байт на 80-ый порт»), чем больше мы сдвигаемся вправо тем более высокоуровневым становится код («добавить товар в заказ»).

Реализация


Домен


Первым мы реализуем слой Домена. Как говорилось ранее, наше приложение будет полностью рабочим, но не будет при этом полноценным магазином. Таким образом код, реализующий Домен будет достаточно коротким и мы его реализуем в рамках одного файла:
// $GOPATH/src/domain/domain.go

package domain

import (
    "errors"
)

type CustomerRepository interface {
    Store(customer Customer)
    FindById(id int) Customer
}

type ItemRepository interface {
    Store(item Item)
    FindById(id int) Item
}

type OrderRepository interface {
    Store(order Order)
    FindById(id int) Order
}

type Customer struct {
    Id   int
    Name string
}

type Item struct {
    Id        int
    Name      string
    Value     float64
    Available bool
}

type Order struct {
    Id       int
    Customer Customer
    Items    []Item
}

func (order *Order) Add(item Item) error {
    if !item.Available {
        return errors.New("Cannot add unavailable items to order")
    }
    if order.value()+item.Value > 250.00 {
        return errors.New(`An order may not exceed
            a total value of $250.00`)
    }
    order.Items = append(order.Items, item)
    return nil
}

func (order *Order) value() float64 {
    sum := 0.0
    for i := range order.Items {
        sum = sum + order.Items[i].Value
    }
    return sum
}


Сразу видно, что этот код не имеет в зависимостях ничего значимого — мы только импортировали пакет «errors», поскольку некоторые методы возвращают ошибку. Хотя доменные сущности, описанные здесь в конечном итоге это будут строки в БД, на данном слое нет кода связанного с БД.

Вместо этого, мы определяем Go интерфейсы для трех так называемых репозиториев. Репозиторий представляет собой концепцию из DDD (Domain Driven Design): это способ абстракции от того факта, что сущности должны быть сохранены или получены через некий механизм работы с персистентным хранением. С точки зрения Домена репозиторий — это просто контейнер через который доменные сущности забираются (FindById) или сохраняются (Store).

CustomerRepository, ItemRepository и OrderRepository — просто интерфейсы. Они будут реализованы в рамках слоя Интерфейсов, поскольку реализуют интерфейсы между БД и приложением. Это то как Правило Зависимостей может быть реализовано в Go-приложениях — абстрактный интерфейс, который не относится к чему-либо во внешних слоях и определенный во внутренних слоях. Его реализация определяется в наружном слое. Реализация интерфейса делается в том слое, который должен впоследствии использовать его. Мы это увидим позже, когда дойдем до слоя Сценариев.

Таким образом, слой Сценариев может ссылаться на концепт из доменного слоя через репозитории — при этом используя только чистый Go в слое Домена. Тем не менее фактически код исполняется на слое Интерфейсов.

Для каждой части каждого слоя, есть три вопроса, представляющих интерес: где она используется, где ее интерфейс, где ее реализация?

Если мы посмотрим на OrderRepository, ответы будут следующими: он используется в слое Сценариев, его интерфейс принадлежит к слою Домена, а его реализация принадлежит к слою Интерфейсов.

С другой стороны, метод Add сущности Order используется так же на слое Сценариев и по сути это интерфейс к слою Домена. Но его реализация осуществляется в слое Домена поскольку методу ничего за его пределами на самом деле не нужно.

Также мы определяем следующие 3 структуры: Customer (клиент), Order (заказ) и Item (товар). Это представление наших трех доменных сущностей. Сущность Order (заказ) реализуется так же двумя методами Add и value, причем value — вспомогательная функция только для внутреннего использования. Метод Add реализует специфичную для бизнеса функцию, которая необходима для использования в слое Сценариев.

Также в этом коде есть дополнительные детали которые значимы когда речь идет об архитектуре в целом. Как вы видите наше приложение имеет несколько правил в различных местах и нам стоит обсудить какие правила чему принадлежат.

Первое правило определяет в добавление в заказ товаров которые не доступны — это явно бизнес-правило. Запрет клиенту добавлять в заказ недоступные товары — это правило которое применяется к случаю заказа через интернет магазин и, скажем, через заказ по телефону посредством оператора. Это правило не является специфичным для приложения, это требование бизнеса.

То же самое касается правила, что заказы не могут превышать общую стоимость $ 250 — не важно, наш магазин представляет ли собой веб-сайт или настольную игру, это бизнес-правило, которое всегда применяется.

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

С другой стороны код интерфейса к БД при сохранении заказа может прекрасно сохранить заказ стоимостью более 250 долларов США, поскольку это ограничение идущее от бизнес-правил и интерфейс к БД не должен заботиться о таких вещах. Этот пример является отличной иллюстрацией почему я так люблю принципы Дядюшки Боба — представьте, что лимит на заказ в 250 долларов реализуется в хранимой процедуре в БД. Со временем эти правила расползутся по разным местам и станут тем сложнее в поддержке, чем больше ваше приложение. Я предпочитаю всегда иметь все бизнес-правила в одном месте.

Продолжение в следующей части
Вадим Мадисон @trong
карма
17,0
рейтинг 0,0
Самое читаемое Разработка

Комментарии (11)

  • +19
    Прямо неделя Го наступила.
    • 0
      прекрасно. Много че нового и полезного
  • 0
    Если это интернет-магазин и СУБД, то нет никакой гарантии что сумма заказа не превысит $250. Так что домен в том числе зависит от приложения. То что красиво работает в однопользовательской консольке вовсе не работает в многосерверных сайтах.
    • 0
      Это же статья не про бизнес всё таки, а про архитектуру приложения.
    • +1
      Если честно, не вижу связи между магазином, СУБД и превышением 250$
    • +1
      простой пример а не готовый код в продакшин
  • –4
    Это крутая статья, читал в оригинале, но вот интересно было бы пообщаться с теми, кто этот подход к архитектуре реально на практике использует.
  • +1
    О! Помню эту статью, я автору огромный комментарий написал от всей души, а он мне ответил на него через несколько месяцев одной строчкой, которая противоречит его же постулатам из статьи. Если кто-нибудь категорически разделяет и понимает позицию автора, буду благодарен ответу на свои вопросы из оригинального комментария. Его легко найти внизу от оригинальной статьи, способа сделать ссылку на комментарий к сожалению не нашёл.
    • 0
      > способа сделать ссылку на комментарий к сожалению не нашёл.
      Вот так, если я правильно нашёл комментарий.
  • 0
    Весьма интересная тема. Жду продолжения!
  • +1
    Жаль, что это переводная статья. Я бы хотел немного подискутировать именно с автором статьи, а не перевода. Возможно, кто-то захочет ответить?

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

    Круто — то, что нужно.

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


    И главное в каком месте, а? Читаем дальше:
    Но это одна из тех вещей, которые в последствии будет сложно исправить. Причина в том, что для 99% ситуаций допущение, что пользователи и клиенты — это одно и то же ровно ничего не значит, до тех пор пока не вступает в игру оставшийся 1%.


    Оказывается, вся эта архитектура была сделана для одного: чтобы можно было абстрагироваться от базы:
    В то же время это лакмусовая бумажка для наших внутренних слоев — должны ли мы изменить хоть одну строчку кода при переключении с MySQL на Oracle?


    Так вот… Честно говоря, мне редко встречалось в практике менять тип базы с одного на другой. А вот смена бизнес-требований — это постоянная работа. Когда приходит бизнес и говорит «а вот раньше у нас был такой тип клиентов, а мы сейчас взяли заказ ещё у того-то». И по факту приходится заниматься именно подгонкой приложения на разных слоях, а не менять базу. И предугадать, где завтра бизнес принесёт деньги — просто тухлый номер. А у него прям так всё красиво: мы на старте уже знаем все архитектурные допущения. Лол.

    Толи автор сам не понимает того, что пытается сделать, толи продаёт правильные идеи неправильно их аргументируя.

Только зарегистрированные пользователи могут оставлять комментарии. Войдите, пожалуйста.