Автор книги: Роберт Мартин
Жанр: Программирование, Компьютеры
Возрастные ограничения: +16
сообщить о неприемлемом содержимом
Текущая страница: 6 (всего у книги 24 страниц) [доступный отрывок для чтения: 6 страниц]
Если вы испытали шок от схемы классов, представленной выше, взгляните на нее еще раз. Основная сложность в ней заключается в необходимости сориентировать зависимости между компонентами в правильных направлениях.
Например, интерфейс шлюза финансовых данных между генератором финансового отчета и диспетчером финансовых данных добавлен с целью обратить направление зависимости, которая иначе была бы направлена из компонента интерактора в компонент базы данных. То же относится к интерфейсу презентатора финансового отчета и двум интерфейсам представлений.
Сокрытие информацииИнтерфейс заказчика финансового отчета служит другой цели – защитить контроллер финансового отчета от необходимости знать внутренние особенности интерактора. В отсутствие этого интерфейса контроллер получил бы транзитивные зависимости от финансовых сущностей.
Транзитивные (переходящие) зависимости нарушают общий принцип, согласно которому программные сущности не должны зависеть от того, что они не используют непосредственно. Мы вновь встретимся с этим принципом, когда будем обсуждать принципы разделения интерфейсов и совместного повторного использования (Common Reuse Principle; CRP).
Поэтому, даже при том, что высший приоритет имеет защита интерактора от изменений в контроллере, мы также должны защитить контроллер от изменений в интеракторе, скрыв детали реализации интерактора.
ЗаключениеПринцип открытости/закрытости – одна из движущих сил в архитектуре систем. Его цель – сделать систему легко расширяемой и обезопасить ее от влияния изменений. Эта цель достигается делением системы на компоненты и упорядочением их зависимостей в иерархию, защищающую компоненты уровнем выше от изменений в компонентах уровнем ниже.
9. Принцип подстановки Барбары Лисков
В 1988 году Барбара Лисков написала следующие строки с формулировкой определения подтипов.
Здесь требуется что-то вроде следующего свойства подстановки: если для каждого объекта o1 типа S существует такой объект o2 типа T, что для всех программ P, определенных в терминах T, поведение P не изменяется при подстановке o1 вместо o2, то S является подтипом T[24]24
Barbara Liskov. Data Abstraction and Hierarchy, SIGPLAN Notices 23, 5 (May 1988).
[Закрыть].
Чтобы понять эту идею, известную как принцип подстановки Барбары Лисков (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 был преобразован в более широкий принцип проектирования программного обеспечения, который распространяется также на интерфейсы и реализации.
Подразумеваемые интерфейсы могут иметь множество форм. Это могут быть интерфейсы в стиле 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 в базе данных с настройками. Настройки могли бы выглядеть как-то так:
UPI
Acme.com
Формат команды
/pickupAddress/%s/pickupTime/%s/dest/%s
UPI
*.*
Формат команды
/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]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]26
Мой первый работодатель хранил в шкафу несколько десятков колод перфокарт с исходным кодом библиотек подпрограмм. Когда кто-то писал новую программу, он просто брал требуемую колоду и добавлял ее в конец колоды со своей программой.
[Закрыть]. Библиотеки хранились в исходном коде, а не в двоичном.
Проблема такого подхода в ту эпоху состояла в том, что устройства были медленными, а память стоила дорого, и поэтому ее объем был ограничен. Компиляторам требовалось выполнить несколько проходов по исходному коду, но памяти было недостаточно, чтобы уместить в ней весь исходный код. Как следствие, компилятору приходилось несколько раз читать исходный код, используя медленные устройства.
Это требовало много времени, и чем больше была библиотека, тем дольше работал компилятор. Компиляция большой программы могла длиться часами.
Чтобы сократить время компиляции, программисты отделяли исходный код библиотек от приложений. Компилировали эти библиотеки отдельно и загружали готовый двоичный код в известный адрес – например, 20008. Они создавали таблицу символов для библиотеки и компилировали ее со своим прикладным кодом. Когда им требовалось запустить приложение, они загружали двоичный код библиотеки[27]27
В действительности на многих старых ЭВМ использовалась энергонезависимая оперативная память, которая не очищалась при выключении питания. Поэтому мы часто в течение нескольких дней использовали библиотеку, загруженную однажды.
[Закрыть], а затем загружали приложение. Память была организована, как показано на рис. 12.1.
Рис. 12.1. Организация памяти на заре программирования
Такой прием прекрасно работал, пока приложение умещалось в объем между адресами 00008 и 17778. Но если размер приложения оказывался больше отведенного адресного пространства, программисту приходилось разбивать программу на два сегмента, располагавшихся по обеим сторонам сегмента с библиотекой (рис. 12.2).
Рис. 12.2. Деление приложения на два сегмента
Очевидно, что так не могло продолжаться вечно. Добавляя новые функции в библиотеку, программисты выходили за границы объема, отведенного для нее, и были вынуждены выделять дополнительный сегмент (в этом примере начинающийся с адреса 70008). Такое фрагментирование программ и библиотек продолжалось с увеличением объемов памяти в компьютерах.
Совершенно понятно, что с этим нужно было что-то делать.
Внимание! Это не конец книги.
Если начало книги вам понравилось, то полную версию можно приобрести у нашего партнёра - распространителя легального контента. Поддержите автора!Правообладателям!
Данное произведение размещено по согласованию с ООО "ЛитРес" (20% исходного текста). Если размещение книги нарушает чьи-либо права, то сообщите об этом.Читателям!
Оплатили, но не знаете что делать дальше?