Доменная модель
В посте о связях в 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 до тех пор, пока это не станет реальной проблемой для производительности. Как только станет заметно тормозить, то оптимизировать, либо хранимкой, либо еще как.
Так вообще делать нельзя. Будут дикие тормоза при работе с такими деревьями. Нужно использвоаться паттерн NestedSet.
ReplyDeleteБольшое спасибо за совет. Сейчас буду смотреть что можно сделать в этом направлении :)
ReplyDeleteNestedSet это прекрастно, только управление деревом затруднительно в орм-ах
ReplyDelete