В любой системе легко выделить три компонента: пользовательский интерфейс, бизнес-правила и базу данных. Для простых систем этого более чем достаточно. Но для большинства систем число компонентов должно быть больше.
Рассмотрим, например, простую компьютерную игру. В ней легко выделить три компонента. Пользовательский интерфейс обрабатывает все сообщения от пользователя и передает их правилам игры. Правила сохраняют состояние игры в некоторой хранимой структуре данных. Но действительно ли это все, что нужно?
Давайте немного конкретизируем. Возьмем в качестве примера почтенную приключенческую игру «Охота на Вампуса», придуманную в 1972 году. В этой текстовой игре используются очень простые команды, такие как GO EAST (идти быстро) и SHOOT WEST (выстрелить в западном направлении). Игрок вводит команду, а компьютер в ответ сообщает, что персонаж видит, обоняет, слышит и чувствует. Игрок охотится за Вампусом в лабиринте пещер и должен избегать ловушек, ям и других опасностей. Желающие без труда найдут правила игры в Интернете.
Допустим, мы решили сохранить текстовый интерфейс, но отделить его от правил игры, чтобы наша версия могла обрабатывать команды на разных языках и распространяться в разных странах. Игровые правила взаимодействуют с компонентом пользовательского интерфейса посредством прикладного интерфейса, не зависящего от языка, а пользовательский интерфейс транслирует команды API на соответствующий естественный язык.
При правильной организации зависимостей, как показано на рис. 25.1, можно создать произвольное количество компонентов с пользовательским интерфейсом, использующих те же игровые правила. Правила игры ничего не знают об используемом естественном языке общения с пользователем.
Допустим также, что состояние игры сохраняется в некотором хранилище — это может быть флешка, облачное хранилище или просто ОЗУ. В любом из этих случаев игровые правила не должны знать деталей. Поэтому снова мы создаем прикладной интерфейс, который игровые правила смогут использовать для взаимодействия с компонентом хранилища.
Рис. 25.1. Одни и те же игровые правила могут использоваться любым числом компонентов пользовательского интерфейса
Игровые правила не должны ничего знать о разных видах хранилищ, поэтому зависимости должны быть направлены в соответствии с правилом зависимостей, как показано на рис. 25.2.
Рис. 25.2. Следование правилу зависимостей
Очевидно, что в этом контексте мы легко смогли бы применить приемы создания чистой архитектуры со всеми вариантами использования, границами, сущностями и соответствующими структурами данных. Но действительно ли мы нашли все важнейшие архитектурные границы?
Например, язык не является единственной осью изменения для пользовательского интерфейса. Также может измениться механизм ввода текста. Например, мы можем использовать обычное окно командной оболочки, текстовые сообщения или приложение чата. Возможности в этом плане бесчисленны.
Это означает, что существует потенциальная архитектурная граница, определяемая этой осью изменения. Возможно, нам следует сконструировать API, пересекающий границу и отделяющий язык от механизма ввода; эта идея показана на рис. 25.3.
Рис. 25.3. Следующая версия диаграммы
Диаграмма на рис. 25.3 стала сложнее, но не содержит никаких сюрпризов. Пунктиром обведены абстрактные компоненты, определяющие API, реализуемый компонентами, стоящими выше или ниже их. Например, Language API реализуют компоненты English и Spanish.
GameRules взаимодействует с компонентом Language через API, который определяет GameRules и реализует Language. Также компонент Language взаимодействует с компонентом TextDelivery посредством API, который определяется в Language и реализуется в TextDelivery. Как видите, API определяется и принадлежит компоненту-пользователю, но не реализующему его.
Если заглянуть в GameRules, можно увидеть полиморфные пограничные интерфейсы, используемые внутри GameRules и реализованные в компоненте Language. Имеются также полиморфные пограничные интерфейсы, используемые компонентом Language и реализованные в GameRules.
Заглянув в Language, мы увидели бы то же самое: полиморфные пограничные интерфейсы, используемые кодом в Language и реализованные в TextDelivery, и полиморфные пограничные интерфейсы, используемые кодом в TextDelivery и реализованные в Language.
В каждом случае API определяется пограничными интерфейсами, принадлежащими компоненту, находящемуся уровнем выше.
Варианты, такие как English, SMS и CloudData, предоставляются полиморфными интерфейсами, определяемыми в API абстрактных компонентов и реализуемыми конкретными компонентами, которые обслуживают их. Например, предполагается, что полиморфные интерфейсы, объявленные в Language, будут реализованы в English и Spanish.
Эту диаграмму можно упростить, устранив все варианты и сосредоточившись исключительно на API компонентов, как показано на рис. 25.4.
Рис. 25.4. Упрощенная диаграмма
Обратите внимание, что диаграмма на рис. 25.4 ориентирована так, чтобы все стрелки указывали вверх. В результате компонент GameRules оказался вверху. Такая ориентация имеет определенный смысл, потому что GameRules содержит политики высшего уровня.
Рассмотрим направление движения информации. Ввод от пользователя передается через компонент TextDelivery снизу слева. Она поднимается вверх до компонента Language, где транслируется в команды, понятные GameRules. GameRules обрабатывает ввод пользователя и посылает соответствующие данные вниз, в компонент DataStorage справа внизу.
Затем GameRules посылает ответ обратно в компонент Language, который переводит его на соответствующий язык и возвращает пользователю через компонент TextDelivery.
Такая организация эффективно делит поток данных на два потока. Поток слева соответствует взаимодействию с пользователем, а поток справа — с хранилищем данных. Оба потока встречаются на вершине, в компоненте GameRules — конечном обработчике данных, через который проходят оба потока.
Всегда ли существует только два потока данных, как в данном примере? Нет, не всегда. Представьте, что мы захотели реализовать сетевой вариант игры «Охота на Вампуса», в которой участвует несколько игроков. В этом случае нам потребуется сетевой компонент, как показано на рис. 25.5. В данном случае поток данных делится на три потока, управляемых компонентом GameRules.
Рис. 25.5. Добавление сетевого компонента
То есть с ростом сложности системы структура компонентов может разбиваться на несколько потоков.
Сейчас вы наверняка подумали, что все потоки в конечном счете встречаются на вершине диаграммы, в единственном компоненте. Ах, если бы все было так просто! Увы, действительность намного сложнее.
Рассмотрим компонент GameRules для игры «Охота на Вампуса». Часть игровых правил связана с механикой карты. Они знают, как соединены пещеры и какие объекты находятся в каждой пещере. Они знают, как переместить игрока из пещеры в пещеру и как генерировать события для игрока.
Но есть еще ряд политик на еще более высоком уровне — политик, которые управляют здоровьем персонажа и действием определенных событий. Эти политики могут вызывать ухудшение здоровья у персонажа или улучшать его, давая персонажу еду и питье. Низкоуровневые политики, отвечающие за механику перемещений, могут определять события для этой высокоуровневой политики, такие как FoundFood или FellInPit. А высокоуровневая политика могла бы управлять состоянием персонажа (как показано на рис. 25.6). В конечном итоге эта политика могла бы определять окончательный итог — победу или проигрыш в игре.
Рис. 25.6. Высокоуровневая политика управляет состоянием персонажа
Является ли это архитектурной границей? Нужно ли нам определить API, отделяющий MoveManagement от PlayerManagement? А давайте сделаем ситуацию еще интереснее и добавим микрослужбы.
Допустим, что мы получили массивную многопользовательскую версию игры «Охота на Вампуса». Компонент MoveManagement действует локально, на компьютере игрока, а PlayerManagement действует на сервере. PlayerManagement предлагает API микрослужбы для всех подключенных компонентов MoveManagement.
Диаграмма на рис. 25.7 представляет несколько упрощенное отражение этого сценария. Элементы Network в действительности немного сложнее, чем показано на диаграмме, но сама идея должна быть понятна. В данном случае между MoveManagement и PlayerManagement пролегает полноценная архитектурная граница.
Рис. 25.7. Добавление API микрослужб
Что из всего этого следует? Почему я взял эту до абсурда простую программу, которую можно уместить в 200 строк кода на языке оболочки Kornshell, и развил ее до огромных размеров со всеми этими сумасшедшими архитектурными границами?
Этот пример призван был показать, что архитектурные границы существуют повсюду. Мы как архитекторы должны проявлять осторожность и проводить их, только когда они действительно нужны. Мы также должны помнить, что полная реализация границ обходится дорого.
В то же время мы должны помнить, что игнорирование границ может вызвать сложности в будущем — даже при наличии всеобъемлющего набора тестов и жесткой дисциплины рефакторинга.
Итак, что мы должны делать как архитекторы? Ответ едва ли удовлетворит вас. С одной стороны, некоторые очень умные люди много лет говорили нам, что мы не должны испытывать потребности в абстракциях. Это философия YAGNI: «You Aren’t Going to Need It» («Вам это не понадобится»). В этом есть определенная мудрость, поскольку излишнее усложнение конструкции часто намного хуже ее упрощения. С другой стороны, когда обнаруживается, что в том или ином месте действительно необходимо провести архитектурную границу, стоимость и риск ее добавления могут оказаться очень высокими.
Вот так-то, Архитектор Программного Обеспечения, вы должны предвидеть будущее. Вы должны предугадывать с пониманием дела. Вы должны взвесить все за и против, определить, где пролегают архитектурные границы и какие из них должны быть реализованы полностью, какие частично, а какие можно вообще игнорировать.
Но это не единовременное решение. Невозможно раз и навсегда решить на ранних этапах проектирования, какие границы реализовать, а какие игнорировать. Вы должны наблюдать за развитием системы, отмечать места, где может потребоваться провести новую границу, и затем внимательно следить за появлением первых трений, возникающих из-за отсутствия границ.
В этот момент нужно взвесить затраты на реализацию границ и цену их игнорирования и принять решение. Ваша цель — создать границу прямо в точке перегиба, когда реализовать ее окажется дешевле, чем продолжать игнорировать.
Для этого вы должны наблюдать очень внимательно.
. — Примеч. пер.
Также должно быть ясно, что мы не будем применять приемы создания чистой архитектуры для таких тривиальных программ, как эта игра. В конце концов, программу целиком можно уместить в 200 строк кода и даже меньше. В этом случае мы используем простую программу как прокси для более крупной системы со значимыми архитектурными границами.
Если вас взволновало несоответствие направлений стрелок на рис. 25.4, напомню, что они соответствуют зависимостям в исходном коде, но не движению потоков данных.
В далеком прошлом мы назвали бы компонент на вершине центральным преобразователем (Central Transform). Подробности см. в книге Meilir Page-Jones, Practical Guide to Structured Systems Design», 2nd ed., 1988.