Jan 12, 2010

Маппинг деревьев в NHibernate

Доменная модель

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

public class Category
{
    private ISet<Category> childCategories;
    private Category parentCategory;

    public Category()
    {
        childCategories = new HashedSet<Category>();
    }

    public virtual int Id { get; protected set; }

    public virtual string Name { get; set; }

    public virtual ReadOnlyCollection<Category> ChildCategories
    {
        get
        {
            return new ReadOnlyCollection<Category>(new List<Category>(childCategories));
        }
    }

    public virtual Category ParentCategory
    {
        get
        {
            return parentCategory;
        }
    }

    public virtual void AddChildCategory(Category category)
    {
        if (category == null) throw new ArgumentNullException("category");
        childCategories.Add(category);
    }

    public virtual void SetParentCategory(Category category)
    {
        if (category == null) throw new ArgumentNullException("category");
        parentCategory = category;
    }
}

Каждая категория содержит ссылку на своего родителя и содержит список дочерних категорий. На данный момент модель не представляет никакой гарантии того, что дерево действительно будет деревом. Что я имею ввиду: если есть следующая вложенность: “Parent” –> “Child” -> “Child of Child”. Доменная модель никаким образом не запретит следующий код: Category(“Child Of Child”).AddChildCategory(“Parent”). Это создаст цикл и никакие рекурсивные алгоритмы без дополнительных действий работать не будут.

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

Маппинг

Теперь обратимся к Nhibernate. Маппинг для данного класса будет следующим:

public class CategoryMap : ClassMap<Category>
{
    public CategoryMap()
    {
        Id(x => x.Id).GeneratedBy.Native();

        Map(x => x.Name)
            .Length(200)
            .Not.Nullable();

        References(x => x.ParentCategory)
            .Column("ParentCategoryId")
            .Access.CamelCaseField();

        HasMany(x => x.ChildCategories)
            .Cascade.AllDeleteOrphan()
            .AsSet()
            .KeyColumn("ParentCategoryId")
            .Access.CamelCaseField();
    }
}

По маппингу можно сгенерировать следующую таблицу:

Таблица категорий

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

using (var transaction = Global.CurrentSession.BeginTransaction())
{
    Category parent = new Category();
    parent.Name = "parent";

    Category child = new Category();
    child.Name = "child";

    parent.AddChildCategory(child);

    Category childOfTheChild = new Category();
    childOfTheChild.Name = "child of the child";

    child.AddChildCategory(childOfTheChild);

    Global.CurrentSession.SaveOrUpdate(parent);
    transaction.Commit();
}

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

Немного о производительности

Вопросы выборок иерархических структур сложны в самом SQL. Если оставить все как есть, то Nhibernate будет работать в режиме обычного lazy load и вытаскивать каждую коллекцию подкатегорий отдельным запросом. Для примера следующий код:

protected void Button1_Click(object sender, EventArgs e)
{
    ICriteria criteria = Global.CurrentSession.CreateCriteria(typeof (Category));
    criteria.Add(Restrictions.IsNull("ParentCategory"));
    foreach (Category category in criteria.List<Category>())
    {
        EnumerateChilds(category);
    }
}

protected void EnumerateChilds(Category category)
{
    foreach (Category childCategory in category.ChildCategories)
    {
        EnumerateChilds(childCategory);
    }
}

Тут вытаскиваются все родительские категории, и мы пробегаемся по всем дочерним. Если существует следующая вложенность: “Category”->”Child Category”->”Child of child category”, то данный код выполнит запросы:

-- Выбираем родительскую категорию 
SELECT this_.Id as Id0_0_, 
       this_.Name as Name0_0_, 
       this_.ParentCategoryId as ParentCa3_0_0_ 
FROM [Category] this_ 
WHERE this_.ParentCategoryId is null
-- Выборка подкатегорий каждой из категорий 
SELECT childcateg0_.ParentCategoryId as ParentCa3_1_, 
       childcateg0_.Id as Id1_, 
       childcateg0_.Id as Id0_0_, 
       childcateg0_.Name as Name0_0_, 
       childcateg0_.ParentCategoryId as ParentCa3_0_0_ 
FROM [Category] childcateg0_ 
WHERE childcateg0_.ParentCategoryId=@p0   /* @p0=4 */

SELECT childcateg0_.ParentCategoryId as ParentCa3_1_, 
       childcateg0_.Id as Id1_, 
       childcateg0_.Id as Id0_0_, 
       childcateg0_.Name as Name0_0_, 
       childcateg0_.ParentCategoryId as ParentCa3_0_0_ 
FROM [Category] childcateg0_ 
WHERE childcateg0_.ParentCategoryId=p0    /* @p0=5 */

SELECT childcateg0_.ParentCategoryId as ParentCa3_1_, 
       childcateg0_.Id as Id1_, 
       childcateg0_.Id as Id0_0_, 
       childcateg0_.Name as Name0_0_, 
       childcateg0_.ParentCategoryId as ParentCa3_0_0_ 
FROM [Category] childcateg0_ 
WHERE childcateg0_.ParentCategoryId=@p0   /* @p0=6 */

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

var categories = Global.CurrentSession
                       .CreateQuery("select c from Category c join fetch c.ChildCategories")
                       .SetResultTransformer(new DistinctRootEntityResultTransformer())
                       .List<Category>(); 

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

-- statement #1
select category0_.Id                 as Id0_0_,
       childcateg1_.Id               as Id0_1_,
       category0_.Name               as Name0_0_,
       category0_.ParentCategoryId   as ParentCa3_0_0_,
       childcateg1_.Name             as Name0_1_,
       childcateg1_.ParentCategoryId as ParentCa3_0_1_,
       childcateg1_.ParentCategoryId as ParentCa3_0__,
       childcateg1_.Id               as Id0__
from   [Category] category0_
       inner join [Category] childcateg1_
         on category0_.Id = childcateg1_.ParentCategoryId

-- statement #2
SELECT childcateg0_.ParentCategoryId as ParentCa3_1_,
       childcateg0_.Id               as Id1_,
       childcateg0_.Id               as Id0_0_,
       childcateg0_.Name             as Name0_0_,
       childcateg0_.ParentCategoryId as ParentCa3_0_0_
FROM   [Category] childcateg0_
WHERE  childcateg0_.ParentCategoryId = 8 /* @p0 */

-- statement #3
SELECT childcateg0_.ParentCategoryId as ParentCa3_1_,
       childcateg0_.Id               as Id1_,
       childcateg0_.Id               as Id0_0_,
       childcateg0_.Name             as Name0_0_,
       childcateg0_.ParentCategoryId as ParentCa3_0_0_
FROM   [Category] childcateg0_
WHERE  childcateg0_.ParentCategoryId = 10 /* @p0 */

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

Такое же поведение наблюдается если выключить lazy load и поставить fetch-mode=”join”.

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

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

Пример кода.

Dec 25, 2009

Fluent NHibernate. Маппинг наследования

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

Nhibernate поддерживает 3 способа реализации наследования. Различаются они количеством необходимых для этого таблиц. Далее будут рассмотрены 2 из них подробнее.

Table per class hierarchy

В данной схеме для всей иерархии классов используется одна таблица. Рассмотрим пример со следующей моделью:

image

Для хранения такой структуры будет достаточно одной таблицы с полями Id, Name, UploadDate, WhoIsOnPicture, Lenght, Type. Стоит обратить внимание на то, что поле Type используется Nhibernate’ом для того, чтобы узнать какой конкретный тип находится в конкретной строке. Итак маппинг будет выглядеть следующим образом:

public class ContentMapping: ClassMap<Content>
{
    public ContentMapping()
    {
        Id(x => x.Id).GeneratedBy.Native();
        Map(x => x.Name).Length(200).Not.Nullable();
        Map(x => x.UploadDate).Not.Nullable();
        DiscriminateSubClassesOnColumn("ContentType");
    }
}

public class PhotoMapping : SubclassMap<Photo>
{
    public PhotoMapping()
    {
        Map(x => x.WhoIsOnPicture);
        DiscriminatorValue("Photo");
    }
}

public class VideoMapping: SubclassMap<Video>
{
    public VideoMapping()
    {
        Map(x => x.Length);
        DiscriminatorValue("Video");
    }
}

Генерируемый XML довольно громоздкий, и полностью приводить его я не буду. Важно лишь то, что сформирован маппинг, и в нем используются теги subclass для photo и video.

Недостатком данной схемы является то, что в таблице Content колонки WhoIsOnPicture и Length обязательно должны позволять сохранять NULL.

Table per subclass

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

public class ContentMapping: ClassMap<Content>
{
    public ContentMapping()
    {
        Id(x => x.Id).GeneratedBy.Native();
        Map(x => x.Name).Length(200).Not.Nullable();
        Map(x => x.UploadDate).Not.Nullable();
    }
}
public class PhotoMapping : SubclassMap<Photo>
{
    public PhotoMapping()
    {
        Map(x => x.WhoIsOnPicture);
    }
}
public class VideoMapping : SubclassMap<Video>
{
    public VideoMapping()
    {
        Map(x => x.Length);
    }
}

Этот маппинг приведет к использованию joined-subclass элементов.

Dec 23, 2009

Проблема наследования в Nhibernate

Для меня данный вопрос остаётся открытым вот уже год. В обещанном примере приложения есть следующая иерархия:

Диаграмма классов с наследованием

Схема базы будет сгенерирована автоматически, если убрать комментарий со строки в Global.asax (ExposeConfiguration(BuildSchema)).

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

var photo = Global.CurrentSession.Get<Photo>(2);
var video = new Video();
video.FromPhoto(photo);

Global.CurrentSession.Evict(photo);

Global.CurrentSession.SaveOrUpdateCopy(video);

Где метод FromPhoto выглядит так:

public virtual void FromPhoto(Photo photo)
{
    this.Id = photo.Id;
    this.Name = photo.Name;
    this.UploadDate = photo.UploadDate;
}

В общем у меня не получается заставить хибернейт изменить тип контента, возможно кто-то поможет?

Исходный код

Dec 22, 2009

Когда использовать наследование в ORM средствах

NHibernate поддерживает три способа имитации наследования в реляционных базах данных. Table per class hierarchy, table per subclass, table per concrete class. О каждой из этих стратегий можно найти множество примеров как их реализовывать. Я же хотел бы поделиться своим опытом о том, когда же стоит применять наследование.

Вплоть до моего последнего проекта, в моих базах содержались таблицы называющиеся на подобие ContentType и содержащие две колонки – Id, TypeName. Необходимость таких таблиц с точки зрения базы действительно необходимо. Вот к примеру на сайтах социальных сетей можно загружать фото и видео. У обоих есть общие атрибуты, такие как название, подпись, комментарии, дата добавления и т.д. Естественно, что будет логично расположить информацию о фото и видео в таблице Content и добавить поле Type, чтобы приложение могло отличить фото от видео (хотя бы для того, чтоб правильно отображать каждое из них).

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

Схема базы с локализацией

Если же перенести знание о типе контента в приложение, то вы можете создать следующую иерархию классов:

Диаграмма классов с наследованием

Абстрактный класс Content содержит абстрактное поле ContentType, и если же объединить этот подход с локализацией, которую я описывал ранее, то можно получить чистую доменную модель, и базу, лишенную необходимости в сложной структуре из-за локализации (как Вы сами понимаете, в базе при использовании стратегии per class hierarchy будет содержаться лишь одна таблица Content, в которое будет поле ContentTypeId, но семантика этого TypeId будет содержаться в приложении, и NHibernate сам позаботится о том, чтобы поставить для вас правильный тип).

Конечно же можно оставить в базе данных таблицу ContentType, и при выводе для пользователя проверять его значение и доставать по ключу из ресурсов нужную локализованную строку. Но при добавлении нового типа контента, я могу дать 90% гарантию, что кто-то забудет добавить строку в ресурсы, которая будет соответствовать новому типу. Или же переименование типа в базе данных приведет к полной неработоспособности приложения, но узнать об этом можно будет лишь на этапе выполнения, а не компиляции (именно это и случалось довольно часто в последнем моём проекте).

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

public double GetPrice()
{
    switch (ContentType)
    {
        case "Photo":
            return PHOTO_PRICE;
        case "Video":
            return VIDEO_PRICE;
        default:
            return 0;
    }
}

Не важно что будет в этом switch – будь то строки, или какой то enum. Важно, что такая конструкция будет не одна, а будет она везде где необходимо выполнить что-то в зависимости от типа контента. Как искать все условные операторы, если к фото и видео добавились еще и музыкальные файлы? Самое плохое то, что опять же ошибку вы сможете найти только во время выполнения, и самое страшное, что если забыть дописать в этом switch взимание платы за музыкальные файлы – пользователи будут загружать их по нулевой цене!

Так что всякий раз когда вы создаете таблицу с именем <Entity>Types или что-то подобное – подумайте не стоит ли заменить её полноценной схемой наследования.

Но есть одна проблема, которую мне так и не удалось решить. Возможно она специфична для NHibernate, но мне так не кажется. Если существует функционал изменения типа объекта. Например пользователь захочет наложить на фотографию музыку, и это будет уже видео. ORM не даст так просто изменить тип объекта, который привязан к определенному Id. Этот вопрос возникал и в Google groups, и я его задавал на StackOverflow.

Почему Вы не можете удалить фото, и создать вместо него объект видео? Это объясняется следующим, вероятней всего для просмотра контента вы будете использовать url наподобие www.mysite.com/Content?Id=2. И поле Id будет уникальным для контента (либо авто инкрементное поле в базе, либо hilo), значит Id вновь созданного объекта будет другим. Картинка пользователя пользовалась огромной популярностью, но пользователи, которые воспользуются старой ссылкой уже не найдут интересующее.

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

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

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

P.S. если кто-то найдет решение вопроса со сменой типа – огромная просьба поделиться :)

Dec 21, 2009

Автоматизируем изменения в Setup Project

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

Для автоматизации внесения изменений я использовал две утилиты. MsiDiff и MakeMsi.

MsiDiff позволяет сравнивать два msi файла, и генерирует для вас скрипт изменения. Этот скрипт можно выполнить при помощи утилиты MakeMsi, которая имеет множество возможностей по созданию и изменению msi файлов.

Использовать MsiDiff довольно просто. Для этого вносим все нужные для нас изменения в MSI файл с помощью любой утилиты редактирования (я использовал orca). Далее проводим сравнение двух файлов – исходного и измененного. Кстати тут есть пошаговое описание процесса.

Для моей задачи было необходимо изменить тип для Cusom Action. Сгенерированный скрипт изменения выглядел следующим образом:

#define MsiBEFORE     .\Template.msi

#include "OpenMsi.MMH"

<$Msi "<$MsiBEFORE>">
<$Table "CustomAction">
   #(
       <$Row
           @Where="`Action` = '_8126FDDA_EDC6_4CC9_9CDC_A26F93BA0A18.commit'"       
             Type="1537"      ;;Previous value = 3585
       >
   #)

<$/Table>

<$/Msi>

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

> MSI ERROR #2235 (see the Windows Installer documentation)
> ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
> Column 'ExtendedType' not present or ambiguous in SQL query: SELECT
`Action`,`Type`,`Source`,`Target`,`ExtendedType` FROM CustomAction WHERE
`Action` = '_8116FDDA_EDC6_4CC9_9CDC_A16F93BA0A18.commit' (in
".\result\Result.msi")

Чтобы разобраться с этой ошибкой пришлось задать вопрос в яху группах, и ответ был получен. Поле ExtendedType было добавлено начиная с версии windows installer 4.5, а сетап проекты строятся для версии 3.1. В общем чтобы скрипт выполнялся, пришлось добавить следующее:

#define TABLES_DEFINE.CustomAction.ExtendedType N

Теперь скрипт выполняется без ошибок. Для полной автоматизации требуется возможность запустить процесс изменения msi файла не через интерфейс, а через bat файл. Если MsiDiff правильно установлен, то можно использовать следующую команду:

MM.CMD <fileName>

где fileName путь к вашему скрипту. В проекте данные изменения выполняются каждый раз как часть сборки нового билда с помощью Cruise Control .NET (как настроить).

Имперсонализация в Setup Projects

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

Для выполнения кода разработчика в Setup projects используются Custom Actions. Особенностью является то, что процесс установки запускается от имени пользователя SYSTEM. И поэтому Ваш код так же выполняется от имени SYSYTEM.

В одном из проектов было требование, чтобы приложение автоматически запускалось после завершения установки. Setup project не позволяют отловить нажатие кнопки Finish в конце установки. Наиболее позднее событие, доступное для обработки это Commit. Оно вызывается в момент фиксации успешной установки (визуально это происходит когда прогресс бар дошел до конца). Для запуска приложения я использовал Process.Start. Но приложение запускалось от имени SYSTEM. Из-за этого ему были не доступны настройки прокси сервера из браузера, а они были необходимы для работы.

Требовался способ запустить установку от имени текущего пользователя. Поиск велся в направлении Impersonation in setup projects.

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

You'd revert to running impersonated by editing the MSI file with the tool Orca in the Windows SDK and turning off the msidbCustomActionTypeNoImpersonate bit in the CustomAction table entries for your custom action, in the Type value.

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

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

  1. Установить утилиту orca
  2. Открыть с её помощью Ваш msi файл
  3. В левой колонке найти раздел CustomAction и выбрать его
  4. В правой части найти необходимый Custom Action
  5. Найти необходимый вам Action (в именах действий будет предшествовать некоторый Guid)
  6. Из цифры Type, соответствующей нужному действию вычесть 2048 (на самом деле этим выставляется в 0 определенный бит)
  7. Заменить Type на полученный результат
  8. Сохранить

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

Dec 7, 2009

Не работает USB мышь на windows 7

В 7рке была у меня одна проблема – usb мышь после спящего режима не всегда включалась. Решил вопрос следующим образом:

Панель управления –> оборудование и звук

Панель управления

В разделе электропитание выбираем “Настройка перехода в спящий режим”

Электропитание

Далее выбираем “Изменить дополнительные параметры питания”

Дополнительные параметрыВ появившемся окне раскрываем пункт “Параметры usb”. И в значении “Параметр временного отключения USB-порта” выбираем “Запрещено”

Usb параметры  Теперь мышь включается без проблем при выходе из спящего режима.