Всем привет! Меня зовут Александр. Я Android разработчик в СберЗдоровье.
Статей про «чистую» архитектуру и многомодульность очень много. Но не многие компании готовы делиться своим опытом и полученными результатами от внедрения этих практик. Я хочу попробовать исправить ситуацию.
В этом материале я расскажу о принципах построения многомодульного приложения, как мы применяем их при разработке мобильного приложения СберЗдоровье под Android и что это нам даёт.
Да, эта статья тоже про многомодульность, но не спешите пролистывать, потому что описанные ниже принципы, можно применять не только в мобильной разработке, но и в разработке ПО в целом.
Приступим.
Чистая архитектура и ее цель
В классическом варианте архитектура приложения — это набор модулей и связей между ними, которые обеспечивают функциональность ПО. Но на практике мало собрать и объединить компоненты — без предварительной проработки четкой иерархии, зависимостей и организации взаимодействия, такая разработка обречена на провал. Не верите? Скажите это сотням разработчиков, которые допустили подобные ошибки и теперь сталкиваются с трудностями при поддержке, масштабировании и обновлении своего софта.
Чтобы предупредить любые трудности, при разработке приложений (как для Android, так и других систем), желательно придерживаться принципов чистой архитектуры, предложенных Робертом С. Мартином.
Определений чистой архитектуры много, но я постарался выделить наиболее значимое для себя.
Чистая архитектура — это набор верных решений об организации ПО, которые бы хотелось применять на разных этапах работы над проектом: от выбора структурных элементов и их интерфейсов до определения сценариев взаимодействия с приложением.
Цель чистой архитектуры — снизить человеческие трудозатраты при создании и сопровождении системы. Правильно организованная архитектура упрощает тестирование, поддержку, модификацию, разработку и развертывание, а также обеспечивает независимость.
Предшественники чистой архитектуры
Рекомендации Роберта С. Мартина по организации чистой архитектуры основаны на идеях и принципах прежних архитектурных построений, которые можно считать предшественниками. Их несколько:
Hexagonal Architecture (Гексагональная архитектура), также известная как архитектура портов и адаптеров. Подразумевает создание слабо связанных компонентов приложений, которые можно легко подключить к их программной среде с помощью портов и адаптеров. Это делает компоненты взаимозаменяемыми на любом уровне и упрощает автоматизацию тестирования.
Onion Architecture (Луковая архитектура). Подразумевает разделение приложения на уровни. При такой организации первый уровень, который находится в центре, — независим. Но от него зависит второй, от второго — третий, от третьего — четвертый и так далее.
Data-Context-Interaction (Данные, Контекст, Взаимодействие). Парадигма, используемая для программирования систем взаимодействующих объектов. Подразумевает улучшение читаемости объектно-ориентированного кода, а также четкое разделение кода для быстрого изменения поведения системы. Парадигма отделяет данные от контекста и взаимодействия.
Boundary-Control-Entity (Граница, Управление, Сущность). Представляет собой подход к объектному моделированию, основанный на трехфакторном представлении классов. В правильно спроектированной иерархии пакетов актер может взаимодействовать только с пограничными объектами Boundary, объекты-сущности Entity могут взаимодействовать только с управляющими объектами Control и управляющие объекты из Control могут взаимодействовать с объектами любого типа. Основным преимуществом подхода, BCE является группирование классов в виде иерархических уровней. Это способствует лучшему пониманию модели и уменьшает ее сложность.
У всех архитектур, на которых основана чистая архитектура, есть общий признак — цель, разделить программное обеспечение на уровни. При этом, в каждом из вариантов, для бизнес-правил, пользовательского и системного интерфейса предусмотрены отдельные уровни — их желательно разделять.
Компоненты
Для удобства понимания, приложение можно представить в виде дома, а компоненты — в виде его комнат, то есть наименьших частей целого. В разработке, компоненты — наименьшие сущности, которые можно развернуть в составе системы. В java и kotlin — это jar файлы, в android — aar и модули.
Например, в нашем приложении под Android наименьшая часть (компонент) — модули.
Теперь рассмотрим принципы организации компонентов.
Забегу немного вперед — мы применяем api, impl подход, который хорошо описан в статье Еще раз про многомодульность Android-приложений. В материале также есть ссылка на гит-репозиторий с примером.
Есть два принципа построения компонентов, которыми нужно руководствоваться:
связанность компонентов;
сочетаемость компонентов.
Разберем подробнее каждый из них.
Связанность компонентов и определяющие ее принципы
Связанность компонентов определяет, к какому компоненту отнести тот или иной класс. Это решение должно приниматься в соответствии с зарекомендовавшими себя принципами разработки ПО. Но надо уточнить, что зачастую подобный выбор зависит от контекста.
Связанность компонентов определяют три принципа:
REP (Release Equivalence Principle) — принцип эквивалентности повторного использования и выпусков.
Принцип гласит, что классы или модули, составляющие компонент, должны принадлежать связанной группе. То есть классы, объединяемые в компонент, должны выпускаться вместе. В нашем случае, хорошим примером являются core модули, в которых классы объединяются по общему признаку: работа с ресурсами, сетью и так далее.
CCP (Common Closure Principle) — принцип согласованного изменения.
Подразумевает объединение всех классов, которые может понадобиться изменить по одной общей причине. То есть, если два класса тесно связаны (физически или концептуально) настолько, что будут меняться вместе, они должны принадлежать одному компоненту.
CRP (Common Reuse Principle) — принцип совместного повторного использования.
Принцип помогает определить, какие классы должны включаться в компонент. При этом его главная концепция — не вынуждать пользователей компонента зависеть от ненужного. CRP — обобщенная версия принципа разделения интерфейсов (ISP) из SOLID, который советует не создавать зависимости от интерфейсов, методы которых не используют.
Согласно принципу CRP не стоит создавать зависимости от компонентов, которые имеют неиспользуемые классы, интерфейсы и в целом не создавать зависимости от чего-либо неиспользуемого.
Примечательно, что принципы противоречат друг другу. Так:
Принципы REP и CCP — включительные. Они стремятся сделать ваш компонент крупнее.
Принцип CRP наоборот — исключительный. Он стремится сделать ваши компоненты, как можно мельче.
Отсюда у нас появляется диаграмма противоречий принципов связности, которая показывает их влияние друг на друга. Ребра на диаграмме описывают цену нарушения принципа на противоположной вершине.
Диаграмма позволяет найти золотую середину, которая будет отвечать текущим нуждам разработчиков. Но нужно помнить — ищите баланс, исходя из потребностей приложения.
Сочетаемость компонентов
Теперь разберемся с сочетаемостью компонентов. Здесь уже поинтереснее, чем раскидывать классы по логическим связям.
Сочетаемость компонентов — это взаимоотношение между ними. Чтобы понять, что это такое, надо разобрать три принципа.
ADP (Acyclic Dependency Principle) — принцип ацикличности зависимостей.
Согласно принципу, циклы в графе зависимостей недопустимы. Здесь нужно рассмотреть схему ниже.
На схеме видно циклическую зависимость: feature-two использует feature-one, feature-one использует feature-three, а feature-three использует feature-two.
Чтобы обеспечить ацикличность в такой схеме нужно, чтобы каждый компонент мог работать независимо. При этом, если какой-то компонент должен зависеть от другого, то необходимо разорвать цикл. Для этого необходимо создать новый модуль и использовать принцип DIP (инверсию зависимостей)
SDP (Stable Dependencies Principle) — принцип устойчивых зависимостей.
Согласно SDP, зависимости должны быть направлены в сторону устойчивости. При этом, устойчивым компонентом считается тот, от которого зависит много других компонентов, так как для его изменения требуется больше усилий и согласований — этот компонент независим. На схеме видно устойчивый компонент X. Стрелочками показано, что от него зависят другие компоненты.
Например, в нашем Android-приложении устойчивыми считаются core компоненты.
В случае с неустойчивыми компонентами все наоборот — неустойчивый компонент Y (считайте это feature компонент) зависит от множества других.
Важно отметить, что не все компоненты должны быть устойчивыми — если все компоненты в системе устойчивые, ее невозможно изменить.
SAP (Stable Abstraction Principle) — принцип устойчивых абстракций.
Согласно SAP, устойчивость компонента пропорциональна его абстракции. SAP и упомянутый ранее SDP вместе соответствуют принципу инверсии зависимости (DIP) для компонентов. Так, принцип SDP требует, чтобы зависимости были направлены в сторону устойчивости, а SAP — чтобы устойчивость подразумевала абстрактность. В нашем случае модуль feature-one-api полностью соответствует двум принципам: он устойчив, так как ни от кого не зависит, и он абстрактный, так как в нем нет никакой реализации.
Пример упаковки компонентов для функционала в приложении СберЗдоровье.
В нашем случае схема содержит неустойчивый фиче модуль feature-one-impl. Он зависит от двух устойчивых:
core модуля;
легковесного feature-one-api, который например содержит интерфейс загрузки нашей функциональности, которая может быть загружена из разных точек приложения
Дополнительно, если требуется, в схему также включаются внешние зависимости в виде абстракций.
Также отмечу, что в целях удобства и обеспечения стабильности, внешние зависимости функционала, которые используются в нескольких модулях, выносятся нами в core-feature модули
Пример архитектуры внутри функционального модуля в Android-приложении СберЗдоровье
Есть стандартная схема и правила зависимостей, например внутри функциональных модулей.
Правила определяют, как следует разделять слои и как должно происходить общение между ними с помощью инверсии зависимости (DIP) из SOLID.
На следующей схеме изображено, как общение между слоями налажено в Android-приложении СберЗдоровье.
Вы видите, что мы нарушаем правило зависимостей. Это сделано, потому что мы считаем, что плодить интерфейсы в полностью изолированном impl фиче модуле — избыточно. Особенно с учетом того, что он максимально неустойчив и в нем все помечено модификатором internal.
Но здесь также есть исключения — если есть функциональные зависимости, которые нужно использовать в других компонентах, мы используем инверсию зависимостей (DIP) и добавляем такие зависимости в api или core-feature модули функциональности, которые максимально неустойчивы.
Результаты нашего кейса
В своем проекте мы придерживаемся как рекомендаций чистой архитектуры, так и принципов модуляризации. Благодаря этому мы получаем ряд преимуществ:
Независимая разработка. Каждая команда работает в своем функциональном модуле, не затрагивая другие части приложения и не конфликтуя друг с другом.
Масштабируемость архитектуры. Мы можем расширять нашу систему, как угодно, без какого-либо вреда для нее.
Скорость тестирования. Мы можем тестировать каждый компонент отдельно, а не прогонять тесты по всему приложению. Это проще, точнее и ускоряет Time to market.
Скорость сборки проекта. За счет api модулей наш проект не будет пересобираться полностью, так как изменения мы добавляем в impl модуль, а он у нас максимально неустойчивый, то есть не добавляется в виде зависимости в другие модули (кроме основного app модуля, который знает про все модули нашего приложения).
Но надо понимать, что чистая архитектура — это не конечная реализация архитектуры, а свойство. Поэтому ее обеспечение — важный, но непрерывный процесс.
А вы можете назвать свое Android-приложение модульным? Что в вашем случае дала модуляризация? Делитесь опытом!