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 до тех пор, пока это не станет реальной проблемой для производительности. Как только станет заметно тормозить, то оптимизировать, либо хранимкой, либо еще как.

Пример кода.