Обобщенный Model-View-Controller
    Сообщений 1    Оценка 75        Оценить  
Система Orphus

Обобщенный Model-View-Controller

Повышение качества графического интерфейса пользователя

Автор: Сергей Рогачев
ЗАО "Би-Эй-Си" | Группа компаний Астерос

Источник: RSDN Magazine #4-2008
Опубликовано: 26.04.2009
Исправлено: 10.12.2016
Версия текста: 2.0
1 Я возьму с собой в дорогу…
2 Шаблон проектирования Observer
2.1 Оповещение подписчиков и потоковая безопасность
2.2 Недействительные подписчики и утечка памяти
2.3 Безопасность инициализации подписчиков
2.4 Дефектные подписчики и отказоустойчивый издатель
2.5 Издатель на слабых ссылках
2.6 Множественный издатель
3 Потоковая безопасность модели
4 Шаблон проектирования Command
4.1 Команда
4.2 Контроллер
5 Защитное программирование
5.1 Ожидание ошибки
5.2 Локализация ошибки
5.3 Пример
5.4 Утверждения
5.5 Контрактные спецификации
6 Источники

Errare humanum est perseverare diabolicum. Seneka
СОВЕТ

Статья продолжает одноименный материал, опубликованный ранее (http://rsdn.ru/article/patterns/generic-mvc.xml), рассмотрением ошибок, допущенных в реализации обобщенного Model-View-Controller. Вместе с тем работа рассматривает общие проблемы и решения в области безопасного программирования, в частности: потоковую безопасность, ликвидацию утечки памяти, безопасность инициализации и защитное программирование на основе контрактных спецификаций – поэтому предполагается, что статья будет интересна всем, кто заинтересован в повышении качества своих приложений. В описании приводятся реализации шаблонов проектирования Observer, Command, Model-View-Presenter. Примеры построены на модульном тестировании и используют аспектно-ориентированное программирование. Предполагается наличие у читателя знания языка программирования Java 5 и модульного тестирования на основе платформы JUnit.

Source | Javadoc | AJdoc

Прежде всего, хочу ответить на одну из претензий к предыдущей статье: работа не раскрывает минусов проектирования приложений с графическим интерфейсом пользователя без использования шаблона проектирования Model-View-Controller (далее просто MVC).

В работе были описаны основные используемые в MVC шаблоны проектирования: Mediator, Observer, Command – как решения типовых задач, которые возникают при проектировании графического приложения. И мне показалось излишним приводить примеры недальновидного кода. Во-первых, эта задача потребовала бы рассмотрения огромного количества вариантов, как например, это делается в книге [1]. А во-вторых, у меня была задача не просто написать очередную работу, описывающую достоинства классического MVC, а именно показать, как можно реализовать MVC с помощью обобщенного программирования языков Java и C#.

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

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

1 Я возьму с собой в дорогу…

Я возьму с собой в дорогу,
Ту не пройденную даль,
Эту речку - недотрогу,
Сердца радость и печаль.
Теплый взгляд любимых глаз,
Нежный плен прикосновений,
Уложу в тугой рюкзак…
Горы, степи и леса,
Песен ноты и куплеты,
И родные голоса,
Я возьму с собой в дорогу,
В путь к вершинам,
В небеса.
Алексей Митин

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

Все примеры собраны в проекте Eclipse SDK 3.3.0 (Europe) под названием MVC RSDN Refactoring. Пакеты проекта приводятся в соответствии с последовательностью повествования, надеюсь, это поможет вам не запутаться:

В подкаталоге проекта lib собраны следующие библиотеки:

  1. Три библиотеки с именами org.eclipse.*_3.3.0.*.jar необходимы программам, использующим графическую библиотеку SWT (Standard Widget Toolkit). В принципе, вы могли бы самостоятельно найти эти библиотеки в Eclipse SDK, просто я немного сэкономил ваше время.
  2. Библиотека net.sf.oval_1.0.jar представляет OVal (Object Validation Framework) версии 1.0, который используется в примерах защитного программирования на основе контрактных спецификаций.

Практически все демонстрационные примеры работы представляют собой модульные тесты.

ПРИМЕЧАНИЕ

Модульное тестирование (Unit Testing) – технология программирования, позволяющая проверять корректность выполнения отдельных модулей исходного кода программы. Идея состоит в том, чтобы писать тесты для каждой нетривиальной функции или метода. Это позволит достаточно быстро проверить, не привело ли очередное изменение кода к регрессии, то есть к появлению ошибок в уже написанных и протестированных местах программы, и, что самое главное, облегчит локализацию и устранение этих сложно предсказуемых ошибок.

В качестве платформы модульного тестирования использовался JUnit 4, который входит в комплект стандартной поставки Eclipse SDK (Eclipse Classic).

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

СОВЕТ

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

Приведенные в статье модульные тесты изначально компилировались на Java SE 6 (Mustang).

ПРИМЕЧАНИЕ

Часто Java SE 6 также называют по имени проекта Peabody. Пока компания Sun Microsystems так и не озвучила официальное кодовое название Java SE 6 (J2SE Code Names) и использует оба синонима (Mustang и Peabody) при упоминании JDK (Java Development Kit) или Java SE (Standard Edition) версии 6.

Примеры же, посвященные защитному программированию на основе контрактных спецификаций, проверялись аспектно-ориентированным компилятором AspectJ. Его поддержкой в Eclipse SDK я пользовался благодаря модулю расширения AJDT (AspectJ Development Tools) версии 1.5. Имейте в виду, что AJDT в стандартный комплект Eclipse SDK не входит.

ПРЕДУПРЕЖДЕНИЕ

К сожалению, я так и не смог решить проблему несовместимости аспектного компилятора с синтаксисом Java SE 6, поэтому откатил исходный код проекта на Java SE 5 (Tiger).

В описании примеров я достаточно часто буду ссылаться на внутреннюю реализацию классов, поставляемых в комплекте JDK, не подтверждая сказанное листингом соответствующего исходного кода. Но вы можете самостоятельно убедиться в верности всего сказанного. Удобнее всего будет переходить от кода примеров к коду классов JDK, если подключить в Eclipse SDK исходный код JDK, который поставляется вместе с ним (файлом src.zip).

Для построения диаграмм классов использовалась программа ArgoUML 0.24.

Вот теперь, вооружившись до зубов компиляторами и библиотеками, пожалуй, и начнем.

2 Шаблон проектирования Observer

Пожалуй, я буду первым, кто признается,
что никогда не мог написать по настоящему многопоточную программу,
что на C++, что на Java, несмотря на годы изучения.
Потому что это очень сложно.
Брюс Эккель

Шаблон проектирования Observer (Обозреватель), или Publisher/Subscriber (Издатель/Подписчик), позволяет построить такую зависимость 1:N (один ко многим), при которой изменение состояния одного объекта (издателя) приводит к автоматическому оповещению зависимых от него объектов (подписчиков). Для этого издатель ведет список подписчиков, по соответствующему запросу регистрирует или снимает регистрацию подписчиков, а также оповещает зарегистрированных подписчиков в случае соответствующего события. При этом между издателем и подписчиками формируется слабая связь: издатель обладает минимумом информации о своих подписчиках, достаточной для организации оповещения. Издатель диктует только контракт оповещения: любой объект, его реализующий (подписчик), может получать оповещения. Таким образом, достаточно однажды реализовать издатель, чтобы позже неоднократно подключать к нему любые объекты, заинтересованные в его состоянии.

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

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

2.1 Оповещение подписчиков и потоковая безопасность

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

Реализацию класса можно считать поточнобезопасной, если никакая последовательность операций над классом (вызовы методов, чтение и запись полей класса), как при однопоточном доступе, так и при доступе из нескольких потоков, не может привести к ситуации, когда класс перестанет удовлетворять собственной спецификации. Подробнее характеристики безопасности потока рассматриваются в публикации [3].

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

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

В первой части работы класс java.util.concurrent.CopyOnWriteArrayList был выбран в качестве списка подписчиков активной модели (класс Model<P>), которая в рамках обобщенного MVC является издателем, в изменении состояния которого заинтересована модель списка (класс ListModel<P>) и базовое представление (класс BaseView<M, P>). И теперь я покажу на примере, почему выбор пал именно на эту реализацию коллекции: сравним реализации издателя со списком подписчиков на основе класса Vector<E> и на основе класса CopyOnWriteArrayList<E>.

Абстрактный подписчик – ISubscriber.java

Абстрактный подписчик (интерфейс ISubscriber) описывает контракт оповещения: с одной стороны, любой объект, желающий получать уведомления от издателя, должен реализовать данный интерфейс, с другой стороны, издатель обязуется посредством вызова метода eventHappen оповестить зарегистрированного подписчика об изменении собственного состояния.

Подписчик – Subscriber.java

Подписчик (класс Subscriber) является тривиальным примером реализации данного контракта.

Издатель – AbstractPublisher.java

Абстрактный издатель (класс AbstractPublisher<T>) является заготовкой издателя. Параметр класса T определяет тип коллекции подписчиков – произвольная реализация интерфейса Collection<ISubscriber>. Сама коллекция (поле subscribers) инициализируется в конструкторе переданным параметром: таким образом, все наследники абстрактного класса AbstractPublisher<T> будут обязаны произвести явный вызов родительского конструктора первой инструкцией своего конструктора. Методы subscribe и unsubscribe описывают соответственно регистрацию и снятие регистрации подписчика. Метод getSubscribers, возвращающий неизменяемую коллекцию подписчиков, понадобиться нам при рассмотрении проблемы утечки памяти.

Тестирование абстрактного издателя – AbstractPublisherTest.java

Класс AbstractPublisherTest<T> представляет основу тестирования оповещения подписчиковиздателем. Класс AbstractPublisherTest<T> является абстрактным издателем, так как наследует класс AbstractPublisher<T> с уточнением: в качестве коллекции подписчиков будет использоваться список (реализация интерфейса List<ISubscriber>). Индексированная коллекция (список) необходима для тестирования перебора списка подписчиков циклом.

Все тесты оповещения первым делом регистрируют подписчик (поле subscriber), а затем с переменным успехом пытаются разослать уведомления зарегистрированным подписчикам, в нашем случае всего одному. Усложним им эту задачу: в цикле первой инструкцией (как было указано выше, это потенциально опасный участок кода: операция определения наличия элемента в списке выполнена, но элемент еще не был извлечен) имитируем вклинивание другого потока. Сторонний поток пытается снять регистрацию с подписчика в списке издателя, уведомление которому вот-вот должен отправить издатель. Имитация проблемы производится методом invokeTrouble: запускается новый поток, снимающий регистрацию с подписчика, основной же поток для верности на секунду усыпляется.

Первый тест (метод unsafeNotifyByCycle) осуществляет проверку оповещения подписчиков на основе перебора списка циклом. Метод перегружен методом с параметром для последующего использования в наследниках. Второй тест (метод unsafeNotifyByIterator) осуществляет аналогичную проверку, только перебор идет на основе использования java.util.Iterator.

Итак, проверим, как пройдут эти тесты на соответствие спецификации издатели со списком подписчиков на основе классов Vector<ISubscriber> и CopyOnWriteArrayList<ISubscriber>. И пусть победит сильнейший!

Тестирование издателя со списком подписчиков на основе java.util.Vector – VectorPublisherTest.java

По тесту (классу VectorPublisherTest) видно, что наш первый кандидат (издатель на основе класса Vector<ISubscriber>), к сожалению, провалился по всем статьям: каждый тест завершился соответствующим исключением. Это означает, что издатель по факту мог оповестить не всех подписчиков. Таким образом, издатель на основе класса Vector<ISubscriber> не выполнил требования спецификации, определенной нами выше, а значит, считать потокобезопасной подобную реализацию издателя мы не можем.

Первый тест (оповещение подписчиков циклическим перебором списка, метод unsafeNotifyByCycle) завершился исключением java.lang.ArrayIndexOutOfBoundsException. Объяснение здесь простое: основной поток определил наличие в списке одного элемента, поэтому зашел внутрь цикла, но затем вклинился сторонний поток, удалив единственный элемент из списка, и поэтому далее попытка основного потока извлечь из пустого уже списка элемент с индексом 0 завершилась соответствующим сбоем, который генерирует метод get класса Vector<E>.

ПРИМЕЧАНИЕ

Приводя в публикации [4] аналогичный пример оповещения, Брайан Гетц почему-то утверждает, что в нем потенциально возможен выброс исключения java.lang.NullPointerException. То есть он хотел сказать, что вместо регистрации обращения за границы массива, из списка все-таки будет извлечен элемент – пустая ссылка, а вот уже при попытке вызвать у нее метод eventHappen будет сгенерировано исключение NullPointerException. Полагаю, здесь Гетц просто ошибся.

Второй тест (оповещение на основе итеративного перебора списка, метод unsafeNotifyByIterator) завершился исключением java.util.ConcurrentModificationException. Исключение генерируется в реализации интерфейса Iterator<E>, которую предоставляет класс Vector<E>. Итератор определяется в классе java.util.AbstractList, который является родительским классом Vector<E>. При любой попытке модификации списка во время итеративного перебора внутренний класс ListIntr класса AbstractList<E> генерирует исключение ConcurrentModificationException.

Тесты safeNotifyByCycleOnSynchronize и safeNotifyByCycleOnCopy иллюстрируют частные решения данной проблемы: в первом случае список подписчиков просто блокируется на весь период оповещения, второй вариант осуществляет оповещение подписчиков циклическим проходом по временно созданной копии списка. По тесту видно, что данные варианты оповещения подписчиков делают издатель на основе класса Vector<ISubscriber> потокобезопасным. Тем не менее, оба варианта неблагоприятно отражаются на производительности: первый вариант блокирует все потоки, которым может потребоваться доступ к списку подписчиков, а второй вариант при каждом оповещении осуществляет достаточно затратную операцию по созданию временной коллекции и копированию в нее подписчиков.

Теперь посмотрим, как покажет себя в деле второй кандидат – издатель на основе класса CopyOnWriteArrayList<ISubscriber>.

Тестирование издателя со списком подписчиков на основе java.util.concurrent.CopyOnWriteArrayList – CopyOnWriteArrayListPublisherTest.java

Как и ожидалось, первый тест (метод unsafeNotifyByCycle) издатель на основе класса CopyOnWriteArrayList<ISubscriber> тоже провалил. Причина аналогичная: операция извлечения элемента при циклическом проходе по списку не является атомарной.

Но зато оповещение подписчиков с помощью итеративного прохода по списку (второй тест в методе unsafeNotifyByIterator) было выполнено в соответствии со спецификацией издателя. Дело в том, что класс CopyOnWriteArrayList<E> гарантирует неизменность списка за счет перераспределения и копирования внутреннего массива данных при любой модификации. Таким образом, с одной стороны, любой поток может безопасно модифицировать список в любой момент времени, а с другой стороны, поток, перебирающий элементы списка с помощью предоставляемой реализации интерфейса Iterator<E>, будет считать, что список не изменяется, получая, тем не менее, после каждой модификации актуальную на данный момент времени версию списка. Стоит отметить также, что методы класса CopyOnWriteArrayList<E>, изменяющие внутренний массив данных, исключают гонки при перераспределении и копировании массива данных с помощью новой производительной блокировки, появившейся в JDK 1.5 в виде класса java.util.concurrent.locks.ReentrantLock. Детальное сравнение стандартной (synchronized) и блокировки с использованием класса ReentrantLock рассматривается в публикации [5].


Рисунок 1. Диаграмма классов тестирования потоковой безопасности издателя при оповещении подписчиков.

Итак, подведем итоги тестирования. С одной стороны, отдельные операции класса Vector<E> являются потокобезопасными, так как синхронизированы с помощью стандартного механизма блокировки synchronized. С другой стороны, последовательность действий, когда одно действие зависит от результата предыдущего, к примеру, проиллюстрированный выше перебор элементов, в котором действие извлечения элемента из списка зависит от действия по определению его наличия в списке, может привести к сбою. Подобные реализации называют условно потокобезопасными. Использование же класса CopyOnWriteArrayList<E> гарантирует потокобезопасность реализации издателя за счет встроенного перераспределения и копирования внутреннего массива данных при любой модификации списка, при этом риски конкурентной модификации списка исключаются с помощью более производительной блокировки (на основе класса ReentrantLock), нежели это делается в классе Vector<E>. Может показаться, что реализация неизменности списка в классе CopyOnWriteArrayList<E> пагубно скажется на производительности, ведь каждое изменение списка влечет за собой копирование внутреннего массива данных. Но развейте демонов сомнения, вспомните, что в большинстве случаев издатель имеет относительно небольшое количество подписчиков (внутренний массив данных класса CopyOnWriteArrayList<E> будет достаточно малым), а оповещение подписчиков (проход по списку элементов) вызывается намного чаще, нежели регистрация или снятие регистрации (модификация списка: соответственно добавление и удаление элемента списка). Таким образом, копирование внутреннего массива данных класса CopyOnWriteArrayList<E> не является столь затратной операцией, тем более что вызывается она в нашем случае достаточно редко. Подробнее о параллельных классах коллекций реализованных в JDK 1.5 читайте в публикации [6].

К чему я вел, вы уже, скорее всего, давно догадались: в качестве списка подписчиков в нашей потокобезопасной реализации издателя будет использоваться класс CopyOnWriteArrayList<E>.

ПРЕДУПРЕЖДЕНИЕ

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

2.2 Недействительные подписчики и утечка памяти

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

Например, издателем является объект Timer (программный таймер), в изменении состояния которого заинтересован объект Clock, отображающий на экранной форме часы с секундной стрелкой. При инициализации объект Clock регистрируется в качестве подписчика объекта Timer: при поступлении события от объекта Timer объект Clock перерисовывает текущее положение стрелок. Затем пользователь закрывает экранную форму с часами – все, жизненный цикл объекта Clock завершен. Предположим, что по причине безалаберности программиста (на самом деле могут быть и другие причины, мы рассмотрим их далее) объект Clock не снимает регистрацию по завершению работы, а значит, в списке подписчиков объекта Timer остается ссылка на неактуальный уже объект Clock. А тем временем объект Timer продолжает свое существование: когда пользователь снова пожелает посмотреть время, Timer будет помогать в перерисовке другому экземпляру класса Clock.

В итоге, чем дальше, тем хуже.

Во-первых, чем больше пользователь будет открывать часы, тем больше будет становиться список подписчиков в объекте Timer. И это несмотря на то, что после закрытия экранной формы с часами, ни один из подписчиков уже не является актуальным. В результате мы получаем необоснованный рост потребляемой памяти. В программных платформах, где управлением памятью занимается специальный код, называемый сборщиком мусора (Garbage Collector, GC), в общем случае, то есть, опуская все тонкости работы GC (в цели работы это не входит), память, выделенная под объект, может быть освобождена тогда и только тогда, когда на данный объект никто не ссылается. Это означает, что пока издатель хранит ссылку на подписчика, память, выделенная под объект подписчика, а также под все объекты, на которые в свою очередь ссылается сам подписчик, будет удерживаться в памяти до тех пор, пока не завершит работу сам издатель. Этот неприятный эффект называют удержанием памяти или утечкой памяти. К удивлению многих начинающих программистов, он имеет место, в том числе и в управляемых платформах.

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

ПРИМЕЧАНИЕ

Четверка программистов: Эрих Гамма, Ричард Хелм, Ральф Джонсон и Джон Влиссидес – известная больше под псевдонимом Банда четырех (Gang of Four, GoF), в своей книге [7], ставшей классической по шаблонам проектирования, впервые обозначила данную проблему под названием «висячие ссылки на удаленные субъекты».

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

2.2.1 Решение на стороне подписчиков

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

Тестирование подписчика, который может привести к утечке памяти и пересмотр его кода – MemoryLeakSubscriber.java

Класс MemoryLeakSubscriberTest содержит два теста. Каждый тест пытается вывести в консоль содержимое файла, при этом желает получать уведомления от издателя, которые также выводит в консоль (см. класс Subscriber). В первом тесте (метод memoryLeakTest) показан пример неграмотной реализации подписчика (метод unversedPrintFile), второй тест (метод memoryNormTest) показывает пересмотр кода подписчика (метод printFile). В обоих случаях из-за отсутствия файла, содержимое которого подписчик пытается распечатать, выбрасывается исключение java.io.FileNotFoundException. Первый тест показывает, что регистрация не снимается: список издателя по завершению не пустой – и это несмотря на то, что снятие регистрации было закодировано. Второй же тест напротив, корректно отрабатывает даже при исключении.

Кроме того, в прошлой работе я допустил непростительную оплошность: использовал для снятия регистрации нетривиальные финализаторы подписчиков – переопределенные в наследнике методы finalize суперкласса java.lang.Object. В Java-коде базовое представление (класс BaseView<P>) снимало подписку с модели (класс Model<P>) с помощью переопределенного метода finalize, а в C#-реализации представление (класс View<P>) и представление списка (класс ListView<P>) пользовались для снятия регистрации деструкторами. Дело в том, что сборщик мусора гарантирует финализацию объекта тогда и только тогда, когда на объект никто не ссылается. Таким образом, пока издатель хранит ссылку на подписчика, его финализатор вызван не будет. В итоге, в подписчиках фактически была закодирована утечка памяти.

Рассмотрим проблему еще раз на простом примере.

Подписчик, использующий финализатор для снятия регистрации – NonTrivialFinalizeSubscriber.java

Класс NonTrivialFinalizeSubscriber является подписчиком (реализует интерфейс ISubscriber), который регистрируется в издателе при инициализации и снимает регистрацию при финализации (переопределенный метод finalize).

ПРЕДУПРЕЖДЕНИЕ

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

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

Тестирование работы издателя с подписчиками, которые используют финализатор для снятия регистрации, что приводит к утечке памяти – MemoryLeakNotTrivialFinalizeSubscriberTest.java

Класс MemoryLeakNotTrivialFinalizeSubscriberTest представляет собой издателя (наследник класса AbstractPublisher<CopyOnWriteArrayList<ISubscriber>>). В тесте (метод memoryLeakTest) цикл на каждой итерации создает подписчика (экземпляр класса NonTrivialFinalizeSubscriber), который регистрируется в издателе (см. конструктор класса NonTrivialFinalizeSubscriber). Несмотря на то, что принудительно вызывается сборка мусора (статическим методом gc класса System), в конце каждой итерации цикла регистрируется увеличение количества зарегистрированных подписчиков на единицу. Каждая новая итерация цикла создает подписчик, при этом, нигде не сохраняя ссылку на него, то есть вроде бы это должно означать, что все подписчики созданные на предыдущих итерациях цикла являются потенциальными кандидатами на утилизацию сборщиком мусора, так как на них никто не ссылается. Но совсем позабыли, что ссылка остается в издателе! Приведенный тест показывает, что ни один из отработанных подписчиков не снял свою регистрацию с издателя: это регистрирует проверка по завершению цикла. Таким образом, ни один из финализаторов наших подписчиков вызван не был.

ПРИМЕЧАНИЕ

Более того, применение финализаторов, в принципе, является пагубной практикой.

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

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


Рисунок 2. Диаграмма классов тестирования недействительных подписчиков и утечки памяти.

Итого, решение проблемы утечки памяти при использовании шаблона проектирования Observer на стороне подписчиков несет определенные трудности: проектирование каждого очередного подписчика требует высокого уровня ответственности. Так почему бы не попытаться решить проблему на стороне издателя? Ведь данный компонент проектируется один раз в отличие от множества его подписчиков.

2.2.2 Решение на стороне издателя

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

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

ПРЕДУПРЕЖДЕНИЕ

Стоит отметить, что предлагаемое решение не является универсальным для шаблона проектирования Observer. Дело в том, что нам все равно необходимо иметь хотя бы одну жесткую ссылку на подписчика из произвольного объекта приложения, иначе сборщик мусора может утилизировать объект подписчика, невзирая на то, успел он выполнить свою задачу или нет. Но в нашем случае, то есть при использовании Observer в шаблоне проектирования MVC, можно не беспокоиться по данному поводу. В MVC издателем является модель, на уведомления которой подписываются представления, то есть объекты, которые в графическом приложении в любом случае удерживаются жесткими ссылками. Слабая связь здесь просто делает модель более независимой от представлений.

В JDK слабая ссылка представлена классом java.lang.ref.WeakReference. Объект, с которым необходимо установить слабую связь, передается в конструктор класса WeakReference<T> и его значение может быть получено методом get, который вернет пустую ссылку (null) в случае, если объект уже утилизирован. Подробнее про устранение проблемы утечки памяти посредством слабых ссылок можно прочитать в работе [10].

ПРИМЕЧАНИЕ

Реализация издателя, использующего слабую связь с подписчиками, будет приведена в конце главы, когда уже будут озвучены все подводные камни реализации шаблона проектирования Observer.

2.3 Безопасность инициализации подписчиков

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

Конструкторы имеют отличную от методов семантику. Предполагается, что объект будет полностью инициализирован, то есть перейдет в такое устойчивое состояние, в котором уже будет готов выполнять действия, диктуемые его спецификацией, только после завершения конструктора. Поэтому публиковать ссылку на не полностью сконструированный объект очень опасно. Когда конструктор делает видимым указатель на объект, говорят о так называемой потере указателя this. Подробнее про методы безопасного конструирования вы можете прочитать в публикации [11].

Посмотрим, к чему может привести потеря указателя this на примере.

Подписчик с риском безопасности инициализации из-за потери указателя this – UnsafeInitSubscriber.java

Непосредственно в своем конструкторе класс UnsafeInitSubscriber регистрируется как подписчик переданного параметром publisher издателя, передавая при этом ссылку на себя (this) в его метод subscribe (см. класс AbstractPublisher<T>). И все бы ничего, но посмотрим, что может произойти при наследовании данного класса.

Подписчик, в котором проявляется наследованный риск безопасности инициализации – InheritedUnsafeInitSubscriber.java

Класс InheritedUnsafeInitSubscriber представляет собой подписчик, который ведет историю изменений состояния издателя, сохраняя временные метки событий во внутреннем хранилище. Заметьте, что наследование класса UnsafeInitSubscriber принуждает класс InheritedUnsafeInitSubscriber вызвать родительный конструктор первой инструкций собственного конструктора, и только после этого подписчик может инициализировать коллекцию временных меток (поле класса eventHistory).

ПРИМЕЧАНИЕ

В данном отношении хочется отметить интересное отличие поведения .NET и Java. В .NET код конструктора, инициализирующий члены класса, выполняется, точнее, вставляется компилятором в генерируемый код на языке MSIL (Microsoft Intermediate Language, то есть промежуточный язык общеязыковой исполняющей среды CLR (Common Language Runtime), являющийся функциональным аналогом байт-кода исполнительной среды Java – JRE (Java Runtime Environment)) до вызова конструктора родительского класса. Таким образом, разработчики .NET частично решили проблему безопасности инициализации. Более того, аналогичным образом поступает, к примеру, компилятор языка программирования Scala под виртуальную машину Java, тем самым, по сути, по умолчанию генерируя более безопасный байт-код, нежели компилятор Java.

Детально данный вопрос обсуждался на форуме RSDN: Virtual member call in constructor. Плохо?

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

Издатель, который выявляет проблему небезопасного конструирования подписчиков – InitNotifyPublisher.java

Класс InitNotifyPublisher является издателем, который при регистрации подписчика осуществляет его немедленное оповещение.

ПРИМЕЧАНИЕ

Так, в прошлой работе модель (класс Model<P>) при подписке на события изменения ее состояния, вызывала принудительное оповещение, что упрощало начальное отображение данных моделипредставлением (класс BaseView<P>).

Все, в принципе, сбой уже закодирован, осталось только запустить регистрирующий его тест.

Тестирование подписчика, в котором проявляется наследованный риск безопасности инициализации – InheritedUnsafeInitSubscriberTest.java

Тест InheritedUnsafeInitSubscriberTest создает издателя и передает ссылку на него в конструктор подписчика, в результате чего регистрирует исключение NullPointerException. Что же произошло? Дело в том, что издатель (класс InitNotifyPublisher) вызвал оповещение (метод eventHappen) еще не до конца инициализированного подписчика (класс InheritedUnsafeInitSubscriber). В частности подписчик еще не успел инициализировать коллекцию временных меток (см. конструктор класса InheritedUnsafeInitSubscriber). В результате при попытке сохранить временную метку события в методе eventHappen подписчик обратился к полю eventHistory, в котором на данный момент была пустая ссылка.

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


Рисунок 3. Диаграмма классов тестирования безопасности инициализации подписчиков.

ПРЕДУПРЕЖДЕНИЕ

Итого, подписчики событий ни в коем случае не должны регистрироваться из конструкторов. В первой части работы не допускался риск безопасности инициализации в подписчикахмодели (класс Model<P>): базовом представлении (класс BaseView<P>) и модели списка (ListModel<P>). Тем не менее, сейчас мы рассмотрели данную проблему, потому что проектирование подписчиков является многократно повторяемой операцией в отличие от проектирования издателя. В частности, если вы закодируете риск безопасности инициализации в некотором своем подписчике, желающем знать об изменениях состояния модели, вы можете получить сбой, аналогичный описанным. И в этом будете виноваты только вы.

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

Риск безопасности инициализации из-за неявной потери this при опубликовании подписчика, являющегося экземпляром анонимного класса – UnsafeImplicitInitSubscriber.java
Проявление наследованного риска безопасности инициализации из-за неявной потери this при опубликовании подписчика, являющегося экземпляром анонимного класса – InheritedUnsafeImplicitInitSubscriber.java
Тестирование проявления наследованного риска безопасности инициализации из-за неявной потери this при опубликовании подписчика, являющегося экземпляром анонимного класса – InheritedUnsafeImplicitInitSubscriberTest.java

2.4 Дефектные подписчики и отказоустойчивый издатель

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

ПРИМЕЧАНИЕ

В Java все исключения делятся на два вида: контролируемые (checked) и неконтролируемые (unchecked). Неконтролируемыми считаются исключения, возникновение которых может быть непреднамеренным: обращение к методу или полю по пустой ссылке, превышение ограничений на размер стека или занятой динамической памяти и прочее. Контролируемые же исключения предназначены для передачи сообщений о возникновении специфических ситуаций и всегда явно создаются в таких ситуациях. Неконтролируемое исключение может наследоваться от класса java.lang.Error, если оно обозначает серьезную ошибку, которая не может быть обработана в рамках обычного приложения, или от класса java.lang.RuntimeException, если оно может возникать при нормальной работе виртуальной машины. Если класс исключения не является наследником одного из этих классов, исключение считается контролируемым. Все классы контролируемых исключений, возникновение которых возможно при работе метода, должны быть описаны в его заголовке после ключевого слова throws.

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

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

Дефектный подписчик – FaultySubscriber.java

Класс FaultySubscriber иллюстрирует дефектный подписчик: посланное издателем оповещение (метод eventHappen) приведет к генерации исключения java.lang.UnsupportedOperationException, являющегося неконтролируемым (наследником класса RuntimeException).

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

Тестирование отказоустойчивого издателя – FaultTolerantPublisherTest.java

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

Первый тест (метод crashNotifyTest) показывает, к чему может привести оповещение подписчиков (метод notifySubscribers), при наличии среди них хотя бы одного дефектного. Тест регистрирует возникновение исключение RuntimeException, а также показывает, что работоспособный подписчик, зарегистрировавшийся в издателе вторым после дефектного, так и не получает ожидаемое оповещение.

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


Рисунок 4. Диаграмма классов тестирования дефектных подписчиков и отказоустойчивого издателя.

Рассматриваемая далее реализация издателя является отказоустойчивой.

2.5 Издатель на слабых ссылках

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

  1. Список подписчиков должен быть организован на основе класса CopyOnWriteArrayList<E>.
  2. Перебор списка подписчиков при оповещении должен осуществляться итеративно.
  3. Издатель должен хранить слабые ссылки на подписчиков.
  4. Издатель должен быть отказоустойчивым по отношению к потенциально дефектным реализациям подписчиков.
Издатель, использующий слабую связь с подписчиками – Publisher.java

Абстрактный класс Publisher параметризован типом подписчика (параметр S). Таким образом, на основе класса Publisher<S> можно построить издатель произвольного контракта оповещения, подставив при наследовании вместо параметра S конкретный интерфейс абстрактного подписчика.

Список подписчиков (поле subscribers) представляет коллекцию на основе класса CopyOnWriteArrayList<WeakReference<S>>, таким образом, список хранит слабые ссылки на подписчиков – объекты типа WeakReference<S>. При регистрации подписчика (метод subscribe) на основе переданной в метод жесткой ссылки (параметр subscriber) создается слабая ссылка и добавляется в список. При снятии регистрации (метод unsubscribe) издателю необходимо по переданной жесткой ссылке (параметр subscriber) найти слабую ссылку и удалить ее из списка. Обратите внимание, что, перебирая список, издатель попутно удаляет слабые ссылки на уже недействительных подписчиков, то есть те слабые ссылки, метод get которых возвращает пустую ссылку.

Метод getSubscribers предназначен для реализации оповещения подписчиков в конкретных реализациях издателя (наследниках класса Publisher<S>), поэтому метод имеет видимость protected. В данном методе издатель также избавляется от уже недействительных подписчиков. Кроме того, обратите внимание, что класс Publisher<S> полностью скрывает свою внутреннюю реализацию на слабых ссылках: метод getSubscribers возвращает актуальные жесткие ссылки.

Предполагается, что добросовестные подписчики, работающие с издателем Publisher<S>, могут и будут снимать свою регистрацию по окончанию жизненного цикла методом unsubscribe. Но издатель не сильно расстроится, если один из его подписчиков будет закодирован более безответственно: как только подписчик выполнит предначертанное ему назначение в рамках приложения, его утилизирует сборщик мусора, а издатель перестанет оповещать его.

ПРИМЕЧАНИЕ

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

Рассмотрим пример издателя на слабых ссылках.

Пример издателя, использующего слабую связь с подписчиками – ConcretePublisher.java

Класс ConcretePublisher является издателем с контрактом оповещения на основе интерфейса ISubscriber, использующим слабую связь с подписчиками за счет наследования класса Publisher<ISubscriber>. Состояние издателя хранит поле класса state, изменение которого приводит к оповещению всех подписчиков. Обратите внимание, что оповещение (метод notifySubscribers) является отказоустойчивым.

Тестирование примера издателя, использующего слабую связь с подписчиками – ConcretePublisherTest.java

Класс ConcretePublisherTest содержит два теста издателя на основе класса ConcretePublisher. Метод testNotify тестирует оповещение подписчика. Тест в методе testWeakReference иллюстрирует слабую связь издателя с подписчиком: после регистрации подписчика subscriber в издателе publisher, ссылка на подписчика «забывается» (в переменную subscriber записывается пустая ссылка) и принудительно вызывается сборка мусора, после чего фиксируется автоматическое снятие регистрации с подписчика в издателе.


Рисунок 5. Диаграмма классов тестирования издателя на слабых ссылках.

ПРИМЕЧАНИЕ

Код обобщенного MVC был пересмотрен с использованием приведенной реализации издателя. Модель, являющаяся издателем, события которой принимают базовое представление и модель списка, теперь организует со своими подписчиками слабую связь: класс Model<P> наследует класс Publisher<IModelSubscriber<P>>. Модель списка (класс ListModel<P>) является наследником модели, поэтому также является издателем, использующим слабую связь с подписчиками. Модель реализует отказоустойчивое оповещение подписчиков.

Кроме того, теперь базовое представление уже не использует финализаторы, чтобы автоматически снимать регистрацию с модели по завершению работы. С одной стороны, метод unsubscribe абстрактного класса BaseView<P> доступен наследникам (модификатор видимости protected), таким образом, добросовестные представления имеют возможность вручную снять регистрацию. С другой стороны, они могут этого и не делать: модель снимает регистрацию недействительного подписчика автоматически. Все представления в примерах использования обобщенного MVC пользуются этой возможностью.

2.6 Множественный издатель

В реальном приложении встречаются объекты, которые инкапсулируют несколько логически не связанных между собой внутренних состояний. Использовать в данном случае класс Publisher<S> мы не сможем, так как он ограничивает издателя одним контрактом оповещения, который задает параметр S, а пользоваться же множественным наследованием в платформах Java и .NET нет ни возможности, ни желания. Потому рассмотрим реализацию обобщенного издателя, который позволит обслуживать произвольное количество контрактов подписки. Назовем такой издатель незамысловато – множественным. Разумеется, все решения, отраженные в приведенной выше реализации издателя, были перенесены и в реализацию множественного издателя.

Множественный издатель – MultiPublisher.java

Множественный издатель (класс MultiPublisher) отличается от издателя (класса Publisher<S>) поддержкой различных контрактов подписки, поэтому вместо списка подписчиков используется карта. Карта построена на потокобезопасной реализации интерфейса java.util.Mapjava.util.concurrent.ConcurrentHashMap. Выбор пал именно на данную реализацию карты по тем же причинам, что ранее привели к выбору класса CopyOnWriteArrayList<E> для организации списка подписчиков. Класс ConcurrentHashMap<K, V> используется для хранения соответствий между контрактом подписки и коллекцией подписчиков, удовлетворяющих данному контракту. Контракт подписки представляет собой тип интерфейса абстрактного подписчика: вместо первого параметра K класса ConcurrentHashMap подставляется метакласс java.lang.Class. Список подписчиков организуется аналогичным издателю образом, то есть на основе коллекции слабых ссылок на подписчиков: вместо второго параметра V класса ConcurrentHashMap подставляется коллекция слабых ссылок на произвольные объекты – Collection<WeakReference<Object>>.

Все методы множественного издателя параметризованы контрактом подписки (параметр S).

Регистрация осуществляется методом subscribe, при этом указывается тип подписчика (параметр _class) и жесткая ссылка на подписчика (параметр subscriber). При регистрации первого подписчика конкретного контракта подписки множественный издатель создает список слабых ссылок на основе класса CopyOnWriteArrayList<E> и сохраняет в карте (поле subscribers) соответствие типа контракта и данного списка. Здесь и далее, если множественный издатель находит зарегистрированные подписчики такого же типа, то создает слабую ссылку на подписчика на основе переданной в метод жесткой и добавляет ее в коллекцию подписчиков этого типа.

Снятие регистрации (метод unsubscribe) происходит аналогично издателю (см. метод unsubscribe класса Publisher<S>) за тем исключением, что поиск слабой ссылки на подписчика по переданной в метод жесткой (параметр subscriber) производится в коллекции, которая выбирается из карты subscribers по переданному в метод типу контракта подписки (параметр _class). Аналогичным образом видоизменено получение коллекции жестких ссылок на актуальных подписчиков (метод getSubscribers).

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

Тестирование примера множественного издателя, использующего слабую связь с подписчиками – ConcreteMultiPublisherTest.java

Класс ConcreteMultiPublisherTest одновременно является примером множественного издателя (наследником класса MultiPublisher) и содержит тесты его работы. Множественный издатель инкапсулирует два состояния, которые хранятся в полях класса state и state1, и оповещает соответствующих подписчиков при любом изменении данных состояний. Контракт оповещения об изменении состояния (значения поля state) основан на интерфейсе ISubscriber. А объекты желающие узнавать об изменении дополнительного состояния (значения поля state1) множественного издателя должны реализовать интерфейс ISubscriber1, листинг которого не приводится, поскольку он аналогичен ISubscriber.

Набор тестов множественного издателя аналогичен набору тестов издателя, рассмотренных выше.


Рисунок 6. Диаграмма классов тестирования множественного издателя.

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

2.6.1 Шаблон проектирования Model-View-Presenter

Демонстрационные программы: Обобщенный Model-View-Presenter (ASP.NET)

При реализации шаблона проектирования Model-View-Presenter (далее просто MVP) я обнаружил, что предъявитель является не только источником информации об изменении состояния модели, но, кроме того, знает, какие действия представление может осуществлять над данными модели в конкретный момент.

ПРИМЕЧАНИЕ

Шаблон проектирования MVP является модификацией MVC, примененной впервые в компании IBM в 90-ых годах прошлого века при работе над объектно-ориентированной операционной системой Taligent. Позднее MVP подробно описал Майк Потел [12].

Отличие шаблона проектирования MVP от MVC заключается в большей роли отводимой предъявителю (Presenter), нежели роль выполняемая контроллером (Controller) в MVC. В MVP представление и модель не знают о существовании друг друга. Всю связующую работу осуществляет предъявитель, который, с одной стороны, интерпретирует действия, инициированные представлением, оповещая модель о необходимости изменений, а с другой стороны, оповещает представление о необходимости обновления для отображения изменения данных. Взаимодействие компонентов в MVP получается более простым, чем в MVC, где имеет место замыкание: при активном действии пользователя представление обращается к контроллеру, который соответствующим образом изменяет модель, модель в свою очередь генерирует событие о своем изменении, которое принимает представление. Кроме того, предъявитель, являющийся связующим звеном MVP-триады, позволяет построить более легкое представление и использовать пассивную модель.

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

Действие Доступность
Загрузка В общем случае имеет смысл дать пользователю возможность загрузить данные в любой момент времени. Исключением являются приложения, работающие с хранилищами, данные которых могут изменяться только с помощью средств приложения, а доступ к хранилищу в каждый момент времени может получить только один экземпляр приложения. В этом случае производить загрузку данных имеет смысл только при наличии изменений данных, произведенных пользователем в приложении и несохраненных в хранилище: загрузку можно оптимизировать, подгружая из хранилища только измененные данные, тем самым, отменяя произведенные в них изменения.
Сохранение Действие подразумевает отправку изменений данных в хранилище, соответственно доступно только при их наличии.
Добавление В общем случае действие может быть доступно всегда.
Удаление Действие доступно только в случае наличия загруженных данных. Кроме того, доступность действия может зависеть от выделения элементов экранной формы, к примеру, при работе с таблицей для удаления необходимо выделить соответствующую строку.
Изменение Аналогично предыдущему.
Отмена Действие отменяет последнее произведенное изменение, соответственно доступно при наличии хотя бы одного изменения.
Повтор Действие повторяет последнее отмененное изменение, соответственно доступно при наличии хотя бы одного отмененного изменения.
Таблица 1. Доступность действий редактора данных

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

Демонстрационный пример использования обобщенного MVP в ASP.NET-приложении иллюстрирует использование данного решения на основе множественного издателя (см. ссылку выше).

3 Потоковая безопасность модели

Смотрите, блоков нет!
Брайан Гетц

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

Хорошая новость для платформы Java: начиная с Java SE 5 программистам наконец-то стали доступны так называемые неблокирующие алгоритмы, которые изучаются уже более 20 лет. Алгоритмы являются неблокирующими, потому что исключается сама потребность в блокировании: обеспечивается атомарность выполняемых действий. Неблокирующие алгоритмы также называют оптимистичными в том смысле, что они выполняются в предположении, что конфликтные ситуации в принципе исключены.

Современные процессоры имеют специальные инструкции для атомарного обновления разделяемых данных, которые чувствуют вмешательство стороннего потока при обновлении данных. И атомарные классы, которые вы можете найти в пакете JDK java.util.concurrent.atomic, используют данную возможность вычислителей в алгоритме сравнить и обновить (Compare And Set, CAS). Словесное описание алгоритма CAS можно сформулировать следующим образом: обновить переменную новым значением, но отказать, если другой поток изменил значение после моего последнего просмотра.

Атомарные классы имеют несомненные преимущества перед блокировками:

  1. Выигрыш производительности дает сам принцип синхронизации. Синхронизация выполняется на аппаратном уровне в отличие от блокировки фрагмента кода виртуальной машины с помощью synchronized или класса ReentrantLock.
  2. Практически не происходит так называемого блокирования (приостановки) потоков. Потоки, проигравшие состязание за общие данные, могут попытаться получить доступ к желаемым данным непосредственно сразу.
  3. Кроме того, снижается шанс возникновения состязания по причине мелкой модульности. Операция, разделяющая данные, атомарная, то есть фактически является одной инструкцией.
ПРИМЕЧАНИЕ

Неблокирующие алгоритмы применяются сборщиком мусора, который ускоряет обработку памяти, осуществляя ее параллельно, и планировщиком потоков (и процессов). Кроме того, в JDK 1.6 на основе атомарных классов были переписаны несколько классов для параллельных вычислений. Подробнее про неблокирующие алгоритмы рассказывает уже помянутый нами Брайан Гетц, являющийся экспертом в данной области [13].

На основе атомарного класса ссылки (класс java.util.concurrent.atomic.AtomicReference) был пересмотрен код модели в части потокобезопасности по доступу к свойству модели.

Модель – Model.java

Класс Model<P> теперь инкапсулирует атомарную ссылку на свойство модели (поле ref). В конструкторе класса Model<P> на основе переданной ссылки на свойство модели (параметр property) создается атомарная ссылка – экземпляр класса AtomicReference<P>.

В общем случае изменение свойства модели может инициировать не только контроллер, но и другие компоненты реального приложения, к примеру, компоненты уровня доступа к данным (Data Access Layer, DAL), так называемые объекты доступа к данным (Data Access Object, DAO), которые ответственны за загрузку данных и отправку изменений свойства модели в определенное хранилище данных. При этом изменение свойства модели приводит к автоматическому оповещению подписчиков (представлений и модели списка). Состязание за ссылку на свойство модели может привести к тому, что подписчики получат уведомление от модели об изменении свойства модели не на то значение, о появлении которого модель собственно и хотела их оповестить. Представлениям это, возможно, не столь критично, по-настоящему опасным является то, что DAO-объект может получить ссылку на свойство модели, которая уже не является актуальной.

Простейший CAS-алгоритм в методе setProperty исключает состязание изменения ссылки. Изменение ссылки потоком может привести к оповещению подписчиков только в том случае, если поток сделает это первым: успеет захватить старое значение ссылки и передать новое значение в метод getAndSet атомарного класса ссылки.

После изменения свойства модели идет оповещение подписчиков. При этом подписчики (к примеру, представления) вызовут у модели метод getProperty. Что если в этот момент другой поток уже снова изменяет ссылку на свойство модели новым значением? Да, представления при оповещении затребуют свойство модели и получат еще более новое значение ссылки, причем дважды: первый раз от первого потока, изменившего ссылку, затем повторно от следующего за ним. Это исключается блокировкой кода оповещения с помощью класса ReentrantLock.

ПРИМЕЧАНИЕ

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

4 Шаблон проектирования Command

Simply Brilliant: все сводится к простым истинам,
которыми зачастую пренебрегают в поиске ответов на сложные вопросы.
Фергус О’Коннел

Шаблон проектирования Command (Команда) (или Action (Действие), или Transaction (Транзакция)) используется в случае, если некоторый объект (инициатор), формирующий запрос на выполнение определенного действия, не обладает информацией о том, в чьей компетенции находится реализация данного действия. Решение заключается в том, что запрос инкапсулирует объект Command (Команда), который задает интерфейс выполнения действия. Инициатор формирует объект ConcreteCommand (Конкретная Команда) и отправляет его в запросе на выполнение. Клиент принимает запрос, устанавливает получателя запроса и преобразует объект ConcreteCommand в набор действий над получателем.

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

  1. Взаимодействие представления, являющегося инициатором запросов на выполнение действий над моделью, и контроллера, представляющего клиента, было построено таким образом, что определением получателя запроса (модели) занималось представление, в то время как это прямая обязанность контроллера.
  2. Использование перечислений в качестве команд привело к тому, что реализацию действий по запросу взял на себя контроллер, отобрав данную обязанность у команды.

Таким образом, произошло достаточно сильное смещение обязанностей: от команды к контроллеру и от контроллера к представлению – в итоге команда получилась примитивной, а представление наоборот стало выполнять чужую работу. Кроме того, команды на основе перечислений являются далеко не лучшим решением в шаблоне проектирования Command даже в случае языка программирования Java, в котором перечисления могут содержать реализацию.

4.1 Команда

Команда – ICommand.java

Команда (интерфейс ICommand<T>), параметризованная получателем (параметр T), не только описывает выполнение действий над получателем (метод execute), но и требует от своих реализаций предоставления команды, которая описывает выполнение отменяющих действий (метод execute возвращает команду). При этом предполагается, что реализация метода execute вернет команду, которая сможет отменить все произведенные действия, только если данная команда действительно выполнится. В противном случае метод может вернуть пустую ссылку. Кроме того, обратите внимание, что команда получает информацию о том, над кем будут производиться действия (получателя), только в приказе на выполнение (параметр recipient метода execute). Клиент (контроллер) прикажет команде выполнить действия, когда установит получателя (модель) запроса, отправленного инициатором (представлением).

Рассмотрим три реализации команды: изменение, добавление и удаление модели – рассмотренные в прошлой работе команды отмены последнего изменения и повторения последнего отмененного действия оказались избыточными в пересмотренном варианте шаблона проектирования Command.

Команда на изменение модели – EditCommand.java

Команда на изменение модели (класс EditCommand<P>) в своем конструкторе получает значение нового свойства модели, которым предполагается обновить модель. При поступлении приказа на выполнение (метод execute), когда уже известен получатель (модель указывается в параметре model), команда сравнивает новое значение и значение свойства модели до изменения. И только в случае их отличия выполняет обновление модели новым значением свойства модели.

ПРИМЕЧАНИЕ

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

Кроме того, напомним, что холостые изменения модели, а значит и бессмысленные обновления представлений, будут исключены только в том случае, если объект, который вы будете подставлять в качестве свойства модели, соответствующим образом переопределит наследованный от суперкласса Object метод equals.

Команда отмены формируется и возвращается клиенту (контроллеру) после изменения модели. Если изменение модели не производится, то отменять собственно нечего и возвращается пустая ссылка. Команда отмены изменения также является командой на изменение модели, просто в качестве нового значения свойства модели (параметр конструктора property) она получает значение свойства модели до изменения (значение переменной oldProperty).

Таким образом, класс EditCommand<P> покрывает функционал трех действий, которые были в прошлой работе разделены: изменение, отмену последнего изменения и повтор последнего отмененного изменения. При рассмотрении контроллера станет ясно, что действия отмены и повтора принципиально не отличаются друг от друга.

Команда на добавление моделей – AddCommand.java

Команда на добавление моделей (класс AddCommand<P>) получает в конструкторе (параметром _models) модели, которые необходимо добавить в модель списка (получатель запроса). Кроме того, инициатор (представление) может формировать запрос на добавление одной модели, пользуясь другим конструктором класса AddCommand<P>: коллекция добавляемых моделей (поле models) инициализируется коллекцией из одного элемента. В приказе на выполнение команда принимает модель списка (получателя) и добавляет в нее последовательно все модели. В качестве отменяющей команда формирует команду на удаление моделей (экземпляр класса RemoveCommand<P>), передавая ей список только что добавленных моделей. Листинг исходного кода класса RemoveCommand<P> не приводится, потому что он отличается от процитированного только тем, что метод execute осуществляет удаление моделей и возвращает экземпляр класса AddCommand<P>. В этом проявляется зеркальность команд на добавление и удаление: каждая является отменяющей действия другой.

4.2 Контроллер

Контроллер – Controller.java

Функционал, который ранее выполняли контроллер и контроллер списка, являющиеся реализациями интерфейса абстрактного контроллера, удалось теперь реализовать всего в одном классе. В зависимости от подставленного вместо параметра M типа модели: модель (класс Model<P>) или модель списка (класс ListModel<P>) – класс Controller<M, P> будет представлять контроллер или контроллер списка. С одной стороны, это приведет к тому, что контроллер будет инкапсулировать модель соответствующего типа (полем model). С другой стороны, это ограничит тип команд, которые он (как клиент) сможет принимать в запросе от представления (инициатора). Это значит, что контроллеру (классу Controller<Model<P>, P>) из рассмотренных нами выше реализаций команд можно будет отправить запрос только с командой на изменение модели, потому что ее получателем является модель: класс EditCommand<P> реализует интерфейс ICommand<Model<P>>. А контроллеру списка (класс Controller<ListModel<P>, Collection<Mode<P>>>) можно будет отправлять запросы только с командами на добавление или удаление моделей, потому что их получателем является модель списка: классы AddCommand<P> и RemoveCommand<P> реализуют интерфейс ICommand<ListModel<P>>.

Модель, находящаяся в ведении контроллера, может меняться методом setModel, который сможет вызывать представление. Таким образом, инициатор (представление) имеет возможность, однажды назначив клиенту (контроллеру) подчиненного – получателя (модель), далее уже только слать запросы, то есть приказы с приложенной к ним инструкцией в виде команды. Представление может снова переназначить модельконтроллеру, когда решит переключиться с отображения одной модели на отображение другой. Кроме того, представление может и вовсе решить отключиться от отображения каких-либо данных, в этом случае у контроллера будет отобран подчиненный: представление передаст в метод setModel пустую ссылку.

Поддержку истории изменений, а соответственно возможность отмены последнего изменения и повтора последнего отмененного изменения, контроллер реализует на основе двух стеков: стек отмен (поле undoCommands) и стек повторений (поле redoCommands). Элементом истории, конечно же, является команда. В этом отношении более уместно другое название шаблона проектирования Command – Transaction.

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

Таким образом, контроллер может принимать запросы трех видов:


Рисунок 7. Диаграмма классов реализации шаблона проектирования Command в обобщенном MVC.

Итого, перераспределив обязанности между инициатором, командой и клиентом, получили следующее. Поддержка транзакций стала интуитивно проще: теперь команда, которая обладает полной информацией о производимых действиях над получателем, сама диктует, как отменить свои действия. А контроллер (клиент) занимается только своей основной задачей: прием запросов от представления (инициатора), сопоставление запроса с моделью (получателем), выполнение и диспетчеризация команд для обеспечения возможности отмены изменений и повторения отмененных изменений.

5 Защитное программирование

В конце концов, я по происхождению программист,
а оптимизм - это профессиональная болезнь данного ремесла.
Фредерик Брукс

Защитное программирование основано на важной предпосылке:
худшее, что может сделать модуль – это принять неправильные входные данные
и затем вернуть неверный, но правдоподобный результат.
Г. Майерс

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

Защитное программирование (Defensive Programming) является методикой написания программ, которая упрощает выявление и идентификацию появляющихся ошибок. Пожалуй, наиболее лаконичное определение принадлежит Майерсу [14], которое красуется эпиграфом данной главы.

Защитное программирование предполагает следование двум принципам: ожидание и локализация ошибки.

5.1 Ожидание ошибки

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

5.2 Локализация ошибки

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

5.3 Пример

Рассмотрим классический пример с самым популярным, а потому нелюбимым программистами исключением, то есть исключением обращения по пустой ссылке NullPointerException.

Хранитель объекта, который не предполагает поступление ошибочных данных – OptimisticObjectHolder.java

В классе OptimisticObjectHolder закодирована потенциальная ошибка, причина которой – сделанное в переопределенном методе toString оптимистичное предположение относительно значения поля obj. Посмотрим, к чему может привести такая беспечность.

Тестирование хранителя объекта, который не предполагает поступление ошибочных данных – OptimisticObjectHolderTest.java

Первый тест (метод normTest) создает оптимальные условия, на которые и надеется класс OptimisticObjectHolder. Но автор класса OptimisticObjectHolder и не предполагал, что его детище попадет в ситуацию, показанную во втором тесте (метод crashTest): в конструктор класса передается пустая ссылка и при попытке вывести объект в консоль регистрируется исключение NullPointerException.

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

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

Чтобы найти источник ошибки нашего примера в общем случае придется пройти по пути ее следования в обратном направлении через следующие точки:

  1. Сбой происходит в методе crashTest класса OptimisticObjectHolderTest.
  2. К нему приводит вызов метода toString класса OptimisticObjectHolder.
  3. Метод toString генерирует исключительную ситуацию, потому что пытается обратиться к объекту по пустой ссылке (полю класса obj).
  4. Класс OptimisticObjectHolder инициализирует поле obj в своем конструкторе.
  5. Конструктор класса OptimisticObjectHolder вызывается в методе crashTest класса OptimisticObjectHolderTest. В конструктор передается переменная obj.
  6. Переменная obj инициализируется пустой ссылкой в первой строке метода crashTest класса OptimisticObjectHolderTest.

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

Пересмотрим код нашего примера в соответствии с принципами защитного программирования.

Хранитель объекта, который предполагает возможность поступления ошибочных данных – ObjectHolder.java

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

Тестирование хранителя объекта, который предполагает возможность поступления ошибочных данных и локализует ошибку – ObjectHolderTest.java

Тест ObjectHolderTest регистрирует ошибку на второй строке метода test. Таким образом, ошибка еще не успела замаскироваться: именно на попытке это сделать она и была выявлена, то есть в месте максимально близком к месту своего возникновения.

5.4 Утверждения

Работу по написанию защитных проверок могут облегчить утверждения (операторы assert). В этом случае не нужно задавать оператор условия и исключения: оператор assert подразумевает проверку, неудачное выполнение которой приводит к генерации исключения java.lang.AssertionError.

ПРИМЕЧАНИЕ

В этом утверждения сродни проверкам, которые осуществляются платформой модульного тестирования JUnit 4 с помощью класса org.junit.Assert. Ранее проверки в JUnit версии 3 генерировали проприетарное исключение junit.framework.AssertionFailedError.

Таким образом, вы можете заменить достаточно избыточную запись:

        if (obj == null)
	thrownew NullPointerException("Пустая ссылка");

на более лаконичную и выразительную:

assert obj != null: "Пустая ссылка";

Кроме того, виртуальная машина может игнорировать утверждения (что по умолчанию и делается), то есть при необходимости все защитные проверки могут быть отключены разовым действием. Чтобы задействовать проверки, необходимо запускать виртуальную машину с параметром -ea (или -enableassertions). Данный ключ используется для разрешения утверждений во время выполнения приложения, без этого ключа операторы assert игнорируются, но все равно присутствуют в байт-коде, генерируемом компилятором.

Тем не менее, в общем случае утверждения не стоит рассматривать как основной инструмент защитного программирования. Пользоваться утверждениями стоит только для проверки корректности внутренней реализации классов, проверку же корректности данных передаваемых классу параметрами публичных методов (и конструкторов) следует осуществлять стандартно. Эта рекомендация обусловлена тем, что проверки корректности обращения к библиотеке, активно использующей утверждения, не сработают, если программист запустит виртуальную машину, не указав соответствующий ключ. В итоге, кто будет виноват? Программист, использующий вашу библиотеку классов и не прочитавший предварительно документацию, в которой вы черным по белому написали, что на этапе отладки программу рекомендуется запускать с разрешенными утверждениями? Или все-таки вы? Наверно все зависит от случая. Стандартный подход более надежен в этом отношении, но имеет два существенных недостатка. Во-первых, от метода к методу задавать оператор условия и исключения достаточно утомительно, утверждения же более лаконичны. Во-вторых, на этапе эксплуатации может возникнуть потребность повысить производительность программы посредством отключения всех проверок внутренней корректности уже отлаженной программы: стандартного решения нет, утверждения же можно разрешать или запрещать перед запуском программы по желанию.

5.5 Контрактные спецификации

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

ПРИМЕЧАНИЕ

Аспектно-ориентированное программирование (АОП) – парадигма программирования, основанная на идее разделения сквозной функциональности для улучшения разбиения программы на модули. Под сквозным здесь понимается функционал, повторяющийся в различных местах программы, которые разнесены по модулям на основе другого критерия функциональности, как в случае ООП на основе наследования.

Основными областями применения АОП являются трассировка, обработка ошибок, построение системы безопасности и контрактное программирование (его мы далее и рассмотрим). Впервые данная методология была предложена группой инженеров исследовательского центра Xerox PARC под руководством Грегора Кикзалеса, который и разработал наиболее успешный на текущий день компилятор аспектно-ориентированного языка AspectJ.

На основе АОП развивается другое направление, предоставляющее развитые возможности по защитному программированию в ООП – контрактное программирование (Design By Contract, DBC). В идеале контрактное программирование должно гарантировать, что все компоненты системы выполняют именно то, что они и призваны выполнять, и пытается достичь этого явным указанием в интерфейсе компонента его функциональности и ожиданий клиентов. Ожидания клиентов реализуются на основе проверок входных данных компонента, которые называются предусловиями. Обязательство же компонента по реализации требуемой функциональности описывается постусловием, которое проверяет результат обработки входных данных, выполненной компонентом.

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

Хорошая новость для платформы Java: контракты можно строить на основе аннотаций, если использовать библиотеку OVal и аспектный компилятор AspectJ. При этом за счет того, что библиотека OVal предоставляет общую среду построения пред- и постусловий на Java SE 5, знание синтаксиса аспектного языка, в принципе, не требуется. Безопасное программирование на основе АОП и библиотеки OVal рассматривается в публикации [15].

ПРЕДУПРЕЖДЕНИЕ

Хочется отметить, что данный подход не стоит рассматривать как единственно верный в защитном программировании. Как вы могли заметить выше по листингу кода, я пользовался стандартным подходом в Java.

Я рассматриваю зависимость приложения от стороннего компилятора как некоторый недостаток. Во-первых, аспектный компилятор AspectJ (iajc) полностью заменяет компилятор Java (javac). Во-вторых, я так и не смог настроить AspectJ для работы с синтаксисом Java SE 6, который минимально и, тем не менее, отличается от синтаксиса Java SE 5.

Но я нашел очень удобным применение библиотеки OVal для защитного программирования объектов уровня бизнес-логики (Business Logic Layer, BLL). Именно это применение мы и рассмотрим далее.

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

ПРИМЕЧАНИЕ

Напомню, что в рамках обобщенного MVC объект бизнес-логики одновременно представляет данные предметной области (Domain Model) и является свойством модели. Модель (класс Model параметризованный свойством модели P) представляет реализацию шаблона проектирования Mediator в обобщенном MVC, то есть является посредником между объектами MVC-триады и данными предметной области.

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

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

Компания – Company.java

Класс Company помечен аннотацией net.sf.oval.guard.Guarded, которая включает генерирование контрактных проверок класса.

ПРИМЕЧАНИЕ

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

Все поля класса помечены аннотацией net.sf.oval.constraint.NotNull. По данной аннотации будут сгенерированы предусловия – проверки любой попытки заменить значение соответствующего поля пустой ссылкой. Кроме того, будут созданы также и постусловия – проверки возврата значения поля класса в любом методе. При несоответствии контракту проверки генерируется исключение net.sf.oval.guard.ConstraintsViolatedException. Таким образом, однажды пометив поле класса аннотацией NotNull можно быть уверенным, что оно никогда не станет равным пустой ссылке.

Аннотация net.sf.oval.constraint.MatchPattern представляет контрактную спецификацию на основе регулярного выражения. К примеру, в случае ИНН регулярное выражение, задаваемое параметром pattern, описывает любую последовательность цифр, соответствующее регулярное выражение было построено и для электронной почты.

Обратите внимание, что в конструкторы контрактных спецификаций передаются строковые константы, которые являются сообщениями, уточняющими ошибки несоответствия контракту. При возникновении исключения несоответствия контрактной спецификации, данную уточняющую информацию можно получить у класса ConstraintsViolatedException методом getMessage, который наследуется от класса java.langThrowable. Эту информацию можно использовать для протоколирования и сигнализации пользователя.


Рисунок 8. Сквозная функциональность классов примера.

СОВЕТ

Можно декомпилировать байт-код файла Company.class и посмотреть код проверок, который генерирует и вставляет в соответствующие места компилятор AspectJ. Кроме того, AJDT предоставляет несколько визуализаторов, которые помогают отслеживать распространение сквозной функциональности по иерархии классов проекта (см. рисунки 8 и 9).


Рисунок 9. Сквозная функциональность класса Company.

OVal предоставляет достаточно большой набор реализованных контрактных спецификаций, которые можно найти в пакете net.sf.oval.constraint. Тем не менее, я не смог найти контрактную спецификацию, с помощью которой можно было бы задать несколько допустимых длин строки. Поэтому данную контрактную спецификацию PossibleLength реализовал самостоятельно. Посмотрим, насколько просто это делается с OVal.

Контрактная спецификация, описывающая допустимые длины строки – PossibleLength.java

Аннотация PossibleLength описывает необходимую нам контрактную спецификацию.

В разделе метаданных на основе аннотации java.lang.annotation.Retention задается область видимости нашей аннотации: метаинформация будет записана в байт-код и доступна во время исполнения через механизм рефлексии. С помощью аннотации java.lang.annotation.Target задается область применения аннотации PossibleLength. Нашу контрактную спецификацию можно применить к полям классов, параметрам методов (и конструкторов) и методу. Первые два случая служат для генерации предусловий, третий – постусловия. И последнее, что описывают метаданные – это класс, включающий логику проверки, соответствующей нашей контрактной спецификации. В конструкторе аннотации net.sf.oval.configuration.annotation.Constraint указан класс PossibleLengthCheck, который будет описан ниже.

PossibleLength включает два члена: метод lengths задает массив допустимых значений длины, message – сообщение об ошибке.

Проверка контрактной спецификации, описывающей допустимые длины строки – PossibleLengthCheck.java

Класс PossibleLengthCheck наследуется от абстрактного класса проверки на основе аннотации net.sf.oval.configuration.annotation.AbstractAnnotationCheck, которая уточняется описанной выше аннотацией PossibleLength. Переопределенный метод configure класса AbstractAnnotationCheck осуществляет настройку проверки: метод получает метаданные, которые использует для осуществления дальнейших проверок. Метод isSatisfied, определенный в наследованном интерфейсе net.sf.oval.Check, задает логику нашей проверки. Кроме того, обратите внимание, что описанная нами проверка, в принципе, может работать с любым объектным типом, потому что проверяет длину строки, которая получается вызовом метода toString у проверяемого объекта.

Теперь протестируем работу определенных нами контрактных спецификаций компании.

Тестирование срабатывания контрактных спецификаций компании на некорректных данных – CompanyCrashTest.java

Класс CompanyCrashTest оформлен параметризованным тестом, то есть представляет набор тестов значений, которые задаются методом data. Для каждого заданного кортежа значений: название, ИНН и электронная почта – будет вызван отдельный тест (метод test). Все заданные нами кортежи являются некорректными по одному из заданных значений: или задается пустая ссылка, или другое некорректное значение. Поэтому тест регистрирует генерирование исключения ConstraintsViolatedException при попытке создать экземпляра класса Company на основе данных значений. Кроме того, исключение в тесте перехватывается для того, чтобы вывести в консоль уточняющую информацию. Эту информацию исключению предоставляет контрактная спецификация, которая не может быть удовлетворена. После успешного прохождения теста в консоли можно наблюдать следующее:

        19.09.2007 21:01:55 net.sf.oval.guard.GuardAspect <init>
INFO: Instantiated
Не указано название
Не указан ИНН
Не указана электронная почта
ИНН должен содержать 10 цифр
ИНН долен содержать только цифры
Некорректная электронная почта

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


Рисунок 10. Окно программы-примера.

В качестве примера я создал представление компании (класс CompanyView наследующий класс View<Company>), которое позволяет изменять характеристики компании, но при этом контролирует ввод. Представление перехватывает исключение контрактной спецификации ConstraintsViolatedException компании при попытке создать экземпляр класса Company на основании введенных пользователем данных, протоколирует ошибку и сигнализирует пользователя о некорректном вводе.

ПРИМЕЧАНИЕ

Чтобы не загромождать материал, я описал только малую часть возможностей OVal. Но вы можете найти в OVal и другой интересный функционал. К примеру, задавать проверки контрактных спецификаций можно более выразительно, используя для этого один из поддерживаемых скриптовых языков: BeanShell, Groovy, JavaScript, MVEL и OGNL.

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

6 Источники

  1. Рефакторинг с использованием шаблонов, Джошуа Кериевски.
  2. Jump into JUnit 4. Streamlined testing with Java 5 annotations by Andrew Glover | Переходим на JUnit 4. Streamlined testing with Java 5 annotations, Эндрю Гловер.
  3. Java theory and practice: Characterizing thread safety. Thread safety is not an all-or-nothing proposition by Brian Goetz | Теория и практика Java: Характеристика безопасности потока. Безопасность потока не является понятием типа "все-или-ничего", Брайан Гетц.
  4. Java theory and practice: Be a good (event) listener. Guidelines for writing and supporting event listeners by Brian Goetz | Теория и практика Java: Будьте хорошим подписчиком (событий). Рекомендации по написанию и поддержке подписчиков событий, Брайан Гетц.
  5. Java theory and practice: More flexible, scalable locking in JDK 5.0. New lock classes improve on synchronized – but don't count synchronized out just yet by Brian Goetz | Теория и практика Java: Более гибкая, масштабируемая блокировка в JDK 5.0. Новые классы блокировки усовершенствуют synchronized – но не спешите списывать synchronized со счетов, Брайан Гетц.
  6. Java theory and practice: Concurrent collections classes. ConcurrentHashMap and CopyOnWriteArrayList offer thread safety and improved scalability by Brian Goetz | Теория и практика Java : Параллельные классы коллекций. ConcurrentHashMap и CopyOnWriteArrayList предлагают потокобезопасность и улучшенную масштабируемость, Брайан Гетц.
  7. Приемы объектно-ориентированного проектирования. Паттерны проектирования, Эрих Гамма, Ричард Хелм, Ральф Джонсон, Джон Влиссидес.
  8. Java theory and practice: A brief history of garbage collection. How does garbage collection work? by Brian Goetz | Теория и практика Java: Краткая история развития технологии утилизации памяти. Как работает технология утилизации памяти? Брайан Гетц.
  9. Java theory and practice: Garbage collection and performance. Hints, tips, and myths about writing garbage collection-friendly classes by Brian Goetz | Теория и практика Java: Сборка мусора и производительность. Подсказки, советы и мифы, связанные с разработкой классов, легко поддающихся сборке мусора, Брайан Гетц.
  10. Java theory and practice: Plugging memory leaks with weak references. Weak references make it easy to express object lifecycle relationships by Brian Goetz | Теория и практика Java: Устранение утечек памяти посредством слабых ссылок. Слабые ссылки упрощают выражение связей жизненного цикла объекта, Брайан Гетц.
  11. Java theory and practice: Safe construction techniques. Don't let the "this" reference escape during construction by Brian Goetz | Теория и практика Java: Методы безопасного конструирования. Не позволяйте указателю "this" пропадать во время конструирования, Брайан Гетц.
  12. MVP: Model-View-Presenter. The Taligent Programming Model for C++ and Java by Mike Potel.
  13. Java theory and practice: Introduction to nonblocking algorithms. Look Ma, no locks! by Brian Goetz | Теория и практика Java: Введение в неблокирующие алгоритмы. Смотрите, блоков нет! Брайан Гетц.
  14. Надежность программного обеспечения, Г. Майерс.
  15. In pursuit of code quality: Defensive programming with AOP. OVal takes the legwork out of writing repetitive conditionals by Andrew Glover | В погоне за качеством кода: Безопасное программирование с помощью АОП. OVal позволяет исключить утомительную работу при создании повторяющихся условных зависимостей, Эндрю Гловер.
  16. RFC 2142 Mailbox names for common services, roles and functions by D. Crocker.

Эта статья опубликована в журнале RSDN Magazine #4-2008. Информацию о журнале можно найти здесь
    Сообщений 1    Оценка 75        Оценить