Чистая архитектура. Искусство разработки программного обеспечения Мартин Роберт
В один компонент должны включаться классы, изменяющиеся по одним причинам и в одно время. В разные компоненты должны включаться классы, изменяющиеся в разное время и по разным причинам.
Это принцип единственной ответственности (SRP), перефразированный для компонентов. Так же, как принцип SRP, гласящий, что класс не должен иметь нескольких причин для изменения, принцип согласованного изменения (CCP) требует, чтобы компонент не имел нескольких причин для изменения.
Для большинства приложений простота сопровождения важнее возможности повторного использования. Если возникнет необходимость изменить код приложения, предпочтительнее, если все изменения будут сосредоточены в одном месте, а не разбросаны по нескольким компонентам[29]. Если изменения ограничиваются единственным компонентом, нам потребуется развернуть только один, изменившийся компонент. Другие компоненты, не зависящие от измененного, не придется повторно проверять и развертывать.
Принцип CCP требует от нас собирать вместе все классы, которые может понадобиться изменить по одной, общей причине. Если два класса тесно связаны, физически или концептуально, настолько, что всегда будут изменяться вместе, они должны принадлежать одному компоненту. Это поможет уменьшить трудозатраты, имеющие отношение к повторному выпуску, тестированию и развертыванию программного обеспечения.
Этот принцип тесно связан с принципом открытости/закрытости (Open Closed Principle; OCP). Фактически он означает «закрытость» в смысле принципа OCP. Принцип открытости/закрытости (OCP) требует, чтобы классы были закрыты для изменений, но открыты для расширения. Так как 100% закрытость невозможна, она должна носить скорее стратегический характер. Мы проектируем свои классы так, чтобы они были закрыты для изменений, наиболее типичных из ожидаемых по опыту.
Принцип согласованного изменения (CCP) развивает эту идею, предписывая объединять в один компонент только классы, закрытые для одного и того же вида изменений. То есть увеличивает вероятность, что изменение требований повлечет необходимость изменения минимального количества компонентов.
Сходство с принципом единственной ответственности
Как отмечалось выше, принцип согласованного изменения (CCP) есть форма принципа единственной ответственности (SRP) для компонентов. Принцип SRP требует выделять методы в разные классы, если они изменяются по разным причинам.
Принцип CCP аналогично требует выделять классы в разные компоненты, если они изменяются по разным причинам. Оба принципа можно привести к общей формуле:
Собирайте вместе все, что изменяется по одной причине и в одно время. Разделяйте все, что изменяется в разное время и по разным причинам.
Принцип совместного повторного использования
Не вынуждайте пользователей компонента зависеть от того, чего им не требуется.
Принцип совместного повторного использования (Common Reuse Principle; CRP) — еще один принцип, помогающий определять, какие классы и модули должны включаться в компонент. Он указывает, что в компонент должны включаться классы и модули, используемые совместно.
Классы редко используются по отдельности. Обычно многократно используемые классы взаимодействуют с другими классами, являющимися частью многократно используемой абстракции. Принцип CRP указывает, что такие классы должны включаться в один компонент. Мы надеемся увидеть в компонентах классы, имеющие множественные зависимости друг от друга.
Простейшим примером могут служить класс коллекции и связанные с ним итераторы. Эти классы используются вместе, потому что они тесно связаны друг с другом. Соответственно, должны находиться в одном компоненте.
Но принцип CRP говорит не только о том, какие классы должны включаться в компонент; он также сообщает, какие классы не должны объединяться. Когда один компонент использует другой компонент, между ними образуется зависимость. Использующий компонент может нуждаться только в одном классе из используемого компонента, но это не ослабляет зависимости. Использующий компонент все так же зависит от используемого компонента.
Из-за этой зависимости изменение используемого компонента часто влечет необходимость соответствующих изменений в использующем компоненте. Даже если в использующем компоненте ничего не нужно изменять, его почти наверняка потребуется повторно скомпилировать, протестировать и развернуть. Это верно, даже если реализация использующего компонента совершенно не зависит от изменений в используемом компоненте.
То есть когда образуется зависимость от компонента, желательно, чтобы она распространялась на все классы в этом компоненте. Иначе говоря, классы, включаемые в компонент, должны быть неотделимы друг от друга — чтобы нельзя было зависеть от одних и не зависеть от других. Иначе нам придется повторно развертывать больше компонентов, чем требуется, и тратить существенно больше усилий.
Итак, принцип совместного повторного использования (CRP) в большей степени говорит о том, какие классы не должны объединяться, чем какие должны объединяться. Принцип CRP указывает, что классы, не имеющие тесной связи, не должны включаться в один компонент.
Связь с принципом разделения интерфейсов
Принцип совместного повторного использования (CRP) является обобщенной версией принципа разделения интерфейсов (ISP). Принцип ISP советует не создавать зависимостей от классов, методы которых не используются. Принцип CRP советует не создавать зависимостей от компонентов, имеющих неиспользуемые классы.
Обобщая, эти советы можно объединить в один:
Не создавайте зависимостей от чего-либо неиспользуемого.
Диаграмма противоречий для определения связности компонентов
Возможно, вы уже заметили, что три принципа связности компонентов вступают в противоречие друг с другом. Принципы эквивалентности повторного использования (REP) и согласованного изменения (CCP) являются включительными: оба стремятся сделать компоненты как можно крупнее. Принцип повторного использования (CRP) — исключительный, стремящийся сделать компоненты как можно мельче. Задача хорошего архитектора — разрешить это противоречие.
На рис. 13.1 изображена диаграмма противоречий[30], показывающая, как три принципа связности влияют друг на друга. Ребра на диаграмме описывают цену нарушения принципа на противоположной вершине.
Архитектор, уделяющий внимание только принципам REP и CRP, обнаружит, что простое изменение вовлекает слишком большое количество компонентов. С другой стороны, архитектор, уделяющий особое внимание принципам CCP и REP, вынужден будет выпускать слишком много ненужных версий.
Рис. 13.1. Диаграмма противоречий принципов связности
Хороший архитектор найдет в этом треугольнике противоречий золотую середину, отвечающую текущим нуждам разработчиков, а также подумает об изменениях, которые могут произойти в будущем. Например, на ранних этапах разработки проекта принцип CCP намного важнее, чем REP, удобство разработки важнее удобства повторного использования.
Вообще говоря, в начале разработки наибольшую важность имеет правая сторона треугольника, когда единственной жертвой является повторное использование. Но по мере развития и интеграции в другие проекты фокус начинает смещаться влево. То есть организация компонентов в проекте может изменяться с течением времени. Это больше связано с тем, как проект разрабатывается и используется, нежели с тем, что фактически этот проект делает.
Заключение
В прошлом мы смотрели на связность проще, чем предполагают принципы эквивалентности повторного использования (REP), согласованного изменения (CCP) и совместного повторного использования (CRP). Когда-то мы думали, что связность — это просто атрибут, что модуль выполняет одну и только одну функцию. Однако три принципа связности компонентов описывают намного более сложное многообразие. Выбирая классы для включения в компоненты, нужно учитывать противодействующие силы, связанные с удобством повторного использования и разработки. Поиск баланса этих сил, исходя из потребностей приложения, — непростая задача. Кроме того, баланс практически всегда постоянно смещается. То есть разбиение, считающееся удачным сегодня, может оказаться неудачным через год. Как следствие, состав компонентов почти наверняка будет изменяться с течением времени и смещением фокуса проекта с удобства разработки к удобству повторного использования.
Глава 14. Сочетаемость компонентов
Следующие три принципа определяют правила взаимоотношений между компонентами. И снова мы сталкиваемся с противоречиями между удобством разработки и логической организацией. Силы, воздействующие на архитектуру структуры компонентов, носят технический, политический и непостоянный характер.
Принцип ацикличности зависимостей
Циклы в графе зависимостей компонентов недопустимы.
Бывало ли у вас так, что вы целый день проработали, заставили что-то заработать, а на следующее утро, придя на работу, обнаружили, что опять ничего не работает? Почему это произошло? Потому что кто-то задержался на работе дольше вас и изменил что-то, от чего зависит ваш код! Я называю это «синдромом следующего утра».
«Синдром следующего утра» возникает, когда одни и те же файлы с исходным кодом правят сразу несколько разработчиков. В относительно небольших проектах, над которыми трудится малое количество разработчиков, эта проблема не доставляет особых хлопот. Но в крупных проектах с многочисленным коллективом следующее утро может превратиться в настоящий кошмар. Нередко проходят неделя за неделей, а команда все не в состоянии собрать стабильную версию проекта. Вместо этого разработчики правят и правят свой код, пытаясь заставить его работать с изменениями, сделанными кем-то другим.
За последние десятилетия было выработано два решения этой проблемы, и оба пришли из телекоммуникационной отрасли. Первое: «еженедельные сборки». И второе: соблюдение принципа ацикличности зависимостей (Acyclic Dependencies Principle; ADP).
Еженедельные сборки
Еженедельные сборки часто используются в проектах среднего размера. Это решение действует так: все разработчики работают независимо первые четыре дня в неделе. Они изменяют собственные копии кода и не заботятся об интеграции результатов своего труда в коллективную основу. Затем, в пятницу, они объединяют свои изменения и пытаются собрать систему.
Этот подход имеет замечательное преимущество, позволяя разработчикам работать в изоляции четыре дня из пяти. Недостаток, конечно же, — большие трудозатраты на интеграцию в пятницу.
К сожалению, с развитием проекта становится все сложнее завершить интеграцию в пятницу. Бремя интеграции продолжает расти, пока не начинает захватывать субботу. Нескольких таких суббот достаточно, чтобы разработчики пришли к выводу, что интеграция должна начинаться в четверг — так начало интеграции постепенно переползает ближе к середине недели.
Одновременно с уменьшением отношения продолжительности разработки к продолжительности интеграции снижается эффективность команды. В конечном итоге ситуация становится настолько удручающей, что разработчики и руководители проекта заявляют о необходимости перехода на двухнедельный цикл сборки. Это ослабляет проблему на какое-то время, но время, затрачиваемое на интеграцию, продолжает расти вместе с размерами проекта.
В конечном счете этот сценарий приводит к кризису. Для поддержания эффективности на высоком уровне график сборки должен постоянно удлиняться, но такое удлинение увеличивает риски. Интеграция и тестирование становятся все сложнее, а команда теряет преимущества, которые дает быстрая обратная связь.
Устранение циклических зависимостей
Решение этой проблемы заключается в разделении проекта на компоненты, которые могут выпускаться независимо. Компоненты становятся единицами работы, ответственность за которые можно возложить на одного разработчика или на небольшую группу. Когда разработчики добиваются работоспособности компонента, они выпускают новую версию для использования другими разработчиками. Они присваивают этой версии номер и помещают в каталог, доступный другим разработчикам. Затем продолжают разработку компонента, изменяя свои локальные копии. А все остальные используют выпущенную версию.
Когда появляется новая версия компонента, другие команды могут выбирать — сразу же задействовать новую версию или подождать. Если принято решение подождать, они просто продолжают использовать предыдущую версию. Но как только они решат, что готовы, они начинают использовать новую версию.
В результате ни одна команда не отдается на милость другим. Изменения в одном компоненте не оказывают немедленного влияния на другие команды. Каждая команда сама решает, когда начать адаптацию своего компонента для использования новой версии другого компонента. Кроме того, интеграция происходит небольшими шагами. Нет единого момента времени, когда все разработчики должны собраться вместе и интегрировать все, что они создали.
Этот очень простой и рациональный процесс получил широкое распространение. Однако чтобы добиться успеха, вы должны управлять структурой зависимостей компонентов. В ней не должно быть циклических зависимостей. Если в структуре зависимостей появятся циклы, «синдрома следующего утра» не избежать.
Взгляните на диаграмму компонентов на рис. 14.1. Она демонстрирует типичную структуру компонентов, собранных в приложение. В данном случае назначение приложения не играет роли — важна сама структура зависимостей компонентов. Обратите внимание, что структура имеет вид ориентированного (направленного) графа. Компоненты играют роль узлов, а зависимости между ними — ориентированных ребер.
Рис. 14.1. Типичная диаграмма компонентов
Обратите внимание на одну важную особенность: с какого бы компонента вы ни начали, вы не сможете пройти по связям-зависимостям и вернуться обратно в этот же компонент. Эта структура не имеет циклов. Это ациклический ориентированный граф (Directed Acyclic Graph; DAG).
Теперь представьте, что произойдет, когда одна из команд выпустит новую версию компонента Presenters. Определить, кого затронет этот факт, нетрудно, нужно лишь проследовать по стрелкам входящих зависимостей. То есть затронуты будут компоненты View и Main. В этот момент разработчики, развивающие эти компоненты, должны решить, когда начать их интеграцию с новой версией Presenters.
Отметьте также, что выпуск новой версии компонента Main не затронет никакой другой компонент в системе. Их разработчики вообще могут не знать о существовании компонента Main и никак не учитывать изменения, происходящие в нем. Это замечательно. Это означает, что выпуск новой версии Main оказывает минимальное влияние.
Когда разработчики компонента Presenters пожелают протестировать его, им просто нужно собрать свою версию Presenters с версиями компонентов Interactors и Entities, используемыми в данный момент. Никакой другой компонент в системе им не потребуется для этого. Это замечательно. Это означает, что разработчикам Presenters не придется прилагать значительных усилий для подготовки к тестированию и им достаточно учесть небольшое количество переменных.
Когда придет время выпустить новую версию всей системы, процесс будет протекать снизу вверх. Сначала будет скомпилирован, протестирован и выпущен компонент Entities. Затем те же действия будут выполнены с компонентами Database и Interactors. За ними последуют Presenters, View, Controllers и затем Authorizer. И наконец, очередь дойдет до Main. Это очевидный и легко воспроизводимый процесс. Мы знаем, в каком порядке собирать систему, потому что понимаем, как связаны зависимостями отдельные ее части.
Влияние циклов в графе зависимостей компонентов
Предположим, что появление новых требований вынудило нас изменить один из классов в компоненте Entities так, что он стал использовать класс из компонента Authorizer. Например, допустим, что класс User из Entities стал использовать класс Permissions из Authorizer. В результате образовалась циклическая зависимость, как показано на рис. 14.2.
Этот цикл немедленно приводит к появлению проблем. Например, разработчики, развивающие компонент Database, знают, что для выпуска новой версии они должны проверить совместимость с компонентом Entities. Но из-за образовавшегося цикла компонент Database теперь также должен быть совместим с Authorizer. Но Authorizer зависит от Interactors. Все это усложняет выпуск новой версии Database. Entities, Authorizer и Interactors фактически превращаются в один большой компонент — это означает, что всех разработчиков, развивающих эти компоненты, будет преследовать «синдром следующего утра». Они будут постоянно наступать друг другу на пятки из-за необходимости использования одних и тех же версий компонентов друг друга.
Рис. 14.2. Циклическая зависимость
Но список проблем этим не исчерпывается. Подумайте, что произойдет, когда нам потребуется протестировать компонент Entities. К нашему глубокому огорчению обнаружится, что мы должны собрать и интегрировать его с Authorizer и Interactors. Такая степень зависимости компонентов вызывает беспокойство, если она вообще допустима.
Возможно, вам уже приходилось задаваться вопросом, почему для простого модульного тестирования одного из классов приходится подключать так много библиотек и всякой другой всячины. Если бы вы копнули глубже, то наверняка бы обнаружили циклы в графе зависимостей. Такие циклы существенно усложняют изоляцию компонентов. Модульное тестирование и выпуск новой версии превращается в очень сложную задачу. Кроме того, проблемы, проявляющиеся во время сборки, начинают нарастать в геометрической прогрессии от количества модулей.
Более того, наличие циклов в графе зависимостей усложняет определение порядка сборки компонентов. И действительно, в этом случае нет правильного порядка. Это может повлечь другие неприятности в языках, таких как Java, которые извлекают объявления из скомпилированных двоичных файлов.
Разрыв цикла
Образовавшуюся циклическую зависимость всегда можно разорвать и привести граф зависимостей к форме ациклического ориентированного графа (DAG). Для этого используются два основных механизма:
1. Применить принцип инверсии зависимостей (Dependency Inversion Principle; DIP). В этом случае, как показано на рис. 14.3, можно было бы создать интерфейс, определяющий методы, необходимые классу User, затем поместить этот интерфейс в Entities и унаследовать его в Authorizer. Это обратило бы зависимость между Entities и Authorizer и разорвало цикл.
Рис. 14.3. Инверсия зависимости между Entities и Authorizer
2. Создать новый компонент, от которого зависят Entities и Authorizer. Поместить в новый компонент класс(ы), от которых они оба зависят (рис. 14.4).
Рис. 14.4. Новый компонент, от которого зависят Entities и Authorizer
«Изменчивость»
Второе решение предполагает зависимость структуры компонентов от изменения требований. И действительно, с ростом приложения структура зависимостей компонентов растет и изменяется. Поэтому ее постоянно нужно проверять на предмет появления циклов. Когда образуются циклы, их нужно разрывать тем или иным способом. Иногда для этого приходится создавать новые компоненты, что заставляет разрастаться структуру зависимостей.
Проектирование сверху вниз
Проблемы, что мы обсудили к данному моменту, ведут к однозначному заключению: структура компонентов не может проектироваться сверху вниз. К этому выводу приходят не сразу, как только начинают проектировать систему, но это неизбежно случается с ростом и изменением системы.
Некоторые читатели могут счесть это утверждение нелогичным. Мы обычно ожидаем, что разложение на крупные составляющие, такие как компоненты, будет также соответствовать разложению по функциональному признаку.
Рассматривая крупноблочную диаграмму, такую как структура зависимостей компонентов, мы полагаем, что компоненты должны каким-то образом представлять функции системы. Но в действительности это не является непременным атрибутом диаграмм зависимостей компонентов.
Фактически диаграммы зависимостей компонентов слабо отражают функции приложения. В большей степени они являются отражением удобства сборки и сопровождения приложения. В этом главная причина, почему они не проектируются в начале разработки проекта. В этот период нет программного обеспечения, которое требуется собирать и сопровождать, поэтому нет необходимости составлять карту сборки и сопровождения. Но с появлением все большего числа модулей на ранних стадиях реализации и проектирования возрастает потребность управлять зависимостями, чтобы проект можно было разрабатывать, не опасаясь «синдрома следующего утра». Кроме того, появляется желание максимально ограничить влияние изменений, поэтому мы начинаем обращать внимание на принципы единственной ответственности (SRP) и согласованного изменения (CCP) и объединять классы, которые наверняка будут изменяться вместе.
Одной из главных задач такой структуры зависимостей является изоляция изменчивости. Нам не нужны компоненты, часто изменяющиеся по самым мелким причинам и влияющие на другие компоненты, которые иначе были бы вполне стабильными. Например, косметические изменения в графическом интерфейсе не должны влиять на бизнес-правила. Добавление и изменение отчетов не должно влиять на высокоуровневые политики. Следовательно, граф зависимостей компонентов создается и формируется архитекторами для защиты стабильных и ценных компонентов от влияния изменчивых компонентов.
По мере развития приложения мы начинаем беспокоиться о создании элементов многократного пользования. На этом этапе на состав компонентов начинает влиять принцип совместного повторного использования (CRP). Наконец, с появлением циклов мы начинаем применять принцип ацикличности зависимостей (ADP), в результате начинает изменяться и разрастаться граф зависимостей компонентов.
Попытка спроектировать структуру зависимостей компонентов раньше любых классов, скорее всего, потерпит неудачу. На этом этапе мы почти ничего не знаем о согласовании изменений, не представляем, какие элементы можно использовать многократно и почти наверняка создадим компоненты, образующие циклические зависимости. Поэтому структура зависимостей компонентов должна расти и развиваться вместе с логическим дизайном системы.
Принцип устойчивых зависимостей
Зависимости должны быть направлены в сторону устойчивости.
Дизайн не может оставаться статичным. Некоторая изменчивость все равно необходима, если предполагается сопровождать дизайн. Следуя принципу согласованного изменения (CCP), мы создаем компоненты, чувствительные к одним изменениям и невосприимчивые к другим. Некоторые из компонентов изначально проектируются как изменчивые. То есть мы ожидаем, что они будут изменяться.
Компоненты, с большим трудом поддающиеся изменению, не должны зависеть от любых изменчивых компонентов. Иначе изменчивый компонент тоже трудно будет изменять.
Это одна из превратностей программного обеспечения, когда модуль, проектировавшийся специально, чтобы упростить возможность изменений, становится сложно изменять просто из-за того, что от него зависит другой модуль. Представьте: вы не изменили ни строчки кода в своем модуле, и вдруг его стало сложно изменять. Следуя принципу устойчивых зависимостей (Stable Dependencies Principle; SDP), мы гарантируем, что модули, с трудом поддающиеся изменению, не будут зависеть от модулей, спроектированных для упрощения изменений.
Устойчивость
Что подразумевается под «устойчивостью»? Представьте себе монету, стоящую на ребре. Является ли такое ее положение устойчивым? Скорее всего, вы ответите «нет». Однако если оградить ее от вибраций и дуновений ветра, она может оставаться в таком положении сколь угодно долго. То есть устойчивость напрямую не связана с частотой изменений. Монета не изменяется, но едва ли кто-то скажет, что, стоя на ребре, она находится в устойчивом положении.
В толковом словаре говорится, что устойчивость — это «способность сохранять свое состояние при внешних воздействиях». Устойчивость связана с количеством работы, которую требуется проделать, чтобы изменить состояние. С одной стороны, монета, стоящая на ребре, находится в неустойчивом состоянии, потому что требуется приложить крошечное усилие, чтобы опрокинуть ее. С другой стороны, стол находится в очень устойчивом состоянии, потому что для его опрокидывания требуются намного более существенные усилия.
Какое отношение все это имеет к программному обеспечению? Существует множество факторов, усложняющих изменение компонента, например его размер, сложность и ясность. Но мы оставим в стороне все эти факторы и сосредоточим внимание на кое-чем другом. Есть один верный способ сделать программный компонент сложным для изменения — создать много других компонентов, зависящих от него. Компонент с множеством входящих зависимостей очень устойчив, потому что согласование изменений со всеми зависящими компонентами требует значительных усилий.
На рис. 14.5 представлена диаграмма с устойчивым компонентом X. От него зависят три других компонента, то есть имеется три веские причины не изменять его. Мы говорим, что X несет ответственность за эти три компонента. Сам компонент X, напротив, ни от чего не зависит, то есть на него не оказывается никаких внешних воздействий, которые могли бы привести к изменению. Мы говорим, что он независим.
На рис. 14.6 изображен очень неустойчивый компонент Y. От него не зависит никакой другой компонент, поэтому мы говорим, что он лишен ответственности. Имеется также три компонента, от которых зависит Y, поэтому необходимость его изменения может проистекать из трех внешних источников. Мы говорим, что Y зависим.
Рис. 14.5. X: устойчивый компонент
Рис. 14.6. Y: очень неустойчивый компонент
Метрики устойчивости
Как оценить устойчивость компонента? Один из способов — подсчитать количество входящих и исходящих зависимостей этого компонента. Эти числа позволят вычислить меру его устойчивости.
• Fan-in (число входов): количество входящих зависимостей. Эта метрика определяет количество классов вне данного компонента, которые зависят от классов внутри компонента.
• Fan-out (число выходов): количество исходящих зависимостей. Эта метрика определяет количество классов внутри данного компонента, зависящих от классов за его пределами.
• I: неустойчивость: I = Fan-out (Fan-in + Fan-out). Значение этой метрики изменяется в диапазоне [0, 1]. I = 0 соответствует максимальной устойчивости компонента, I = 1 — максимальной неустойчивости.
Метрики Fan-in (число входов) и Fan-out (число выходов)[31] определяются как количество классов за пределами компонентов, связанных зависимостями с классами внутри компонента. Рассмотрим пример, изображенный на рис. 14.7.
Рис. 14.7. Пример
Рассчитаем устойчивость компонента Cc. Вне компонента Cc имеется три класса, зависящих от классов внутри Cc. То есть Fan-in = 3. Кроме того, вне Cc имеется один класс, от которого зависят классы внутри Cc. То есть Fan-out = 1 и I = 1/4.
В C++ эти зависимости обычно представлены инструкциями #include. В действительности метрику I проще вычислять, когда исходный код организован так, что каждый класс хранится в отдельном файле. В Java метрику I можно вычислить, подсчитав количество инструкций import и полных квалифицированных имен.
Если метрика I равна 1, это означает, что никакой другой компонент не зависит от данного компонента (Fan-in = 0) и данный компонент зависит от других компонентов (Fan-out > 0). Это признак неустойчивости компонента; он безответствен и зависим. Отсутствие зависящих компонентов означает, что он не может служить причиной изменения других компонентов, а его собственная зависимость может послужить веским основанием для изменения самого компонента.
Напротив, когда метрика I равна 0, это означает, что от компонента зависят другие компоненты (Fan-in > 0), но сам он не зависит от других компонентов (Fan-out = 0). Такой компонент ответствен и независим. Он занимает максимально устойчивое положение. Зависимости от него усложняют изменение компонента, а отсутствие компонентов, от которых он зависит, означает отсутствие сил, которые могли бы заставить его измениться.
Принцип устойчивых зависимостей (SDP) говорит, что метрика I компонента должна быть больше метрик I компонентов, которые от него зависят. То есть метрики I должны уменьшаться в направлении зависимости.
Не все компоненты должны быть устойчивыми
Если все компоненты в системе будут иметь максимальную устойчивость, такую систему невозможно будет изменить. Это нежелательная ситуация. В действительности структура компонентов должна проектироваться так, чтобы в ней имелись и устойчивые, и неустойчивые компоненты. Диаграмма на рис. 14.8 демонстрирует идеальную организацию системы с тремя компонентами.
Изменяемые компоненты находятся вверху и зависят от устойчивого компонента внизу. Размещение неустойчивых компонентов в верхней части диаграммы — общепринятое и очень удобное соглашение, потому что любые стрелки, направленные вверх, ясно покажут нарушение принципа устойчивых зависимостей (и, как вы убедитесь далее, принципа ацикличности зависимостей).
Рис. 14.8. Идеальная организация системы с тремя компонентами
Диаграмма на рис. 14.9 демонстрирует нарушение принципа SDP.
Компонент Flexible специально проектировался так, чтобы его было легко изменять. Предполагалось, что он будет неустойчивым. Но кто-то из разработчиков, работающих над компонентом Stable, создал зависимость от компонента Flexible. Это явное нарушение принципа SDP, потому что
Рис. 14.9. Нарушение принципа SDP
метрика I компонента Stable намного меньше метрики I компонента Flexible. Как результат, создание такой зависимости усложнило возможное изменение компонента Flexible. Теперь любые изменения в компоненте Flexible придется согласовывать с компонентом Stable и всеми компонентами, зависящими от него.
Чтобы исправить проблему, нужно разорвать зависимость Stable от Flexible. Зачем нужна эта зависимость? Допустим, что в компоненте Flexible имеется класс C, который используется другим классом U из компонента Stable (рис. 14.10).
Рис. 14.10. Класс U в компоненте Stable использует класс C в компоненте Flexible
Исправить ситуацию можно, применив принцип инверсии зависимостей (DIP). Для этого определим интерфейс US и поместим его в компонент с именем UServer. Этот интерфейс должен объявлять все методы, используемые классом U. Затем реализуем этот интерфейс в классе C, как показано на рис. 14.11. Это разорвет зависимость Stable от Flexible и вынудит оба компонента зависеть от UServer. UServer очень устойчив (I = 0), а Flexible сохранит желаемую неустойчивость (I = 1). Теперь все зависимости простираются в сторону уменьшения I.
Рис. 14.11. Класс C реализует интерфейс US
Абстрактные компоненты
Кому-то может показаться странным, что мы создали компонент — в данном примере UService, — не содержащий ничего, кроме интерфейса. То есть компонент не сдержит выполняемого кода! Однако, как оказывается, это весьма распространенная и единственно возможная тактика в языках со статической системой типов, таких как Java и C#. Такие абстрактные компоненты очень устойчивы и поэтому служат идеальной целью для зависимостей в менее устойчивых компонентах.
В языках с динамической системой типов, таких как Ruby или Python, подобные абстрактные компоненты вообще отсутствуют, так же как зависимости, которые можно было бы нацелить на них. Структура зависимостей в этих языках намного проще, потому что для инверсии зависимостей не требуется объявлять или наследовать интерфейсы.
Принцип устойчивости абстракций
Устойчивость компонента пропорциональна его абстрактности.
Куда поместить высокоуровневые правила?
Некоторые части программных систем должны меняться очень редко. Эти части представляют высокоуровневые архитектурные и другие важные решения. Никто не желает, чтобы такие решения были изменчивыми. Поэтому программное обеспечение, инкапсулирующее высокоуровневые правила, должно находиться в устойчивых компонентах (I = 0). Неустойчивые (I = 1) должны содержать только изменчивый код — код, который можно было бы легко и быстро изменить.
Но если высокоуровневые правила поместить в устойчивые компоненты, это усложнит изменение исходного кода, реализующего их. Это может сделать всю архитектуру негибкой. Как компонент с максимальной устойчивостью (I = 0) сделать гибким настолько, чтобы он сохранял устойчивость при изменениях? Ответ заключается в соблюдении принципа открытости/закрытости (OCP). Этот принцип говорит, что можно и нужно создавать классы, достаточно гибкие, чтобы их можно было наследовать (расширять) без изменения. Какие классы соответствуют этому принципу? Абстрактные.
Введение в принцип устойчивости абстракций
Принцип устойчивости абстракций (Stable Abstractions Principle; SAP) устанавливает связь между устойчивостью и абстрактностью. С одной стороны, он говорит, что устойчивый компонент также должен быть абстрактным, чтобы его устойчивость не препятствовала расширению, с другой — он говорит, что неустойчивый компонент должен быть конкретным, потому что неустойчивость позволяет легко изменять его код.
То есть стабильный компонент должен состоять из интерфейсов и абстрактных классов, чтобы его легко было расширять. Устойчивые компоненты, доступные для расширения, обладают достаточной гибкостью, чтобы не накладывать чрезмерные ограничения на архитектуру.
Принципы устойчивости абстракций (SAP) и устойчивых зависимостей (SDP) вместе соответствуют принципу инверсии зависимостей (DIP) для компонентов. Это верно, потому что принцип SDP требует, чтобы зависимости были направлены в сторону устойчивости, а принцип SAP утверждает, что устойчивость подразумевает абстрактность. То есть зависимости должны быть направлены в сторону абстрактности.
Однако принцип DIP сформулирован для классов, и в случае с классами нет никаких полутонов. Класс либо абстрактный, либо нет. Принципы SDP и SAP действуют в отношении компонентов и допускают ситуацию, когда компонент частично абстрактный или частично устойчивый.
Мера абстрактности
Мерой абстрактности компонента служит метрика A. Ее значение определяется простым отношением количества интерфейсов и абстрактных классов к общему числу классов в компоненте.
• Nc: число классов в компоненте.
• Na: число абстрактных классов и интерфейсов в компоненте.
• A: абстрактность. A = Na Nc.
Значение метрики A изменяется в диапазоне от 0 до 1. 0 означает полное отсутствие абстрактных классов в компоненте, а 1 означает, что компонент не содержит ничего, кроме абстрактных классов.
Главная последовательность
Теперь мы можем определить зависимость между устойчивостью (I) и абстрактностью (A). Для этого нарисуем график со значениями A по вертикальной оси и значениями I — по горизонтальной (рис. 14.12). Если нанести на график «хорошие» компоненты обоих видов, обнаружится, что максимально устойчивые и абстрактные находятся слева вверху, в точке с координатами (0, 1), а максимально неустойчивые и конкретные — справа внизу, в точке (1, 0).
Рис. 14.12. График I/A
Но не все компоненты попадают в эти две точки, потому что компоненты имеют разные степени абстрактности и устойчивости. Например, очень часто один абстрактный класс наследует другой абстрактный класс. В результате получается абстрактный класс, имеющий зависимость. Поэтому, несмотря на абстрактность, он не будет максимально устойчивым. Зависимость уменьшает устойчивость.
Так как нельзя потребовать, чтобы все компоненты находились в двух точках (0, 1) или (1, 0), мы должны предположить, что на графике A/I имеется некоторое множество точек, определяющих оптимальные позиции для компонентов. Вывести это множество можно, определив области, где компоненты не должны находиться, — иными словами, определив зоны исключения (рис. 14.13).
Рис. 14.13. Зоны исключения
Зона боли
Рассмотрим компонент в точке (0, 0). Это очень устойчивый и конкретный компонент. Такие компоненты нежелательны, потому что слишком жесткие. Их нельзя расширить, потому что они неабстрактные, и очень трудно изменить из-за большой устойчивости. Поэтому правильно спроектированные компоненты обычно не должны находиться рядом с точкой (0, 0). Область вокруг точки (0, 0) — это зона исключения, которую называют зоной боли.
Некоторые программные сущности действительно попадают в зону боли. Примером может служить схема базы данных. Схемы баз данных печально известны своей изменчивостью, до предела конкретны и к ним тянется множество зависимостей. Это одна из причин, почему так сложно управлять интерфейсом между объектно-ориентированными приложениями и базами данных и почему изменение схемы обычно связано с большой болью.
Другой пример программного обеспечения, лежащего поблизости от точки (0, 0) — конкретная библиотека вспомогательных функций. Хотя такая библиотека имеет метрику I со значением 1, в действительности она может быть очень негибкой. Возьмем для примера компонент String. Даже при том, что все классы в нем конкретны, он используется настолько широко, что его изменение может породить хаос. Поэтому String — негибкий.
Негибкие компоненты в зоне, окружающей точку (0, 0), безопасны, потому что, скорее всего, не будут изменяться. По этой причине проблемы вызывают только изменчивые программные компоненты, находящиеся в зоне боли. Чем более изменчив компонент, находящийся в зоне боли, тем больше «боли» он доставляет. Фактически изменчивость можно рассматривать как третью ось графика. С этой точки зрения на рис. 14.13 изображена самая болезненная плоскость, где изменчивость = 1.
Зона бесполезности
Рассмотрим компонент рядом с точкой (1, 1). Такие компоненты также нежелательны, потому что они максимально абстрактны и не имеют входящих зависимостей. Они бесполезны. Поэтому данная область так и называется: зона бесполезности.
Программные сущности, находящиеся в этой области, являются своего рода осколками. Часто это оставшиеся абстрактные классы, которые так и не были реализованы. Нам иногда доводится натыкаться на них в системах, где они лежат без использования.
Компонент, находящийся глубоко в зоне бесполезности, должен содержать значительную долю таких сущностей. Очевидно, что присутствие таких бесполезных сущностей нежелательно.
Как не попасть в зоны исключения
Кажется очевидным, что наиболее изменчивые компоненты должны находиться как можно дальше от зон исключения. Точки, максимально удаленные от обеих зон, лежат на прямой, соединяющей точки (1, 0) и (0, 1). Я называю эту прямую главной последовательностью[32].
Компонент, располагающийся на главной последовательности, не «слишком абстрактный» для своей устойчивости и не «слишком неустойчив» для своей абстрактности. Он не бесполезен и не доставляет особенной боли. От него зависят другие компоненты в меру его абстрактности, и сам он зависит от других в меру конкретности.
Самыми желательными позициями для компонента являются конечные точки главной последовательности. Хорошие архитекторы стремятся разместить подавляющее большинство компонентов в этих точках. Однако, по моему опыту, в большой системе всегда найдется несколько компонентов, недостаточно абстрактных и недостаточно устойчивых. Такие компоненты обладают великолепными характеристиками, когда располагаются на или вблизи главной последовательности.
Расстояние до главной последовательности
Мы подошли к последней нашей метрике. Коль скоро желательно, чтобы компонент располагался на или вблизи главной последовательности, можно определить метрику, выражающую удаленность компонента от идеала.
• D[33]: расстояние. D = |A+I–1| . Эта метрика принимает значения из диапазона [0, 1]. Значение 0 указывает, что компонент находится прямо на главной последовательности. Значение 1 сообщает, что компонент располагается на максимальном удалении от главной последовательности.
Взяв на вооружение эту метрику, можно исследовать весь дизайн на близость к главной последовательности. Метрику D можно вычислить для любого компонента. Любой компонент со значением метрики D, далеким от нуля, требует пересмотра и реструктуризации.
Также можно выполнить статистический анализ дизайна. Можно найти среднее и дисперсию всех метрик D для компонентов в нашей архитектуре. Если среднее и дисперсия близки к нулю, значит, весь дизайн находится рядом с главной последовательностью. Для дисперсии можно установить «контрольные границы» и рассматривать выход за эти границы как признак появления компонентов, оказавшихся в «исключительной» зоне, в сравнении с остальными.
На диаграмме рассеяния, изображенной на рис. 14.14, можно видеть, что основная масса компонентов располагается вдоль главной последовательности, но некоторые отклоняются от среднего более чем на одно стандартное отклонение (Z = 1). На эти отклоняющиеся компоненты следует обратить особое внимание. По каким-то причинам они оказались слишком абстрактными и с малым количеством входящих зависимостей или слишком конкретными и с большим количеством входящих зависимостей.
Рис. 14.14. Диаграмма рассеяния компонентов
Другой интересный способ использования метрик — построение графика изменения метрики D каждого компонента с течением времени. График на рис. 14.15 демонстрирует изменение метрики D вымышленного компонента. Можно заметить, что в последних нескольких выпусках в компонент Payroll проникли какие-то странные зависимости. На график нанесен контрольный порог для D = 0.1. В точке R2.1 порог оказался превышен, поэтому стоит побеспокоиться и узнать, почему компонент отдалился от главной последовательности.
Рис. 14.15. График изменения метрики D компонента с течением времени
Заключение
Метрики управления зависимостями, описанные в этой главе, помогают получить количественную оценку соответствия структуры зависимостей и абстракции дизайна тому, что я называю «хорошим» дизайном. Как показывает опыт, есть хорошие зависимости, есть плохие. Данная оценка отражает этот опыт. Однако метрики не являются истиной в последней инстанции; это всего лишь измерения на основе произвольного стандарта. Эти метрики несовершенны — в лучшем случае, но я надеюсь, что вы найдете их полезными.
Часть V. Архитектура
Глава 15. Что такое архитектура
Слово «архитектура» вызывает ассоциации с такими понятиями, как власть и тайна. Оно заставляет нас воображать себе важные решения и высочайшее техническое мастерство. Архитектура программного обеспечения находится на вершине технических достижений. Думая об архитекторе программного обеспечения, мы представляем кого-то, обладающего властью и пользующегося непререкаемым авторитетом. Какой молодой и целеустремленный разработчик не мечтает стать архитектором?
Но что такое «архитектура программного обеспечения»? Что делает архитектор программного обеспечения и когда он это делает?
Прежде всего, архитектор программного обеспечения — это программист; и он продолжает оставаться таковым. Не верьте, если кто-то вам скажет, что архитекторы не занимаются программированием и решают проблемы более высокого уровня. Это не так! Архитекторы — это лучшие программисты, и они продолжают заниматься программированием, но при этом направляют других членов команды в сторону дизайна, максимизирующего продуктивность. Архитекторы могут писать не так много кода, как другие программисты, но они продолжают программировать. Они делают это, потому что не смогут справиться со своей работой, если не будут испытывать проблем, которые сами создают для других программистов.
Архитектура программной системы — это форма, которая придается системе ее создателями. Эта форма образуется делением системы на компоненты, их организацией и определением способов взаимодействий между ними.
Цель формы — упростить разработку, развертывание и сопровождение программной системы, содержащейся в ней.
Главная стратегия такого упрощения в том, чтобы как можно дольше иметь как можно больше вариантов.
Возможно, это утверждение удивило вас. Может быть, вы считали, что главной целью архитектуры программного обеспечения является правильное функционирование системы. Конечно, все мы хотим, чтобы наши системы работали правильно, и для архитектуры это должно быть одним из высших приоритетов.
Однако архитектура системы слабо влияет на работу системы. Существует масса систем с ужасной архитектурой, которые прекрасно работают. Их проблемы кроются не в функционировании; чаще они возникают в процессе развертывания, сопровождения и продолжительного развития.
Это не означает, что архитектура не играет никакой роли в поддержании правильного функционирования системы. Эта роль присутствует и, безусловно, имеет важнейшее значение. Но она носит скорее пассивный, косметический характер. Есть несколько вариантов поведения, которые архитектура системы может оставить открытыми.
Главное предназначение архитектуры — поддержка жизненного цикла системы. Хорошая архитектура делает систему легкой в освоении, простой в разработке, сопровождении и развертывании. Конечная ее цель — минимизировать затраты на протяжении срока службы системы и максимизировать продуктивность программиста.
Разработка
Программной системе, которую трудно развивать, едва ли стоит рассчитывать на долгую и здоровую жизнь. Поэтому архитектура системы должна делать ее простой в развитии для тех, кто ее разрабатывает.
Разные организации коллективов разработчиков предполагают разные архитектурные решения. С одной стороны, маленькая команда из пяти разработчиков может эффективно развивать монолитную систему без четкого определения границ между компонентами или интерфейсами. Фактически, на ранних этапах разработки такая команда может посчитать архитектурные рамки чем-то вроде препятствия. Вероятно, в этом заключается причина, почему многие системы имеют плохую архитектуру: их архитектура не была продумана с самого начала, потому что разработчиков было немного и они не желали отвлекаться на создание общей структуры.
С другой стороны, система, разрабатываемая пятью разными командами по семь разработчиков в каждой, едва ли добьется прогресса, если не разделить ее на четко очерченные компоненты с надежными и устойчивыми интерфейсами. Если оставить без внимания другие факторы, архитектура такой системы, скорее всего, превратится в пять компонентов — по одному для каждой команды.
Такая архитектура «по одному компоненту на команду» вряд ли будет лучшей для развертывания, эксплуатации и сопровождения. Тем не менее именно к такой архитектуре будет стремиться группа команд, если они руководствуются исключительно графиком разработки.
Развертывание
Чтобы достичь высокой эффективности, программная система должна легко разворачиваться. Чем выше стоимость развертывания, тем ниже эффективность системы. Соответственно, целью архитектуры является создание системы, которую можно развернуть в одно действие.
К сожалению, стратегия развертывания редко принимается во внимание на начальных этапах разработки. В результате может получиться архитектура, обеспечивающая простоту разработки, но существенно усложняющая развертывание.
Например, на ранних порах разработчики системы могут решить использовать «архитектуру микрослужб», посчитав, что такая архитектура значительно облегчает разработку, потому что компоненты имеют четко очерченные границы, а интерфейсы относительно устойчивы. Однако когда дело доходит до развертывания системы, они могут обнаружить, что количество микрослужб оказалось слишком велико; настройка соединений между ними и синхронизация их запуска могут оказаться неисчерпаемым источником ошибок.
Если бы архитекторы учитывали проблемы развертывания на самых ранних этапах, они, возможно, приняли бы решение создать меньшее количество служб, разбить систему на блоки, являющиеся гибридами служб и компонентов, и использовать более интегрированные средства управления взаимодействиями.
Эффективность работы
Влияние архитектуры системы на ее эффективную работу менее драматично, чем на разработку, развертывание и сопровождение. Практически любые проблемы эффективности можно решить вводом в систему нового аппаратного обеспечения без существенного влияния на ее архитектуру.
Подтверждение этому мы видим снова и снова. Системы с неэффективной архитектурой могут работать вполне эффективно за счет простого увеличения объема памяти или количества серверов. Из-за дешевизны аппаратного обеспечения и дороговизны человеческих трудозатрат архитектуры, ограничивающие эффективность работы, оказываются менее дорогостоящими, чем архитектуры, усложняющие разработку, развертывание и сопровождение.
Это не означает, что архитектура, нацеленная на эффективную работу системы, нежелательна. Она желательна! Просто уравнение затрат больше ориентируется на разработку, развертывание и сопровождение.
Между тем существует еще одна роль, которую архитектура играет в работе системы: хорошая архитектура способствует высокой эффективности.
Можно даже сказать, что хорошая архитектура делает работу системы понятной разработчикам. Архитектура должна отражать особенности работы. Архитектура должна возводить варианты использования, особенности и ожидаемые реакции системы до уровня полноправных сущностей, которые послужат видимыми ориентирами для разработчиков. Это упростит понимание системы и, соответственно, окажет значительную помощь в разработке и сопровождении.
Сопровождение
Из всех аспектов программной системы сопровождение является самым дорогостоящим. Бесконечный парад новых особенностей и неизбежная череда дефектов и исправлений требуют огромных человеческих трудозатрат.
Основная стоимость сопровождения складывается из стоимости исследования и рисков. Затраты на исследование обусловлены необходимостью поиска мест в существующем программном обеспечении и стратегий для добавления новых особенностей или устранения дефектов. При внесении таких изменений всегда существует вероятность что-то испортить, что увеличивает стоимость рисков.
Продуманная архитектура значительно снижает эти затраты. Разделив систему на компоненты и изолировав их за устойчивыми интерфейсами, можно осветить пути к будущим особенностям и существенно уменьшить риск непреднамеренных ошибок.
Сохранение разнообразия вариантов
Как говорилось в главе 2, всякая программная система имеет две ценности: поведение и структуру. Вторая из них больше, потому что именно она делает программное обеспечение гибким.