Чистая архитектура. Искусство разработки программного обеспечения Мартин Роберт
Такие замены осуществляются не всегда просто. Если первоначально система опиралась на веб-интерфейс, создание плагина для интерфейса клиент/сервер может оказаться сложной задачей. Вполне вероятно, что придется переделать какие-то взаимодействия между бизнес-правилами и новым пользовательским интерфейсом. И все же, допустив существование архитектуры со сменными модулями (плагинами), мы сделали подобную замену практически возможной.
Аргумент в пользу плагинов
Рассмотрим отношения между ReSharper и Visual Studio. Эти компоненты производятся совершенно разными коллективами разработчиков в совершенно разных компаниях. И действительно, компания JetBrains, создатель ReSharper, находится в России. Компания Microsoft, конечно, находится в Редмонде, штат Вашингтон, США. Трудно представить себе более разные команды разработчиков.
Какая команда может помешать другой? Какая команда защищена от влияния другой? Структура зависимости четко отвечает на оба вопроса (рис. 17.6). Исходный код ReSharper зависит от исходного кода Visual Studio. То есть команда ReSharper никак не сможет помешать команде Visual Studio. Но команда Visual Studio может полностью заблокировать команду ReSharper, если пожелает.
Это крайне асимметричное отношение, и именно его желательно воспроизводить в своих системах. Некоторые модули должны иметь абсолютную
Рис. 17.6. ReSharper зависит от Visual Studio
защиту от влияния других. Например, работа бизнес-правил не должна нарушаться из-за изменения формата веб-страницы или схемы базы данных. Изменения в одной части системы не должны нарушать работу не связанных с ней других частей системы. Наши системы не должны быть настолько хрупкими.
Архитектура плагинов в наших системах создает защитные барьеры, препятствующие распространению изменений. Если графический интерфейс подключается к бизнес-правилам, изменения в графическом интерфейсе не смогут повлиять на бизнес-правила.
Границы проводятся там, где есть ось изменения. Компоненты по разным сторонам границы изменяются с разными скоростями и по разным причинам.
Графические интерфейсы изменяются в иное время и с иной скоростью, чем бизнес-правила, поэтому их должна разделять граница. Бизнес-правила изменяются в иное время и по иным причинам, чем фреймворки внедрения зависимостей, поэтому их должна разделять граница.
Это снова простой принцип единственной ответственности, подсказывающий, где провести границы.
Заключение
Прежде чем провести линии границ в архитектуре программного обеспечения, систему нужно разделить на компоненты. Некоторые из этих компонентов реализуют основные бизнес-правила; другие являются плагинами, содержащими функции, которые не имеют прямой связи с бизнес-правилами. Затем можно организовать код в компонентах так, чтобы стрелки между ними указывали в одном направлении — в сторону бизнес-правил.
В этом без труда можно заметить принципы инверсии зависимостей (Dependency Inversion Principle) и устойчивости абстракций (Stable Abstractions Principle). Стрелки зависимостей направлены от низкоуровневых деталей в сторону высокоуровневых абстракций.
Глава 18. Анатомия границ
Архитектура системы определяется множеством программных компонентов и границами, разделяющими их, которые могут принимать самые разные формы. В этой главе мы рассмотрим некоторые наиболее типичные из них.
Пересечение границ
Пересечение границы во время выполнения — это не что иное, как вызов функции из другой функции, находящейся по другую сторону границы и передающей некоторые данные. Вся хитрость создания подобающих пересечений границ заключается в управлении зависимостями на уровне исходного кода.
Почему исходного кода? Потому что, когда изменяется один модуль с исходным кодом, может потребоваться изменить или перекомпилировать другие модули и затем повторно развернуть их. Создание и управление барьерами, защищающими от таких изменений, — вот главная цель проведения границ.
Ужасный монолит
Наиболее простые и типичные архитектурные границы не имеют явного физического представления. Это просто организационное разделение функций и данных, действующих и находящихся в одном процессе, в общем адресном пространстве. В предыдущей главе я назвал это режимом разделения на уровне исходного кода.
С точки зрения развертывания это единый выполняемый файл — так называемый монолит. Этот файл может быть статически скомпонованным проектом на C или C++, множеством файлов классов Java, объединенных в выполняемый jar-файл, множеством двоичных ресурсов .NET, объединенных в один выполняемый файл .EXE, и т.д.
Невидимость границ на этапе развертывания монолита не означает, что они отсутствуют или не играют значительной роли. Даже когда элементы программы статически компонуются в один выполняемый файл, возможность независимой разработки разных компонентов очень ценна для окончательной сборки.
Такие архитектуры почти всегда зависят от некоторой разновидности динамического полиморфизма[38], используемой для управления внутренними зависимостями. Это одна из причин, почему объектно-ориентированная парадигма приобрела такую важность в последние десятилетия. В отсутствие объектно-ориентированной или эквивалентной формы полиморфизма архитекторы вынуждены возвращаться к порочной практике использования указателей на функции, чтобы добиться требуемого разделения. Большинство архитекторов считают, что использование указателей для ссылки на функции слишком рискованно, и они вынужденно отказываются от любых видов разделения на компоненты.
Простейшим пересечением границы является вызов низкоуровневым клиентом функции в высокоуровневой службе. Обе зависимости — времени выполнения и времени компиляции — указывают в одном направлении, в сторону высокоуровневого компонента.
На рис. 18.1 изображен поток управления, пересекающий границу слева направо. Компонент Client вызывает функцию f(), находящуюся в компоненте Service. Выполняя вызов, он передает экземпляр данных Data. Метка <DS> просто сообщает, что это структура данных (Data Structure). Структура Data может передаваться как аргумент функции или каким-то иным, более сложным способом. Обратите внимание, что определение структуры Data находится на вызываемой стороне.
Рис. 18.1. Поток управления пересекает границу в направлении от нижнего уровня к верхнему
Когда требуется организовать вызов низкоуровневой службы из высокоуровневого клиента, для обращения зависимости потока управления используется динамический полиморфизм. Зависимость времени выполнения в этом случае имеет направление, противоположное зависимости времени компиляции.
На рис. 18.2 изображен поток управления, пересекающий границу слева направо, как и прежде. Высокоуровневый компонент Client вызывает функцию f(), находящуюся в низкоуровневом компоненте ServiceImpl, посредством интерфейса Service. Но обратите внимание, что все зависимости пересекают границу в направлении справа налево и указывают в сторону высокоуровневого компонента. Отметьте также, что теперь определение структуры данных находится на вызывающей стороне.
Рис. 18.2. Пересечение границы в направлении, противоположном потоку управления
Даже в монолитном, статически скомпонованном выполняемом файле такой вид организационного разделения значительно помогает в разработке, тестировании и развертывании проекта. Команды разработчиков могут трудиться независимо друг от друга и не наступая друг другу на пятки. Высокоуровневые компоненты остаются независимыми от низкоуровневых деталей.
Взаимодействия между компонентами в монолите протекают быстро и эффективно. Обычно они сводятся к простым вызовам функций. Как следствие, взаимодействия через границы, проведенные на уровне исходного кода, могут быть очень обширными.
Поскольку для развертывания монолита обычно требуется выполнить компиляцию и статическую компоновку, компоненты в таких системах часто поставляются в исходном коде.
Компоненты развертывания
Простейшим примером физического представления архитектурной границы может служить динамическая библиотека DLL в .Net, jar-файл в Java, gem-файл в Ruby или разделяемая библиотека (.so) в UNIX. Развертывание в этом случае не связано с компиляцией — компоненты поставляются в двоичном виде или в иной форме, пригодной к непосредственному развертыванию. Это режим разделения на уровне развертывания. Акт развертывания заключается в простой сборке единиц развертывания в удобную форму, например WAR-файл, или даже в обычном копировании файлов в каталог.
Кроме этого единственного исключения, компоненты уровня развертывания остаются теми же, что и в монолите. Все функции обычно действуют в одном процессе и в общем адресном пространстве. Стратегии разделения компонентов и управления их зависимостями не меняются[39].
Так же как в монолите, взаимодействия между границами развертываемых компонентов часто осуществляются посредством вызовов функций и, соответственно, обходятся очень дешево. Динамическая компоновка или загрузка во время выполнения могут однократно увеличивать потребление вычислительных ресурсов, но сами взаимодействия через границы все еще могут быть очень обширными.
Потоки выполнения
Монолиты и компоненты развертывания могут выполняться в многопоточном режиме. Потоки выполнения не являются архитектурными границами или единицами развертывания, это способ организации планирования и выполнения задач. Они могут вообще не выходить за рамки компонента или охватывать сразу несколько компонентов.
Локальные процессы
Локальные процессы представляют более надежные физические архитектурные границы. Обычно локальный процесс запускается из командной строки или с помощью эквивалентного системного вызова. Локальные процессы выполняются на одном процессоре или группе процессоров (в многопроцессорной системе), но в разных адресных пространствах. Механизмы защиты памяти обычно не позволяют таким процессам совместно использовать одну и ту же область памяти, хотя для взаимодействий нередко используются сегменты разделяемой памяти.
Чаще всего локальные процессы взаимодействуют друг с другом посредством сокетов или других средств связи, поддерживаемых операционной системой, таких как почтовые ящики или очереди сообщений.
Каждый локальный процесс может быть статически скомпонованным монолитом или динамически компонуемой группой компонентов развертывания. В первом случае несколько монолитных процессов могут содержать одни и те же компоненты, скомпилированные и скомпонованные в них. Во втором — они могут совместно использовать динамически составляемые компоненты развертывания.
Локальный процесс можно считать своеобразным суперкомпонентом: процесс состоит из низкоуровневых компонентов и управляет их зависимостями с помощью динамического полиморфизма.
Стратегия разделения между локальными процессами остается той же, что для монолитов и двоичных компонентов. Зависимости в исходном коде направлены в одну сторону через границу и всегда в сторону высокоуровневого компонента.
Для локальных процессов это означает, что исходный код высокоуровневых процессов не должен содержать имен, физических адресов или ключей в реестре, соответствующих низкоуровневым процессам. Не забывайте, что главная архитектурная цель — сделать низкоуровневые процессы плагинами (сменными модулями) для высокоуровневых процессов.
Взаимодействия через границы локальных процессов связаны с обращением к системным вызовам, маршалингом и декодированием данных, а также переключением контекстов и обходятся умеренно дорого. Количество таких взаимодействий следует тщательно ограничивать.
Службы
Самыми надежными являются границы служб. Часто служба — это процесс, который запускается из командной строки или с помощью эквивалентного системного вызова. Службы не зависят от физического местоположения. Две взаимодействующие службы могут или не могут действовать на одном процессоре или группе процессоров в многопроцессорной системе. Службы предполагают, что все взаимодействия осуществляются по сети.
Взаимодействия через границы служб осуществляются очень медленно в сравнении с вызовами функций. Время между запросом и ответом может составлять от десятков миллисекунд до нескольких секунд. Взаимодействия следует ограничивать по мере возможностей. Взаимодействия на этом уровне должны учитывать возможность больших задержек.
В остальном к службам применяются те же правила, что и к локальным процессам. Низкоуровневые службы должны «подключаться» к высокоуровневым службам. Исходный код высокоуровневых служб не должен содержать никакой конкретной информации (например, URI) о низкоуровневых службах.
Заключение
Большинство систем, кроме монолитных, используют несколько стратегий разграничения. Система, состоящая из служб, может также иметь несколько локальных процессов. В действительности служба часто является всего лишь фасадом для нескольких взаимодействующих локальных процессов. Служба или локальный процесс чаще имеют форму монолита, составленного из исходных кодов компонентов, или группы динамически подключаемых компонентов развертывания.
Это означает, что границы в системе часто будут представлены смесью локальных границ (недорогих в пересечении) и границ, страдающих задержками.
Глава 19. Политика и уровень
Программные системы — это заявления о направлении действий. Фактически любая компьютерная программа является таким заявлением. Компьютерная программа — это подробное описание политики преобразования входных данных в выходные.
В большинстве нетривиальных систем общую политику можно разбить на множество более мелких заявлений. Некоторые из этих заявлений могут описывать действие конкретных бизнес-правил. Другие могут определять оформление отчетов. А третьи — описывать порядок проверки входных данных.
Отчасти искусство создания программных архитектур заключается в отделении этих политик друг от друга и их перегруппировке с учетом способов их изменения. Политики, изменяющиеся по одинаковым причинам и в одно время, находятся на одном уровне и принадлежат одному компоненту. Политики, изменяющиеся по разным причинам или в разное время, находятся на разных уровнях и должны помещаться в разные компоненты.
Искусство создания программных архитектур нередко связано с организацией перегруппированных компонентов в ориентированный ациклический граф. Узлами такого графа являются компоненты, содержащие политики одного уровня. А ориентированными ребрами — зависимости между компонентами. Они соединяют компоненты, находящиеся на разных уровнях.
К ним относятся зависимости на уровне исходного кода или времени компиляции. В Java они выражаются инструкциями import. В C# — инструкциями using. В Ruby — инструкциями require. Все эти зависимости необходимы компилятору для выполнения своей работы.
В хорошей архитектуре направление этих зависимостей обусловлено уровнем компонентов, которые они соединяют. В любом случае низкоуровневые компоненты проектируются так, чтобы они зависели от высокоуровневых компонентов.
Уровень
Термин «уровень» имеет строгое определение: «удаленность от ввода и вывода». Чем дальше политика от ввода и вывода, тем выше ее уровень. Политики, управляющие вводом и выводом, являются самыми низкоуровневыми в системе.
Диаграмма потоков данных на рис. 19.1 соответствует простой программе шифрования, которая читает символы из устройства ввода, преобразует их с использованием таблицы и записывает преобразованные символы в устройство вывода. Направления потоков данных показаны на диаграмме извилистыми сплошными стрелками. Правильно спроектированные зависимости в исходном коде показаны пунктирными стрелками.
Рис. 19.1. Простая программа шифрования
Компонент, выполняющий преобразование, — это самый высокоуровневый компонент в данной системе, потому что он дальше других находится от ввода и вывода[40].
Обратите внимание, что потоки данных и зависимости в исходном коде не всегда указывают в одном направлении. Это тоже один из элементов искусства создания программных архитектур. Мы должны отделить зависимости в исходном коде от потоков данных и связать с уровнем.
Мы легко могли бы создать неправильную архитектуру, реализовав программу так:
function encrypt() {
while(true)
writeChar(translate(readChar()));
}
Это неправильная архитектура, потому что высокоуровневая функция encrypt зависит от низкоуровневых функций readChar и writeChar.
Более удачная архитектура для этой системы изображена на рис. 19.2. Обратите внимание на пунктирную границу, окружающую класс Encrypt, и интерфейсы CharWriter и CharReader. Все зависимости, пересекающие границу, указывают внутрь. Этот модуль является элементом высшего уровня в системе.
Рис. 19.2. Диаграмма классов, демонстрирующая более удачную архитектуру для системы
На диаграмме также изображены классы ConsoleReader и ConsoleWriter. Они находятся на более низком уровне, потому что расположены ближе к вводу и выводу.
Обратите внимание, как эта структура отделяет высокоуровневую политику шифрования от низкоуровневых политик ввода/вывода. Это позволяет использовать политику шифрования в широком диапазоне контекстов. Когда в политиках ввода и вывода происходят изменения, они никак не затрагивают политику шифрования.
Помните, что политики группируются в компоненты по способам изменения. Политики, изменяющиеся по одним причинам и в одно время, объединяются в соответствии с принципами единственной ответственности (SRP) и согласованного изменения (CCP). Чем дальше политика от ввода и вывода, тем выше ее уровень и тем реже она изменяется и по более важным причинам. Чем ближе политика к вводу и выводу, тем ниже ее уровень и тем чаще она изменяется и по более неотложным, но менее важным причинам.
Например, даже в тривиальной программе шифрования вероятность изменения устройств ввода/вывода намного выше, чем вероятность изменения алгоритма шифрования. Изменение алгоритма шифрования наверняка будет обусловлено более серьезной причиной, чем изменение устройств ввода/вывода.
Отделение политик друг от друга и организация зависимостей в исходном коде так, что все они направлены в сторону политик более высокого уровня, уменьшает влияние изменений. Тривиальные, но срочные изменения на более низких уровнях системы не влияют или слабо влияют на более высокие уровни.
На эту проблему можно взглянуть с другой стороны, если вспомнить, что низкоуровневые компоненты должны быть плагинами для высокоуровневых компонентов. Этот взгляд демонстрирует диаграмма компонентов на рис. 19.3. Компонент Encryption ничего не знает о компоненте IODevices; а компонент IODevices зависит от компонента Encryption.
Рис. 19.3. Низкоуровневые компоненты должны быть плагинами для высокоуровневых компонентов
Заключение
На этом этапе в обсуждение политик были вовлечены принципы единственной ответственности (SRP), открытости/закрытости (OCP), согласованного изменения (CCP), инверсии зависимостей (DIP), устойчивых зависимостей (SDP) и устойчивости абстракций (SAP). А теперь вернитесь назад и посмотрите, сможете ли вы определить, где каждый из принципов используется и почему.
Глава 20. Бизнес-правила
Прежде чем пытаться делить приложение на бизнес-правила и плагины, необходимо понять, какие бизнес-правила существуют. Как оказывается, их несколько видов.
Строго говоря, бизнес-правила — это правила или процедуры, делающие или экономящие деньги. Еще строже говоря, бизнес-правила — это правила, делающие или экономящие деньги независимо от наличия или отсутствия их реализации на компьютере. Они делают или экономят деньги, даже когда выполняются вручную.
Банк взимает N% за кредит — это бизнес-правило, которое приносит банку деньги. И неважно, имеется ли компьютерная программа, вычисляющая процент, или служащий вычисляет его на счетах.
Мы будем называть такие правила критическими бизнес-правилами, потому что они имеют решающее значение для бизнеса и будут существовать даже в отсутствие системы, автоматизирующей их.
Критические бизнес-правила обычно требуют каких-то данных для работы. Например, в случае с кредитом нужно иметь сумму остатка, процентную ставку и график платежей.
Мы будем называть такие данные критическими бизнес-данными. Эти данные существуют даже в отсутствие системы автоматизации.
Критические правила и критические данные неразрывно связаны друг с другом, поэтому являются отличными кандидатами на объединение в объект. Мы будем называть такие объекты сущностями[41].
Сущности
Сущность — это объект в компьютерной системе, воплощающий небольшой набор критических бизнес-правил, оперирующих критическими бизнес-данными. Объект-сущность или содержит критические бизнес-правила в себе, или имеет простой доступ к ним. Интерфейс сущности состоит из функций, реализующих критические бизнес-правила и оперирующих этими данными.
Например, на рис. 20.1 показано, как могла бы выглядеть сущность Loan (представляющая банковский кредит) в виде класса на диаграмме UML. Она включает три фрагмента взаимосвязанных критических бизнес-данных и реализует интерфейс с тремя взаимосвязанными критическими бизнес-правилами.
Рис. 20.1. Сущность Loan в виде класса на диаграмме UML
Создавая такой класс, мы объединяем программную реализацию идеи, имеющей решающее значение для бизнеса, и отделяем ее от остальных задач в создаваемой нами системе автоматизации. Этот класс играет роль представителя бизнеса. Он не зависит от выбора базы данных, пользовательского интерфейса или сторонних фреймворков. Он может служить целям бизнеса в любой системе, независимо от того, какой пользовательский интерфейс она имеет, как хранит данные или как организованы компьютеры в этой системе. Сущность — это бизнес в чистом виде и больше ничего.
Кто-то из вас, возможно, забеспокоился, когда я назвал сущность классом. Не волнуйтесь. Чтобы создать сущность, не требуется использовать объектно-ориентированный язык. Необходимо лишь связать воедино критические бизнес-данные с критическими бизнес-правилами и выделить их в отдельный программный модуль.
Варианты использования
Не все бизнес-правила так же чисты, как сущности. Некоторые бизнес-правила делают или экономят деньги, определяя и ограничивая деятельность автоматизированной системы. Эти правила не могут выполняться вручную, потому что имеют смысл только как часть автоматизированной системы.
Например, представьте приложение, используемое служащими банка для оформления нового кредита. Банк может решить, что сотрудники, оформляющие кредиты, не должны предлагать график погашения кредита, пока не соберут и не проверят контактную информацию и не убедятся, что кандидат имеет кредитный балл 500 или выше. По этой причине банк может потребовать, чтобы система не отображала форму с графиком платежей, пока не будет заполнена и проверена форма с контактной информацией и не придет подтверждение, что кредитный балл клиента выше требуемого порога.
Это вариант использования[42]. Вариант использования описывает способ использования автоматизированной системы. Он определяет, что должен ввести пользователь, что должно быть выведено в ответ и какие действия должны быть выполнены для получения выводимой информации. В отличие от критических бизнес-правил внутри сущностей, вариант использования описывает бизнес-правила, характерные для конкретного приложения.
На рис. 20.2 изображен пример варианта использования. Обратите внимание, что в последней строке упоминается Клиент. Это ссылка на сущность Клиент, содержащую критические бизнес-правила, регулирующие отношения между банком и клиентами.
Рис. 20.2. Пример варианта использования
Варианты использования определяют, как и когда вызываются критические бизнес-правила в сущности. Варианты использования управляют действиями сущности.
Отмечу также, что варианты использования не описывают пользовательский интерфейс, они лишь неформально определяют входные и выходные данные, поступающие и возвращаемые через интерфейс. По вариантам использования нельзя определить, является ли данная система веб-приложением, толстым клиентом, утилитой командной строки или чистой службой.
Это очень важно. Варианты использования не описывают, как выглядит система для пользователя. Они описывают только конкретные правила работы приложения, определяющие порядок взаимодействий между пользователями и сущностями. Для вариантов использования абсолютно неважно, как система осуществляет ввод/вывод данных.
Вариант использования — это объект. Он имеет одну или несколько функций, реализующих конкретные прикладные бизнес-правила. Он также имеет элементы данных, включая входные данные, выходные данные и ссылки на соответствующие сущности, с которыми он взаимодействует.
Сущности не знают ничего о вариантах использования, контролирующих их. Это еще один пример ориентации зависимостей в соответствии с принципом инверсии зависимостей (Dependency Inversion Principle). Высокоуровневые элементы, такие как сущности, ничего не знают о низкоуровневых элементах, таких как варианты использования. Напротив, низкоуровневые варианты использования знают все о высокоуровневых сущностях.
Почему сущности относятся к высокому уровню, а варианты использования к низкому? Потому что варианты использования характерны для единственного приложения и, соответственно, ближе к вводу и выводу системы. Сущности — это обобщения, которые можно использовать в множестве разных приложений, соответственно, они дальше от ввода и вывода системы. Варианты использования зависят от сущностей; сущности не зависят от вариантов использования.
Модели запросов и ответов
Варианты использования принимают входные данные и возвращают результат. Однако в правильно сформированном объекте варианта использования ничто не должно говорить о способах передачи данных пользователю или другим компонентам. И тем более код в классе, реализующем вариант использования, ничего не должен знать об HTML или SQL!
Класс варианта использования принимает простые структуры данных на входе и возвращает простые структуры данных на выходе. Эти структуры данных ни от чего не зависят. Они не связаны со стандартными интерфейсами фреймворков, такими как HttpRequest и HttpResponse. Они ничего не знают о Веб и не используют никакие атрибуты пользовательского интерфейса.
Такое отсутствие зависимостей имеет решающее значение. Если модели запросов и ответов зависят от чего-то, варианты использования, зависящие от них, оказываются косвенно связанными с зависимостями, которые вносят эти модели.
У вас может возникнуть соблазн включить в эти структуры ссылки на объекты сущностей. На первый взгляд кажется, что в этом есть определенный смысл, потому что сущности и модели запросов/ответов совместно используют так много данных. Боритесь с этим искушением! Эти два объекта имеют слишком разные цели. Со временем они будут меняться по разным причинам, поэтому создание связи между ними нарушит принципы согласованного изменения (CCP) и единственной ответственности (SRP). В результате у вас появится множество кочующих данных и масса условных инструкций в коде.
Заключение
Бизнес-правила являются причиной существования программной системы. Они составляют основу функционирования. Они порождают код, который делает или экономит деньги. Они — наши семейные реликвии.
Бизнес-правила должны оставаться в неприкосновенности, незапятнанными низкоуровневыми аспектами, такими как пользовательский интерфейс или база данных. В идеале код, представляющий бизнес-правила, должен быть сердцем системы, а другие задачи — просто подключаться к ним. Реализация бизнес-правил должна быть самым независимым кодом в системе, готовым к многократному использованию.
Глава 21. Кричащая архитектура
Представьте, что перед вами чертежи здания. Они подготовлены архитектором и представляют собой планы здания. О чем они вам говорят?
Если это чертежи коттеджа, вы наверняка увидите на них парадный вход, вестибюль, ведущий в гостиную и, может быть, в столовую. Рядом со столовой, скорее всего, будет расположена кухня. Рядом с кухней, вероятно, будет малая столовая и где-то поблизости — общая комната. При рассмотрении этих планов у вас не возникнет сомнений, что вы видите коттедж для одной семьи. Архитектура как бы кричит: «Это коттедж».
А теперь представьте, что вы рассматриваете архитектуру библиотеки. Вы наверняка увидите центральный вход, места для размещения библиотекарей, читальные залы, небольшие конференц-залы и галереи для хранения книг. Архитектура кричит: «Это библиотека».
А что кричит архитектура вашего приложения? Увидев высокоуровневую структуру каталогов и пакетов с исходным кодом, услышите ли вы, как она кричит: «Это медицинская система», или «Это система учета», или «Это система управления складским хозяйством»? Или вы услышите: «Rails», или «Spring/Hibernate», или «ASP»?
Тема архитектуры
Отвлекитесь и прочитайте фундаментальный труд Ивара Якобсона об архитектуре программного обеспечения: книгу Object Oriented Software Engineering[43]. Обратите внимание на подзаголовок книги: A Use Case Driven Approach[44]. В этой книге Якобсон подчеркивает, что архитектура программного обеспечения — это структура, поддерживающая варианты использования системы. В точности как план библиотеки или коттеджа кричит о назначении здания, архитектура программного обеспечения должна кричать о вариантах использования приложения.
Архитектуры не связаны (и не должны быть связаны) с фреймворками. Архитектура не должна определяться фреймворками. Фреймворки — это инструменты, а вовсе не аспекты, определяющие черты архитектуры. Если ваша архитектура опирается на фреймворки, она не сможет опираться на варианты использования.
Цель архитектуры
Хорошие архитектуры опираются на варианты использования и помогают архитекторам описывать структуры, поддерживающие эти варианты использования, не связывая себя фреймворками, инструментами и окружениями. Взгляните на план коттеджа еще раз. Одной из первоочередных забот архитектора является удобная планировка дома, а не выбор материала для стен. Действительно, архитектор прилагает максимум усилий, чтобы дать домовладельцу возможность самому выбрать материал (кирпич, камень или дерево), потом, когда планы, соответствующие вариантам использования, уже будут начерчены.
Хорошая архитектура позволяет отложить решение о выборе фреймворков, баз данных, веб-серверов и других инструментов и элементов окружения. Фреймворки относятся к возможностям, которые должны оставаться открытыми. Хорошая архитектура позволяет отложить выбор Rails, Spring, Hibernate, Tomcat или MySQL на отдаленный срок. Хорошая архитектура позволяет с легкостью менять подобные решения. Хорошая архитектура подчеркивает варианты использования и отделяет их от второстепенных проблем.
А что насчет Веб?
Веб — это архитектура? Зависит ли архитектура системы от того факта, что свои услуги она предоставляет через Веб? Конечно нет! Всемирная паутина (или Веб) — это механизм доставки, устройство ввода/вывода. И архитектура вашего приложения должна интерпретировать его именно так, а не иначе. Факт предоставления услуг через Веб — это деталь, и она не должна определять структуру системы. В действительности решение о предоставлении услуг через Веб относится к разряду решений, которые должны откладываться. Архитектура системы должна быть максимально нейтральной к механизмам доставки услуг. У вас должна иметься возможность реализовать систему в форме консольного приложения, веб-приложения, толстого клиента или даже веб-службы без чрезмерного усложнения или изменения основной архитектуры.
Фреймворки — это инструменты, а не образ жизни
Фреймворки могут быть очень мощными и удобными. Авторы фреймворков часто глубоко убеждены в своих творениях. Примеры, которые они пишут, рассказывают об использовании их фреймворков с точки зрения истинно верующих. Другие авторы, пишущие книги о фреймворках, тоже часто стоят на позициях истинной веры. Они показывают способы использования фреймворков, часто с позиции всеобъемлющего, всепроникающего и повсеместного применения.
Вы не должны вставать на эту позицию.
Рассматривайте фреймворки с холодком. Смотрите на них скептически. Да, они могут помочь, но какой ценой? Спросите себя, как бы вы их использовали и как защитились бы от них. Подумайте, как сохранить приоритет вариантов использования для архитектуры. Разработайте стратегию, не позволяющую фреймворку влиять на архитектуру.
Тестируемые архитектуры
Если архитектура вашей системы основана исключительно на вариантах использования и вам удалось удержать фреймворки на расстоянии, у вас должна иметься возможность протестировать все эти варианты использования без привлечения фреймворков. Вы не должны испытывать потребности в веб-сервере для выполнения тестов. Вы не должны испытывать потребности в подключении к базе данных для выполнения тестов. Ваши сущности должны быть самыми обычными объектами, не зависящими от фреймворков, баз данных или чего-то другого. Ваши объекты вариантов использования должны координировать действия сущностей. Наконец, должна иметься возможность протестировать их вместе без привлечения любых фреймворков.
Заключение
Ваша архитектура должна рассказывать о системе, а не о фреймворках, использованных в системе. Если вы работаете над системой для медицинского учреждения, тогда первый же взгляд на репозиторий с исходным кодом должен вызывать у новых программистов, подключающихся к проекту, мысль: «Да, это медицинская система». Новые программисты должны иметь возможность изучить все возможные варианты использования, даже не зная, как будут доставляться услуги системы. Они могут подойти к вам и сказать:
«Мы видим что-то похожее на модели, но где контроллеры и представления?»
А вы должны ответить:
«Это такая мелочь, которая нас пока не заботит. Мы решим этот вопрос позже».
Глава 22. Чистая архитектура
За последние несколько десятилетий мы видели целый ряд идей об организации архитектур. В том числе:
• Гексагональная архитектура (Hexagonal Architecture, также известна как архитектура портов и адаптеров), разработанная Алистером Кокберном и описанная Стивом Фриманом и Натом Прайсом в их замечательной книге Growing Object Oriented Software with Tests.
• DCI[45], предложенная Джеймсом Коплиеном и Трюгве Реенскаугом.
• BCE[46], предложенная Иваром Якобсоном в книге Object Oriented Software Engineering: A Use-Case Driven Approach.
Несмотря на различия в деталях, все эти архитектуры очень похожи. Они все преследуют одну цель — разделение задач. Они все достигают этой цели путем деления программного обеспечения на уровни. Каждая имеет хотя бы один уровень для бизнес-правил и еще один для пользовательского и системного интерфейсов.
Каждая из этих архитектур способствует созданию систем, обладающих следующими характеристиками:
• Независимость от фреймворков. Архитектура не зависит от наличия какой-либо библиотеки. Это позволяет рассматривать фреймворки как инструменты, вместо того чтобы стараться втиснуть систему в их рамки.
• Простота тестирования. Бизнес-правила можно тестировать без пользовательского интерфейса, базы данных, веб-сервера и любых других внешних элементов.
• Независимость от пользовательского интерфейса. Пользовательский интерфейс можно легко изменять, не затрагивая остальную систему. Например, веб-интерфейс можно заменить консольным интерфейсом, не изменяя бизнес-правил.
• Независимость от базы данных. Вы можете поменять Oracle или SQL Server на Mongo, BigTable, CouchDB или что-то еще. Бизнес-правила не привязаны к базе данных.
• Независимость от любых внешних агентов. Ваши бизнес-правила ничего не знают об интерфейсах, ведущих во внешний мир.
Диаграмма на рис. 22.1 — это попытка объединить все эти архитектуры в одну практически осуществимую идею.
Рис. 22.1. Чистая архитектура
Правило зависимостей
Концентрические круги на рис. 22.1 представляют разные уровни программного обеспечения. Чем ближе к центру, тем выше уровень. Внешние круги — это механизмы. Внутренние — политики.
Главным правилом, приводящим эту архитектуру в действие, является правило зависимостей (Dependency Rule):
Зависимости в исходном коде должны быть направлены внутрь, в сторону высокоуровневых политик.
Ничто во внутреннем круге ничего не знает о внешних кругах. Например, имена, объявленные во внешних кругах, не должны упоминаться в коде, находящемся во внутренних кругах. Сюда относятся функции, классы, переменные и любые другие именованные элементы программы.
Точно так же форматы данных, объявленные во внешних кругах, не должны использоваться во внутренних, особенно если эти форматы генерируются фреймворком во внешнем круге. Ничто во внешнем круге не должно влиять на внутренние круги.
Сущности
Сущности заключают в себе критические бизнес-правила уровня предприятия. Сущность может быть объектом с методами или набором структур данных и функций. Сама организация не важна, если сущности доступны для использования разными приложениями на предприятии.
Если вы пишете только одно приложение и не для предприятия, эти сущности становятся бизнес-объектами приложения. Они инкапсулируют наиболее общие и высокоуровневые правила. Их изменение маловероятно под влиянием внешних изменений. Например, трудно представить, что эти объекты изменятся из-за изменения структуры навигации или безопасности страницы. Никакие изменения в работе любого конкретного приложения не должны влиять на уровень сущностей.
Варианты использования
Программное обеспечение на уровне вариантов использования содержит бизнес-правила, характерные для приложения. Оно инкапсулирует и реализует все варианты использования системы. Варианты использования организуют поток данных в сущности и из них и требуют от этих сущностей использовать их критические бизнес-правила для достижения своих целей.
Изменения внутри этого уровня не должны влиять на сущности. Аналогично изменения во внешних уровнях, например в базе данных, пользовательском интерфейсе или в любом из общих фреймворков, не должны влиять на этот уровень. Уровень вариантов использования изолирован от таких проблем.
Но изменения в работе приложения безусловно повлияют на варианты использования и, соответственно, на программное обеспечение, находящееся на этом уровне. Изменение деталей вариантов использования определенно затронет некоторый код на этом уровне.
Адаптеры интерфейсов
Программное обеспечение на уровне адаптеров интерфейсов — это набор адаптеров, преобразующих данные из формата, наиболее удобного для вариантов использования и сущностей, в формат, наиболее удобный для некоторых внешних агентов, таких как база данных или веб-интерфейс. Именно на этом уровне целиком находится архитектура MVC графического пользовательского интерфейса. Презентаторы, представления и контроллеры — все принадлежат уровню адаптеров интерфейсов. Модели — часто лишь структуры данных, которые передаются из контроллеров в варианты использования и затем обратно из вариантов использования в презентаторы и представления.
Аналогично на этом уровне преобразуются данные из формата, наиболее удобного для вариантов использования и сущностей, в формат, наиболее удобный для инфраструктуры хранения данных (например, базы данных). Никакой код, находящийся в других внутренних кругах, не должен ничего знать о базе данных. Если данные хранятся в базе данных SQL, тогда весь код на языке SQL должен находиться именно на этом уровне, точнее, в элементах этого уровня, связанных с базой данных.
Также в этом уровне находятся любые другие адаптеры, необходимые для преобразования данных из внешнего формата, например полученных от внешней службы, во внутренний, используемый вариантами использования и сущностями.
Фреймворки и драйверы
Самый внешний уровень модели на рис. 22.1 обычно состоит из фреймворков и инструментов, таких как база данных и веб-фреймворк. Как правило, для этого уровня требуется писать не очень много кода, и обычно этот код играет роль связующего звена со следующим внутренним кругом.
На уровне фреймворков и драйверов сосредоточены все детали. Веб-интерфейс — это деталь. База данных — это деталь. Все это мы храним во внешнем круге, где они не смогут причинить большого вреда.
Толька четыре круга?
Круги на рис. 22.1 лишь схематически изображают основную идею: иногда вам может понадобиться больше четырех кругов. Фактически нет никакого правила, утверждающего, что кругов должно быть именно четыре. Но всегда действует правило зависимостей. Зависимости в исходном коде всегда должны быть направлены внутрь. По мере движения внутрь уровень абстракции и политик увеличивается. Самый внешний круг включает низкоуровневые конкретные детали. По мере приближения к центру программное обеспечение становится все более абстрактным и инкапсулирует все более высокоуровневые политики. Самый внутренний круг является самым обобщенным и находится на самом высоком уровне.
Пересечение границ
Справа внизу на рис. 22.1 приводится пример пересечения границ кругов. На нем изображены контроллеры и презентаторы, взаимодействующие с вариантами использования на следующем уровне. Обратите внимание на поток управления: он начинается в контроллере, проходит через вариант использования и завершается в презентаторе. Отметьте также, как направлены зависимости в исходном коде: каждая указывает внутрь, на варианты использования.
Обычно мы разрешаем это кажущееся противоречие с использованием принципа инверсии зависимостей (Dependency Inversion Principle). В таких языках, как Java, например, мы можем определять интерфейсы и использовать механизм наследования, чтобы направление зависимостей в исходном коде было противоположно направлению потока управления на границах справа.
Допустим, что вариант использования должен вызвать презентатора. Такой вызов нельзя выполнить непосредственно, потому что иначе нарушится правило зависимостей: никакие имена, объявленные во внешних кругах, не должны упоминаться во внутренних. Поэтому вариант использования должен вызвать интерфейс (на рис. 22.1 подписан как «Порт вывода варианта использования»), объявленный во внутреннем круге, а презентатор во внешнем круге должен реализовать его.
Тот же прием используется для всех пересечений границ в архитектуре. Мы используем преимущество динамического полиморфизма, чтобы обратить зависимости в исходном коде в направлении, противоположном потоку управления, и тем самым соблюсти правило зависимостей при любом направлении потока управления.
Какие данные пересекают границы
Обычно через границы данные передаются в виде простых структур. При желании можно использовать простейшие структуры или объекты передачи данных (Data Transfer Objects; DTO). Данные можно также передавать в вызовы функций через аргументы. Или упаковывать их в ассоциативные массивы или объекты. Важно, чтобы через границы передавались простые, изолированные структуры данных. Не нужно хитрить и передавать объекты сущностей или записи из базы данных. Структуры данных не должны нарушать правило зависимостей.
Например, многие фреймворки для работы с базами данных возвращают ответы на запросы в удобном формате. Их можно назвать «представлением записей». Такие представления записей не должны передаваться через границы внутрь. Это нарушает правило зависимостей, потому что заставляет внутренний круг знать что-то о внешнем круге.
Итак, при передаче через границу данные всегда должны принимать форму, наиболее удобную для внутреннего круга.
Типичный сценарий
Диаграмма на рис. 22.2 показывает типичный сценарий работы веб-системы на Java, использующей базу данных. Веб-сервер принимает исходные данные от пользователя и передает их контроллеру Controller в левом верхнем
Рис. 22.2. Типичный сценарий работы веб-системы на Java, использующей базу данных
углу. Контроллер упаковывает данные в простой Java-объект InputData и передает его через интерфейс InputBoundary классу UseCaseInteractor. Класс UseCaseInteractor интерпретирует данные и использует их для управления действиями сущностей Entities. Он также переносит данные из базы данных Database в память сущностей Entities через интерфейс DataAccessInterface. По завершении UseCaseInteractor забирает данные из сущностей Entities и конструирует из них другой простой Java-объект OutputData. Затем объект OutputData передается через интерфейс OutputBoundary презентатору Presenter.
Презентатор Presenter переупаковывает данные из объекта OutputData в объект ViewModel, еще один простой Java-объект, содержащий в основном строки и флаги, используемые представлением View для отображения данных. Объект OutputData мог бы содержать объекты типа Date, но Presenter преобразует их в процессе формирования ViewModel в строковые значения, пригодные для отображения. То же произойдет с объектами типа Currency и любыми другими бизнес-данными. Имена кнопок Button и пунктов меню MenuItem помещаются в ViewModel вместе с флагами, сообщающими представлению View, следует ли отобразить эти кнопки и пункты меню в неактивном состоянии.
В результате представлению View практически ничего не приходится делать, кроме как перенести данные из ViewModel в HTML-страницу.
Обратите внимание на направления зависимостей. Все они пересекают линии границ только в направлении внутрь, следуя правилу зависимостей.
Заключение
Следование этим простым правилам не требует больших усилий и поможет сохранить душевный покой в будущем. Разделив программное обеспечение на уровни и соблюдая правило зависимостей, вы создадите систему, которую легко протестировать — со всеми вытекающими из этого преимуществами. Когда какой-либо из внешних элементов системы, например база данных или веб-фреймворк, устареет, вы сможете без всякой суеты заменить его.
Глава 23. Презентаторы и скромные объекты
В главе 22 мы познакомились с понятием презентатора (presenter). Презентаторы являются разновидностью шаблона проектирования «Скромный объект» (Humble Object), помогающего выявлять и защищать архитектурные границы. Фактически чистая архитектура в предыдущей главе полна реализациями шаблона «Скромный объект».
Шаблон «Скромный объект»
Шаблон проектирования «Скромный объект»[47] первоначально предлагался для использования в модульном тестировании как способ отделения поведения, легко поддающегося тестированию, от поведения, с трудом поддающегося тестированию. Идея очень проста: разделить поведение на два модуля или класса. Один из модулей, который называется «скромным», содержит все, что с трудом поддается тестированию, в виде, упрощенном до предела. Второй — все, что было выброшено из «скромного» модуля.