Шаблон Хранитель в АОП реализации

Вторым на очереди в описании оказался шаблон проектирования Хранитель (Memento). На мой взгляд этот шаблон достаточно просто реализовать, по крайней мере его идеологическая суть ясна многим без детальных объяснений.

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

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

Общая информация

Для полноты картины, я считаю, что надо все так же привести основные характеристики и назначение шаблона.

Хранитель относится к категории Поведенческих шаблонов.

Намерение

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

Суть

Сохранить состояние объекта, и иметь возможность вернуться к исходному состоянию, не раскрывая своего содержимого внешнему миру.

Очень важным моментом является — «не раскрывая своего содержимого внешнему миру» — это суть инкапсуляции и ООП. В качестве быстрого примера, могу привести пример с объектом, который получает при конструировании уникальный номер. Этот номер нельзя никак задать с помощью публичного API. При восстановлении объекта из Memento есть доступ к внутренним полям и эта операция пройдет абсолютно честно и просто.

Реализация

Классическая реализация говорит о следующих особенностях реализации шаблона:

  • Необходимо создать класс-хранитель «мгновенных снимков» целевого класса
  • Целевой класс должен уметь сохранять и принимать свои «мгновенные снимки»

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

memento

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

  • SetMemento()
  • CreateMemento()

Опять возникает ситуация, когда реализация шаблона изменяет публичный API объекта.

На мой взгляд, такой функционал нужен только в районе графических интерфейсов, т.е. где-то в Controller/ViewModel эти методы будут востребованы. На уровне домена – очень сомнительно. Мне приходит в голову только ситуация с копированием объекта для проверки каких-то операций, но в этом случае должно срабатывать клонирование, а не использование Хранителя.

ООП реализация

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

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

В классе нет ничего особенного кроме того, что переопределен метод ToString() для удобства вывода данных при сохранении/откате измененных данных.

Далее необходимо вручную подготовить класс к тому, чтобы с ним в дальнейшем мог работать класс-хранитель. Т.е. создать класс типа Memento и формировать его содержимое в целевом классе SuperHero.

В примере выше класс SuperHeroMemento сохраняет в себе всё необходимое внутреннее состояние класса SuperHero.

В данном конкретном случае методы CreateMemento() и SetMemento() реализованы «в лоб». Это использовано для наглядности, в любом реальном проекте используйте автомапперы, чтобы автоматически создавать копии объектов, переносить данные по одинаковым именам. Например, можно использовать AutoMapper, он прост в освоении и быстр.

Опытные разработчики сразу понимают в каких местах этого примера в будущем будут проблемы. Для неискушенных поясню, что с изменением класса SuperHero, потребуется дописывать логику в методы CreateMemento(), SetMemento(). Конечно, все значимые изменения должны быть отражены и в классе SuperHeroMemento.

Пока что никакого rocket science в коде не применялось. Все интересное начинается в классе-хранителе, в зависимости от того, насколько богатую логику с undo/redo вы хотите реализовать. Самое простое – это сделать только undo. У меня уже были наработки для команды redo, поэтому я приведу пример с более сложной логикой. Эта логика будет содержаться в отдельном классе. Начнем с хранителя.

Для удобства сразу привожу весь код класса. Класс Хранителя является шаблонным, может работать с любыми типами данных, но только с одним одновременно. В этой реализации я использовал List<T>, так как на мой взгляд, с ним легче и проще реализовать функцию redo. По этой же причине CurrentVersion имеет публичные get/set.

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

 

Остальные методы на мой взгляд достаточно просты и не нуждаются в детальном описании. Этого уже достаточно, чтобы реализовать операции undo/redo. Для коллекции объектов понадобится дополнительный сервис, который будет управлять соответствием между объектом и его хранителем. Это может выглядеть примерно следующим образом:

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

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

  • Класс-Хранитель работает только с одним инстанциированным объектом за раз. Дополнительный сервис хранителей позволяет решить эту проблему.
  • Для каждого нового класса необходимо создавать новый сервис работы с хранителями.
  • При изменении класса, необходимо внести правки как минимум в один дополнительный класс – Memento. Это справедливо, если используются автомаперы, в противном случае надо изменить еще 2 метода.
  • Шаблон внедряется в тело класса.
  • Класс-хранитель и сервис хранителей можно вынести в отдельную сборку.

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

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

АОП реализация

Основываясь на ООП реализации, можно подумать о том, где выиграть в АОП реализации.

Суть

PostSharp позволяет внедрять в классы методы, интерфейсы, свойства.

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

Реализация

Цели, которые попытаемся добиться с помощью АОП реализации:

  • Полностью отделить код шаблона от целевого класса
  • Убрать зависимость реализации шаблона от структуры целевого класса
  • Реализовать универсальность применения шаблона

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

На этапе компиляции можно будет собрать все данные о полях класса, чтобы потом пробегаться по ним и собирать/восстанавливать данные. Это делается с помощью метода CompileTimeInitialize().

Так же стоит сказать, что реализация для Ultimate и Community версии не отличается.

Идея

Итоговым результатом должно стать примерно следующее.

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

Ядро

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

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

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

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

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

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

Метод предлагает в качестве параметров тип и информацию по аспекту. Нас интересует только тип – это будет тот тип, к которому будет применен аспект. Если брать конкретный пример с SuperHero, то переменная type укажет на этот класс.

Далее, для простоты кода и понимания, я выделяю в отдельные поля:

  • Необходимый структурный компонент класса
  • Характеристики этого компонента

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

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

С помощью метода FindMembers() находятся все поля с заданными характеристиками и конвертируются в LocationInfo, для того, чтобы PostSharp мог воспользоваться ими в run-time и чтобы можно было корректно их сериализовать.

Вызов базовой реализации метода рекомендуется оставить как есть.

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

С такой структурой данных реализация сохранения и восстановления данных выглядит весьма тривиально.

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

Восстановление данных проходит так же просто. После проверки длины стека, берется элемент со стека и данные из словаря (который мы взяли с верхушки стека) записываются в поля.

 

Добавлением еще одного стека, можно реализовать поведение операции Redo(). Но это я оставлю вам в качестве домашнего задания.

 

Использование в клиентском коде

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

Представим, что мы пишем клиентский код для класса SuperHero.

ООП реализация

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

АОП реализация

Вывод

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

Общие выводы

В качестве кратких главных выводов можно сказать, что:

  • аспект можно реализовать с помощью АОП
  • логика полностью выносится в отдельный класс

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

Снова повторюсь насчет того, что всегда надо оценивать трудозатраты на реализацию шаблона по отношению к его частоте использования. АОП позволяет переиспользовать код очень гибко.

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

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

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

 

 

Код: https://github.com/VioletTape/GoF_PostSharp

PDF: http://softblog.violet-tape.ru/pdf/aop_memento.pdf

 

 

Оставить комментарий