Чистая архитектура. Искусство разработки программного обеспечения Мартин Роберт
Например, графические пользовательские интерфейсы сложны для модульного тестирования, потому что трудно писать тесты, которые могут видеть изображение на экране и проверять присутствие соответствующих элементов. Однако в действительности большая часть поведения пользовательского интерфейса легко тестируется. Используя шаблон «Скромный объект», можно разделить два вида поведения на два разных класса, которые называют Презентатором (Presenter) и Представлением (View).
Презентаторы и представления
Представление (View) — это «скромный» объект, сложный для тестирования. Код в этом объекте упрощается до предела. Он просто переносит данные в графический интерфейс, никак не обрабатывая их.
Презентатор (Presenter) — это легко тестируемый объект. Его задача — получить данные от приложения и преобразовать их так, чтобы Представление (View) могло просто переместить их на экран. Например, если приложению потребуется отобразить дату в некотором поле, оно должно передать Презентатору объект Date. Презентатор затем должен преобразовать дату в строку и поместить ее в простую структуру данных, которую называют Моделью представления (View Model), где Представление сможет найти ее.
Если приложению потребуется отобразить на экране денежную сумму, оно может передать Презентатору объект Currency. Презентатор должен преобразовать этот объект в строковое представление десятичного числа с соответствующим количеством десятичных знаков и знаком валюты и поместить полученную строку в Модель представления. Если отрицательные суммы должны отображаться красным цветом, тогда в Модели представления должен устанавливаться соответствующий флаг.
Каждая кнопка на экране имеет имя. Это имя является строкой в Модели представления, помещаемой туда Презентатором. Если кнопка должна отображаться как неактивная, Презентатор установит соответствующий логический флаг в Модели представления. Имя каждого пункта меню представлено строкой в Модели представления, помещаемой туда Презентатором. Имена всех радиокнопок, флажков и текстовых полей и соответствующих логических флагов устанавливаются Презентатором в Модели представления. Таблицы чисел, которые должны отображаться на экране, преобразуются Презентатором в таблицы форматированных строк в Модели представления.
Все, что отображается на экране и чем так или иначе управляет приложение, представлено в Модели представления строкой, или логическим значением, или элементом перечисления. На долю Представления остается только перенести данные из Модели представления на экран. То есть Представление играет скромную роль.
Тестирование и архитектура
Давно известно, что простота тестирования является характерным признаком хорошей архитектуры. Шаблон «Скромный объект» — хороший пример, потому что раздел между легко и тяжело тестируемыми частями часто совпадает с архитектурными границами. Раздел между Презентаторами и Представлениями — одна из таких границ, но существует много других.
Шлюзы к базам данных
Между средствами управления в вариантах использования и базами данных находятся шлюзы к базам данных[48]. Эти шлюзы являются полиморфными интерфейсами, содержащими методы для каждой операции создания, чтения, изменения и удаления, которые приложение может выполнить в базе данных. Например, если приложению может понадобиться узнать фамилии всех пользователей, работавших вчера, в интерфейсе UserGateway должен иметься метод с именем getLastNamesOfUsersWhoLoggedInAfter, принимающий объект Date и возвращающий список фамилий.
Как вы помните, мы полностью отвергаем возможность появления кода на SQL на уровне вариантов использования; для этого используются интерфейсы шлюзов, имеющие определенные методы. Эти шлюзы реализуются классами на уровне базы данных. Такие реализации являются «скромными объектами». Они просто используют код SQL или любой другой интерфейс доступа к базе данных для извлечения данных, необходимых каждому из методов. Механизмы управления в вариантах использования, напротив, не являются «скромными», потому что заключают бизнес-правила, характерные для приложения. Даже при том, что они не являются «скромными», средства управления легко поддаются тестированию, потому что шлюзы можно заменить соответствующими заглушками.
Преобразователи данных
Вернемся к теме баз данных. На каком уровне, по вашему мнению, должны находиться фреймворки ORM, такие как Hibernate?
Во-первых, давайте кое-что проясним: нет такой штуки, как инструмент объектно-реляционного преобразования (Object Relational Mapper; ORM), потому что объекты не являются структурами данных, по крайней мере с точки зрения пользователя. Пользователи объекта не видят данных, потому что все они хранятся в приватных полях. Пользователи видят только общедоступные методы объекта. То есть с точки зрения пользователя объект — это набор операций.
Структура данных, напротив, — набор общедоступных данных, не обладающих подразумеваемым поведением. Название «преобразователи данных» лучше подходит для инструментов ORM, потому что они загружают данные из реляционных таблиц в структуры.
Где должны находиться такие системы ORM? Конечно, на уровне базы данных. В действительности инструменты ORM представляют еще одну разновидность границы «Скромный объект» между интерфейсами шлюза и базой данных.
Службы
А что можно сказать о службах? Если приложение взаимодействует со службами или реализует свой набор служб, можно ли в этом случае применить шаблон «Скромный объект» для создания границы, отделяющей службу?
Конечно! Приложение должно загружать данные в простые структуры и передавать эти структуры через границу модулям, которые преобразуют данные и посылают их внешним службам. С другой стороны, в точке ввода слушатель службы должен принимать данные через интерфейс службы, преобразовывать их в простые структуры данных, пригодные для использования в приложении, и затем передавать их через границу службы.
Заключение
Практически на каждой архитектурной границе можно найти возможность применить шаблон «Скромный объект». Взаимодействия через границу почти всегда осуществляются с применением некой простой структуры данных, и граница часто делит что-то на сложное и простое для тестирования. Применение этого шаблона для организации архитектурных границ значительно улучшает возможность тестирования системы в целом.
Глава 24. Неполные границы
Полноценные архитектурные границы обходятся дорого. Они требуют определения двусторонних пограничных интерфейсов, структур для входных и выходных данных и управления зависимостями для выделения двух сторон в компоненты, компилируемые и развертываемые независимо. Это требует значительных усилий для создания и сопровождения.
Во многих ситуациях хороший архитектор мог бы посчитать затраты на создание такой границы слишком высокими, но хотел бы сохранить место для такой границы на будущее.
Подобное упреждающее проектирование часто расценивается многими последователями гибкой разработки как нарушение принципа YAGNI: «You Aren’t Going to Need It» («Вам это не понадобится»). Однако некоторые архитекторы смотрят на эту проблему и думают: «А мне может это понадобиться». В этом случае они могут реализовать неполную границу.
Пропустить последний шаг
Один из способов сконструировать неполную границу — проделать все, что необходимо для создания независимо компилируемых и развертываемых компонентов, и затем просто оставить их в одном компоненте. В этом компоненте будут присутствовать парные интерфейсы, структуры входных и выходных данных и все остальное, но все это будет компилироваться и развертываться как один компонент.
Очевидно, что для реализации такой неполной границы потребуется тот же объем кода и подготовительного проектирования, что и для полной границы. Но в этом случае не потребуется администрировать несколько компонентов. Не потребуется следить за номерами версий и нести дополнительное бремя управления версиями. Это отличие не нужно недооценивать.
Эта стратегия использовалась в начале развития FitNesse. Компонент веб-сервера FitNesse проектировался с возможностью отделения от компонентов вики и тестирования. Мы думали, что впоследствии у нас может появиться желание создать другие веб-приложения, использующие этот веб-компонент. В то же время мы не хотели вынуждать пользователей загружать два компонента. Как вы помните, одна из наших целей выражалась фразой «Загрузи и вперед». Мы специально хотели, чтобы пользователям нужно было загрузить и выполнить только один jar-файл, не заботясь о поиске других jar-файлов с совместимыми версиями и т.д.
История FitNesse также указывает на одну из опасностей такого подхода. Со временем, когда стало ясно, что отдельный веб-компонент никогда не понадобится, грань между веб-компонентом и компонентом вики стала размываться. Начали появляться зависимости, пересекающие линию в неправильном направлении. Разделить их сейчас было бы очень сложно.
Одномерные границы
Для оформления полноценной архитектурной границы требуется создать парные пограничные интерфейсы для управления изоляцией в обоих направлениях. Поддержание разделения в обоих направлениях обходится дорого не только на начальном этапе, но и на этапе сопровождения.
На рис. 24.1 показана более простая схема, помогающая зарезервировать место для последующего превращения в полноценную границу. Это пример традиционного шаблона «Стратегия». Клиенты пользуются интерфейсом ServiceBoundary, который реализуют классы ServiceImpl.
Рис. 24.1. Шаблон «Стратегия»
Должно быть ясно, что это создает основу для будущей архитектурной границы. Здесь имеет место инверсия зависимости, необходимая для отделения клиента от класса ServiceImpl. Также должно быть ясно, что разделение может очень быстро стираться, о чем свидетельствует пунктирная стрелка на диаграмме. В отсутствие парных интерфейсов ничто не мешает появлению таких обратных зависимостей, кроме старательности и дисциплинированности разработчиков и архитекторов.
Фасады
Еще более простой подход к организации границ дает шаблон «Фасад», изображенный на рис. 24.2. В этом случае отсутствует даже инверсия зависимостей. Граница определяется простым классом Facade c методами, представляющими службы и реализующими обращения к службам, к которым клиенты, как предполагается, не должны иметь прямого доступа.
Рис. 24.2. Шаблон «Фасад»
Обратите внимание, однако, что клиент имеет транзитивную (переходную) зависимость от всех этих классов служб. В языках со статической системой типов изменение исходного кода в одном из классов служб вызывает необходимость повторной компиляции клиента. Также представьте, насколько просто в этой схеме создать обратные связи.
Заключение
Мы увидели три простых способа реализации неполных архитектурных границ. Конечно, таких способов намного больше. Эти три стратегии служат лишь примерами.
Каждый из представленных подходов имеет свои достоинства и недостатки. Каждый подходит на роль заменителя полноценной архитектурной границы в определенных контекстах. И каждый может со временем деградировать, если граница никогда не будет материализована.
Одна из задач архитектора — решить, где провести архитектурную границу и как ее реализовать, частично или полностью.
Глава 25. Уровни и границы
В любой системе легко выделить три компонента: пользовательский интерфейс, бизнес-правила и базу данных. Для простых систем этого более чем достаточно. Но для большинства систем число компонентов должно быть больше.
Рассмотрим, например, простую компьютерную игру. В ней легко выделить три компонента. Пользовательский интерфейс обрабатывает все сообщения от пользователя и передает их правилам игры. Правила сохраняют состояние игры в некоторой хранимой структуре данных. Но действительно ли это все, что нужно?
Охота на Вампуса
Давайте немного конкретизируем. Возьмем в качестве примера почтенную приключенческую игру «Охота на Вампуса»[49], придуманную в 1972 году. В этой текстовой игре используются очень простые команды, такие как GO EAST (идти быстро) и SHOOT WEST (выстрелить в западном направлении). Игрок вводит команду, а компьютер в ответ сообщает, что персонаж видит, обоняет, слышит и чувствует. Игрок охотится за Вампусом в лабиринте пещер и должен избегать ловушек, ям и других опасностей. Желающие без труда найдут правила игры в Интернете.
Допустим, мы решили сохранить текстовый интерфейс, но отделить его от правил игры, чтобы наша версия могла обрабатывать команды на разных языках и распространяться в разных странах. Игровые правила взаимодействуют с компонентом пользовательского интерфейса посредством прикладного интерфейса, не зависящего от языка, а пользовательский интерфейс транслирует команды API на соответствующий естественный язык.
При правильной организации зависимостей, как показано на рис. 25.1, можно создать произвольное количество компонентов с пользовательским интерфейсом, использующих те же игровые правила. Правила игры ничего не знают об используемом естественном языке общения с пользователем.
Допустим также, что состояние игры сохраняется в некотором хранилище — это может быть флешка, облачное хранилище или просто ОЗУ. В любом из этих случаев игровые правила не должны знать деталей. Поэтому снова мы создаем прикладной интерфейс, который игровые правила смогут использовать для взаимодействия с компонентом хранилища.
Рис. 25.1. Одни и те же игровые правила могут использоваться любым числом компонентов пользовательского интерфейса
Игровые правила не должны ничего знать о разных видах хранилищ, поэтому зависимости должны быть направлены в соответствии с правилом зависимостей, как показано на рис. 25.2.
Рис. 25.2. Следование правилу зависимостей
Чистая архитектура?
Очевидно, что в этом контексте мы легко смогли бы применить приемы создания чистой архитектуры[50] со всеми вариантами использования, границами, сущностями и соответствующими структурами данных. Но действительно ли мы нашли все важнейшие архитектурные границы?
Например, язык не является единственной осью изменения для пользовательского интерфейса. Также может измениться механизм ввода текста. Например, мы можем использовать обычное окно командной оболочки, текстовые сообщения или приложение чата. Возможности в этом плане бесчисленны.
Это означает, что существует потенциальная архитектурная граница, определяемая этой осью изменения. Возможно, нам следует сконструировать 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.
Такая организация эффективно делит поток данных на два потока[51]. Поток слева соответствует взаимодействию с пользователем, а поток справа — с хранилищем данных. Оба потока встречаются на вершине[52], в компоненте 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» («Вам это не понадобится»). В этом есть определенная мудрость, поскольку излишнее усложнение конструкции часто намного хуже ее упрощения. С другой стороны, когда обнаруживается, что в том или ином месте действительно необходимо провести архитектурную границу, стоимость и риск ее добавления могут оказаться очень высокими.
Вот так-то, Архитектор Программного Обеспечения, вы должны предвидеть будущее. Вы должны предугадывать с пониманием дела. Вы должны взвесить все за и против, определить, где пролегают архитектурные границы и какие из них должны быть реализованы полностью, какие частично, а какие можно вообще игнорировать.
Но это не единовременное решение. Невозможно раз и навсегда решить на ранних этапах проектирования, какие границы реализовать, а какие игнорировать. Вы должны наблюдать за развитием системы, отмечать места, где может потребоваться провести новую границу, и затем внимательно следить за появлением первых трений, возникающих из-за отсутствия границ.
В этот момент нужно взвесить затраты на реализацию границ и цену их игнорирования и принять решение. Ваша цель — создать границу прямо в точке перегиба, когда реализовать ее окажется дешевле, чем продолжать игнорировать.
Для этого вы должны наблюдать очень внимательно.
Глава 26. Главный компонент
В каждой системе имеется хотя бы один компонент, который создает другие компоненты, наблюдает за ними и координирует их действия. Я называю такой компонент главным (Main).
Конечная деталь
Компонент Main — это конечная деталь, политика самого низкого уровня. Он является точкой входа в систему. От него ничего не зависит, кроме работоспособности системы. Его задача — создать все Фабрики, Стратегии и другие глобальные средства и затем передать управление высокоуровневым абстракциям в системе.
Именно в компоненте Main должны внедряться все зависимости с использованием инфраструктуры внедрения зависимостей. После этого компонент Main должен распространить эти зависимости, обычно без использования инфраструктуры.
Компонент Main можно считать самым грязным из всех грязных компонентов.
Рассмотрим следующий компонент Main из последней версии игры «Охота на Вампуса». Обратите внимание, как он загружает все строки, о которых не должен знать основной код.
public class Main implements HtwMessageReceiver {
private static HuntTheWumpus game;
private static int hitPoints = 10;
private static final List<String> caverns =
new ArrayList<>();
private static final String[] environments = new String[]{
"bright",
"humid",
"dry",
"creepy",
"ugly",
"foggy",
"hot",
"cold",
"drafty",
"dreadful"
};
private static final String[] shapes = new String[] {
"round",
"square",
"oval",
"irregular",
"long",
"craggy",
"rough",
"tall",
"narrow"
};
private static final String[] cavernTypes = new String[] {
"cavern",
"room",
"chamber",
"catacomb",
"crevasse",
"cell",
"tunnel",
"passageway",
"hall",
"expanse"
};
private static final String[] adornments = new String[] {
"smelling of sulfur",
"with engravings on the walls",
"with a bumpy floor",
"",
"littered with garbage",
"spattered with guano",
"with piles of Wumpus droppings",
"with bones scattered around",
"with a corpse on the floor",
"that seems to vibrate",
"that feels stuffy",
"that fills you with dread"
};
Далее следует функция main. Обратите внимание, как она использует HtwFactory для создания игры. Она передает имя класса, htw.game.HuntTheWumpusFacade, потому что этот класс даже грязнее, чем Main. Это предотвращает изменения в данном классе из-за повторной компиляции/развертывания Main.
public static void main(String[] args) throws IOException {
game = HtwFactory.makeGame("htw.game.HuntTheWumpusFacade",
new Main());
createMap();
BufferedReader br =
new BufferedReader(new InputStreamReader(System.in));
game.makeRestCommand().execute();
while (true) {
System.out.println(game.getPlayerCavern());
System.out.println("Health: " + hitPoints + " arrows: " +
game.getQuiver());
HuntTheWumpus.Command c = game.makeRestCommand();
System.out.println(">");
String command = br.readLine();
if (command.equalsIgnoreCase("e"))
c = game.makeMoveCommand(EAST);
else if (command.equalsIgnoreCase("w"))
c = game.makeMoveCommand(WEST);
else if (command.equalsIgnoreCase("n"))
c = game.makeMoveCommand(NORTH);
else if (command.equalsIgnoreCase("s"))
c = game.makeMoveCommand(SOUTH);
else if (command.equalsIgnoreCase("r"))
c = game.makeRestCommand();
else if (command.equalsIgnoreCase("sw"))
c = game.makeShootCommand(WEST);
else if (command.equalsIgnoreCase("se"))
c = game.makeShootCommand(EAST);
else if (command.equalsIgnoreCase("sn"))
c = game.makeShootCommand(NORTH);
else if (command.equalsIgnoreCase("ss"))
c = game.makeShootCommand(SOUTH);
else if (command.equalsIgnoreCase("q"))
return;
c.execute();
}
}
Отметьте также, что функция main создает поток ввода и содержит главный цикл игры, в котором происходит интерпретация простых команд, но их обработка поручается другим, высокоуровневым компонентам.
Наконец, посмотрите, как main создает карту подземелий.
private static void createMap() {
int nCaverns = (int) (Math.random() * 30.0 + 10.0);
while (nCaverns-- > 0)
caverns.add(makeName());
for (String cavern : caverns) {