Архитектура системы определяется множеством программных компонентов и границами, разделяющими их, которые могут принимать самые разные формы. В этой главе мы рассмотрим некоторые наиболее типичные из них.
Пересечение границы во время выполнения — это не что иное, как вызов функции из другой функции, находящейся по другую сторону границы и передающей некоторые данные. Вся хитрость создания подобающих пересечений границ заключается в управлении зависимостями на уровне исходного кода.
Почему исходного кода? Потому что, когда изменяется один модуль с исходным кодом, может потребоваться изменить или перекомпилировать другие модули и затем повторно развернуть их. Создание и управление барьерами, защищающими от таких изменений, — вот главная цель проведения границ.
Наиболее простые и типичные архитектурные границы не имеют явного физического представления. Это просто организационное разделение функций и данных, действующих и находящихся в одном процессе, в общем адресном пространстве. В предыдущей главе я назвал это режимом разделения на уровне исходного кода.
С точки зрения развертывания это единый выполняемый файл — так называемый монолит. Этот файл может быть статически скомпонованным проектом на C или C++, множеством файлов классов Java, объединенных в выполняемый jar-файл, множеством двоичных ресурсов .NET, объединенных в один выполняемый файл .EXE, и т.д.
Невидимость границ на этапе развертывания монолита не означает, что они отсутствуют или не играют значительной роли. Даже когда элементы программы статически компонуются в один выполняемый файл, возможность независимой разработки разных компонентов очень ценна для окончательной сборки.
Такие архитектуры почти всегда зависят от некоторой разновидности динамического полиморфизма, используемой для управления внутренними зависимостями. Это одна из причин, почему объектно-ориентированная парадигма приобрела такую важность в последние десятилетия. В отсутствие объектно-ориентированной или эквивалентной формы полиморфизма архитекторы вынуждены возвращаться к порочной практике использования указателей на функции, чтобы добиться требуемого разделения. Большинство архитекторов считают, что использование указателей для ссылки на функции слишком рискованно, и они вынужденно отказываются от любых видов разделения на компоненты.
Простейшим пересечением границы является вызов низкоуровневым клиентом функции в высокоуровневой службе. Обе зависимости — времени выполнения и времени компиляции — указывают в одном направлении, в сторону высокоуровневого компонента.
На рис. 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-файл, или даже в обычном копировании файлов в каталог.
Кроме этого единственного исключения, компоненты уровня развертывания остаются теми же, что и в монолите. Все функции обычно действуют в одном процессе и в общем адресном пространстве. Стратегии разделения компонентов и управления их зависимостями не меняются.
Так же как в монолите, взаимодействия между границами развертываемых компонентов часто осуществляются посредством вызовов функций и, соответственно, обходятся очень дешево. Динамическая компоновка или загрузка во время выполнения могут однократно увеличивать потребление вычислительных ресурсов, но сами взаимодействия через границы все еще могут быть очень обширными.
Монолиты и компоненты развертывания могут выполняться в многопоточном режиме. Потоки выполнения не являются архитектурными границами или единицами развертывания, это способ организации планирования и выполнения задач. Они могут вообще не выходить за рамки компонента или охватывать сразу несколько компонентов.
Локальные процессы представляют более надежные физические архитектурные границы. Обычно локальный процесс запускается из командной строки или с помощью эквивалентного системного вызова. Локальные процессы выполняются на одном процессоре или группе процессоров (в многопроцессорной системе), но в разных адресных пространствах. Механизмы защиты памяти обычно не позволяют таким процессам совместно использовать одну и ту же область памяти, хотя для взаимодействий нередко используются сегменты разделяемой памяти.
Чаще всего локальные процессы взаимодействуют друг с другом посредством сокетов или других средств связи, поддерживаемых операционной системой, таких как почтовые ящики или очереди сообщений.
Каждый локальный процесс может быть статически скомпонованным монолитом или динамически компонуемой группой компонентов развертывания. В первом случае несколько монолитных процессов могут содержать одни и те же компоненты, скомпилированные и скомпонованные в них. Во втором — они могут совместно использовать динамически составляемые компоненты развертывания.
Локальный процесс можно считать своеобразным суперкомпонентом: процесс состоит из низкоуровневых компонентов и управляет их зависимостями с помощью динамического полиморфизма.
Стратегия разделения между локальными процессами остается той же, что для монолитов и двоичных компонентов. Зависимости в исходном коде направлены в одну сторону через границу и всегда в сторону высокоуровневого компонента.
Для локальных процессов это означает, что исходный код высокоуровневых процессов не должен содержать имен, физических адресов или ключей в реестре, соответствующих низкоуровневым процессам. Не забывайте, что главная архитектурная цель — сделать низкоуровневые процессы плагинами (сменными модулями) для высокоуровневых процессов.
Взаимодействия через границы локальных процессов связаны с обращением к системным вызовам, маршалингом и декодированием данных, а также переключением контекстов и обходятся умеренно дорого. Количество таких взаимодействий следует тщательно ограничивать.
Самыми надежными являются границы служб. Часто служба — это процесс, который запускается из командной строки или с помощью эквивалентного системного вызова. Службы не зависят от физического местоположения. Две взаимодействующие службы могут или не могут действовать на одном процессоре или группе процессоров в многопроцессорной системе. Службы предполагают, что все взаимодействия осуществляются по сети.
Взаимодействия через границы служб осуществляются очень медленно в сравнении с вызовами функций. Время между запросом и ответом может составлять от десятков миллисекунд до нескольких секунд. Взаимодействия следует ограничивать по мере возможностей. Взаимодействия на этом уровне должны учитывать возможность больших задержек.
В остальном к службам применяются те же правила, что и к локальным процессам. Низкоуровневые службы должны «подключаться» к высокоуровневым службам. Исходный код высокоуровневых служб не должен содержать никакой конкретной информации (например, URI) о низкоуровневых службах.
Большинство систем, кроме монолитных, используют несколько стратегий разграничения. Система, состоящая из служб, может также иметь несколько локальных процессов. В действительности служба часто является всего лишь фасадом для нескольких взаимодействующих локальных процессов. Служба или локальный процесс чаще имеют форму монолита, составленного из исходных кодов компонентов, или группы динамически подключаемых компонентов развертывания.
Это означает, что границы в системе часто будут представлены смесью локальных границ (недорогих в пересечении) и границ, страдающих задержками.
Статический полиморфизм (например, обобщенные типы или шаблонные классы) иногда оказывается ценным инструментом управления зависимостями в монолитных системах, особенно в таких языках, как C++. Однако разделение, выполненное с применением обобщенных типов, не способно защитить вас от необходимости выполнять повторные компиляцию и развертывание, как это может динамический полиморфизм.
Хотя в этом случае статический полиморфизм даже нельзя рассматривать как вариант.