Книга: Чистая архитектура. Искусство разработки программного обеспечения
Назад: 15. Что такое архитектура
Дальше: 17. Границы: проведение разделяющих линий

16. Независимость

Martin_Page_173_Image_0001.tif 

Как отмечалось выше, хорошая архитектура должна обеспечивать:

• Разнообразие вариантов использования и эффективную работу системы.

• Простоту сопровождения системы.

• Простоту разработки системы.

• Простоту развертывания системы.

Варианты использования

Первый пункт в списке — разнообразие вариантов использования — означает, что архитектура системы должна поддерживать назначение системы. Если система — это онлайн-магазин, архитектура должна поддерживать разные варианты использования онлайн-магазина. Фактически в этом заключается главная задача архитектора и главная цель архитектуры. Архитектура должна поддерживать разнообразие вариантов использования.

Однако, как обсуждалось выше, архитектура не оказывает большого влияния на поведение системы. Существует очень мало вариантов поведения, которые архитектура может оставить открытыми. Но влияние — это еще не все. Самое важное, что хорошая архитектура может сделать для поддержки поведения, — раскрывать и прояснять это поведение, чтобы назначение системы было видимо на архитектурном уровне.

Приложение онлайн-магазина с хорошей архитектурой будет выглядеть как приложение онлайн-магазина. Варианты использования такой системы будут ясно видны в ее структуре. Разработчикам не придется искать черты поведения, потому что они будут представлены полноценными элементами, видимыми на верхнем уровне системы. Такими элементами могут быть классы, функции или модули, занимающие видные позиции в архитектуре и имеющие имена, ясно описывающие их назначение.

В главе 21 «Кричащая архитектура» мы подробнее рассмотрим этот пункт.

Эффективность работы

Более существенную и менее косметическую роль архитектура играет в эффективной работе системы. Если система должна обслуживать 100 000 клиентов в секунду, архитектура должна поддерживать такую пропускную способность и время отклика для каждого возможного варианта. Если система должна запрашивать большие массивы данных за миллисекунды, архитектура должна быть структурирована так, чтобы обеспечивать возможность подобных операций.

Для одних систем это означает создание массивов маленьких служб, выполняющихся параллельно на множестве разных серверов. Для других — создание множества легковесных потоков выполнения, действующих в общем адресном пространстве, внутри одного процесса, выполняющегося на одном процессоре. Для третьих достаточно запустить несколько процессов, действующих в изолированных адресных пространствах. А для некоторых вполне подойдут простые монолитные программы.

Как ни странно, это решение относится к числу тех, которые хороший архитектор стремится оставить открытыми. Систему с монолитной организацией и зависящую от этой монолитной структуры трудно будет реорганизовать для поддержки выполнения в нескольких процессах, нескольких потоках или нескольких микрослужбах, если это потребуется. Напротив, архитектуру, предусматривающую надлежащую изоляцию компонентов и не предполагающую конкретных средств взаимодействий между компонентами, будет намного проще провести через спектр нескольких потоков, процессов и служб по мере изменения потребностей.

Разработка

Архитектура играет важную роль в поддержке разработки. В данном случае начинает действовать закон Конвея:

Любая организация, разрабатывающая систему, невольно будет формировать дизайн, структура которого повторяет структуру взаимодействий внутри этой организации.

В организации с множеством команд, решающей множество задач, должна получиться архитектура, которая обеспечивает независимую работу этих команд, чтобы команды не мешали друг другу во время разработки. Это достигается делением системы на изолированные компоненты, которые можно разрабатывать независимо. Затем эти компоненты распределяются между командами, которые могут работать независимо друг от друга.

Развертывание

Огромную роль архитектура играет также в простоте развертывания системы. Главная цель — «немедленное развертывание». Хорошая архитектура не полагается на десятки маленьких сценариев и файлов с определениями настроек. Она не требует вручную создавать каталоги или файлы и располагать их в особом порядке. Хорошая архитектура помогает системе достичь уровня немедленной готовности к развертыванию сразу после сборки.

И снова это достигается за счет разделения системы на компоненты и их изоляции, включая специальные компоненты, которые связывают систему воедино и гарантируют правильный запуск, интеграцию и контроль за работой каждого компонента.

Сохранение разнообразия вариантов

Хорошая архитектура стремится достичь баланса всех этих потребностей компонентной организации с целью удовлетворить их. Звучит просто, правда? Согласен, мне легко писать.

В действительности добиться такого баланса очень трудно. Проблема в том, что зачастую мы не знаем всех возможных вариантов использования, эксплуатационных ограничений, структуры команды разработчиков или требований к развертыванию. Хуже того, даже если бы мы все это знали, нет никаких гарантий, что ничего из этого не изменится в течение жизненного цикла системы. Проще говоря, мы преследуем нечеткие и переменчивые цели. Добро пожаловать в реальный мир.

Но не все потеряно: некоторые принципы строительства архитектуры относительно недороги в реализации и могут помочь сбалансировать эти нужды, даже когда нет четкой картины целей, к которым вы стремитесь. Эти принципы помогают нам делить системы на хорошо изолированные компоненты, что позволяет максимально долго оставлять как можно больше вариантов открытыми.

Хорошая архитектура упрощает возможность изменения системы во всех ее вариантах, оставляя возможности открытыми.

Разделение уровней

Поговорим о вариантах использования. Архитектор стремится получить структуру системы, поддерживающую необходимые варианты использования, но не знает их все. Зато он знает основное назначение системы. Это может быть онлайн-магазин, система учета материалов или система обработки заказов. Поэтому, опираясь на контекст назначения системы, архитектор может использовать принципы единственной ответственности и согласованного изменения для отделения друг от друга всего, что изменяется по разным причинам, и объединения всего, что изменяется по одной причине.

Что изменяется по разным причинам? Кое-что из этого сразу бросается в глаза. Пользовательский интерфейс изменяется по причинам, не имеющим ничего общего с бизнес-правилами. Все варианты использования включают оба этих элемента. Очевидно, что поэтому хороший архитектор отделит часть, отвечающую за пользовательский интерфейс, от части, реализующей бизнес-правила, так, что их можно будет изменять независимо друг от друга, сохраняя при этом ясную видимость вариантов использования.

Сами бизнес-правила могут делиться на более или менее тесно связанные с приложением. Например, проверка полей ввода — это бизнес-правило, тесно связанное с приложением. Вычисление процентов по вкладу и подсчет запасов, напротив, — это бизнес-правила, более тесно связанные с предметной областью. Эти два разных вида бизнес-правил будут изменяться с разной скоростью и по разным причинам, поэтому их следует разделить так, чтобы их можно было изменять независимо.

База данных, язык запросов и даже схема — все это технические детали, которые не имеют ничего общего с бизнес-правилами и пользовательским интерфейсом. Они будут изменяться со скоростью и по причинам, не зависящим от других аспектов системы. Следовательно, в архитектуре они должны отделяться от остальной системы, чтобы их можно было изменять независимо.

То есть систему можно разделить на горизонтальные уровни: пользовательский интерфейс, бизнес-правила, характерные для приложения, бизнес-правила, не зависящие от приложения, и база данных — кроме всего прочего.

Разделение вариантов использования

Что еще меняется по разным причинам? Сами варианты использования! Добавление заказов в систему сопровождения заказов почти наверняка будет изменяться с иной скоростью и по иным причинам, чем удаление заказов из системы. Варианты использования дают очень естественный способ деления системы.

В то же время варианты использования образуют узкие вертикальные срезы, пересекающие горизонтальные слои системы. Каждый вариант использования включает какую-то долю пользовательского интерфейса, часть бизнес-правил, характерных для приложения, какие-то бизнес-правила, не зависящие от приложения, и некоторые функции базы данных. То есть вместе с делением системы на горизонтальные слои мы одновременно делим ее на тонкие вертикальные варианты использования, пересекающие эти слои.

Чтобы получить желаемое разделение, мы должны отделить пользовательский интерфейс добавления заказа от пользовательского интерфейса удаления заказа. То же самое нужно проделать с бизнес-правилами и базой данных. Вертикальные срезы случаев использования системы мы сохраняем отдельными.

Здесь можно увидеть определенный шаблон. Разделив элементы системы, изменяющиеся по разным причинам, вы сможете добавлять новые варианты использования, не влияя на имеющиеся. Если при этом вы сгруппируете поддержку этих случаев использования в пользовательском интерфейсе и в базе данных так, что для всех них в пользовательском интерфейсе и базе данных будут предусмотрены свои аспекты, тогда добавление новых случаев использования почти гарантированно не повлияет на имеющиеся.

Режим разделения

Теперь задумаемся, какое значение имеет разделение для эффективной работы. Если разные варианты использования разделены, тогда те, что должны работать с максимальной пропускной способностью, уже наверняка отделены от работающих с низкой пропускной способностью. Если пользовательский интерфейс и база данных отделены от бизнес-правил, значит, все они могут выполняться на разных серверах. Части, которые должны выполняться с максимальной пропускной способностью, можно запустить на нескольких серверах.

Проще говоря, разделение вариантов использования также помогает повысить эффективность работы. Однако для этого разделение должно иметь соответствующий режим. Чтобы разделенные компоненты могли выполняться на собственных серверах, они не должны зависеть от наличия общего адресного пространства в памяти. Они должны быть независимыми службами, взаимодействующими по сети.

Многие архитекторы называют такие компоненты «службами» или «микрослужбами», в зависимости от некоторого неопределенного понятия величины. Архитектуру, основанную на службах (или сервисах), часто называют сервис-ориентированной архитектурой.

Если в ответ на увиденные термины в вашей голове зазвенел звоночек, не волнуйтесь. Я не собираюсь убеждать вас, что SOA — лучшая из возможных архитектур или что за микрослужбами будущее. Я лишь хотел отметить, что иногда мы должны разделять свои компоненты вплоть до создания отдельных служб.

Помните, что хорошая архитектура оставляет возможности открытыми. Режим разделения — одна из таких возможностей.

Прежде чем продолжить обсуждение этой темы, рассмотрим два других пункта.

Возможность независимой разработки

В третьем пункте, в начале главы, говорилось о простоте разработки. Очевидно, что полное разделение компонентов ослабляет взаимовлияние ­команд разработчиков. Если бизнес-правила никак не зависят от пользовательского интерфейса, тогда команда, занятая его разработкой, едва ли сможет как-то помешать команде, занятой реализацией бизнес-правил. Если варианты использования отделены друг от друга, тогда команда, сосредоточенная на реализации варианта addOrder, не сможет помешать команде, сосредоточенной на варианте deleteOrder.

Архитектура системы, разделенная на уровни и варианты, будет поддерживать любую организацию команд, по какому бы признаку они ни были организованы — функциональному, по компонентам, по уровням или как-то иначе.

Возможность независимого развертывания

Разделение на уровни и варианты использования помогает также добиться максимальной гибкости при развертывании. Фактически при эффективном разделении должна открываться возможность «горячей» замены уровней и вариантов использования в действующей системе. Добавление нового варианта использования может заключаться в простом копировании нескольких jar-файлов или служб, без влияния на что-то другое.

Дублирование

Архитекторы часто попадают в ловушку, зависящую от их страха перед дублированием.

Вообще в программном обеспечении считается, что дублирования следует избегать. Мы стараемся предотвратить дублирование программного кода. Когда обнаруживается действительно повторяющийся код, мы с благоговением смотрим, как профессионалы устраняют его.

Но есть разные виды дублирования. Есть истинное дублирование, когда любое изменение в одной копии требует того же изменения во всех остальных копиях. А есть ложное или случайное дублирование. Если два фрагмента кода, кажущиеся одинаковыми, развиваются разными путями — если они изменяются с разной скоростью и по разным причинам — этот случай не является истинным дублированием. Вернитесь к ним через несколько лет, и вы увидите, что они совершенно не похожи друг на друга.

Теперь представьте два варианта использования, одинаково отображающиеся на экране. Многие архитекторы будут испытывать непреодолимое желание использовать общую реализацию отображения. Но правы ли они? Действительно ли это истинное дублирование? Или сходство получилось случайным?

Скорее всего, это случайность. С течением времени эти два экрана будут приобретать все больше и больше различий и, наконец, будут выглядеть совершенно непохоже. По этой причине желательно избежать их объединения. Иначе потом разделить их будет очень сложно.

В процессе создания вертикального деления на варианты использования могут возникать проблемы и желание объединить какие-то варианты со схожими отображениями на экране, или алгоритмами, или запросами к базе данных и/или схемами. Будьте осторожны. Сопротивляйтесь рефлекторному желанию устранить дублирование. Убедитесь прежде, что дублирование действительно истинное.

Точно так же, выполняя горизонтальное деление на уровни, можно заметить, что структура данных в какой-то записи в базе данных очень похожа на структуру данных в экранной форме. В результате может возникнуть желание просто передать запись из базы данных в пользовательский интерфейс, не создавая промежуточную модель представления, которая, казалось бы, просто копирует элементы. Будьте осторожны: эта схожесть почти всегда оказывается случайной. Создание отдельной модели представления не требует много усилий и поможет вам правильно отделить уровни.

Режимы разделения (еще раз)

Вернемся к режимам. Существует много разных способов деления на уровни и варианты использования. Деление можно выполнить на уровне исходного кода, на уровне двоичного кода (развертывания) и на уровне единиц выполнения (служб).

• Уровень исходного кода. Мы можем так настроить зависимости между модулями с исходным кодом, чтобы изменения в одном модуле не вынуждали производить изменения в других (например, Ruby Gems).

При использовании этого режима разделения все компоненты выполняются в общем адресном пространстве и взаимодействуют, просто вызывая функции друг друга. То есть имеется единственный выполняемый файл, загружаемый в память компьютера. Люди часто называют это монолитной структурой.

• Уровень развертывания. Мы можем так настроить зависимости между единицами развертывания, jar-файлами или динамически загружаемыми библиотеками, чтобы изменения в исходном коде в одном модуле не вынуждали производить повторную сборку и развертывание других.

Многие компоненты могут находиться в общем адресном пространстве и взаимодействовать, вызывая функции друг друга. Другие компоненты могут выполняться в других процессах на той же машине и взаимодействовать посредством механизмов межпроцессных взаимодействий, сокетов или разделяемой памяти. Важно отметить, что в этом случае разделенные компоненты находятся в независимых единицах развертывания, таких как jar-, gem-файлы или динамически загружаемые библиотеки.

• Уровень служб. Мы можем ограничить зависимости до уровня структур данных и взаимодействовать, обмениваясь исключительно сетевыми пакетами, чтобы каждая единица выполнения была по-настоящему независимой от изменений в исходном и двоичном коде в других (как, например, службы и микрослужбы).

Какой режим лучше?

Ответ заключается в следующем: на ранних этапах разработки проекта трудно понять, какой режим лучше. В действительности по мере взросления проекта оптимальный режим может изменяться.

Например, нетрудно представить, что система, вполне комфортно чувствовавшая себя на одном сервере, может разрастись до масштабов, когда удобнее перенести некоторые ее компоненты на отдельные серверы. Пока система действует на одном сервере, разделения на уровне исходного кода может быть вполне достаточно. Но позднее может понадобиться разделить ее на единицы развертывания или даже на службы.

Одно из решений (набирающее популярность в наши дни) заключается в изначальном разделении на службы. Однако такой подход дорог и способствует разделению на слишком крупные блоки. Какими бы «микро» ни получились микрослужбы, маловероятно, что разделение будет выполнено на достаточно мелкие блоки.

Еще одна проблема разделения на службы — дороговизна, и в отношении разработки, и в отношении системных ресурсов. Для преодоления границ между службами, когда в действительности в них нет необходимости, требуются дополнительные усилия, память и машинные такты. И да, я помню, что последние два дешевы, но первое — нет.

Я предпочитаю доводить деление до того состояния, когда при необходимости легко можно сформировать отдельные службы, но оставлять компоненты в общем адресном пространстве до тех пор, пока это возможно. Это оставляет открытой возможность организации служб.

При таком подходе компоненты первоначально разделяются на уровне исходного кода. Этого может оказаться достаточно для продолжительного развития проекта. Если возникают проблемы с развертыванием или разработкой, разделения до уровня развертывания может быть достаточно — по крайней мере на какое-то время.

По мере накопления проблем с разработкой, развертыванием и эффективной работой я тщательно отбираю единицы развертывания, которые можно преобразовать в службы, и постепенно двигаю систему в этом направлении.

Со временем требования к эффективности работы могут снижаться. И для системы, прежде требовавшей разделения до уровня служб, теперь может оказаться достаточно разделения до уровня развертывания или даже исходного кода.

Хорошая архитектура позволит создать монолитную систему, развертываемую как один файл, а затем превратить ее в набор независимых единиц развертывания и далее в независимые службы и/или микрослужбы. Позднее, когда что-то изменится, она должна позволить обратить все вспять и вновь вернуться к монолитной структуре.

Хорошая архитектура защищает большую часть исходного кода от таких изменений. Она оставляет режим разделения открытым, поэтому для крупномасштабных вариантов развертывания может использоваться один режим, а для мелкомасштабных — другой.

Заключение

Да, все это непросто. Впрочем, я и не говорю, что изменение режимов разделения должно сводиться к тривиальным изменениям в конфигурации (хотя иногда такое возможно). Я говорю, что режим разделения системы на компоненты относится к числу вариантов, которые, скорее всего, будут изменяться со временем, и хороший архитектор должен предвидеть такие изменения и стремиться к упрощению.

SOA (Service-Oriented Architecture): сервис-ориентированная архитектура. — Примеч. пер.

Назад: 15. Что такое архитектура
Дальше: 17. Границы: проведение разделяющих линий