Фасад доступа к данным — Фасад и Адаптер чтения

Фасад

Теория

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

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

Про Unit of Work (UoW) рассказано было ранее, паттерн Specification так же уже рассматривался, так что на этих типах данных останавливаться не будем.

Общий принцип работы такой:

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

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

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

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

Реализация

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

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

Привнесем некоторой гибкости при работе, когда можно не перегружать весь UoW. Далее реализация свойства UnitOfWork, которое не представляет интереса. Зато остановимся подробнее на методах Get() и Commit()

Метод Get() позволяет получить данные из базы данных, причем отфильтровать их с соответствии с заданной спецификацией.

Не будем пока рассматривать интерфейсы адаптеров (IReadAdapter), примите на веру, что там есть метод Get с такой же сигнатурой. Работу метода можно описать достаточно просто:

  • найди адаптер по типу данных шаблона,
  • получи данные,
  • отфильтруй список.

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

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

Посмотрим, как устроен метод Commit().

Если при работе с Get() нам нужен только один адаптер, то при сохранении, надо получить полный набор адаптеров записи, при этом необходимо их отсортировать в порядке сохранения данных в базу. Особенность некоторых иерархичных структур такова, что L2S или же трансляторы не в состоянии построить полный граф элементов, если начинать строить его с произвольного элемента. Т.е. построение такого графа добавить чрезмерной сложности и ветвистости коду, а сложность и ветвистость источник трудноуловимых ошибок. Гораздо легче и быстрее сохранять некоторые элементы раньше других, благодаря чему проблему удается решить малой кровью и ошибка в зависимостях ключей может произойти, только если данных вообще нет, либо мы выставили неправильный порядок сохранения данных.

О порядке сохранения данных

Небольшой пример на пальцах. Допустим, у нас есть тип данных адрес. Для нормализации таблиц и поддерживая хорошую структуру данных, мы можем создать классы/таблицы: Страна, Область, Город, Адрес. Зависимость их пояснений не требует. Так вот при создании совершенно нового адреса, система может запнуться, так как никоим образом не гарантируется порядок получения адаптеров сохранения и Адрес или Город может сохраняться в базу данных до того, как была сохранена Страна. Или мы можем получить дубликаты Стран, Областей.

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

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

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

Но вернемся к методу Commit(), после того, как все Адаптеры записи упорядочены, для каждого из них вызывается метод Save(), аргументом которого является UoW. Каждый адаптер работает только со своим типом данных, по которому он получает пользовательские значения из UoW.

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

Обо всем этом подробно будет написано ниже.

Адаптеры чтения

Для любителей и знающих UML предоставляю схему ниже, чтобы сразу оценить взаимодействие и структуру классов и наследования. TDomain – доменный класс, TData – сгенерированный Linq2Sql класс на основании таблицы данных.

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

IReadAdapter

Начнем с самого верха, с интерфейса IReadAdapter. Как видно из схемы, он состоит только из объявления сигнатуры одного, уже знакомого нам метода. От данного интерфейса кроме абстрактного класса ReadOnlyAdapter никто не наследуется, но данный интерфейс, само его наличие, играет важную роль в деле регистрации и получения конкретных адаптеров (CustomerAdapter, WareAdapter) в фасаде. Если вы вернетесь в фасад и посмотрите на код для получения адаптера, то вы увидите, что используется только один дженерик параметр. И это единственный параметр доступный клиентской части. Клиент (домен) ничего не знает о базе данных и о том, какие классы сгенерировал L2S, так что это является механизмом с помощью которого мы в принципе можем получить нужный адаптер.

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

ReadOnlyAdapter

Данный класс является одним из самых важных во всей схеме, поэтому уделим ему максимум внимания. Как следует из схемы, ReadOnlyAdapter реализует интерфейс IReadAdapter, поэтому ключевым методом можно считать метод Get().

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

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

Далее идет разделение: получение данных с помощью оптимизированного запроса и трансформация данных в доменные записи.

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

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

Я думаю, вы уже заметили, что метод Reconstitute() относится к классу Базовый класс, который передается в класс в момент создания.

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

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

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

После того, как были созданы оптимизационные настройки, переходим к созданию контекста Linq2Sql. В нашем примере контекст называется WarehouseDataContext и после его создания указываются опции для загрузки данных. Никаких особенностей в этих строках нет.

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

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

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

Конкретные адаптеры

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

Так как класс небольшой, то я привел его полный текст. Данный класс наследуется от абстрактного класса ReadOnlyAdapter, при этом указываются классы доменный (Customer) и сгенерированный (customer).  Адаптер работает только с одним транслятором, поэтому конструктор конкретного класса можно оставить пустым, а транслятор передать конструктору базового класса.

Значение DataLoadOptions

Пусть по условиям задачи получается так, что в подавляющем большинстве случаев нам интересны заказчики с заказами, т.е. при загрузке заказчиков мы хотим сразу получать списки заказов. Для того, чтобы получать их одним запросом, мы даем подсказки L2S с помощью заполнения класса DataLoadOptions в методе DoConfigureLoad().

Представим, что я не перегружаю метод DoConfigureLoad() в конкретном адаптере. Забегая немного вперед представим, что в трансляторе некоторым образом маппятся (восстанавливаются) объекты не только Customer, но и Invoice. Данную работу можно представить в виде небольшого теста:

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

Запускаем тест и смотрим на результаты.

  1. Выполнился запрос для получения всех элементов из базы данных
  2. Получаются и обрабатываются заказы для каждого заказчика.
  3. Повторять п.2 пока не закончатся заказчики.

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

 

Теперь можно посмотреть, как себя поведет движок L2S с подсказками по тому, какие типы данных нам понадобятся сразу, т.е. метод DoConfigureLoad() перегружен в конкретном адаптере. Запускаем тест и видим следующее:

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

Типичный адаптер чтения

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

Просто и без лишних сложностей.

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

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