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

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

Заглянули к себе в компоненты и обнаружили, что там бардак и обобщенные имена типа Cоmmon, Shared, Core, Base, Utils? Это текст для вас: сам был на вашем месте, помогу навести порядок. 

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

Принцип ацикличности зависимостей

На иллюстрации — ациклический ориентированный граф. В этой структуре, с какого компонента ни начни, вернуться по цепочке связей-зависимостей в этот же компонент не получится. Если разработчик поменяет компонент Common, изменения затронут компоненты Authorization, Settings и Main.

Чтобы проверить работу Common, необходимы только последние версии Database и Session. Изменения в Main не затронут вообще ни один другой компонент в системе, их влияние минимально.

Но, как всегда в работе, задачи развиваются, появляются новые требования. Допустим, понадобилось изменить один из классов в Session, чтобы он использовал класс Permissions из Authorization.Í$

Теперь, чтобы проверить работу Common, нужны Database, Session, Authorization, Settings. Изменения в этих компонентах нужно учитывать при сборке новой версии. Образовался цикл, потому что на два последних компонента могут влиять изменения уже в самом Common. 

Пагубную циклическую зависимость всегда можно разорвать и вернуться к ациклическому ориентированному графу (DAG). Для этого есть два основных механизма:

  • применить принцип инверсии зависимостей — в этом случае можно создать интерфейс;

  • создать новый компонент, от которого зависят компоненты, образующие цикл.

Принцип устойчивых зависимостей

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

От компонента X зависят три других компонента: A, B, C. Целых три веских причины никак его не изменять. Это и есть устойчивость компонента — необходимость согласовать любое изменение в нем с компонентами, зависящими от него. При этом сам X ни от чего не зависит, внешние воздействия не могут привести к его изменению.

Некоторые компоненты сразу проектируются как изменчивые, ожидается, что они будут меняться в процессе разработки. Например, Y. Другие компоненты от него не зависят, зато он зависит от A, B, C. Есть сразу три компонента-источника, из-за которых разработчику может прийти задача что-то поменять в Y. 

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

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

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

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

Чтобы оценить устойчивость того или иного компонента, нужно подсчитать, сколько компонентов зависят от него (Fan-in) и от скольких зависит он сам (Fan-out). Такие зависимости обычно представлены инструкциями import. Число I (неустойчивость компонента) вычисляется по формуле Fan-out ÷ (Fan-in + Fan-out): при значении 0 компонент максимально устойчив, при 1 — максимально неустойчив.

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

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

В данном случае принцип соблюден: изменяемые компоненты зависят от неизменяемого.

А здесь принцип нарушен: устойчивый компонент стал зависим от неустойчивого. Теперь X потерял свою устойчивость, а D, наоборот, стало сложнее изменить, ведь все коррективы надо согласовывать с тремя модулями, которые от него зависят.

Исправить ситуацию помогает принцип инверсии зависимостей. Нужно определить протокол у компонента X — XProtocol, который будет реализовываться в D. Так D останется неустойчивым, а X — устойчивым. 

Диаграмма направления зависимостей

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

Дизайн-система стоит отдельно и выглядит достаточно устойчиво, но в реальности это не так. На зависимость дизайн-системы от дизайнеров разработчик никак не может повлиять. Устойчивые компоненты не должны зависеть от нее. Весь абстрактный функционал для работы с UI можно вынести в отдельный устойчивый компонент, в дизайн-системе — оставить только функционал, зависящий от дизайнеров.

Компонент с зависимостью от внешних лиц не может быть устойчивым даже с I = 0. Чтобы отразить зависимости от иных систем или третьих лиц, полезно использовать диаграмму направления зависимостей. 

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

Принцип устойчивости абстракций

Устойчивость компонента пропорциональна его абстрактности. Чем абстрактнее компонент, тем он устойчивее. 

Некоторые части программных систем представляют высокоуровневые архитектурные и другие важные решения, поэтому менять их нужно очень редко. Никто и не хочет, чтобы такие решения были изменчивыми. ПО, инкапсулирующее высокоуровневые правила, должно находиться в устойчивых компонентах (I=0). Неустойчивые (I=1) должны содержать только изменчивый код, который можно легко и быстро менять.

Абстрактность компонента тоже можно вычислить. Для этого нужно знать количество публичных протоколов компонента и его же общее число публичных классов, то есть протоколов, структур, перечислений, акторов. Используется формула A (абстрактность) = Na (количество публичных протоколов) ÷ Nc (количество публичных классов).

Три принципа, определяющих связность компонентов

Эти три принципа:

  • принцип эквивалентности повторного использования и выпусков (REP, Reuse/Release Equivalence Principle);

  • принцип согласованного изменения (CCP, Common Closure Principle);

  • принцип совместного повторного использования (CRP, Common Reuse Principle).

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

REP

Единица повторного использования есть единица выпуска.

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

Возьму в качестве отрицательного примера NBNCore:

FileProviderProtocol
KeychainDataStoring
LongMoneyType
CharsetCountLimiter
RepeatingTimer
….
RoundRectBezierPathMaker
….
Strategy
AppVersionGetterProtocol

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

CCP

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

Это перефразированный принцип единственной ответственности (SRP). SRP говорит, что класс не должен иметь нескольких причин для изменения. ССР требует, чтобы нескольких причин для изменения не было уже у компонента. CCP — еще один включительный принцип.

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

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

Все, что изменяется по одной причине и в одно время, должно быть собрано вместе. Все, что изменяется в разное время и по разным причинам, должно быть разделено.

Например, в NBNCore есть TableHeaderFooterView, которое целесообразнее перенести в модуль, отвечающий за UI. Класс используется только в одном месте, нет необходимости делать его публичным и уносить с места использования. Скорее всего, он был написан один раз и не будет изменяться так же быстро, как наша дизайн-система.

CRP

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

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

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

Например, модуль RateUs использует AppVersionGetterProtocol из NBNCore. В нем нет никакого функционала, связанного, к примеру, с KPPNumberLength или InnNumberLength (это структуры, отвечающие за корректную длину полей ИНН и КПП), которые также хранятся в NBNCore. Эти структуры по смыслу никак не могут относиться к оценке приложения. Но если они изменятся, нужно будет поднять версию и проверить, правильно ли после этого работает RateUs.

Три принципа существуют не в вакууме — они напрямую влияют друг на друга, а как именно — помогает понять диаграмма противоречий.

Диаграмма противоречий для определения связности компонентов

На диаграмме противоречий каждое ребро описывает цену нарушения принципа на противоположной вершине. Если налегать только на REP и CRP, то простое изменение вовлекает слишком большое количество компонентов. С другой стороны, когда во главе угла только ССР и REP, необходимо выпускать слишком много ненужных версий.

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

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

  • Flurry (2 протокола и менеджер);

  • Reactive (набор расширений и 3 небольших класса);

  • Config (2 класса и несколько протоколов);

  • API (сессия с конфигурацией и загрузкой глобального конфига).

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

Со временем компонент разросся. В него попал код, который необходимо было просто вынести в отдельное общее место. Например, Client, AppGuideService и так далее. В API попали сессии клиента, авторизации, модель с кодом подтверждения.

Параллельно появились пакеты в проекте, отвечающие за авторизацию, сетевой слой, логирование. Этот код так и остался в Common.

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

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

На поздних этапах разработки компоненты с общим названием без смысловой нагрузки вроде Common, Shared, Core, Base, Utils не могут принести такой же пользы, как на старте. Теперь для компонентов важнее удобство повторного использования.

В сформировавшемся проекте стоит особое внимание уделить принципам объединения для удобства пользователей (REP) и объединения для удобства сопровождения (CCP). Про разделение для устранения лишних выпусков (CRP) забывать не стоит, в этом поможет принцип Open/Closed.

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

Схожий подход

В книге Крэга Лармана «Применение UML и шаблонов проектирования» дан аналогичный подход к проектированию компонентов. Аналогия CRP здесь — Low Coupling. Низкая связанность с другими компонентами облегчает понимание логики компонента, его модификацию, автономное тестирование, а также переиспользование по отдельности. Это признак хорошо структурированной и хорошо спроектированной системы.

REP + CCP аналогичны High Cohesion. Высокая когезия элементов внутри компонента — то, как и насколько задачи, выполняемые компонентом, связаны друг с другом. Сам термин «когезия» на русском применяется в физике, где обозначает свойство взаимного притяжения одинаковых молекул. Использую когезию, потому что связность (Coupling) и связанность (Cohesion) в русском легче легкого путаются.

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

Дублирование

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

Иногда дублировать код менее вредно, чем тянуть зависимость от общего пакета. К примеру, extension URL, вычисляемое свойство isHttpLink из пакета Common.

isHttpLink
    var isHttpLink: Bool {
        scheme == "http"
    }

Когда в пакет импортирован компонент с таким разнообразным функционалом, кажется целесообразным и естественным использовать расширение из компонента. Но в реальности порой проще и удобнее создать расширение у себя в пакете.

Часто именно из-за боязни дублирования в проекте растет ненужная связанность. Разработчик тянет огромную неустойчивую зависимость себе в компонент, чтобы переиспользовать небольшой фрагмент кода. Часто этот функционал, недостаточно большой, чтобы создавать отдельный компонент, призван облегчить работу. Его стараются положить в какой-то общий компонент для переиспользования. 

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

При анализе Common мы выявили несколько компонентов, в которых использовалось только isHttpLink в три строки. Подключить Common к себе в компонент занимает больше времени, добавляет зависимость от неустойчивого и очень захламленного компонента и необходимость следить за Common.

Как провести очистку проекта от устаревших компонентов

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

Первый шаг к качественным компонентам — анализ названия существующих компонентов. Название компонента должно отражать его конкретный функционал. Периодически анализируйте свои названия компонентов. Обнаружились Cоmmon, Shared, Core, Base, Utils? Скорее всего, такие компоненты были созданы давно и теперь не отвечают изначальным требованиям. Именно их в первую очередь надо рассмотреть подробнее.

Второй шаг — анализ содержания компонентов. Разберите их структуру, уберите все лишнее:

  • Удалите файлы, которые уже нигде не используются.

  • Перенесите в компоненты тот функционал, который используется только в одном месте.

  • Перенесите в компоненты тот функционал, для которого появились собственные компоненты.

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

  • Проанализируйте код, написанный, чтобы избежать дублирования, — возможно, дублирование будет лучше лишней зависимости.

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

  • Помечайте спорный функционал как deprecated с описанием причины.

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

Что важно помнить при проектировании и развитии компонента

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

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

Старайтесь минимизировать внешние зависимости компонента.

Обращайте внимание на то, с какой скоростью развивается функционал и как скорость развития влияет на компонент.

Держите в уме направление зависимостей. 

Оценивайте устойчивость компонента не только по зависимостям, но и по степени влияния на него третьих лиц / сервисов.

Отслеживайте и оперативно устраняйте возможные циклические зависимости.

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

Не выносите в публичную часть то, что не отвечает назначению компонента.

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

Не создавайте слишком маленькие компоненты.

Не делайте компоненты слишком большими и универсальными. Компонент не может отвечать за все и сразу.

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

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

Буду рад, если напишете свои вопросы или поделитесь опытом по теме. Прошу в комментарии! 

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