Перечитывая «Чистую архитектуру» (4): принципы построения компонентов
Третья статья была посвящена проектированию модулей и классов. Теперь пойдём дальше и рассмотрим проектирование компонентов.
Компонент — единица развёртывания ПО, наименьшая сущность системы, которую можно развернуть независимо. Компоненты можно разрабатывать отдельно; компонентная плагинная архитектура уже стала привычным способом построения ПО.
Связность компонентов
Принципы связности объясняют, какие модули и классы объединять в компоненты. Основных принципов три:
- Принцип эквивалентности повторного использования и выпуска.
- Принцип общей закрытости.
- Принцип совместного повторного использования.
Эквивалентность повторного использования и выпуска — REP
REP утверждает: минимальная единица повторного использования ПО должна совпадать с минимальной единицей его выпуска.
REP рассматривает повторное использование кода. Он позволяет объединять код с общей темой и функциями в компонент и рекомендует повторно использовать ПО именно компонентами.
Проще говоря, упакованный выпуск компонента получает номер версии или уникальный идентификатор. Мы повторно используем код, подключая готовую библиотеку: единицей подключения служит пакет компонента, а идентификатором — версия.
Общая закрытость — CCP
CCP предписывает объединять в одном компоненте классы, которые меняются одновременно и по одной причине. Классы, которые не меняются одновременно или по одной причине, следует размещать в разных компонентах.
CCP смотрит на сопровождение кода. Объединение кода с общей причиной и целью изменений снижает нагрузку, связанную с выпуском, проверкой и развёртыванием ПО.
Как уже отмечалось, CCP — компонентная версия SRP. Оба принципа можно выразить так:
Объединяйте то, что меняется одновременно по одной причине. Разделяйте то, что меняется по разным причинам и в разное время.
- В SRP речь идёт о функциях и других составляющих класса или модуля.
- В CCP — о классах и модулях, составляющих компонент.
Совместное повторное использование — CRP
CRP утверждает: не заставляйте пользователей компонента зависеть от того, что им не нужно.
Код с разными сценариями и частотой использования следует разносить по компонентам. CRP направлен на предотвращение ненужного разделения и является обобщением принципа разделения интерфейсов ISP.
Как обобщённая форма разделения интерфейсов, CRP вместе с ISP выражается так:
Не зависите от того, чем не пользуетесь.
- В ISP это функции или классы, содержащие ненужные методы.
- В CCP это классы или модули, содержащие ненужные функции.
Размышление
Как связаны REP, CCP и CRP?
Между ними существует конкуренция.
REP и CCP — объединяющие принципы: они подсказывают, какие классы и модули собрать в компонент. CRP — исключающий принцип: он показывает, какие классы и модули не следует объединять.

- Если учитывать только REP и CCP, зависимости ПО будут содержать много ненужных частей, что приведёт к лишним выпускам.
- Если учитывать только CRP и CCP, повторное использование станет очень трудным.
- Если учитывать только REP и CRP, изменение некоторых классов или модулей неизбежно потребует изменений множества связанных модулей.
Задача архитектора — находить компромисс между тремя принципами. Состав компонентов должен эволюционировать вместе с приоритетами проекта и балансом удобства разработки и повторного использования. Поэтому направление развития нужно оценивать динамически.
Зависимости между компонентами
Принципы зависимостей объясняют, как организовывать отношения между компонентами. Их три:
- Принцип ацикличности зависимостей — ADP.
- Принцип устойчивых зависимостей — SDP.
- Принцип устойчивых абстракций — SAP.
Ацикличность зависимостей — ADP
ADP утверждает: в графе зависимостей компонентов не должно быть циклов.
Управление номерами версий решает проблему «синдрома следующего утра», но для этого нужно соблюдать ADP. Сначала посмотрим, как автор описывает этот синдром:
Вы весь день трудились и наконец заставили код работать. На следующее утро обнаруживаете, что он по непонятной причине перестал работать. Скорее всего, кто-то изменил компонент, от которого зависит ваш проект.
Обычно предлагают два решения:
- Еженедельная сборка.
- Управление номерами версий.
Еженедельная сборка: каждый работает в собственном репозитории, а раз в неделю, например в пятницу, проект собирают и устраняют конфликты.
Ограничения этого подхода очевидны:
- По мере роста проекта интеграцию всё труднее завершать вовремя.
- Сборка и тестирование усложняются, цикл обратной связи удлиняется, и качество разработки закономерно падает.
Поэтому требуется управление номерами версий.
Управление номерами версий: после выпуска новой версии компонента каждая зависимая команда самостоятельно решает, переходить ли на неё сразу.
Управление номерами версий не допускает циклов в графе зависимостей компонентов, то есть требует соблюдения ADP. Иначе синдром следующего утра неизбежен. Почему?
Цикл объединяет все входящие в него компоненты в один большой компонент. Их версии должны согласованно совпадать, чтобы остальные компоненты могли успешно подключать их. Циклы также затрудняют тестирование. Хотя mock-заглушки распространены, повторно создавать их для компонентов всего цикла весьма неэлегантно.
Как устранить циклическую зависимость? Есть два способа:
- Создать интерфейсы с помощью инверсии зависимостей.
- Создать новый компонент.
Применение DIP понятно. Рассмотрим рабочий случай, показывающий, как разорвать цикл созданием нового компонента.
Команда проектирует систему с помощью DDD. Сущность Entity Dog зависит от Dog Repo для операции сохранения Save, поэтому модуль Entity зависит от Repo. К несчастью, Dog PO тоже определён в Entity — неудачное решение. Функции Save в Repo нужен PO для преобразования и сохранения объекта. Возникает цикл Entity ↔ Repo. К счастью, Go запрещает взаимные зависимости модулей, и проверка сообщает о циклическом импорте. Как поступить?
Нужно создать модуль PO, перенести в него PO из Entity и отнести туда функции Repo, зависящие от PO. Это устраняет цикл. Другой вариант — внедрение зависимостей. При использовании SprintBoot проблему может решить @AutoWired, но по существу он также создаёт новый компонент — пул зависимостей, — устраняя цикл.
Подобных примеров много. Помните конфликты версий библиотек при установке пакетов Python через anaconda?
Небольшое отступление: проектируя компоненты, я естественным образом пытался сопоставить их функциям системы один к одному. Тогда и граф зависимостей совпадал бы с функциональным разбиением. Казалось очевидным, что такой граф можно получить уже в начале проектирования.
Но автор специально отмечает невозможность проектирования сверху вниз. Посмотрим на объяснение в книге:
[!NOTE]
Граф компонентов должен меняться и расширяться вместе с системой; идеально спроектировать его с самого начала нельзя. Он не соответствует функциям один к одному, а скорее представляет карту возможностей сборки и сопровождения приложения.
По мере появления новых спроектированных и реализованных модулей возникает потребность управлять зависимостями. Мы стремимся максимально ограничить область влияния изменений, поэтому с помощью SRP и CCP объединяем классы, которые часто меняются одновременно.
Важная цель графа компонентов — показывать, как изолировать частые изменения. Часто меняющиеся компоненты не должны влиять на те, которые обязаны оставаться устойчивыми.
По мере роста приложения возрастает и потребность в повторно используемых компонентах. Тогда на их состав начинает влиять CRP. Наконец, при появлении циклических зависимостей применение ADP вызывает перестройку и расширение графа.
Устойчивые зависимости — SDP
SDP утверждает: зависимости должны указывать в сторону большей устойчивости. Как правило, нижние уровни архитектуры должны быть устойчивее.
От компонента, который должен часто меняться, не должен зависеть компонент, который трудно изменить. Иначе первый тоже станет трудноизменяемым. В этом одна из трудностей разработки: тщательно спроектированный гибкий компонент может стать жёстким из-за единственной чужой зависимости. Соблюдение SDP позволяет этого избежать.
Для оценки устойчивости автор предлагает показатель: $$ I=\frac{Fan-out}{Fan-in+Fan-out} $$ Fan-in — число входящих зависимостей, Fan-out — исходящих. Чем меньше у компонента исходящих зависимостей, тем он устойчивее: при нулевом значении отсутствуют внешние факторы, заставляющие его меняться.
SDP требует, чтобы показатель I каждого компонента был больше показателя I компонентов, от которых он зависит. Иными словами, цель зависимости должна быть устойчивее.
SDP не требует устойчивости всех компонентов. Граф проектируют именно для выбора, какие компоненты должны быть устойчивыми, а какие — нет. Полностью устойчивая архитектура негибка, а негибкая архитектура не обладает достаточной архитектурной ценностью.
Устойчивые абстракции — SAP
SAP утверждает: степень абстрактности компонента должна соответствовать его устойчивости.
Высокоуровневые правила должны находиться в устойчивых компонентах, но тогда их трудно менять. К счастью, OCP позволяет проектировать устойчивые компоненты удобными для расширения. Абстрактные классы позволяют сочетать устойчивость с возможностью расширения и изменения.
Абстрактный класс — промежуточная зона между интерфейсом и классом.
SAP связывает устойчивость компонента с его абстрактностью. Устойчивые компоненты должны быть абстрактными, чтобы устойчивость не мешала расширению. Неустойчивые компоненты должны содержать конкретную реализацию, которую легко изменить. Поэтому компонент, претендующий на устойчивость, должен состоять из интерфейсов и абстрактных классов, допускающих дальнейшее расширение.
Аналогично устойчивости, абстрактность можно измерять показателем: $$ A=\frac{N_c}{N_a} $$ Nc — число классов компонента, Na — число абстрактных классов и интерфейсов. A принимает значения от 0 до 1: 0 означает отсутствие абстрактных классов, 1 — наличие только абстрактных классов.
Размышление 🤔
Связаны ли SDP и SAP?
Во-первых, связь SDP, SAP и DIP
Фактически SDP + SAP = DIP для компонентов. SDP требует направлять зависимости к более устойчивому, а SAP говорит, что устойчивость подразумевает абстрактность. Следовательно, зависимости должны направляться к более абстрактному.
Как это понимать?
DIP обеспечивает гибкость на уровне классов. SDP + SAP обеспечивают достаточную гибкость компонентов при сохранении устойчивости. И DIP, и SDP + SAP направляют архитектурные зависимости от конкретных реализаций к абстрактным классам и интерфейсам. Поэтому по своей роли SDP + SAP равны компонентному DIP.
Но различия остаются. Для класса нет серой зоны: он либо абстрактный, либо нет. SDP и SAP применяются к компонентам, которые могут быть частично абстрактными и частично устойчивыми.
Во-вторых, общность целей SAP и SDP
SDP направляет зависимости к устойчивому, а SAP требует размещать высокоуровневые правила в абстрактных компонентах. Это позволяет отделять правила от деталей и поддерживать плагинную разработку. Их цели согласованы.
Снова встретилось выражение плагинная разработка. Где оно было раньше? В DIP, инверсии зависимостей. Это ещё раз показывает сходство SAP + SDP с DIP для компонентов.
Главная последовательность
Если совместить показатель неустойчивости I и показатель абстрактности A на графике, прямая a = -i + 1 называется линией главной последовательности.

На графике выделяют три области:
- Зона боли около (0, 0): компоненты очень устойчивы и одновременно конкретны, поэтому их трудно менять. Пример — таблицы базы данных. Вспомогательные библиотеки также относятся к зоне боли: хотя их I равен 1, поскольку они зависят от множества компонентов и потому неустойчивы, изменять их нельзя без проблем в большом количестве кода.
- Зона бесполезности около (1, 1): компоненты чрезмерно абстрактны, но другие от них не зависят, поэтому ими часто невозможно пользоваться. Проблемы исходного кода и классов здесь обычно имеют исторические причины, например забытый старый код.
- Зона главной последовательности: оптимальные положения компонентов — концы линии (0, 1) и (1, 0). Хороший архитектор должен стремиться приблизить к ним большую часть своих компонентов.
Как оценивать компонент в целом? Рассчитать расстояние до главной последовательности, количественно выражающее соответствие проекта ей. Это показатель D: 0 означает расположение прямо на линии, 1 — максимальное удаление. $$ D=|A+I-1| $$ Степень превышения D над нулём помогает направлять рефакторинг и перепроектирование компонентов.
У D есть и другие применения. Например, среднее значение и дисперсия D всех компонентов позволяют статистически оценивать устройство системы:
- У хорошо спроектированной системы среднее значение и дисперсия D должны быть близки к нулю.
- Дисперсию можно использовать как порог соответствия, выявляя нетипичные компоненты.
- Можно также отслеживать дисперсию D во времени, наблюдая, как меняются устойчивость и абстрактность архитектуры.
(Конец четвёртой статьи)