Чистая архитектура. Искусство разработки программного обеспечения Мартин Роберт
Самое важное, что нужно понять, — в данном примере в создание отчета вовлечены две отдельные ответственности: вычисление данных для отчета и представление этих данных в форме веб-отчета или распечатанного отчета.
Сделав такое разделение, мы должны организовать зависимости в исходном коде так, чтобы изменения в одной из ответственностей не вызывали необходимости изменений в другой. Кроме того, новая организация должна гарантировать возможность расширения поведения без отмены изменений.
Этого можно добиться, выделив процессы в классы, а классы в компоненты, ограниченные двойными линиями на рис. 8.2. Компонент в левом верхнем углу на этом рисунке — контроллер. В правом верхнем углу — интерактор, или посредник. В правом нижнем углу — база данных. Наконец, в левом нижнем углу изображены четыре компонента — презентаторы и представления.
Классы, отмеченные символами <I>, — это интерфейсы; отмеченные символами <DS> — это структуры данных (data structures). Простые стрелки соответствуют отношениям использования. Стрелки с треугольным наконечником соответствуют отношениям реализации или наследования.
Первое, на что следует обратить внимание, — все зависимости определены на уровне исходного кода. Стрелка, направленная от класса A к классу B, означает, что в исходном коде класса A упоминается имя класса B, но в коде класса B не упоминается имя класса A. Так, на рис. 8.2 диспетчер финансовых данных знает о существовании шлюза через отношение реализации, а шлюз финансовых данных ничего не знает о диспетчере.
Рис. 8.2. Выделение процессов в классы и выделение классов в компоненты
Также важно отметить, что каждая двойная линия пересекается только в одном направлении. Это означает, что все отношения компонентов однонаправленны, как показано на графе компонентов (рис. 8.3). Эти стрелки указывают на компоненты, которые мы стремимся защитить от изменения.
Позволю себе повторить еще раз: если компонент A требуется защитить от изменений в компоненте B, компонент B должен зависеть от компонента A.
Нам нужно защитить контроллер от изменений в презентаторах. Нам нужно защитить презентаторы от изменений в представлениях. Нам нужно защитить интерактор от изменений в... во всех остальных компонентах.
Интерактор находится в позиции, лучше соответствующей принципу открытости/закрытости. Изменения в базе данных, или в контроллере, или в презентаторах, или в представлениях не должны влиять на интерактор.
Рис. 8.3. Отношения компонентов однонаправленны
Почему интерактор должен придерживаться такой привилегированной позиции? Потому что он реализует бизнес-правила. Интерактор реализует политики высшего уровня в приложении. Все другие компоненты решают второстепенные задачи. Интерактор решает самую главную задачу.
Несмотря на то что контроллер является не таким важным компонентом, как интерактор, он важнее презентаторов и представлений. А презентаторы, хотя и менее важные, чем контроллеры, в свою очередь, важнее представлений.
Обратите внимание, что в результате выстраивается иерархия защиты, основанная на понятии «уровня». Интеракторы занимают самый верхний уровень, поэтому они должны быть самыми защищенными. Представления занимают самый низкий уровень, поэтому они наименее защищены. Презентаторы находятся уровнем выше представлений, но ниже контроллера или интерактора.
Именно так работает принцип открытости/закрытости на архитектурном уровне. Архитекторы разделяют функциональные возможности, опираясь на то, как, почему и когда их может потребоваться изменить, и затем организуют их в иерархию компонентов. Компоненты, находящиеся на верхних уровнях в такой иерархии, защищаются от изменений в компонентах на нижних уровнях.
Управление направлением
Если вы испытали шок от схемы классов, представленной выше, взгляните на нее еще раз. Основная сложность в ней заключается в необходимости сориентировать зависимости между компонентами в правильных направлениях.
Например, интерфейс шлюза финансовых данных между генератором финансового отчета и диспетчером финансовых данных добавлен с целью обратить направление зависимости, которая иначе была бы направлена из компонента интерактора в компонент базы данных. То же относится к интерфейсу презентатора финансового отчета и двум интерфейсам представлений.
Сокрытие информации
Интерфейс заказчика финансового отчета служит другой цели — защитить контроллер финансового отчета от необходимости знать внутренние особенности интерактора. В отсутствие этого интерфейса контроллер получил бы транзитивные зависимости от финансовых сущностей.
Транзитивные (переходящие) зависимости нарушают общий принцип, согласно которому программные сущности не должны зависеть от того, что они не используют непосредственно. Мы вновь встретимся с этим принципом, когда будем обсуждать принципы разделения интерфейсов и совместного повторного использования (Common Reuse Principle; CRP).
Поэтому, даже при том, что высший приоритет имеет защита интерактора от изменений в контроллере, мы также должны защитить контроллер от изменений в интеракторе, скрыв детали реализации интерактора.
Заключение
Принцип открытости/закрытости — одна из движущих сил в архитектуре систем. Его цель — сделать систему легко расширяемой и обезопасить ее от влияния изменений. Эта цель достигается делением системы на компоненты и упорядочением их зависимостей в иерархию, защищающую компоненты уровнем выше от изменений в компонентах уровнем ниже.
Глава 9. Принцип подстановки Барбары Лисков
В 1988 году Барбара Лисков написала следующие строки с формулировкой определения подтипов.
Здесь требуется что-то вроде следующего свойства подстановки: если для каждого объекта o1 типа S существует такой объект o2 типа T, что для всех программ P, определенных в терминах T, поведение P не изменяется при подстановке o1 вместо o2, то S является подтипом T[24].
Чтобы понять эту идею, известную как принцип подстановки Барбары Лисков (Liskov Substitution Principle; LSP), рассмотрим несколько примеров.
Руководство по использованию наследования
Представьте, что у нас есть класс с именем License, как показано на рис. 9.1. Этот класс имеет метод с именем calcFee(), который вызывается приложением Billing. Существует два «подтипа» класса License: PersonalLicense и BusinessLicense. Они реализуют разные алгоритмы расчета лицензионных отчислений.
Рис. 9.1. Класс License и его производные, соответствующие принципу LSP
Этот дизайн соответствует принципу подстановки Барбары Лисков, потому что поведение приложения Billing не зависит от использования того или иного подтипа. Оба подтипа могут служить заменой для типа License.
Проблема квадрат/прямоугольник
Классическим примером нарушения принципа подстановки Барбары Лисков может служить известная проблема квадрат/прямоугольник (рис. 9.2).
Рис. 9.2. Известная проблема квадрат/прямоугольник
В этом примере класс Square (представляющий квадрат) неправильно определен как подтип класса Rectangle (представляющего прямоугольник), потому что высоту и ширину прямоугольника можно изменять независимо; а высоту и ширину квадрата можно изменять только вместе. Поскольку класс User полагает, что взаимодействует с экземпляром Rectangle, его легко можно ввести в заблуждение, как демонстрирует следующий код:
Rectangle r = ...
r.setW(5);
r.setH(2);
assert(r.area() == 10);
Если на место ... подставить код, создающий экземпляр Square, тогда проверка assert потерпит неудачу. Единственный способ противостоять такому виду нарушений принципа LSP — добавить в класс User механизм (например, инструкцию if), определяющий ситуацию, когда прямоугольник фактически является квадратом. Так как поведение User зависит от используемых типов, эти типы не являются заменяемыми (совместимыми).
LSP и архитектура
На заре объектно-ориентированной революции принцип LSP рассматривался как руководство по использованию наследования, как было показано в предыдущих разделах. Но со временем LSP был преобразован в более широкий принцип проектирования программного обеспечения, который распространяется также на интерфейсы и реализации.
Подразумеваемые интерфейсы могут иметь множество форм. Это могут быть интерфейсы в стиле Java, реализуемые несколькими классами. Или это может быть группа классов на языке Ruby, реализующих методы с одинаковыми сигнатурами. Или это может быть набор служб, соответствующих общему интерфейсу REST.
Во всех этих и многих других ситуациях применим принцип LSP, потому что существуют пользователи, зависящие от четкого определения интерфейсов и замещаемости их реализаций.
Лучший способ понять значение LSP с архитектурной точки зрения — посмотреть, что случится с архитектурой системы при нарушении принципа.
Пример нарушения LSP
Допустим, что мы взялись за создание приложения, объединяющего несколько служб, предоставляющих услуги такси. Клиенты, как предполагается, будут использовать наш веб-сайт для поиска подходящего такси, независимо от принадлежности к той или иной компании. Как только клиент подтверждает заказ, наша система передает его выбранному такси, используя REST-службу.
Теперь предположим, что URI службы является частью информации, хранящейся в базе данных водителей. Выбрав водителя, подходящего для клиента, наша система извлекает URI из записи с информацией о водителе и использует ее для передачи заказа этому водителю.
Допустим, что для водителя с именем Bob адрес URI отправки заказа выглядит так:
purplecab.com/driver/Bob
Наша система добавит в конец этого URI информацию о заказе и пошлет его методом PUT:
purplecab.com/driver/Bob
/pickupAddress/24 Maple St.
/pickupTime/153
/destination/ORD
Это явно означает, что все службы должны соответствовать общему интерфейсу REST. Они должны единообразно интерпретировать поля pickupAddress, pickupTime и destination.
Теперь предположим, что компания такси Acme наняла несколько программистов, которые ознакомились со спецификацией недостаточно внимательно. Они сократили имя поля destination до dest. Компания Acme — крупнейшая компания такси в нашем регионе, и бывшая жена президента компании Acme вышла замуж за президента нашей компании, и... В общем, вы поняли. Что может произойти с архитектурой нашей системы?
Очевидно, мы должны бы добавить особый случай. Запрос с заказом для любого водителя из Acme должен бы конструироваться в соответствии с иным набором правил, чем для всех остальных.
Решить поставленную задачу проще всего простым добавлением инструкции if в модуль, занимающийся пересылкой заказов:
if (driver.getDispatchUri().startsWith("acme.com")) ...
Конечно, ни один архитектор, дорожащий своей репутацией, не позволил бы добавить такую конструкцию в систему. Появление слова «acme» непосредственно в коде создает возможность появления самых разных неприятностей, не говоря уже о бреши в безопасности.
Например, представьте, что компания Acme добилась большого успеха, купила компанию Purple Taxi и объединенная компания решила сменить имя и адрес веб-сайта и объединить все системы оригинальных компаний. Получается, что теперь мы должны добавить еще одну инструкцию if для «purple»?
Архитектор должен изолировать систему от ошибок, подобных этой, и добавить модуль, управляющий созданием команд доставки заказов в соответствии с параметрами, указанным для URI в базе данных с настройками. Настройки могли бы выглядеть как-то так:
URI Формат команды
Acme.com /pickupAddress/%s/pickupTime/%s/dest/%s
*.* /pickupAddress/%s/pickupTime/%s/destination/%s
В результате архитектор вынужден добавить важный и сложный механизм из-за того, что интерфейсы не всех REST-служб оказались совместимыми.
Заключение
Принцип подстановки Барбары Лисков может и должен распространяться до уровня архитектуры. Простое нарушение совместимости может вызвать загрязнение архитектуры системы значительным количеством дополнительных механизмов.
Глава 10. Принцип разделения интерфейсов
Происхождение названия принципа разделения интерфейсов (Interface Segregation Principle; ISP) наглядно иллюстрирует схема на рис. 10.1.
Рис. 10.1. Принцип разделения интерфейсов
В данной ситуации имеется несколько классов, пользующихся операциями в классе OPS. Допустим, что User1 использует только операцию op1, User2 — только op2 и User3 — только op3.
Теперь представьте, что OPS — это класс, написанный на таком языке, как Java. Очевидно, что в такой ситуации исходный код User1 непреднамеренно будет зависеть от op2 и op3, даже при том, что он не пользуется ими. Эта зависимость означает, что изменения в исходном коде метода op2 в классе OPS потребуют повторной компиляции и развертывания класса User1, несмотря на то что для него ничего не изменилось.
Эту проблему можно решить разделением операций по интерфейсам, как показано на рис. 10.2.
Рис. 10.2. Разделение операций
Если снова представить, что этот интерфейс реализован на языке со строгим контролем типов, таком как Java, исходный код User1 будет зависеть от U1Ops и op1, но не от OPS. То есть изменения в OPS, которые не касаются User1, не потребуют повторной компиляции и развертывания User1.
Принцип разделения интерфейсов и язык
Очевидно, что описание выше в значительной степени зависит от типа языка. Языки со статическими типами, такие как Java, вынуждают программистов создавать объявления, которые должны импортироваться или подключаться к исходному коду пользователя как-то иначе. Именно эти инструкции подключения в исходном коде пользователя создают зависимости и вынуждают выполнять повторную компиляцию и развертывание.
В языках с динамической типизацией, таких как Ruby или Python, подобные объявления отсутствуют в исходном коде — они определяются автоматически во время выполнения. То есть в исходном коде отсутствуют зависимости, вынуждающие выполнять повторную компиляцию и развертывание. Это главная причина, почему системы на языках с динамической типизацией получаются более гибкими и с меньшим количеством строгих связей.
Этот факт ведет нас к заключению, что принцип разделения интерфейсов является проблемой языка, а не архитектуры.
Принцип разделения интерфейсов и архитектура
Если отступить на шаг назад и взглянуть на коренные мотивы, стоящие за принципом разделения интерфейсов, можно заметить более глубинные проблемы. В общем случае опасно создавать зависимости от модулей, содержащих больше, чем требуется. Это справедливо не только в отношении зависимостей в исходном коде, которые могут вынуждать выполнять без необходимости повторную компиляцию и развертывание, но также на более высоком уровне — на уровне архитектуры.
Рассмотрим, например, действия архитектора, работающего над системой S. Он пожелал включить в систему некоторый фреймворк F. Теперь представьте, что авторы F связали его с поддержкой конкретной базы данных D. То есть S зависит от F, который зависит от D (рис. 10.3).
Рис. 10.3. Проблемная архитектура
Теперь представьте, что D включает функции, которые не используются фреймворком F и, соответственно, не используются системой S. Изменения в этих функциях внутри D могут вынудить повторно развернуть F и, соответственно, повторно развернуть S. Хуже того, ошибка в одной из таких функций внутри D может спровоцировать появление ошибок в F и S.
Заключение
Из вышесказанного следует вывод: зависимости, несущие лишний груз ненужных и неиспользуемых особенностей, могут стать причиной неожиданных проблем.
Мы развернем эту мысль подробнее при обсуждении принципа совместного использования (Common Reuse Principle; CRP) в главе 13 «Связность компонентов».
Глава 11. Принцип инверсии зависимости
Принцип инверсии зависимости (Dependency Inversion Principle; DIP) утверждает, что наиболее гибкими получаются системы, в которых зависимости в исходном коде направлены на абстракции, а не на конкретные реализации.
В языках со статической системой типов, таких как Java, это означает, что инструкции use, import и include должны ссылаться только на модули с исходным кодом, содержащим интерфейсы, абстрактные классы и другие абстрактные объявления. Никаких зависимостей от конкретных реализаций не должно быть.
То же правило действует для языков с динамической системой типов, таких как Ruby или Python. Исходный код не должен зависеть от модулей с конкретной реализацией. Однако в этих языках труднее определить, что такое конкретный модуль. В частности, это любой модуль, в котором реализованы вызываемые функции.
Очевидно, что принять эту идею за правило практически невозможно, потому что программные системы должны зависеть от множества конкретных особенностей. Например, String в Java — это конкретный класс и его невозможно сделать абстрактным. Зависимости исходного кода от конкретного модуля java.lang.string невозможно и не нужно избегать.
С другой стороны, класс String очень стабилен. Изменения в этот класс вносятся крайне редко и жестко контролируются. Программистам и архитекторам не приходится беспокоиться о частых и непредсказуемых изменениях в String.
По этим причинам мы склонны игнорировать стабильный фундамент операционной системы и платформы, рассуждая о принципе инверсии зависимости. Мы терпим эти конкретные зависимости, потому что уверенно можем положиться на их постоянство.
Мы должны избегать зависимости от неустойчивых конкретных элементов системы. То есть от модулей, которые продолжают активно разрабатываться и претерпевают частые изменения.
Стабильные абстракции
Каждое изменение абстрактного интерфейса вызывает изменение его конкретной реализации. Изменение конкретной реализации, напротив, не всегда сопровождается изменениями и даже обычно не требует изменений в соответствующих интерфейсах. То есть интерфейсы менее изменчивы, чем реализации.
Действительно, хорошие дизайнеры и архитекторы программного обеспечения всеми силами стремятся ограничить изменчивость интерфейсов. Они стараются найти такие пути добавления новых возможностей в реализации, которые не потребуют изменения интерфейсов. Это основа проектирования программного обеспечения.
Как следствие, стабильными называются такие архитектуры, в которых вместо зависимостей от переменчивых конкретных реализаций используются зависимости от стабильных абстрактных интерфейсов. Это следствие сводится к набору очень простых правил:
• Не ссылайтесь на изменчивые конкретные классы. Ссылайтесь на абстрактные интерфейсы. Это правило применимо во всех языках, независимо от устройства системы типов. Оно также накладывает важные ограничения на создание объектов и определяет преимущественное использование шаблона «Абстрактная фабрика».
• Не наследуйте изменчивые конкретные классы. Это естественное следствие из предыдущего правила, но оно достойно отдельного упоминания. Наследование в языках со статической системой типов является самым строгим и жестким видом отношений в исходном коде; следовательно, его следует использовать с большой осторожностью. Наследование в языках с динамической системой типов влечет меньшее количество проблем, но все еще остается зависимостью, поэтому дополнительная предосторожность никогда не помешает.
• Не переопределяйте конкретные функции. Конкретные функции часто требуют зависимостей в исходном коде. Переопределяя такие функции, вы не устраняете эти зависимости — фактически вы наследуете их. Для управления подобными зависимостями нужно сделать функцию абстрактной и создать несколько ее реализаций.
• Никогда не ссылайтесь на имена конкретных и изменчивых сущностей. В действительности это всего лишь перефразированная форма самого принципа.
Фабрики
Чтобы соблюсти все эти правила, необходимо предусмотреть особый способ создания изменчивых объектов. Это объясняется тем, что практически во всех языках создание объектов связано с образованием зависимостей на уровне исходного кода от конкретных определений этих объектов.
В большинстве объектно-ориентированных языков, таких как Java, для управления подобными нежелательными зависимостями можно использовать шаблон «Абстрактная фабрика».
Рисунок 11.1 демонстрирует, как работает такая схема. Приложение Application использует конкретную реализацию ConcreteImpl через интерфейс Service. Однако приложению требуется каким-то образом создавать экземпляры ConcreteImpl. Чтобы решить эту задачу без образования зависимости от ConcreteImpl на уровне исходного кода, приложение вызывает метод makeSvc интерфейса фабрики ServiceFactory. Этот метод реализован в классе ServiceFactoryImpl, наследующем ServiceFactory. Эта реализация создает экземпляр ConcreteImpl и возвращает его как экземпляр интерфейса Service.
Рис. 11.1. Использование шаблона «Абстрактная фабрика» для управления зависимостями
Извилистая линия на рис. 11.1 обозначает архитектурную границу. Она отделяет абстракцию от конкретной реализации. Все зависимости в исходном коде пересекают эту границу в одном направлении — в сторону абстракции.
Извилистая линия делит систему на два компонента: абстрактный и конкретный. Абстрактный компонент содержит все высокоуровневые бизнес-правила приложения. Конкретный компонент содержит детали реализации этих правил.
Обратите внимание, что поток управления пересекает извилистую линию в направлении, обратном направлению зависимостей в исходном коде. Зависимости следуют в направлении, противоположном направлению потока управления — именно поэтому принцип получил название принципа инверсии зависимости.
Конкретные компоненты
Конкретный компонент ConcreteImpl на рис. 11.1 имеет единственную зависимость, то есть он нарушает принцип DIP. Это нормально. Полностью устранить любые нарушения принципа инверсии зависимости невозможно, но их можно сосредоточить в узком круге конкретных компонентов и изолировать от остальной системы.
Большинство систем будет содержать хотя бы один такой конкретный компонент — часто с именем main, потому что включает функцию main[25]. В схеме, изображенной на рис. 11.1, функция main могла бы создавать экземпляр ServiceFactoryImpl и сохранять ссылку на него в глобальной переменной типа ServiceFactory. Благодаря этому приложение Application сможет использовать данную глобальную переменную для обращения к фабрике.
Заключение
По мере продвижения вперед и знакомства с высокоуровневыми архитектурными принципами мы снова и снова будем сталкиваться с принципом инверсии зависимостей. Он будет самым заметным организационным принципом в наших архитектурных диаграммах. Извилистая линия на рис. 11.1 часто будет обозначать архитектурные границы в последующих главах. Зависимости будут пересекать эту извилистую линию в одном направлении, в сторону более абстрактных сущностей, и это станет для нас новым правилом, которое мы будем называть правилом зависимостей.
Часть IV. Принципы организации компонентов
Принципы SOLID определяют, как выкладывать кирпичами стены, образующие комнаты, а принципы организации компонентов — как размещать комнаты в зданиях. Большие программные системы, подобно большим зданиям, строятся из меньших компонентов.
В части IV мы познакомимся с программными компонентами, узнаем, из каких элементов они состоят и как конструировать системы из них.
Глава 12. Компоненты
Компоненты — это единицы развертывания. Они представляют наименьшие сущности, которые можно развертывать в составе системы. В Java — это jar-файлы. В Ruby — gem-файлы. В .Net — библиотеки DLL. В компилирующих языках — комплексы двоичных файлов. В интерпретирующих языках — комплексы файлов с исходным кодом. Во всех языках — элементарная единица развертывания.
Компоненты могут объединяться в один выполняемый файл, собираться в один архив, например файл .war, или развертываться независимо, как отдельные плагины, загружаемые динамически, такие как файлы .jar, .dll или .exe. Но независимо от способа развертывания, правильно спроектированные компоненты всегда сохраняют возможность независимого развертывания и, соответственно, могут разрабатываться независимо.
Краткая история компонентов
На заре разработки программного обеспечения программисты сами определяли организацию памяти в своих программах. В первых строках кода часто присутствовала инструкция origin, объявлявшая начальный адрес в памяти для загрузки программы.
Взгляните не следующую простую программу для PDP-8. Она состоит из подпрограммы с именем GETSTR, которая принимает ввод с клавиатуры в виде строки и сохраняет его в буфер. В ней также имеется короткий модульный тест для проверки GETSTR.
*200
TLS
START, CLA
TAD BUFR
JMS GETSTR
CLA
TAD BUFR
JMS PUTSTR
JMP START
BUFR, 3000
GETSTR, 0
DCA PTR
NXTCH, KSF
JMP -1
KRB
DCA I PTR
TAD I PTR
AND K177
ISZ PTR
TAD MCR
SZA
JMP NXTCH
K177, 177
MCR, -15
Обратите внимание на команду *200 в начале программы. Она сообщает компилятору, что сгенерированный им код будет загружаться в память, начиная с адреса 2008 (в восьмеричной системе счисления).
Такой способ программирования чужд современным программистам. Они редко задумываются, в какую область памяти будет загружаться программа. Но давным-давно это было одним из первых решений, которые программист должен был принять. В ту пору программы были неперемещаемыми.
Как осуществлялся доступ к библиотечным функциям в те дни? Это иллюстрирует предыдущий пример. Программисты включали исходный код библиотек в свои программы и компилировали их как одно целое[26]. Библиотеки хранились в исходном коде, а не в двоичном.
Проблема такого подхода в ту эпоху состояла в том, что устройства были медленными, а память стоила дорого, и поэтому ее объем был ограничен. Компиляторам требовалось выполнить несколько проходов по исходному коду, но памяти было недостаточно, чтобы уместить в ней весь исходный код. Как следствие, компилятору приходилось несколько раз читать исходный код, используя медленные устройства.
Это требовало много времени, и чем больше была библиотека, тем дольше работал компилятор. Компиляция большой программы могла длиться часами.
Чтобы сократить время компиляции, программисты отделяли исходный код библиотек от приложений. Компилировали эти библиотеки отдельно и загружали готовый двоичный код в известный адрес — например, 20008. Они создавали таблицу символов для библиотеки и компилировали ее со своим прикладным кодом. Когда им требовалось запустить приложение, они загружали двоичный код библиотеки[27], а затем загружали приложение. Память была организована, как показано на рис. 12.1.
Рис. 12.1. Организация памяти на заре программирования
Такой прием прекрасно работал, пока приложение умещалось в объем между адресами 00008 и 17778. Но если размер приложения оказывался больше отведенного адресного пространства, программисту приходилось разбивать программу на два сегмента, располагавшихся по обеим сторонам сегмента с библиотекой (рис. 12.2).
Рис. 12.2. Деление приложения на два сегмента
Очевидно, что так не могло продолжаться вечно. Добавляя новые функции в библиотеку, программисты выходили за границы объема, отведенного для нее, и были вынуждены выделять дополнительный сегмент (в этом примере начинающийся с адреса 70008). Такое фрагментирование программ и библиотек продолжалось с увеличением объемов памяти в компьютерах.
Совершенно понятно, что с этим нужно было что-то делать.
Перемещаемость
Решение проблемы было найдено в создании перемещаемого двоичного кода. Идея была проста. Компилятор изменили так, чтобы он производил двоичный код, который мог перемещаться в памяти специальным загрузчиком. Этому загрузчику можно было сообщить, с какого адреса тот должен загрузить перемещаемый код. Код снабжался флагами, сообщающими загрузчику, какие части загружаемых данных нужно изменить, чтобы загрузить в выбранный адрес. Обычно это означало простое добавление начального адреса к любым ссылкам в двоичном коде.
Теперь программист мог указать загрузчику начальный адрес для загрузки библиотеки и адрес для загрузки приложения. Фактически загрузчик мог принять несколько фрагментов двоичного кода и просто загрузить их в память друг за другом, корректируя адреса ссылок в них. Это позволило программистам загружать только необходимые функции.
В компилятор также была встроена возможность, позволяющая генерировать имена функций в виде метаданных. Если программа вызывала библиотечную функцию, компилятор определял ее имя как внешнюю ссылку. Если программа объявляла библиотечную функцию, компилятор определял ее имя как внешнее определение. После этого, зная адреса загрузки функций, загрузчик связывал внешние ссылки с внешними определениями.
Так родился связывающий загрузчик.
Компоновщики
Связывающий загрузчик дал программистам возможность делить свои программы на сегменты, компилируемые и загружаемые по отдельности. Такое решение оставалось жизнеспособным, пока относительно небольшие программы компоновались с относительно небольшими библиотеками. Однако в конце 1960-х — начале 1970-х годов программисты стали более честолюбивыми и их программы значительно выросли в размерах.
В результате связывающий загрузчик оказался слишком медлительным. Библиотеки хранились на устройствах с медленным доступом, таких как накопители на магнитной ленте. Даже дисковые устройства в ту пору были слишком медленными. Связывающие загрузчики должны были читать с этих медленных устройств десятки, а то и сотни библиотек в двоичном коде и корректировать внешние ссылки. С увеличением размеров программ и количества функций в библиотеках связывающему загрузчику часто требовалось больше часа, чтобы загрузить программу.
В конечном итоге загрузка и компоновка (связывание) были разделены на два отдельных этапа. Программисты выделили самый медленный этап — этап компоновки (связывания) — в отдельное приложение, получившее название компоновщик, или редактор связей (linker). В результате работы компоновщика получался скомпонованный и перемещаемый двоичный код, который загружался загрузчиком очень быстро. Это позволило программистам создавать выполняемый код, используя медленный компоновщик, который можно было быстро загрузить в любой момент.
Затем наступили 1980-е годы. Программисты писали программы на высокоуровневых языках, таких как C. С ростом их амбиций росли и создаваемые ими программы. Программы с сотнями и тысячами строк кода стали обычным делом.
Модули с исходным кодом в файлах .c компилировались в файлы .o и затем передавались компоновщику для создания выполняемых файлов, которые можно было быстро загрузить. Компиляция каждого отдельного модуля выполнялась относительно быстро, но компиляция всех модулей порой требовала значительного времени. Для корректировки связей компоновщику могло потребоваться еще больше времени. Во многих случаях длительность этого процесса снова стала достигать часа и даже больше.
Казалось, что программисты обречены всю жизнь ходить по кругу. Все усилия, сделанные в 1960-х, 1970-х и 1980-х годах для достижения высокой скорости рабочего процесса, пошли прахом из-за амбиций программистов, создававших большие программы. Казалось, нет выхода из замкнутого круга многочасового ожидания. Этап загрузки выполнялся быстро, но этап компиляции/компоновки оставался узким местом.
Мы на практике испытывали действие закона Мерфи о размере программы:
Любая программа растет, пока не заполнит все доступное время на компиляцию и компоновку.
Но Мерфи не был единственным на этом ринге. Вскоре появился Мур[28], и в конце 1980-х годов они схлестнулись. Мур вышел из этой схватки победителем. Диски начали уменьшаться в размерах и стали намного быстрее. Компьютерная память стала настолько дешевой, что большую часть данных на диске можно было уместить в кэше, в оперативной памяти. Тактовая частота процессоров выросла с 1 до 100 МГц.
К середине 1990-х годов время, необходимое на компоновку, сокращалось быстрее, чем росли наши программы. Во многих случаях время на компоновку сократилось до нескольких секунд. Для маленьких заданий вновь обрела жизнеспособность идея связывающего загрузчика.
Это была эпоха Active-X, динамических библиотек и первых файлов .jar. Компьютеры и периферийные устройства стали настолько быстрыми, что мы снова получили возможность выполнять компоновку (связывание) во время загрузки. Мы можем связать несколько файлов .jar или несколько динамических библиотек за секунды и выполнить получившуюся программу. Так родилась архитектура сменных компонентов (плагинов).
Сегодня мы обычно поставляем файлы .jar или динамические библиотеки как плагины к существующим приложениям. Чтобы создать новую модель для Minecraft, достаточно просто включить свои файлы .jar в определенную папку. Чтобы подключить плагин Resharper к среде разработки Visual Studio, достаточно добавить соответствующие библиотеки DLL.
Заключение
Динамически связываемые файлы, которые можно подключать во время выполнения, являются программными компонентами в наших архитектурах. Потребовалось 50 лет, но мы достигли такой степени развития, когда архитектура сменных плагинов стала применяться по умолчанию и без титанических усилий, как когда-то.
Глава 13. Связность компонентов
К какому компоненту отнести тот или иной класс? Это важное решение должно приниматься в соответствии с зарекомендовавшими себя принципами разработки программного обеспечения. К сожалению, подобные решения носят особый характер и принимаются почти исключительно исходя из контекста.
В этой главе мы обсудим три принципа, определяющих связность компонентов:
• REP: Reuse/Release Equivalence Principle — принцип эквивалентности повторного использования и выпусков;
• CCP: Common Closure Principle — принцип согласованного изменения;
• CRP: Common Reuse Principle — принцип совместного повторного использования.
Принцип эквивалентности повторного использования и выпусков
Единица повторного использования есть единица выпуска.
За последнее десятилетие появилось множество инструментов управления модулями, таких как Maven, Leiningen и RVM. Эти инструменты приобрели особую важность, потому что за это же время создано огромное количество многократно используемых компонентов и библиотек компонентов. Сейчас мы живем в эпоху программного обеспечения многократного использования, когда исполнилось одно из самых древних обещаний объектно-ориентированной модели.
Принцип эквивалентности повторного использования и выпусков (Reuse/Release Equivalence Principle; REP) выглядит очевидным, по крайней мере сейчас. Люди, нуждающиеся в программных компонентах многократного использования, не смогут и не будут пользоваться компонентами, не прошедшими процесс выпуска и не получившими номер версии.
И совсем не потому, что без номера версии невозможно гарантировать совместимость всех повторно используемых компонентов. А потому, что разработчики программного обеспечения желают знать, когда появится новая версия и какие изменения в этой версии произойдут.
Нередко разработчики, знающие о приближающемся выпуске новой версии, оценивают грядущие изменения и принимают решение о продолжении использования старой версии или переходе на новую. Поэтому в процессе выпуска создатели компонента должны рассылать соответствующие извещения и описание новой версии, чтобы пользователи могли принимать обоснованные решения о том, когда и следует ли переходить на новую версию.
С точки зрения архитектуры и дизайна этот принцип означает, что классы и модули, составляющие компонент, должны принадлежать связной группе. Компонент не может просто включать случайную смесь классов и модулей; должна быть какая-то тема или цель, общая для всех модулей.
Все это, безусловно, очевидно. Однако есть еще один аспект, возможно, не такой очевидный. Классы и модули, объединяемые в компонент, должны выпускаться вместе. Объединение их в один выпуск и включение в общую документацию с описанием этой версии должны иметь смысл для автора и пользователей.
Впрочем, говорить, что что-то должно «иметь смысл» — это все равно, что размахивать руками и пытаться выглядеть авторитетно. Это не самый лучший совет, потому что сложно точно описать, что объединяет классы и модули в единый компонент. Однако слабость совета не умаляет важности самого принципа, потому что его нарушение легко определяется по «отсутствию смысла». Если вы нарушите принцип REP, ваши пользователи узнают об этом и усомнятся в вашей компетентности как архитектора.
Слабость этого принципа с лихвой компенсируется силой двух следующих принципов. В действительности принципы CCP CRP строго определяют этот принцип, хотя и в негативном ключе.
Принцип согласованного изменения