четверг, 28 мая 2009 г.

Влюбляемся в Entity Framework: Шаг пятый: Выполнение запросов и маппинг

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

За выполнение запросов в EF отвечает метод Execute уже знакомого нам класса ObjectQuery. В качестве аргумента метод Execute принимает параметр перечисления MergeOption. MergeOption определяет поведение кэша:
  • AppendOnly: Добавлять в кэш только новые записи. Существующие (в кэше) сущности не обновлять;
  • OverwriteChanges: Заменять текущие (current) значения существующих сущностей полученными значениями;
  • PreserveChanges: Заменять исходные (original) значения существующих сущностей полученными значениями. При этом текущие значения не изменяются, следовательно все изменения, внесенные до сих пор, остаются в силе;
  • NoTracking: Полученные значения не записываются в кэш
Трактовка значений MergeOption, данная выше, - перевод соответствующего раздела MSDN. Не считаю это описание интуитивно понятным, поэтому предлагаю разобраться с этим вопросом детальнее.

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

using (FirstModel ctx = new FirstModel())
{
Person person = ctx.Persons.Where(p => p.PersonId == 1).First();

//После выполнения предыдущей строчки измените значение какого-нибудь свойства данной записи в базе вручную

ObjectQuery<person> personQuery = (ObjectQuery<person>)ctx.Persons.Where(p => p.PersonId == 1);

//Раскоментируйте поочередно нижележащие строчки и обратите внимание на возвращаемый результат
//Person person1 = personQuery.Execute(MergeOption.AppendOnly).First();
//person1.Person person2 = personQuery.Execute(MergeOption.OverwriteChanges).First();
//Person person3 = personQuery.Execute(MergeOption.PreserveChanges).First();
//Person person4 = personQuery.Execute(MergeOption.NoTracking).First();
}
Итак, после выполнения первой строчки у нас есть запись в кэше. Затем мы вручную меняем значение в базе и выполняем последующие запросы в четыре захода (для чистоты эксперимента).
  1. Полученное из базы значение игнорируется - возвращается значение из кэша;
  2. Текущие значения кэша подменяются значениями, полученными из базы - person2 содержит актуальные значения.
  3. Самый интересный случай. Как было описано выше, PreserveChanges лишь подменяет исходные значения, и не оказывает никакого влияния на текущие значения. Однако после выполнения теста в person3 будет содержаться актуальная информация из базы. Это связано с тем, что так как мы не производили никаких манипуляций с person, его текущие свойства не заданы. После выполнения запроса с PreserveChanges текущие значения останутся не заданными, поэтому в результате запроса будут возвращены исходные значения (которые к этому моменту уже были подменены на актуальные данные из базы). Если дополнить считывание person модификацией его свойств (например, если Вы планирует модифицировать в базе фамилию, достаточно дописать person.Surname = "AnySurname") - в person3 будут помещены текущие значения из кэша (в данном случае Surname будет равен AnySurname), а PreserveChanges, как ему и полагается, подменит лишь исходные значения.
  4. В случае NoTracking данные в кэш не заносятся и из кэша не считываются: person4 всегда будет содержать актуальные данные из базы.
Помимо явного вызова метода Execute, в EF есть ряд методов, делающих соответствующий вызов неявно (при этом в качестве MergeOption используется значение AppendOnly). Так, выполнение запроса происходит при вызове метода ObjectQuery.GetEnumenator, который (опять же неявно) происходит при использовании конструкции foreach, а также при вызове некоторых методов-расширений (например, ToList, ToArray и др.). Выполнение запроса произойдет и при вызове ряда других (не связанных с GetEnumenator) методов-расширений, таких как First/FirstOrDefault, Last/LastOfDefault и др.

Маппинг
Вот мы и добрались до самой "вкусной" возможности любой ORM-системы - до маппинга. К счастью, на эту тему уже написано достаточно статей, и повторяться я не буду. Я в очередной раз сошлюсь на статьи Сергея Розовика (раз, два), а опишу лишь маппинг Table splitting, которого в указанных статьях нет.

Table splitting
Представьте, что у Вас есть таблица, которая помимо относительно легких атрибутов содержит и тяжелые, вроде varbinary(max) или filestream (на примере SQL Server), причем в большинстве случаев требуются лишь легкие атрибуты:
  • Если мы храним в varchar(max) фотографию товара в высоком разрешении, то она понадобится лишь в тех редких случаях, когда пользователь интернет-магазина кликнет "посмотреть в полном размере";
  • Если мы храним в filestream содержимое файла библиотеки, то значение этого атрибута понадобится лишь при скачивании файла; во всех остальных случаях необходимы лишь описывающие файл атрибуты: имя, размер, дата загрузки и т. д.;
  • и т. д.
К нам на помощь приходит маппинг Table Splitting, который позволяет разделить работу с одной таблицей на несколько сущностей.

Добавим в таблицу Persons нашей базы данных (бэкап которой можно скачать в прикрепленном ко второй части файле) поле Photo типа varbinary(max). Для того чтобы не скачивать с сервера полуторамегабайтный jpeg каждый раз, когда нам нужна информация о пользователе, реализуем Table Splitting:
  1. В дизайнере скопируем и вставим сущность Person
  2. Переименуем полученную сущность в PersonPhoto (а ее Entity Set - в PersonPhotos) и в Mapping Details зададим Maps To Persons.
  3. Удалим из сущности Person свойство Photo, а из сущности PersonPhoto - свойства Name и Surname
  4. Добавим связь 1:1 между Person и PersonPhoto (правой кнопкой в окне дизайнера -> Add -> Association); затем выделим связь -> Mapping Details -> выберем Maps to Persons)
Сверим модель:
, Mapping Details сущности PersonPhoto:
и маппинг созданной связи:
Если сейчас попробовать сбилдить проект, в результате получим ошибку "Each of the following columns in table Persons is mapped to multiple conceptual side properties: Persons.PersonId is mapped to".

Для того чтобы избавиться от ошибки, необходимо создать ReferentialConstraint, описывающий связь между PersonId сущностей Person и PersonPhoto. К сожалению, дизайнер в EF v. 1 этой возможности не поддерживает, поэтому закроем дизайнер -> щелкнем правой кнопкой по FirstModel.edmx в Solution Explorer -> Open With... -> XML Editor... -> в разделе CSDL найдем определение ассоциации PersonPersonPhoto:

<Association Name="PersonPersonPhoto">
<End Type="FirstModelModel.Person" Role="Person" Multiplicity="1" />
<End Type="FirstModelModel.PersonPhoto" Role="PersonPhoto" Multiplicity="1" />
</Association>
и добавим в него ReferentialConstraint:

<Association Name="PersonPersonPhoto">
<End Type="FirstModelModel.Person" Role="Person" Multiplicity="1" />
<End Type="FirstModelModel.PersonPhoto" Role="PersonPhoto" Multiplicity="1" />
<ReferentialConstraint>
<Principal Role="Person">
<PropertyRef Name="PersonId" />
</Principal>
<Dependent Role="PersonPhoto">
<PropertyRef Name="PersonId" />
</Dependent>
</ReferentialConstraint>
</Association>
Вот теперь нас поджидает successful build.

Воспользуемся созданным маппингом и добавим запись в таблицу:

using (FirstModel ctx = new FirstModel())
{
Person person = new Person();
person.Name = "X";
person.Surname = "Y";

PersonPhoto personPhoto = new PersonPhoto();
personPhoto.Photo = new byte[] { 1, 2, 3, 4, 5 };

person.PersonPhoto = personPhoto;

ctx.AddToPersons(person);

ctx.SaveChanges();
}
При этом будет сгенерирован один INSERT-запрос, в котором задаются все поля (в том числе и Photo), а это значит, что маппинг работает. Правда, есть один нюанс. Так, если при создании сущности необходимо оставить свойству Photo значение NULL, нельзя просто не задавать его, как это обычно делается со скалярынми свойствами. Загвоздка в том, что таблицы Person и PersonPhoto связаны 1:1, а связать их 1:0..1 невозможно, ибо связывается первичный ключ одной и той же таблицы. Поэтому необходимо явно присвоить свойству Photo экземпляр класса PersonPhoto:

using (FirstModel ctx = new FirstModel())
{
Person person = new Person();
person.Name = "X";
person.Surname = "Y";

person.PersonPhoto = new PersonPhoto();

ctx.AddToPersons(person);

ctx.SaveChanges();
}
Считывать значения PersonPhoto можно следующими способами:
  • можно считать непосредственно PersonPhoto (для него создан свой EntitySet), отобрав записи по PersonId;
  • можно считать Person, а затем при помощи defered-loading подгрузить навигационное свойство PersonPhoto;
  • а можно, считывая Person, загрузить навигационное свойство PersonPhoto при помощи eager-loading:

using (FirstModel ctx = new FirstModel())
{
List<Person> persons = ctx.Persons.Include("PersonPhoto").ToList();

...
}
Сгенерированный в этом случае запрос радует глаз: вся загрузка сводится к одному простенькому SELECT'у (точно такому, какой бы Вы написали вручную).

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

На этом посте данный цикл статей будет временно заморожен. Хотя некоторое время спустя я, наверное, к нему вернусь. В ближайшее же время Вас ждет новый цикл статей, который (уж не знаю, к счастью или к сожалению) опять-таки будет посвящен Entity Framework. До скорых встреч!

воскресенье, 24 мая 2009 г.

Сравнительный анализ фреймворков для работы с онтологиями под .NET и Java

Ни для кого не секрет, что львиная доля проектов, связанных с Semantic Web, разрабатывается на Java. Фреймворки для работы с семантическими онтологиями не являются исключением: все основные проекты (Jena, OWL API, Sesame и т. д.) написаны именно на Java. Единственным серьезным представителем, использующим .NET, является фирма Intellidimension с продуктами RDF Gateway и Semantics.SDK.

В этой статье опишу свой опыт работы с вышеуказанными фреймворками и поделюсь результатами тестирования.

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

Перед погружением в технические детали скажу пару слов о фреймворке от Intellidimension (как наименее известном продукте для Java-ориентированного сообщества). В отличие от остальных рассматриваемых в этой статье фреймворков, которые являются OpenSource-проектами, RDF Gateway и Semantics.SDK распространяются с закрытыми исходными кодами и стоят достаточно приличных денег. Так, один лишь RDF Gateway 3.0 Enterprise стоит 10000$ (хотя версия 2.0 стоила «всего» 2000$). К слову, использованные при тестировании ризонеры - Pellet и Owlim - тоже не бесплатны: Pellet распространяется по dual license, а Owlim предлагает бесплатно лишь версию, работающую в памяти; версия, работающая с хранилищем, стоит 700 евро за каждое используемое процессорное ядро.

Тестирование
Передо мной стояла задача подобрать фреймворк для реализации проекта под .NET, поэтому Java-проекты в чистом виде меня не интересовали (изначально я даже не планировал их тестирование). Необходимо было выбрать средство interop’а между Java и .NET. Мой выбор пал на ikvm.net, который позволяет конвертировать jar’ы в .NET dll. Получив .NET-версию Jena, OWL API и Sesame, я принялся за их тестирование. Однако тестирование было бы неполным, если бы не содержало результатов тестирования Java-фреймворков в их родной среде. Таким образом, в тестировании участвуют: Intellidimension Semantics.SDK 1.0, OWL API 2.2.0+Pellet 2.0rc5 (как под Java, так и под .NET), Jena 2.5.7+Pellet2.0rc5 (как под Java, так и под .NET) и отчасти Sesame 2.24+SwiftOwlim 3.0b9.

Из первоначального тестирования пришлось исключить Sesame ввиду отличающейся от Semantic SDK и Pellet политики логического вывода в Owlim (Owlim - основной ризонер, используемый в связке с Sesame). Так, Pellet и Semantic SDK нацелены на вывод во время запроса (query-time reasoning), хотя и включают средства заблаговременного вывода; Owlim же нацелен на полный логический вывод (full materialization). Об этом мы еще поговорим в разделе «Общая информация о ризонерах».

В качестве тестовой была выбрана онтология Thesaurus 09.02d института NCI. Правда, в чистом виде она содержит ряд противоречивостей (inconsistency). После общения со службой поддержки противоречивости были выявлены. Я использовал модифицированную версию 09.02d (которую Вы можете скачать с моего dropbox’а), хотя сейчас уже доступна версия 09.04d, в которой данных противоречивостей нет.

Для тестирования была смоделирована следующая ситуация:
1. Сначала онтология из файла загружалась в модель;
2. Затем к этой модели последовательно выполнялось 3 SparQL-запроса (тексты запросов можно скачать здесь).

Сначала рассмотрим результаты тестирования первого этапа:
Несмотря на то, что .NET/ikvm-фреймворки медленнее своих Java-собратьев в 2-3 раза, они оказались быстрее Intellidimension.

В плане работы с памятью .NET оставил более приятное впечатление. Сборщик мусора в Java требует указания максимального объема оперативной памяти, который может быть выделен под кучу (параметр Xmx); .NET же использует более логичную, на мой взгляд, политику: он потребляет столько памяти, сколько ему нужно (если только не задано ограничение). Ограничение через Xmx - очень старый "баг", который, к сожалению, до сих пор не пофикшен. В качестве решения предлагается просто задавать Xmx с запасом (если, конечно, объем оперативной памяти позволяет), однако в этом случае (тестирование велось с Xmx:12g) сборщик мусора особенно себя не утруждает (что мы и видим по результатам тестирования). Можно пойти другим путем - подбирать минимальное значение Xmx для конкретных входных данных (правда, рискуя напороться на OutOfMemoryException). Таким образом можно приблизить объем потребляемой JVM памяти к аналогичным показателям CLR (пусть и с некоторыми дополнительными потерями производительности на более частые сборки мусора).

Касательно ограничения на максимальный объем используемой памяти произошел довольно курьезный случай. После успешной загрузки онтологии Thesaurus при помощи конвертированного в .NET OWL API, я решил открыть эту онтологию в Protége (который базируется на той же версии OWL API) для ознакомления с ее структурой. Однако вместо дерева концептов и экземпляров я получил OutOfMemoryException (при том, что свободной памяти было предостаточно). И хотя увеличение значения атрибута Xmx разрешило проблему, такая несамостоятельность сборщика мусора в Java не радует. Курьезность ситуации заключается в том, что несмотря на то, что Java-приложение не запускается в родной среде (без танцев с JVM), после конвертации в .NET при помощи ikvm оно работает.

Теперь перейдем ко второму пункту тестирования - выполнению SPARQL-запросов. К сожалению, на этом этапе придется оставить за бортом лидера первого теста - OWL API. Дело в том, что интерфейс OWL API, который реализуют ризонеры, не содержит методов для выполнения SPARQL-запросов. Связано это с тем, что SPARQL создавался как язык запросов к RDF-графам (и с OWL он не очень дружит), а OWL API, как нетрудно догадаться по названию, ориентирован именно на OWL. Сейчас идет работа над стандартом SPARQL-DL и, возможно, его поддержка будет реализована в одной из следующих версий OWL API. На данный момент остается лишь использовать class expressions, которые позволяют писать запросы с использованием манчестерского синтаксиса. Class expressions - это, конечно, не SPARQL… но для большинства задач их достаточно.

Итак, результаты тестирования:
Сначала прокомментирую прочерки в графе Intellidimension. С момента запуска тестового приложения прошло 6 часов, процесс весил порядка 6Гб, а результата все не было. Ждать дольше у меня не хватило терпения. Вынужден засчитать Semantics.SDK техническое поражение. Справедливости ради стоит отметить, что с более мелкими онтологиями Semantics.SDK справляется: и вывод делает, и запросы обрабатывает… правда, сравнивая результаты с Jena+Pellet, могу с уверенностью заявить, что Semantics.SDK не всегда выдает полный результат.

Pellet же справился с выводом за вполне разумное время (запросы были не из легких) и, как и на первом этапе тестирования, .NET/ikvm-фреймворк выглядит предпочтительнее Intellidimension.

На этом этап тестирования закончен. Подводя итоги, можно сказать, что победителем стала система Jena+Pellet, а приз авторских симпатий достается OWL API+Pellet.

Общая информация о ризонерах
В целом, существует два подхода к реализации логического вывода: на базе правил (с использованием алгоритмов forward-chaining и/или backward-chaining) и на базе семантического табло (semantic tableau). На базе правил реализованы Semantics.SDK и Owlim, а на базе семантического табло - Pellet.

Насколько я могу судить, rule-based ризонеры выгодно использовать для языков с низкой выразительностью, а ризонеры на базе семантического табло - для языков с высокой выразительностью. Если Pellet (OWL-DL) и Owlim (OWL-Tiny) подтверждают это наблюдение (находясь по разные стороны баррикады), то Semantics.SDK (OWL-FULL) является исключением (и судя по тестам производительности, ничего хорошего в этом исключении нет).

Рассмотрим диаграмму c официального сайта Owlim:
Как видим, Owlim поддерживает лишь OWL Tiny. Именно эта особенность позволяет ему добиться очень высокой производительности.

Owlim - единственный (из рассмотренных) ризонер, который поддерживает многопоточный вывод. Тем самым Owlim реализует преимущество rule-based систем перед семантическим табло - возможность распараллеливания (алгоритмов распараллеливания процесса построения семантического табло пока нет). Разработчики Semantics.SDK утверждают, что их вывод тоже распараллеливается… но в trial-версии это не реализовано (на мой взгляд, не самое удачное ограничение для trial-версии). Просьба выделить мне краткосрочную лицензию для тестирования осталась без ответа, поэтому приходится верить разработчикам на слово.

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

Заключение
В этой статье акцент сделан на производительности, однако при выборе семантического фреймворка для того или иного проекта акцент нужно, в первую очередь, делать на функционале (поддержка (нереляционного) хранилища, поддержка OWL2 и т. д.). Обсуждение этих вопросов выходит за рамки данной статьи (да и не настолько хорошо я знаком с каждым из фреймворков, чтобы делать такой анализ). В отношении функциональности я хотел было похвалить продукты от Intellidimension: связка RDFGateway и Semantics.SDK представляет собой очень мощный фреймворк, аналогов которому в мире Java нет… но выкрутасы их ризонера охладили мои чувства к этому фреймворку.

P. S. Огромное спасибо за ликбез, оказанный в ходе написания статьи, Клинову Павлу.
PP. S. Статья также опубликована на shcherbak.net и на Хабре. Контент там тот же, а комменты (равно как и сообщество) совершенно разные - советую пролистать.