Фасад доступа к данным — Адаптер записи, Трансляторы

Адаптеры записи

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

Если вы взглянете на метод InfrastructureFacade.Commit() то увидите, что получение всех адаптеров для записи получается из StructureMap скопом по одному только интерфейсу, что весьма удобно, так как они нужны всегда все разом для определения очередности работы.

IWriteAdapter

В интерфейс вынесены метод и свойство необходимые для работы в классе фасада, а именно Save() и Sequence.

Теперь можно переходить к рассмотрению «мяса», основной работы по сохранению данных.

WriteAdapter

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

Конечно же, самым интересным методом в классе будет метод Save(), но о нем чуть позже.

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

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

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

Метод Save() потребует детального рассмотрения, есть в нем некоторые нюансы. Итак:

Беглый взгляд на метод, выделяет 3 логических части:

  • Работа с новыми данными.
  • Работа с обновленными данными.
  • Работа с удаленными данными.

Работа с новыми данными

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

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

Обратим внимание на код в комментариях.

Аудит данных

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

Генерацию всех классов L2S делает с модификатором partial, что дает богатые возможности по дополнению и унификации всех/части классов. Например для аудита можно создать интерфейс IDataAuditable и по этому признаку в методе PersistCreatedAuditableFields() заполнять поля даты создания элемента и логин того, кто данные эти создает. Снимается  разом много монотонной и нудной работы.

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

Работа с целочисленными суррогатными ключами

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

Решением является четкий, заранее заданный порядок сохранения типов сущностей. Причем сохранение в реальную базу надо делать сразу после маппинга, чтобы обновилось значение ключа в dataObject. После этого надо уведомить UnitOfWork о новом ключе unitOfWork.UpdateIdentity().

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

При относительно простых структурах данных и использовании натуральных ключей (или же GUID) можно вынести context.SubmitChanges() за пределы лямбда-функции.

Работа с измененными данными

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

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

Как видно из кода, метод GetIdPredicate() является обязательным к реализации в конкретных классах, в нем будет указано, каким образом, по каким полям однозначно определить что объект TData является отображением на TDomain. Как это выглядит на практике будет в разделе трансляторов.

Если удалось найти такой объект, то транслятор переносит в объект TData (dataObject) значения полей TDomain (domainObject).

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

Еще можно заметить, что система никак не реагирует на ситуацию, когда объект в базе никак не был найден. Это сделано умышленно, так как непонятно как обрабатывать такую ситуацию. Например, запись может быть удалена другим пользователем, и тогда нечего обновлять и в целом это нормальная ситуация, что данные устарели. По бизнес-правилам возможно стоит сообщить об этом пользователю, а возможно что и не надо и это поведение by design. Так что как обрабатывать решать вам в зависимости от того, какие требования предъявляются к системе.

Работа с удаленными данными

Осталось последнее действие – удаление данных.

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

Полное удаление сущностей из базы всегда является опасным и спорным моментом, стоит подумать и проанализировать трижды, прежде чем удалить что-то, предпринять защитные меры от случайного, непреднамеренного удаления, которое может повлечь за собой необратимые последствия в целостности данных. Для этих целей вызывается метод AssertCanBeDeleted(), который не проводит анализа конкретной сущности, а знает только можно ли в принципе удалять сущности типа TData. С одинаковым успехом туда можно передавать и TDomain.

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

Порядок действий и действия после сохранения

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

  1. Создание сущности – создаем какой-либо новый элемент, для последующей работы, один или несколько.
  2. Редактирование сущности – редактируем созданные либо существующие элементы.
  3. Удаление – возможно это откат созданных элементов, или удаление ненужных элементов.

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

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

Можно считать, что абстрактный адаптер записи рассмотрен, и можно перейти к конкретной реализации.

Конкретный адаптер записи

Конкретная реализация мала и красива

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

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

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

Мы рассмотрели работу адаптеров записи и чтения, и настает черед трансляторов.

Трансляторы

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

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

Трансляторы чтения

Транслятор для чтения является абстрактным классов с двумя шаблонными параметрами TDomain – обозначающий доменные классы, TData – обозначающий классы Linq2Sql.

Базовый класс

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

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

Звучит не особенно сложно.

Проверку на пустоту можно не пояснять.

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

В общем случае потребуется от конкретного адаптера определить способ сравнения TData и TDomain, чтобы можно было определить, восстанавливали мы такой элемент уже или нет. Для этого определяем абстрактный метод GetPredicate(). Если нашли элемент, то сразу его возвращаем.

С помощью метода DoReconstitute() производится маппинг объектов в конкретном адаптере, это надо будет описывать для каждого типа данных. Об этом будет чуть позже подробнее. Так же больше внимания уделим классу IsLazy<TDomain>, который на самом деле является спецификацией.

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

 Конкретные трансляторы

Простой/типовой транслятор

К такому типу трансляторов можно отнести следующую реализацию транслятора данных для товаров.

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

Так же в классе представлена реализация метода GetPredicate(), которая не представляет сложности и не требует пояснений.

Сложный транслятор

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

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

В методе DoReconstitute() я показываю начальное восстановление для заказчика и далее начинаются более интересные вещи. Как упоминалось ранее, класс IsLazy является наследником Specification и служит для передачи информации о том, к каким образом строить «ленивые» объекты.

При работе с IsLazy надо проверить, что спецификация не содержит типа, который мы собираемся восстанавливать. Для решения проблемы строгой типизации у класса IsLazy есть шаблонный метод For<>(), который позволяет конвертировать спецификацию для передачи в другие трансляторы, как в нашем случае для транслятора заказов.

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

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

Трансляторы записи

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

Базовый класс

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

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

В данном случае базовый класс обязывает наследников описать способ маппинга доменных объектов на автогенерированные классы L2S (метод Persist()) и явно описывать можно ли удалять объекты или нет (метод AssertCanBeDeleted()).

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

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

Теперь можно перейти к рассмотрению конкретного примера.

Конкретные трансляторы записи

Я думаю, что можно рассмотреть сразу сложный вариант сохранения, с зависимыми данными, когда одна доменная сущность записывается в несколько разных таблиц. Примером такого доменного типа является Invoice. Если бы мы делали маппинг доменного класса только на таблицу invoice, то у нас бы получался пустой заказ, что не имеет смысла. Можно было бы разнести во времени записывание общей информации о заказе, а потом о его составе, но случись что между этими действиями и мы получим нецелостные логические данные, хотя база будет в полном порядке. Именно поэтому конструировать/обновлять класс L2S для данного типа следует для всех связанных элементов.

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

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

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

В данном случае метод AssertCanBeDeleted() пустой, так как элементы можно удалять.

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

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