Всем привет!

Меня зовут Михаил Копченин, я backend-разработчик сервиса биллинга #CloudMTS.

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

На эксперименты с ЧА нас сподвиг модуль биллинга, который разросся до пухлого монолита. Так бывает, когда в mvp хочется быстрее добавлять новые фичи, а вопросы оптимальности архитектуры откладываются на потом.

Какие задачи хотели решить

Проблемный сервис работал по принципу стандартной трехзвенной архитектуры: с фронта (клиентской части приложения) прилетает запрос, он попадает в слой  бизнес-логики (сервер приложений), который взаимодействует со слоем доступа данных (серверы баз данных).

Все функции были в одной куче, бизнес-логика зависела от инфраструктуры — все это «подарило» нам следующие проблемы:

  1. Код было сложно тестировать из-за зависимости от слоя доступа к данным. Последний сложно мокать в тестах. Можно замокать работу Kafka или Redis, а вот с транзакциями баз данных это уже проблематично.

    Для тестов приходилось поднимать всю инфраструктуру (Kafka, MongoDB, PostgreSQL) со всеми зависимостями. Это долго и неудобно. 

  2. Код из 3 000 срок сложно читать и поддерживать. Особенно это чувствуется, если приходит новичок в команду и ему нужно быстро разобраться в структуре проекта.

  3. По сути в один файл вносили изменения сразу несколько разработчиков. Сложно было мерджить и разрешать конфликты.

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

Про Clean-архитектуру (в нашем понимании)

Буквально в паре абзацев напомню, что собой представляет этот подход, предложенный Р. Мартином.

Основная идея чистой архитектуры заключается в создании слоистой архитектуры, в которой каждый слой имеет четкую и отдельную ответственность. Слои организованы таким образом, чтобы изменения в одном слое не влияли на другие.

  • Entities. Здесь содержится бизнес-логика, общая для многих приложений.

  • Use Cases (Interactors). Логика приложения, конкретная реализация бизнес-логики.

Это внутренние слои.

Внешние слои:

  • Frameworks. Слой, который содержит весь код, связанный с использованием сторонних фреймворков, библиотек и инструментов, которые необходимы для реализации внешних интерфейсов (UI) и инфраструктуры приложения (база данных, http-клиент и прочее).

Взаимодействие между внутренними слоями и внешним миром обеспечивает слой Interface Adapters. Эти компоненты преобразуют данные из формата, удобного для использования внутри приложения, в формат, удобный для использования внешними системами, и наоборот. Сюда попадают репозитории, gateways, контроллеры.

Еще один основополагающий момент чистой архитектуры — это принцип обратной зависимости (Dependency Inversion). Он про то, что внутренние слои не зависят от внешних. Взаимодействие между ними должно быть построено через абстракции, те же интерфейсы, о которых упоминали выше. Благодаря этому мы можем поменять БД, которую использует приложение, без изменений в бизнес-логике.

Более детально про ЧА расписано в этой статье.

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

Как раз то, что нужно нам, подумали мы, и устремились.

Как переходили на clean-архитектуру

Рефакторинг проводили в два подхода. На первом этапе мы постарались размотать образовавшийся клубок и поделить все на слои. Мы отделили бизнес-логику от слоя доступа к данным. Выделили юзкейсы. В отличие от канонического варианта, описанного выше, в роли entities у нас выступали модели данных. Это совсем простые модели, которые почти не содержат методов и бизнес-сценариев. Все бизнесовые сценарии сосредоточены в юзкейсах. Они взаимодействуют со слоем инфраструктуры через интерфейсы, которые реализуются через адаптеры. Адаптер реализует интерфейс, который объявлен на стороне юзкейса. То есть адаптер знает, как взаимодействовать с инфраструктурой. Раньше юзкейс напрямую ссылался на адаптер, то есть у него была зависимость от адаптера. 

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

На уровне организации кода всю бизнес-логику вынесли в папку Internal: 

  • Модели.

  • Юзкейсы. В примере на скриншоте мы использовали абстрактные названия бизнес-функций. Так, internal.usecase.apple — каталог с юзкейсами по сущности apple. Содержат в себе .go файлы, которые отрабатывают юзкейсы, связанные с конкретной бизнес-функцией. К примеру, predict_apple_harvest.go обладает всеми методами для выполнения предсказания по урожаю. create.banana.go — то же самое.

  • Контроллеры, где собрано все, что связывает сервис с внешним миром, — http-хендлеры, консьюмеры Kafka, cronjobs и прочее.

  • Адаптеры ко всем внешним системам, начиная от БД и заканчивая всеми зависимыми внешними сервисами.

В cmd, каталоге с точками входа в приложение, также произошли изменения. Ранее там был единственный файл (main.go), который запускал все подсистемы приложения. В новом варианте каждая точка входа была выделена отдельно — cli, cron, grpc, http, kafka.

Это разделение облегчило нам написание тестов. Теперь мы могли тестировать юзкейс независимо от слоя доступа к данным: интерфейс легко замокать и передать туда все что угодно.

Если мы захотим поменять БД, например с PostgreSQL на MySQL, нам так же не придется трогать бизнес-логику. Чтобы тесты были максимально эффективными, мы внутри команды договорились по минимуму писать бизнес-логику в хендлерах и адаптерах.

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

Теперь в папке Internal под каждую бизнес-сущность был добавлен свой пакет. Например, у нас есть бизнес-домен apple. В одноименном пакете будет находится все, что связано с этим доменом: use cases, адаптеры, модели.

В этой версии мы также избавились от папки контроллеров и перенесли на уровень cmd в соответствующие разделы. Для каждой из точек входа была продумана единая структура:

  • app — ядро точки входа;

  • config.go — специфичные настройки конкретной точки входа;

  • bootstrapper.go — загрузчик приложения. Отвечает за инициализацию всех компонентов, связывает между собой handler и юзкейс, юзкейс и adapter.

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

Таким образом, разработчику, работающему над конкретной функцией, не нужно будет бродить по всему дереву и перебирать разные пакеты, а достаточно открыть одну папку. 

Таким образом, разработчику, работающему над конкретной функцией, не нужно будет бродить по всему дереву и перебирать разные пакеты, а достаточно открыть одну папку. 

Впечатления и результаты

Новый подход требует времени, чтобы адаптироваться.

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

  • Еще поначалу было сложно пересилить себя и добавлять на каждый чих свой user case, состоящий из одной строки кода. Это казалось overhead’ом. Да, действительно приходится писать больше кода.

В краткосрочной перспективе это требует дисциплины, но уже через какое-то время новый подход перестал доставлять неудобства. А вот плюсы были видны практически сразу:

  1. Код стал более гранулярным. Отсюда следует сразу несколько профитов:

  • проще реализовывать новые бизнес-требования. Не так страшно вносить изменения в существующий код; 

  • есть разделение ответственности на уровне кода и видно, кто за что отвечает; 

  • код легче читать: исходный файл на 3 000 строк разбился на 30 файликов по 100 строк, из которых легко и быстро понять контекст.

  1. Нет тесной связности бизнес-логики и инфраструктуры: если мы захотим перейти на другую базу, то это не составит труда, так как мы работаем с инфраструктурным слоем через интерфейсы.

  2. Увеличилась производительность за счет того, что несколько разработчиков могут работать на одном проекте без конфликтов и проблем с мерджами, разработчики не мешают друг другу.

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

  4. Наконец, писать юнит-тесты и тестировать код стало легче: не нужно поднимать всю инфраструктуру, задействовать базы данных и прочее.

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

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


  1. VladimirFarshatov
    13.04.2023 09:01

    Код было сложно тестировать из-за зависимости от слоя доступа к данным. Последний сложно мокать в тестах. Можно замокать работу Kafka или Redis, а вот с транзакциями баз данных это уже проблематично.

    В чем была сложность тестирования слоя представления данных? go-sqlmock кмк, вполне годный для этой цели пакет. Мне он показался несколько избыточным, реализовал для себя вот такой интерфейс, т.к. работаем с sqlx пакетом:

    // Sqlable -- для реализации оберток базовых sql, sqlx функций ExecContext(), GetContext(), SelectContext() @see internal/tests/mock_data.go
    type Sqlable interface {
    	// ExecContext -- имитация sql.ExecContext()!  @see internal/tests/mock_data.go: возвращает поле .RetExec
    	ExecContext(ctx context.Context, query string, args ...any) (sql.Result, error)
    	// SelectContext -- имитация sqlx.SelectContext()! @see internal/tests/mock_data.go: возвращает поле .RetSelect
    	SelectContext(ctx context.Context, dest any, query string, args ...interface{}) error
    	// GetContext -- имитация sqlx.GetContext()! @see internal/tests/mock_data.go: возвращает поле .RetGet
    	GetContext(ctx context.Context, dest any, query string, args ...interface{}) error
    }

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

    Реализация проверяет: а) совпадение набора параметров тексту запроса; б) текст запроса его регекспу в тесте; в) тип возвращаемого результата и данные из блока SELECT, если там нет .* Возвращаемые наборы - слайсы, позволяют мокать больше одного запроса в тестируемой функции работы с набором.

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


    1. CloudMTS
      13.04.2023 09:01
      +1

      Владимир, спасибо за вопрос. Cложность как раз заключалась в тестировании отката транзакций. Также мы использовали mongodb, и тот mongodb golang client, который мы юзали, он не реализовывал sql/driver


      1. VladimirFarshatov
        13.04.2023 09:01

        Ну .. там же не так сложно накатать моккер для транзакций с откатом или приемом .. А для Монго писал подобный моккер для тех же самых целей. Там нет ничего сложного

        // MongoCollectable is an object that implements FindOne and Find.
        type MongoCollectable interface {
        	Find(context.Context, interface{}, ...*options.FindOptions) (*mongo.Cursor, error)
        }
        


    1. alewkinr
      13.04.2023 09:01
      +1

      ИМХО

      Нужно с осторожностью подходить к юнитам на слое доступа к данным. Это лишнее время и кодовая база, которую нужно поддерживать. Все становится еще сложнее, если нужно сменить СУБД или библиотеку для работы с ней.

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


  1. vvbob
    13.04.2023 09:01

    1. Нет тесной связности бизнес-логики и инфраструктуры: если мы захотим перейти на другую базу, то это не составит труда, так как мы работаем с инфраструктурным слоем через интерфейсы.

    2. Нет тесной связности бизнес-логики и инфраструктуры: если мы захотим перейти на другую базу, то это не составит труда, так как мы работаем с инфраструктурным слоем через интерфейсы.

    Два раза - это для закрепления материала? :)


    1. CloudMTS
      13.04.2023 09:01

      Задублировалось :) Спасибо!

      п.3 Увеличилась производительность за счет того, что несколько разработчиков могут работать на одном проекте без конфликтов и проблем с мерджами, разработчики не мешают друг другу.