Чистая архитектура. Искусство разработки программного обеспечения Мартин Роберт
Программное обеспечение было изобретено, потому что требовался способ легко и быстро менять поведение машин. Но гибкость в решающей степени зависит от формы системы, организации ее компонентов и способов взаимодействий между ними.
Основной способ сохранить гибкость программного обеспечения заключается в том, чтобы как можно дольше иметь как можно больше вариантов. Что это за варианты, которые нужно иметь? Это детали, не имеющие значения.
Любую программную систему можно разложить на два основных элемента: политику и детали. Политика воплощает все бизнес-правила и процедуры. Политика — вот истинная ценность системы.
Детали — это все остальное, что позволяет людям, другим системам и программистам взаимодействовать с политикой, никак не влияя на ее поведение. К ним можно отнести устройства ввода/вывода, базы данных, веб-системы, серверы, фреймворки, протоколы обмена данными и т.д.
Цель архитектора — создать такую форму для системы, которая сделает политику самым важным элементом, а детали — не относящимися к политике. Это позволит откладывать и задерживать принятие решений о деталях.
Например:
• Не нужно на ранних этапах разработки выбирать тип базы данных, потому что высокоуровневая политика не должна зависеть от этого выбора. У мудрого архитектора высокоуровневая политика не зависит от того, какая база данных будет выбрана впоследствии: реляционная, распределенная, иерархическая или просто набор файлов.
• Не нужно на ранних этапах разработки выбирать тип веб-сервера, потому что высокоуровневая политика не должна даже знать, что она доставляется через Веб. Если высокоуровневая политика не зависит от HTML, AJAX, JSP, JSF и прочих технологий, применяемых в веб-разработке, вы сможете отложить принятие решения о выборе веб-системы до более поздних этапов. Фактически вы даже не должны принимать решения, что система будет предоставлять свои услуги через Веб.
• Не нужно на ранних этапах разработки приспосабливать интерфейс REST, потому что высокоуровневая политика не должна зависеть от интерфейса с внешним миром. Также не нужно приспосабливаться под архитектуру микрослужб или SOA[34]. Высокоуровневая политика не должна зависеть от подобных деталей.
• Не нужно на ранних этапах разработки приспосабливать механизмы внедрения зависимостей, потому что высокоуровневая политика не должна зависеть от способа разрешения зависимостей.
Думаю, вы поняли. Сумев разработать высокоуровневую политику, не связывая ее с окружающими деталями, вы сможете откладывать и задерживать принятие решений об этих деталях на более поздний срок. И чем дольше вы сможете откладывать такие решения, тем большим объемом информации вы будете обладать, чтобы сделать правильный выбор.
Такой подход дает также возможность экспериментировать. Если уже имеется действующая часть высокоуровневой политики, не зависящая от типа базы данных, можно попробовать связать ее с разными базами данных, чтобы проверить их применимость и производительность. То же верно в отношении веб-систем, веб-фреймворков и даже самого Веб.
Чем дольше варианты будут оставаться открытыми, тем больше экспериментов можно провести, тем больше способов опробовать и тем большим объемом информации вы будете обладать, подойдя к моменту, когда принятие решения уже нельзя отложить.
Что делать, если решение уже принято кем-то другим? Что делать, если ваша компания взяла на себя обязательство перед кем-то использовать определенную базу данных, веб-сервер или фреймворк? Хороший архитектор сделает вид, что такого решения не было, и будет формировать систему так, чтобы эти решения можно было принять или изменить как можно позже.
Хороший архитектор максимизирует количество непринятых решений.
Независимость от устройства
Чтобы понять суть, вернемся ненадолго в 1960-е годы, когда компьютеры были совсем юными, а большинство программистов были математиками или инженерами из других областей (и треть или даже больше были женщинами).
В те дни допускалась масса ошибок. Конечно, в те дни мы не знали, что это ошибки. Да и не могли знать.
Одна из таких ошибок — образование прямой зависимости кода от устройств ввода/вывода. Если нам требовалось вывести что-то на принтер, мы писали код, использующий инструкции ввода/вывода, который генерировал команды управления принтером. Наш код зависел от устройства.
Например, когда я писал программы для PDP-8, печатавшие что-то на телепринтере, я использовал примерно такой набор машинных инструкций:
PRTCHR, 0
TSF
JMP .-1
TLS
JMP I PRTCHR
PRTCHR — это подпрограмма, печатающая один символ на телепринтере. Ячейка памяти с нулем в начале подпрограммы использовалась для хранения адреса возврата. (Не спрашивайте, что это такое.) Инструкция TSF пропускает следующую за ней инструкцию, если телепринтер готов напечатать символ. Если телепринтер занят, TSF просто передавала управление следующей за ней инструкции JMP .-1, выполняющей переход обратно на инструкцию TSF. Если телепринтер готов, TSF передавала управление инструкции TLS, которая посылала символ в регистр A телепринтера. Затем инструкция JMP I PRTCHR возвращала управление вызывающему коду.
Первое время этот прием работал безупречно. Если нам нужно было прочитать данные с перфокарт, мы использовали код, напрямую обращающийся к устройству ввода с перфокарт. Если нужно было набить перфокарты, мы писали код, напрямую обращающийся к перфоратору. Такие программы работали замечательно. Могли ли мы знать, что это была ошибка?
Но с большими колодами перфокарт было трудно управляться. Карты могли теряться, мяться, рваться, перемешиваться или проскакивать мимо. Могли теряться отдельные карты или добавляться лишние. То есть целостность данных была большой проблемой.
Магнитные ленты решили эту проблему. Мы получили возможность переносить образы карт на ленту. После сброса данных на ленту записи уже не могли перемешаться. Они не могли потеряться по неосторожности, а также не могли добавиться новые пустые записи. Лента оказалась гораздо надежнее. А кроме того, операции чтения/записи с лентой выполнялись намного быстрее, и намного проще стало делать резервные копии.
К сожалению, все наше программное обеспечение было написано для работы с устройствами чтения перфокарт и с перфораторами. Эти программы пришлось переписывать, чтобы они могли работать с магнитной лентой. Это была большая работа.
К концу 1960-х годов мы выучили урок и придумали, как обрести независимость от устройств. Операционные системы, современные в те дни, абстрагировали устройства ввода/вывода за фасадами программных функций, которые обрабатывали отдельные записи, выглядящие как перфокарты. Программы должны были обращаться к службам операционной системы, представляющим абстрактные устройства хранения единичных записей. Операторы могли сообщить операционной системе, к каким физическим устройствам должны подключаться эти абстрактные службы — устройствам чтения перфокарт, накопителям на магнитной ленте или любым другим устройствам хранения записей.
Теперь одна и та же программа могла читать и записывать данные на перфокарты или магнитные ленты без всяких изменений. Так родился принцип открытости/закрытости (Open-Closed Principle; OCP), но в те дни он еще не имел названия.
Нежелательная почта
В конце 1960-х годов я работал в компании, которая печатала рекламные письма для своих клиентов. Клиенты присылали магнитные ленты с записями, содержащими имена и адреса своих клиентов, а мы должны были писать программы, печатающие персонализированные рекламные объявления.
Примерно такого содержания:
Здравствуйте, мистер Мартин,
Поздравляем!
Мы выбрали ВАС из всех живущих на Вичвуд-лейн, чтобы сделать фантастическое предложение…
Клиенты присылали нам огромные рулоны с заготовками писем, куда мы должны были впечатать имена и адреса или любой другой текст, которые передавались на магнитной ленте. Мы писали программы, читающие эти имена, адреса и другой текст с магнитной ленты и печатающие их точно в отведенных полях.
Такие рулоны весили по 500 фунтов и содержали тысячи писем. Клиенты могли присылать сотни рулонов, и каждый приходилось печатать отдельно.
Сначала у нас была машина IBM 360, печатающая на единственном последовательном принтере. За смену мы могли напечатать несколько тысяч писем. К сожалению, печать таких писем стоила очень дорого. В те дни аренда IBM 360 обходилась в десятки тысяч долларов в месяц.
Поэтому мы настраивали операционную систему так, чтобы вместо принтера она использовала магнитную ленту. Это никак не сказывалось на наших программах, потому что они выводили данные, используя абстракции ввода/вывода операционной системы.
360-я могла заполнить ленту за десять минут или что-то около того — вполне достаточно, чтобы напечатать несколько рулонов писем. Эти ленты затем выносились из машинного зала и монтировались в приводы, подключенные к автономным принтерам. У нас их было пять штук, и эти пять принтеров работали круглые сутки без перерывов и выходных, печатая сотни тысяч рекламных писем каждую неделю.
Ценность независимости от устройства оказалась огромной! Мы могли писать программы, не зная и не заботясь о том, какие устройства будут использоваться, и отлаживать их, используя локальный принтер, подключенный к компьютеру. После этого мы могли просто приказать операционной системе выполнить «печать» на магнитную ленту и получить сотни тысяч готовых форм.
Наши программы обрели форму. Эта форма отделила политику от деталей. Политика заключалась в форматировании записей с именами и адресами. А деталями стали устройства. Мы смогли отложить принятие решения о выборе устройства.
Физическая адресация
В начале 1970-х годов я работал над большой системой учета для местного профсоюза водителей грузовиков. У нас имелся диск емкостью 25 Мбайт, на котором хранились записи с информацией об агентах, работодателях и членах профсоюза. Разные записи имели разные размеры, поэтому первые несколько цилиндров на диске форматировались так, чтобы размер одного сектора в точности соответствовал размеру записи с информацией об агенте. Следующие несколько цилиндров форматировались под записи с информацией о работодателях. И последние несколько цилиндров — под записи с информацией о членах профсоюза.
Мы писали программы, детально зная структуру диска. Мы знали, что на диске имеется 200 цилиндров и 10 головок, что каждый цилиндр имеет несколько десятков секторов на головку. Мы знали, какие цилиндры отводятся для хранения записей того или иного вида. И все это жестко «зашивалось» в код.
Мы также хранили на диске оглавление, позволявшее отыскать каждого агента, работодателя и члена профсоюза. Это оглавление хранилось в еще одной, специально отформатированной группе цилиндров. Оглавление для поиска агентов состояло из записей, включавших идентификационный номер агента, номер цилиндра, номер головки и номер сектора, где хранится запись с информацией об агенте. Для поиска работодателей и членов имелись аналогичные оглавления. Кроме того, информация о членах профсоюза хранилась на диске в форме двусвязного списка: каждая запись хранила номер цилиндра, головки и сектора следующей и предыдущей записи.
Представляете, во что выливался переход на использование нового дискового устройства, в котором на одну головку или на один цилиндр больше? Мы писали специальную программу, которая читала данные в старом формате со старого диска и записывала их на новый диск, преобразуя номера цилиндров/головок/секторов. Нам также приходилось изменять все жестко зашитые номера в коде, которые были повсюду! Все бизнес-правила скрупулезно учитывали схему цилиндр/головка/сектор.
Однажды в наши ряды влился более опытный программист. Увидев наше «творчество», он побледнел и уставился на нас как на инопланетян. Затем осторожно посоветовал нам изменить схему адресации и использовать относительные адреса.
Наш более умудренный опытом коллега предложил рассматривать диск как большой линейный массив секторов с последовательными номерами. Такая интерпретация позволяла написать крошечную подпрограмму преобразования, которая, зная физическую структуру диска, могла бы «на лету» транслировать относительные адреса в номера физических цилиндров/головок/секторов.
К счастью для нас, мы приняли его совет. Мы изменили высокоуровневую политику системы, добавив независимость от физической структуры диска. Это позволило нам отделить решение об организации записей на диске от прикладной задачи.
Заключение
Две истории, рассказанные в этой главе, наглядно демонстрируют один маленький принцип, который широко используют архитекторы. Хорошие архитекторы скрупулезно отделяют детали от политики и затем не менее скрупулезно отделяют политику от деталей, чтобы политика никак не зависела от деталей. Хорошие архитекторы проектируют политику так, чтобы решения о деталях можно было отложить и отодвинуть на как можно более поздний срок.
Глава 16. Независимость
Как отмечалось выше, хорошая архитектура должна обеспечивать:
• Разнообразие вариантов использования и эффективную работу системы.
• Простоту сопровождения системы.
• Простоту разработки системы.
• Простоту развертывания системы.
Варианты использования
Первый пункт в списке — разнообразие вариантов использования — означает, что архитектура системы должна поддерживать назначение системы. Если система — это онлайн-магазин, архитектура должна поддерживать разные варианты использования онлайн-магазина. Фактически в этом заключается главная задача архитектора и главная цель архитектуры. Архитектура должна поддерживать разнообразие вариантов использования.
Однако, как обсуждалось выше, архитектура не оказывает большого влияния на поведение системы. Существует очень мало вариантов поведения, которые архитектура может оставить открытыми. Но влияние — это еще не все. Самое важное, что хорошая архитектура может сделать для поддержки поведения, — раскрывать и прояснять это поведение, чтобы назначение системы было видимо на архитектурном уровне.
Приложение онлайн-магазина с хорошей архитектурой будет выглядеть как приложение онлайн-магазина. Варианты использования такой системы будут ясно видны в ее структуре. Разработчикам не придется искать черты поведения, потому что они будут представлены полноценными элементами, видимыми на верхнем уровне системы. Такими элементами могут быть классы, функции или модули, занимающие видные позиции в архитектуре и имеющие имена, ясно описывающие их назначение.
В главе 21 «Кричащая архитектура» мы подробнее рассмотрим этот пункт.
Эффективность работы
Более существенную и менее косметическую роль архитектура играет в эффективной работе системы. Если система должна обслуживать 100 000 клиентов в секунду, архитектура должна поддерживать такую пропускную способность и время отклика для каждого возможного варианта. Если система должна запрашивать большие массивы данных за миллисекунды, архитектура должна быть структурирована так, чтобы обеспечивать возможность подобных операций.
Для одних систем это означает создание массивов маленьких служб, выполняющихся параллельно на множестве разных серверов. Для других — создание множества легковесных потоков выполнения, действующих в общем адресном пространстве, внутри одного процесса, выполняющегося на одном процессоре. Для третьих достаточно запустить несколько процессов, действующих в изолированных адресных пространствах. А для некоторых вполне подойдут простые монолитные программы.
Как ни странно, это решение относится к числу тех, которые хороший архитектор стремится оставить открытыми. Систему с монолитной организацией и зависящую от этой монолитной структуры трудно будет реорганизовать для поддержки выполнения в нескольких процессах, нескольких потоках или нескольких микрослужбах, если это потребуется. Напротив, архитектуру, предусматривающую надлежащую изоляцию компонентов и не предполагающую конкретных средств взаимодействий между компонентами, будет намного проще провести через спектр нескольких потоков, процессов и служб по мере изменения потребностей.
Разработка
Архитектура играет важную роль в поддержке разработки. В данном случае начинает действовать закон Конвея:
Любая организация, разрабатывающая систему, невольно будет формировать дизайн, структура которого повторяет структуру взаимодействий внутри этой организации.
В организации с множеством команд, решающей множество задач, должна получиться архитектура, которая обеспечивает независимую работу этих команд, чтобы команды не мешали друг другу во время разработки. Это достигается делением системы на изолированные компоненты, которые можно разрабатывать независимо. Затем эти компоненты распределяются между командами, которые могут работать независимо друг от друга.
Развертывание
Огромную роль архитектура играет также в простоте развертывания системы. Главная цель — «немедленное развертывание». Хорошая архитектура не полагается на десятки маленьких сценариев и файлов с определениями настроек. Она не требует вручную создавать каталоги или файлы и располагать их в особом порядке. Хорошая архитектура помогает системе достичь уровня немедленной готовности к развертыванию сразу после сборки.
И снова это достигается за счет разделения системы на компоненты и их изоляции, включая специальные компоненты, которые связывают систему воедино и гарантируют правильный запуск, интеграцию и контроль за работой каждого компонента.
Сохранение разнообразия вариантов
Хорошая архитектура стремится достичь баланса всех этих потребностей компонентной организации с целью удовлетворить их. Звучит просто, правда? Согласен, мне легко писать.
В действительности добиться такого баланса очень трудно. Проблема в том, что зачастую мы не знаем всех возможных вариантов использования, эксплуатационных ограничений, структуры команды разработчиков или требований к развертыванию. Хуже того, даже если бы мы все это знали, нет никаких гарантий, что ничего из этого не изменится в течение жизненного цикла системы. Проще говоря, мы преследуем нечеткие и переменчивые цели. Добро пожаловать в реальный мир.
Но не все потеряно: некоторые принципы строительства архитектуры относительно недороги в реализации и могут помочь сбалансировать эти нужды, даже когда нет четкой картины целей, к которым вы стремитесь. Эти принципы помогают нам делить системы на хорошо изолированные компоненты, что позволяет максимально долго оставлять как можно больше вариантов открытыми.
Хорошая архитектура упрощает возможность изменения системы во всех ее вариантах, оставляя возможности открытыми.
Разделение уровней
Поговорим о вариантах использования. Архитектор стремится получить структуру системы, поддерживающую необходимые варианты использования, но не знает их все. Зато он знает основное назначение системы. Это может быть онлайн-магазин, система учета материалов или система обработки заказов. Поэтому, опираясь на контекст назначения системы, архитектор может использовать принципы единственной ответственности и согласованного изменения для отделения друг от друга всего, что изменяется по разным причинам, и объединения всего, что изменяется по одной причине.
Что изменяется по разным причинам? Кое-что из этого сразу бросается в глаза. Пользовательский интерфейс изменяется по причинам, не имеющим ничего общего с бизнес-правилами. Все варианты использования включают оба этих элемента. Очевидно, что поэтому хороший архитектор отделит часть, отвечающую за пользовательский интерфейс, от части, реализующей бизнес-правила, так, что их можно будет изменять независимо друг от друга, сохраняя при этом ясную видимость вариантов использования.
Сами бизнес-правила могут делиться на более или менее тесно связанные с приложением. Например, проверка полей ввода — это бизнес-правило, тесно связанное с приложением. Вычисление процентов по вкладу и подсчет запасов, напротив, — это бизнес-правила, более тесно связанные с предметной областью. Эти два разных вида бизнес-правил будут изменяться с разной скоростью и по разным причинам, поэтому их следует разделить так, чтобы их можно было изменять независимо.
База данных, язык запросов и даже схема — все это технические детали, которые не имеют ничего общего с бизнес-правилами и пользовательским интерфейсом. Они будут изменяться со скоростью и по причинам, не зависящим от других аспектов системы. Следовательно, в архитектуре они должны отделяться от остальной системы, чтобы их можно было изменять независимо.
То есть систему можно разделить на горизонтальные уровни: пользовательский интерфейс, бизнес-правила, характерные для приложения, бизнес-правила, не зависящие от приложения, и база данных — кроме всего прочего.
Разделение вариантов использования
Что еще меняется по разным причинам? Сами варианты использования! Добавление заказов в систему сопровождения заказов почти наверняка будет изменяться с иной скоростью и по иным причинам, чем удаление заказов из системы. Варианты использования дают очень естественный способ деления системы.
В то же время варианты использования образуют узкие вертикальные срезы, пересекающие горизонтальные слои системы. Каждый вариант использования включает какую-то долю пользовательского интерфейса, часть бизнес-правил, характерных для приложения, какие-то бизнес-правила, не зависящие от приложения, и некоторые функции базы данных. То есть вместе с делением системы на горизонтальные слои мы одновременно делим ее на тонкие вертикальные варианты использования, пересекающие эти слои.
Чтобы получить желаемое разделение, мы должны отделить пользовательский интерфейс добавления заказа от пользовательского интерфейса удаления заказа. То же самое нужно проделать с бизнес-правилами и базой данных. Вертикальные срезы случаев использования системы мы сохраняем отдельными.
Здесь можно увидеть определенный шаблон. Разделив элементы системы, изменяющиеся по разным причинам, вы сможете добавлять новые варианты использования, не влияя на имеющиеся. Если при этом вы сгруппируете поддержку этих случаев использования в пользовательском интерфейсе и в базе данных так, что для всех них в пользовательском интерфейсе и базе данных будут предусмотрены свои аспекты, тогда добавление новых случаев использования почти гарантированно не повлияет на имеющиеся.
Режим разделения
Теперь задумаемся, какое значение имеет разделение для эффективной работы. Если разные варианты использования разделены, тогда те, что должны работать с максимальной пропускной способностью, уже наверняка отделены от работающих с низкой пропускной способностью. Если пользовательский интерфейс и база данных отделены от бизнес-правил, значит, все они могут выполняться на разных серверах. Части, которые должны выполняться с максимальной пропускной способностью, можно запустить на нескольких серверах.
Проще говоря, разделение вариантов использования также помогает повысить эффективность работы. Однако для этого разделение должно иметь соответствующий режим. Чтобы разделенные компоненты могли выполняться на собственных серверах, они не должны зависеть от наличия общего адресного пространства в памяти. Они должны быть независимыми службами, взаимодействующими по сети.
Многие архитекторы называют такие компоненты «службами» или «микрослужбами», в зависимости от некоторого неопределенного понятия величины. Архитектуру, основанную на службах (или сервисах), часто называют сервис-ориентированной архитектурой.
Если в ответ на увиденные термины в вашей голове зазвенел звоночек, не волнуйтесь. Я не собираюсь убеждать вас, что SOA[35] — лучшая из возможных архитектур или что за микрослужбами будущее. Я лишь хотел отметить, что иногда мы должны разделять свои компоненты вплоть до создания отдельных служб.
Помните, что хорошая архитектура оставляет возможности открытыми. Режим разделения — одна из таких возможностей.
Прежде чем продолжить обсуждение этой темы, рассмотрим два других пункта.
Возможность независимой разработки
В третьем пункте, в начале главы, говорилось о простоте разработки. Очевидно, что полное разделение компонентов ослабляет взаимовлияние команд разработчиков. Если бизнес-правила никак не зависят от пользовательского интерфейса, тогда команда, занятая его разработкой, едва ли сможет как-то помешать команде, занятой реализацией бизнес-правил. Если варианты использования отделены друг от друга, тогда команда, сосредоточенная на реализации варианта addOrder, не сможет помешать команде, сосредоточенной на варианте deleteOrder.
Архитектура системы, разделенная на уровни и варианты, будет поддерживать любую организацию команд, по какому бы признаку они ни были организованы — функциональному, по компонентам, по уровням или как-то иначе.
Возможность независимого развертывания
Разделение на уровни и варианты использования помогает также добиться максимальной гибкости при развертывании. Фактически при эффективном разделении должна открываться возможность «горячей» замены уровней и вариантов использования в действующей системе. Добавление нового варианта использования может заключаться в простом копировании нескольких jar-файлов или служб, без влияния на что-то другое.
Дублирование
Архитекторы часто попадают в ловушку, зависящую от их страха перед дублированием.
Вообще в программном обеспечении считается, что дублирования следует избегать. Мы стараемся предотвратить дублирование программного кода. Когда обнаруживается действительно повторяющийся код, мы с благоговением смотрим, как профессионалы устраняют его.
Но есть разные виды дублирования. Есть истинное дублирование, когда любое изменение в одной копии требует того же изменения во всех остальных копиях. А есть ложное или случайное дублирование. Если два фрагмента кода, кажущиеся одинаковыми, развиваются разными путями — если они изменяются с разной скоростью и по разным причинам — этот случай не является истинным дублированием. Вернитесь к ним через несколько лет, и вы увидите, что они совершенно не похожи друг на друга.
Теперь представьте два варианта использования, одинаково отображающиеся на экране. Многие архитекторы будут испытывать непреодолимое желание использовать общую реализацию отображения. Но правы ли они? Действительно ли это истинное дублирование? Или сходство получилось случайным?
Скорее всего, это случайность. С течением времени эти два экрана будут приобретать все больше и больше различий и, наконец, будут выглядеть совершенно непохоже. По этой причине желательно избежать их объединения. Иначе потом разделить их будет очень сложно.
В процессе создания вертикального деления на варианты использования могут возникать проблемы и желание объединить какие-то варианты со схожими отображениями на экране, или алгоритмами, или запросами к базе данных и/или схемами. Будьте осторожны. Сопротивляйтесь рефлекторному желанию устранить дублирование. Убедитесь прежде, что дублирование действительно истинное.
Точно так же, выполняя горизонтальное деление на уровни, можно заметить, что структура данных в какой-то записи в базе данных очень похожа на структуру данных в экранной форме. В результате может возникнуть желание просто передать запись из базы данных в пользовательский интерфейс, не создавая промежуточную модель представления, которая, казалось бы, просто копирует элементы. Будьте осторожны: эта схожесть почти всегда оказывается случайной. Создание отдельной модели представления не требует много усилий и поможет вам правильно отделить уровни.
Режимы разделения (еще раз)
Вернемся к режимам. Существует много разных способов деления на уровни и варианты использования. Деление можно выполнить на уровне исходного кода, на уровне двоичного кода (развертывания) и на уровне единиц выполнения (служб).
• Уровень исходного кода. Мы можем так настроить зависимости между модулями с исходным кодом, чтобы изменения в одном модуле не вынуждали производить изменения в других (например, Ruby Gems).
При использовании этого режима разделения все компоненты выполняются в общем адресном пространстве и взаимодействуют, просто вызывая функции друг друга. То есть имеется единственный выполняемый файл, загружаемый в память компьютера. Люди часто называют это монолитной структурой.
• Уровень развертывания. Мы можем так настроить зависимости между единицами развертывания, jar-файлами или динамически загружаемыми библиотеками, чтобы изменения в исходном коде в одном модуле не вынуждали производить повторную сборку и развертывание других.
Многие компоненты могут находиться в общем адресном пространстве и взаимодействовать, вызывая функции друг друга. Другие компоненты могут выполняться в других процессах на той же машине и взаимодействовать посредством механизмов межпроцессных взаимодействий, сокетов или разделяемой памяти. Важно отметить, что в этом случае разделенные компоненты находятся в независимых единицах развертывания, таких как jar-, gem-файлы или динамически загружаемые библиотеки.
• Уровень служб. Мы можем ограничить зависимости до уровня структур данных и взаимодействовать, обмениваясь исключительно сетевыми пакетами, чтобы каждая единица выполнения была по-настоящему независимой от изменений в исходном и двоичном коде в других (как, например, службы и микрослужбы).
Какой режим лучше?
Ответ заключается в следующем: на ранних этапах разработки проекта трудно понять, какой режим лучше. В действительности по мере взросления проекта оптимальный режим может изменяться.
Например, нетрудно представить, что система, вполне комфортно чувствовавшая себя на одном сервере, может разрастись до масштабов, когда удобнее перенести некоторые ее компоненты на отдельные серверы. Пока система действует на одном сервере, разделения на уровне исходного кода может быть вполне достаточно. Но позднее может понадобиться разделить ее на единицы развертывания или даже на службы.
Одно из решений (набирающее популярность в наши дни) заключается в изначальном разделении на службы. Однако такой подход дорог и способствует разделению на слишком крупные блоки. Какими бы «микро» ни получились микрослужбы, маловероятно, что разделение будет выполнено на достаточно мелкие блоки.
Еще одна проблема разделения на службы — дороговизна, и в отношении разработки, и в отношении системных ресурсов. Для преодоления границ между службами, когда в действительности в них нет необходимости, требуются дополнительные усилия, память и машинные такты. И да, я помню, что последние два дешевы, но первое — нет.
Я предпочитаю доводить деление до того состояния, когда при необходимости легко можно сформировать отдельные службы, но оставлять компоненты в общем адресном пространстве до тех пор, пока это возможно. Это оставляет открытой возможность организации служб.
При таком подходе компоненты первоначально разделяются на уровне исходного кода. Этого может оказаться достаточно для продолжительного развития проекта. Если возникают проблемы с развертыванием или разработкой, разделения до уровня развертывания может быть достаточно — по крайней мере на какое-то время.
По мере накопления проблем с разработкой, развертыванием и эффективной работой я тщательно отбираю единицы развертывания, которые можно преобразовать в службы, и постепенно двигаю систему в этом направлении.
Со временем требования к эффективности работы могут снижаться. И для системы, прежде требовавшей разделения до уровня служб, теперь может оказаться достаточно разделения до уровня развертывания или даже исходного кода.
Хорошая архитектура позволит создать монолитную систему, развертываемую как один файл, а затем превратить ее в набор независимых единиц развертывания и далее в независимые службы и/или микрослужбы. Позднее, когда что-то изменится, она должна позволить обратить все вспять и вновь вернуться к монолитной структуре.
Хорошая архитектура защищает большую часть исходного кода от таких изменений. Она оставляет режим разделения открытым, поэтому для крупномасштабных вариантов развертывания может использоваться один режим, а для мелкомасштабных — другой.
Заключение
Да, все это непросто. Впрочем, я и не говорю, что изменение режимов разделения должно сводиться к тривиальным изменениям в конфигурации (хотя иногда такое возможно). Я говорю, что режим разделения системы на компоненты относится к числу вариантов, которые, скорее всего, будут изменяться со временем, и хороший архитектор должен предвидеть такие изменения и стремиться к упрощению.
Глава 17. Границы: проведение разделяющих линий
Разработка архитектуры программного обеспечения — это искусство проведения разделяющих линий, которые я называю границами. Границы отделяют программные элементы друг от друга и избавляют их от необходимости знать, что находится по ту сторону. Некоторые из этих линий можно провести на самых ранних этапах развития проекта — даже до появления первого программного кода. Другие проводятся намного позже. Границы, проводимые на ранних этапах, призваны отложить принятие решений на как можно долгий срок и предотвратить загрязнение основной бизнес-логики этими решениями.
Напомню, что целью архитектора является минимизация трудозатрат на создание и сопровождение системы. Что может помешать достижению этой цели? Зависимость — и особенно зависимость от преждевременных решений.
Какие решения можно назвать преждевременными? Решения, не имеющие ничего общего с бизнес-требованиями — вариантами использования — системы. К ним можно отнести решения о выборе фреймворка, базы данных, веб-сервера, вспомогательных библиотек, механизма внедрения зависимостей и т.п. В хорошей архитектуре подобные решения носят вспомогательный характер и откладываются на потом. Хорошая архитектура не зависит от таких решений. Хорошая архитектура позволяет принимать эти решения в самый последний момент без существенного влияния на саму архитектуру.
Пара печальных историй
Расскажу одну печальную историю о компании P, которая послужит предупреждением всем, кто торопится принимать решения. В 1980-х годах основатели компании P написали простое монолитное приложение для настольного компьютера. Оно пользовалось большим успехом, и в 1990-е годы этот продукт превратили в популярное и успешное приложение с графическим интерфейсом.
Но потом, в конце 1990-х годов, началось бурное развитие Всемирной паутины. Многие внезапно решили, что у них должны быть свои веб-решения, и P не стала исключением. Клиенты компании P настойчиво требовали написать версию продукта для Веб. Чтобы удовлетворить это требование, компания наняла двадцать с лишним крутых программистов на Java и приступила к проектированию веб-версии своего продукта.
Парни грезили о фермах серверов, поэтому решили выбрать богатую трехуровневую «архитектуру»[36], которую они могли бы развернуть на такой ферме. Они предполагали, что у них будут отдельные серверы для обслуживания интерфейса с пользователем, серверы для промежуточного программного обеспечения и серверы для баз данных. Но красиво было на бумаге, да забыли про овраги.
Программисты слишком рано решили, что все предметные объекты должны иметь по три экземпляра: один для уровня графического интерфейса, один для промежуточного уровня и один для уровня базы данных. Так как все эти экземпляры находились на разных серверах, была создана разветвленная система взаимодействий между процессами и уровнями. Вызовы методов между уровнями преобразовывались в объекты, которые подвергались сериализации и передавались по сети.
Теперь представьте, во что выливается простое изменение, такое как добавление нового поля в существующую запись. Поле нужно добавить в классы на всех трех уровнях, а также в некоторые сообщения, циркулирующие между уровнями. Так как передача данных происходит в обоих направлениях, необходимо спроектировать четыре протокола обмена сообщениями. Каждый протокол имеет отправляющую и принимающую стороны, итого необходимо реализовать восемь обработчиков протоколов. Нужно собрать три выполняемых файла, в каждом из которых находятся три измененных бизнес-объекта, четыре новых сообщения и восемь новых обработчиков.
А теперь задумайтесь, что все эти выполняемые файлы должны делать, чтобы выполнить простейшее действие. Они создают экземпляры объектов, выполняют их сериализацию, маршалинг и демаршалинг, конструируют и анализируют сообщения, выполняют операции с сокетами, обрабатывают тайм-ауты и производят повторные попытки и много чего еще только ради выполнения одной простой операции.
Конечно, во время разработки у программистов не было фермы серверов. В действительности они просто запускали все три выполняемых файла в трех разных процессах на одной машине. Так они развивали проект в течение нескольких лет, но были уверены, что их архитектура правильная. И поэтому даже когда система выполнялась на одной машине, она по-прежнему продолжала создавать объекты, выполнять их сериализацию, маршалинг и демаршалинг, конструировать и анализировать сообщения, оперировать сокетами и много чего еще, ненужного на одной машине.
По иронии ни одна из систем, проданных компанией P, не требовала фермы серверов. Все системы, когда-либо развертывавшиеся компанией, размещались на единственном сервере. И на этом единственном сервере три выполняемых файла продолжали создавать объекты, выполнять их сериализацию, маршалинг и демаршалинг, конструировать и анализировать сообщения, оперировать сокетами и много чего еще в ожидании фермы серверов, которая никогда не существовала и никогда не будет существовать.
Трагедия в том, что архитекторы, приняв преждевременное решение, чрезмерно увеличили трудозатраты на разработку.
История с компанией P не единственная. Я наблюдал нечто подобное много раз и во многих местах. Фактически компания P является лишь одним из примеров.
Но есть еще более печальные примеры, чем пример компании P.
Представьте себе компанию W, местное предприятие, управляющее парками служебных автомобилей. Недавно они наняли «Архитектора», чтобы взять под контроль разрозненные усилия по разработке программного обеспечения. И, хочу вам сообщить, «Контроль» — это второе имя того парня. Он быстро решил, что требуется создать полномасштабную, корпоративную, сервис-ориентированную «АРХИТЕКТУРУ». Он создал гигантскую предметную модель всех возможных «бизнес-объектов», спроектировал набор служб для управления этими объектами и направил разработчиков по пути в Ад. Чтобы было понятнее, представьте, что вам понадобилось добавить имя, адрес и номер телефона контактного лица в запись о продаже. Для этого нужно обратиться в ServiceRegistry, запросить идентификатор службы ContactService. Затем послать сообщение CreateContact в ContactService. Конечно, это сообщение имеет десятки полей, каждое из которых должно содержать достоверные данные — данные, к которым у программиста не было доступа, потому что программист имел только имя, адрес и номер телефона. После заполнения полей ложными данными программист должен был вставить идентификатор вновь созданного контакта в запись и послать сообщение UpdateContact службе SaleRecordService.
Чтобы протестировать все это, нужно по порядку запустить все необходимые службы, поднять шину сообщений и сервер BPel и... И затем все эти сообщения перемещались от службы к службе и ждали обработки то в одной очереди, то в другой.
Чтобы добавить что-то новое, представьте только зависимости между всеми этими службами и объем кода WSDL, который нужно изменить, а потом повторно развернуть модули, в которые были внесены изменения...
В сравнении с этим ад начинает казаться не таким плохим местом.
В программной системе, организованной в виде набора служб, нет ничего принципиально неправильного. Ошибка компании W заключалась в преждевременном решении внедрить комплекс инструментов для поддержки SOA — то есть массивного набора служб для работы с предметными объектами. За эту ошибку пришлось заплатить человеко-часами — большим количеством человеко-часов, — сброшенными с вершины SOA.
Я мог бы описывать архитектурные провалы один за другим. Но давайте лучше поговорим об успешных примерах.
FitNesse
Мой сын Майка и я начали работу над проектом FitNesse в 2001 году. Мы намеревались создать простую вики-страницу, обертывающую инструмент FIT Уорда Каннингема для разработки приемочных тестов.
Это было еще до того, как в Maven «решили» проблему jar-файла. Я был абсолютно уверен: все, что мы производим, не должно вынуждать людей загружать больше одного jar-файла. Я назвал это правило «Загрузи и вперед». Это правило управляло многими нашими решениями.
Одним из первых решений было создание собственного веб-сервера, отвечающего потребностям FitNesse. Это решение может показаться абсурдным. Даже в 2001 году существовало немало веб-серверов с открытым исходным кодом, которые мы могли бы использовать. Тем не менее решение написать свой сервер оправдало себя, потому что реализовать простой веб-сервер совсем несложно и это позволило нам отложить решение о выборе веб-фреймворка на более поздний срок[37].
Еще одно решение, принятое на ранней стадии, — не думать о базе данных. У нас была задумка использовать MySQL, но мы намеренно отложили это решение, использовав дизайн, сделавший это решение несущественным. Суть его заключалась в том, чтобы вставить интерфейс между всеми обращениями к данным и самим хранилищем.
Мы поместили методы доступа к данным в интерфейс с именем WikiPage. Эти методы обеспечивали все необходимое для поиска, извлечения и сохранения страниц. Конечно, мы не реализовали эти методы с самого начала, а просто добавили «заглушки», пока работали над функциями, не связанными с извлечением и сохранением данных.
В действительности в течение трех месяцев мы работали над переводом текста вики-страницы в HTML. Это не потребовало использования какого-либо хранилища данных, поэтому мы создали класс с именем MockWikiPage, содержащий простые заглушки методов доступа к данным.
В какой-то момент этих заглушек оказалось недостаточно для новых функций, которые мы должны были написать. Нам понадобился настоящий доступ к данным, без заглушек. Поэтому мы создали новый класс InMemoryPage, производный от WikiPage. Этот класс реализовал методы доступа к данным в хеш-таблице с вики-страницами, хранящейся в ОЗУ.
Это позволило нам целый год писать функцию за функцией. В результате мы получили первую версию программы FitNesse, действующую именно так. Мы могли создавать страницы, ссылаться на другие страницы, применять самое причудливое форматирование и даже выполнять тесты под управлением FIT. Мы не могли только сохранять результаты нашего труда.
Когда пришло время реализовать долговременное хранение, мы снова подумали о MySQL, но решили, что в краткосрочной перспективе это необязательно, потому что хеш-таблицы было очень легко записывать в простые файлы. Как результат, мы реализовали класс FileSystemWikiPage, который работал с простыми файлами, и продолжили работу над созданием новых возможностей.
Три месяца спустя мы пришли к заключению, что решение на основе простых файлов достаточно хорошее, и решили вообще отказаться от идеи использовать MySQL. Мы отложили это решение до неопределенного будущего и никогда не оглядывались назад.
На этом история закончилась бы, если бы не один из наших клиентов, решивший, что ему очень нужно сохранить свою вики-страницу в MySQL. Мы показали ему архитектуру WikiPages, позволившую нам отложить решение. Через день он вернулся с законченной системой, работавшей в MySQL. Он просто написал производный класс MySqlWikiPage и получил необходимое.
Мы включили этот вариант в FitNesse, но никто больше не использовал его, по крайней мере, поэтому мы отбросили его. Даже клиент, написавший производный класс, в конечном счете отказался от него.
Начиная работу над FitNesse, мы провели границу между бизнес-правилами и базами данных. Эта граница позволила реализовать бизнес-правила так, что они вообще никак не зависели от выбора базы данных, им требовались только методы доступа к данным. Это решение позволило нам больше года откладывать выбор и реализацию базы данных. Оно позволило опробовать вариант с файловой системой и изменить направление, когда мы увидели лучшее решение. Кроме того, оно не препятствовало и даже не мешало движению в первоначальном направлении (к MySQL), когда кто-то пожелал этого.
Факт отсутствия действующей базы данных в течение 18 месяцев разработки означал, что 18 месяцев мы не испытывали проблем со схемами, запросами, серверами баз данных, паролями, тайм-аутами и прочими неприятностями, которые непременно начинают проявляться, как только вы включаете в работу базу данных. Это также означало, что все наши тесты выполнялись очень быстро, потому что не было базы данных, тормозившей их.
Проще говоря, проведение границ помогло нам задержать и отложить принятие решений, и это сэкономило нам немало времени и нервов. Именно такой должна быть хорошая архитектура.
Какие границы проводить и когда?
Отделять линиями нужно все, что не имеет значения. Графический интерфейс не имеет значения для бизнес-правил, поэтому между ними нужно провести границу. База данных не имеет значения для графического интерфейса, поэтому между ними нужно провести границу. База данных не имеет значения для бизнес-правил, поэтому между ними нужно провести границу.
Возможно, кто-то из вас пожелал бы возразить против этих заявлений, особенно против заявления о независимости бизнес-правил от базы данных. Многие из нас привыкли считать, что база данных неразрывно связана с бизнес-правилами. Кое-кто может быть даже уверен, что база данных является воплощением бизнес-правил.
Но, как будет показано в другой главе, эта идея ошибочна. База данных — это инструмент, который бизнес-правила могут использовать опосредованно. Бизнес-правила не зависят от конкретной схемы, языка запросов и любых других деталей организации базы данных. Бизнес-правилам требуется только набор функций для извлечения и сохранения данных. Это позволяет нам скрыть базу данных за интерфейсом.
Это ясно видно на рис. 17.1. Класс BusinessRules использует интерфейс DatabaseInterface для загрузки и сохранения данных. Класс DatabaseAccess реализует интерфейс и выполняет операции в фактической базе данных Database.
Классы и интерфейсы на этой диаграмме носят символический характер. В настоящем приложении может иметься несколько классов бизнес-правил, несколько интерфейсов доступа данным и несколько классов, реализующих этот доступ. Но все они будут следовать примерно одному и тому же шаблону.
Рис. 17.1. База данных за интерфейсом
Где здесь граница? Граница пересекает отношение наследования чуть ниже DatabaseInterface (рис. 17.2).
Рис. 17.2. Линия границы
Обратите внимание на стрелки, исходящие из класса DatabaseAccess. Они обе выходят из класса DatabaseAccess, а это значит, что никакой другой класс не знает о существовании DatabaseAccess.
Теперь отступим на шаг назад и рассмотрим компонент с несколькими бизнес-правилами и компонент с базой данных и всеми необходимыми классами доступа (рис. 17.3).
Рис. 17.3. Компоненты с бизнес-правилами и базой данных
Обратите внимание на направление стрелки. Компонент Database знает о существовании компонента BusinessRules. Компонент BusinessRules не знает о существовании компонента Database. Это говорит о том, что интерфейсы DatabaseInterface находятся в компоненте BusinessRules, а классы DatabaseAccess — в компоненте Database.
Направление этой стрелки важно. Оно показывает, что компонент Database не имеет значения для BusinessRules, но Database не может существовать без BusinessRules.
Если вам это кажется странным, просто вспомните, что компонент Database содержит код, транслирующий вызовы, выполняемые компонентом BusinessRules, на язык запросов базы данных. Именно этот транслирующий код знает о существовании BusinessRules.
Проведя границу между двумя компонентами и направив стрелку в сторону BusinessRules, мы видим, что компонент BusinessRules мог бы использовать базу данных любого типа. Компонент Database можно заменить самыми разными реализациями — для BusinessRules это совершенно неважно.
Хранение данных можно организовать в базе данных Oracle, MySQL, Couch, Datomic или даже в простых файлах. Для бизнес-правил это совершенно неважно. А это означает, что выбор базы данных можно отложить и сосредоточиться на реализации и тестировании бизнес-правил.
О вводе и выводе
Разработчики и клиенты часто неправильно понимают, что такое система. Они видят графический интерфейс и думают, что он и есть система. Они определяют систему в терминах графического интерфейса и считают, что должны сразу начать работу с графическим интерфейсом. Они не понимают важнейшего принципа: ввод/вывод не важен.
В первый момент это может быть трудно понять. Мы часто рассуждаем о поведении системы в терминах ввода/вывода. Возьмем, например, видеоигру. В вашем представлении наверняка доминирует интерфейс: экран, мышь, управляющие клавиши и звуки. Вы забываете, что за этим интерфейсом находится модель — сложный комплекс структур данных и функций, — управляющая им. Что еще важнее, эта модель не нуждается в интерфейсе. Она благополучно будет решать свои задачи, моделируя все игровые события даже без отображения игры на экране. Интерфейс не важен для модели — для бизнес-правил.
И снова мы видим, что компоненты GUI и BusinessRules разделены границей (рис. 17.4). И снова мы видим, что менее важный компонент зависит от более важного компонента. Стрелки показывают, какой компонент знает о существовании другого и, соответственно, какой компонент зависит от другого. Компонент GUI зависит от BusinessRules.
Рис. 17.4. Граница между компонентами GUI и BusinessRules
Проведя границу и нарисовав стрелку, мы теперь видим, что GUI можно заменить интерфейсом какого-то другого вида — для BusinessRules это не важно.
Архитектура с плагинами
Вместе эти два решения о базе данных и графическом интерфейсе образуют шаблон для добавления других компонентов. Это тот же шаблон, что используется в системах, допускающих подключение сторонних сменных модулей — плагинов.
Фактически история технологий разработки программного обеспечения — это история создания плагинов для получения масштабируемой и управляемой архитектуры. Основные бизнес-правила хранятся отдельно и не зависят от компонентов, которые являются необязательными или могут быть реализованы в множестве разных форм (рис. 17.5).
Рис. 17.5. Подключение модулей к бизнес-правилам
Так как в таком дизайне пользовательский интерфейс считается плагином, мы можем позволить подключать множество разных пользовательских интерфейсов. Это могут быть веб-интерфейсы, интерфейсы клиент/сервер, интерфейсы служб, консольные интерфейсы или основанные на других способах взаимодействия с пользователем.
То же верно в отношении базы данных. Решив считать ее плагином, мы можем заменить ее любой базой данных SQL или NOSQL, простыми файлами или любыми другими технологиями хранения данных, которые мы сочтем необходимыми в будущем.