Программные системы — это заявления о направлении действий. Фактически любая компьютерная программа является таким заявлением. Компьютерная программа — это подробное описание политики преобразования входных данных в выходные.
В большинстве нетривиальных систем общую политику можно разбить на множество более мелких заявлений. Некоторые из этих заявлений могут описывать действие конкретных бизнес-правил. Другие могут определять оформление отчетов. А третьи — описывать порядок проверки входных данных.
Отчасти искусство создания программных архитектур заключается в отделении этих политик друг от друга и их перегруппировке с учетом способов их изменения. Политики, изменяющиеся по одинаковым причинам и в одно время, находятся на одном уровне и принадлежат одному компоненту. Политики, изменяющиеся по разным причинам или в разное время, находятся на разных уровнях и должны помещаться в разные компоненты.
Искусство создания программных архитектур нередко связано с организацией перегруппированных компонентов в ориентированный ациклический граф. Узлами такого графа являются компоненты, содержащие политики одного уровня. А ориентированными ребрами — зависимости между компонентами. Они соединяют компоненты, находящиеся на разных уровнях.
К ним относятся зависимости на уровне исходного кода или времени компиляции. В Java они выражаются инструкциями import. В C# — инструкциями using. В Ruby — инструкциями require. Все эти зависимости необходимы компилятору для выполнения своей работы.
В хорошей архитектуре направление этих зависимостей обусловлено уровнем компонентов, которые они соединяют. В любом случае низкоуровневые компоненты проектируются так, чтобы они зависели от высокоуровневых компонентов.
Термин «уровень» имеет строгое определение: «удаленность от ввода и вывода». Чем дальше политика от ввода и вывода, тем выше ее уровень. Политики, управляющие вводом и выводом, являются самыми низкоуровневыми в системе.
Диаграмма потоков данных на рис. 19.1 соответствует простой программе шифрования, которая читает символы из устройства ввода, преобразует их с использованием таблицы и записывает преобразованные символы в устройство вывода. Направления потоков данных показаны на диаграмме извилистыми сплошными стрелками. Правильно спроектированные зависимости в исходном коде показаны пунктирными стрелками.
Рис. 19.1. Простая программа шифрования
Компонент, выполняющий преобразование, — это самый высокоуровневый компонент в данной системе, потому что он дальше других находится от ввода и вывода.
Обратите внимание, что потоки данных и зависимости в исходном коде не всегда указывают в одном направлении. Это тоже один из элементов искусства создания программных архитектур. Мы должны отделить зависимости в исходном коде от потоков данных и связать с уровнем.
Мы легко могли бы создать неправильную архитектуру, реализовав программу так:
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). А теперь вернитесь назад и посмотрите, сможете ли вы определить, где каждый из принципов используется и почему.
Меилир Пейдж-Джонс (Meilir Page-Jones) назвал этот компонент «центральным преобразованием» в своей книге The Practical Guide to Structured Systems Design, 2nd ed., Yourdon Press, 1988.