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 параметры  Теперь мышь включается без проблем при выходе из спящего режима.

Dec 3, 2009

Явные роли – валидация

В тему предыдущего поста о явных ролях в приложениях можно рассмотреть вопрос валидации. Каждый объект может быть валдиным или нет в зависимости от сценария, в котором он участвует в данный момент. Например правило – пользователь может стать избранным, если он сделал 6 и более заказов. Там приложение контролировало это в самом методе MakePreferred и выбрасывало исключение, если пользователь не удовлетворяет условию.

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

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

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

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

public void btnMakePreferred_Click(Object sender, EventArgs e)
{
    Customer customer = ORM.Get<Customer>(customerId);
    if(customer.Orders.Count > 5)
    {
        customer.MakePreferred();
    }
}

И это хорошо если он выглядит именно так, т.е. MakePreferred вызывается у самого объекта, тем самым давая возможность относительно легко изменить правило определения избранного пользователя. А иногда бывает и такое:

public void btnMakePreferred_Click(Object sender, EventArgs e)
{
    Customer customer = ORM.Get<Customer>(customerId);
    if(customer.Orders.Count > 5)
    {
        customer.IsPreferred = true;
    }
}

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

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

Для этого выделим следующий интерфейс (что из себя представляют Entity и IRole описано в предыдущем посте):

public interface IValidator<TEntity, TRole> where TEntity: Entity
                                            where TRole:   IRole
{
    bool IsValid(TRole entity);
}

Он отвечает на единственный порос, походит ли объект для данной роли. И рассмотрим конкретную реализацию:

public class MakeCustomerPreferredValidator: IValidator<Customer, IPreferredMaker>
{
    public bool IsValid(IPreferredMaker entity)
    {
        Customer customer = entity as Customer;
        if(customer == null)
        {
            throw new ArgumentException("Entity must be Customer", "entity");
        }

        return customer.Orders.Count > 5;
    }
}

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

Теперь о том как использовать этот класс, первый вариант (плохой):

IValidator<Customer, IPreferredMaker> validator = new MakeCustomerPreferredValidator();
if (validator.IsValid(preferredMaker))
{
    preferredMaker.MakePreferred();
}

Плохой потому что в этом случае, существует явная зависимость от типа MakeCustomerPreferredValidator. Мне больше нравится для этих целей некоторый Factory класс, который может быть реализован следующим образом:

public class ValidatorsFactory
{
    public IValidator<TEntity, TRole> GetValidator<TEntity, TRole>() where TEntity : Entity
                                                                     where TRole : IRole
    {
        var validators = from t in Assembly.GetAssembly(typeof(ValidatorsFactory)).GetTypes()
                         where t.GetInterfaces().Contains(typeof(IValidator<TEntity, TRole>))
                         select t;

        return (IValidator<TEntity, TRole>) validators.SingleOrDefault();
    }
}

Но я бы рекомендовал использовать любой Service Locator для этих целей.

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

Repository<Customer> customerRepository = new Repository<Customer>(Global.CurrentSession);
ValidatorsFactory factory = new ValidatorsFactory();

IValidator<Customer, IPreferredMaker> validator = factory.GetValidator<Customer, IPreferredMaker>();
IPreferredMaker preferredMaker = customerRepository.Get<IPreferredMaker>(101);

if(validator.IsValid(preferredMaker))
{
    preferredMaker.MakePreferred();
}

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

Исходный код.

Dec 1, 2009

Пустой License Agreement диалог в Setup project

Setup проекты удобны для создания простых инсталляционных пакетов. Они предоставляют довольно широкий спектр различной функциональности. Среди них такие диалоги как лицензионное соглашение (License Agreement dialog) и справку (Read Me). Чтобы их добавить достаточно указать .rft файл с содержимым, которое будет отображаться пользователю.

Но если создать файл в обычном текстовом редакторе (notepad например) и сохранить его как rtf, то при установке пользователь увидит пустое сообщение. В общем чтобы такого не было необходимо rft файлы сохранять через WordPad или что-то подобное.

Nov 30, 2009

Делаем роли явными

Версия 2. Исправления с учетом комментариев.

Данный пост появился в результате многократного прослушивания лекции Udi Dahanan, которая называлась Intentions and Interfaces - Making Patterns Complete (смотреть тут, если не посмотрите, то дальше в общем то читать будет не интересно).

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

Была затронута небезынтересная тема ORM средств, в частности NHibernate, и приведен следующий пример кода:

public class ServiceLayer
{
    public void MakePreferred(Id customerId)
    {
        IMakeCustomerPreferred c = ORM.Get<IMakeCustomerPreferred>(customerId);
        c.MakePreferred();
    }
}

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

Первая попытка реализовать данный подход это использовать схемы наследования, доступные в NHibernate. Не получилось это сделать по причине отсутствия таблицы для интерфейсов (IMakeOrders, IPayment и т.д.), так же нельзя вводить и колонку descriminator, поскольку один объект может реализовывать более одного интерфейса. В общем чистыми маппингами такого дизайна приложения не достичь.

Udi Dahanan запостил у себя на блоге реализацию своего подхода.  Честно сказать – не понравилось. Да, возможно такая реализация как то ближе к DDD или к SOA или еще к куче умных слов, но выделять интерфейс для для каждого бизнес объекта мне кажется излишним.

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

Я бы предпочел построить эту абстракцию выше, без изменения исходного кода класса Session и прочее. И вот к чему я пришел.

Рассмотрим следующую модель:

imageПользователь реализует 2 роли, IOrderMaker и IPreferredMaker. Теперь хотелось бы иметь возможность написать на aspx странице следующий код:

private void MakeOrder(int productId, int customerId, int amount)
{
    Repository<Product> productRepository = new Repository<Product>(Session);
    Repository<Customer> customerRepository = new Repository<Customer>(Session);

    Product product = productRepository.Get(productId);

    IOrderMaker orderMaker = customerRepository.Get<IOrderMaker>(customerId);
    orderMaker.MakeOrder(product, amount);
}

Чтобы это сделать был реализован метод Get в классе Repository:

public TRole Get<TRole>(int id) where TRole:IRole
{
    return (TRole) (Object) session.Get<TEntity>(id);
}

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

Теперь обратимся к роли IPreferredMaker. Допустим пользователь может стать избранным, только если он сделал более 5 заказов. В таком случае следует заставить репозиторий получить покупателя со всеми его заказами. Это делается при помощи fetching strategy. Изменять её хотелось бы независимо от реализации MakePreferred. Поэтому был задан следующий интерфейс:

/// <summary>
/// Sets fetching strategy for specified role
/// </summary>
/// <typeparam name="TEntity"><see cref="Entity"/></typeparam>
/// <typeparam name="TRole"><see cref="IRole"/></typeparam>
public interface IFetchingStrategy<TEntity, TRole>
{
    void AddFetchTo(ICriteria criteria);
}
И реализация:
/// <summary>
/// Loads customer with his orders
/// </summary>
public class MakeCustomerPreferred: IFetchingStrategy<Customer, IPreferredMaker>
{
    public void AddFetchTo(ICriteria criteria)
    {
        criteria.SetFetchMode<Customer>(x => x.Orders, FetchMode.Join);
    }
}
Теперь надо заставить репозиторий использовать эту стратегию, сделать это можно следующим образом (изменяем метод Get):
public TRole Get<TRole>(int id) where TRole:IRole
{
    ICriteria criteria = session.CreateCriteria(typeof(TEntity));
    criteria.Add<TEntity>(x => x.Id == id);

    IEnumerable<Type> fetchingStrategies = GetFetchingStrategies<TRole>();

    foreach (Type fetchingStrategyType in fetchingStrategies)
    {
        IFetchingStrategy<TEntity, TRole> fetchingStrategy = (IFetchingStrategy<TEntity, TRole>)Activator.CreateInstance(fetchingStrategyType);
        fetchingStrategy.AddFetchTo(criteria);
    }

    TRole result = (TRole) criteria.UniqueResult();
    return result;
}

private static IEnumerable<Type> GetFetchingStrategies<TRole>()
{
    return from t in Assembly.GetAssembly(typeof (IFetchingStrategy<TEntity, IRole>)).GetTypes()
           where t.GetInterfaces().Contains(typeof (IFetchingStrategy<TEntity, TRole>))
           select t;
}

Метод GetFetchingStrategies получает все типы, реализующие IFetchingStrategy<TEntity, IRole> (все стратегии для передаваемой роли и сущности). Метод Get создает экземпляр каждой из стратегий по очереди и применяет к запросу. Естественно метод поиска стратегий можно упростить если использовать Service Locator.

Последнее что хотелось бы упомянуть, это расположение файлов в проекте, я обычно делаю примерно так:

Содержимое проекта

О том как тестировать приложения с таким дизайном я напишу позже.

Пример кода

Nov 26, 2009

Uninstall в setup project

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

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

  1. На сетап проекте клацаем правой кнопкой мыши, там View->FileSystem, выбираем Application Folder. Это список файлов, которые будут скопированы при установке на пользовательскую машину.
  2. Нажимаем правой кнопкой и выбираем Add->File и добавляем bat файл следующего содержания (я думаю как создать .bat файл объяснять не надо) :
    %windir%\system32\MsiExec.exe /I{Id вашего приложения}
  3. Чтобы увидеть Id вашего приложения правый клик на сетап проекте и выбираем свойства. Нас интересует ProductCode. Это Guid, который надо скопировать, и вставить в bat файл на месте “Id вашего приложения”. Обратите внимание, что {} должны остаться.

Ну чтобы он не задавал лишних вопросов можно поискать другие флаги для утилиты MsiExec.exe.

Nov 24, 2009

Локализация веб приложений

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

  1. Если использовать локальные ресурсы, то получение строки из них будет следующим: this.GetLocalResourceObject(“Key”); С этой записью есть следующие проблемы:

    • Этот метод возвращает Object, соответственно чтобы получить строку из этой и без того громоздкой записи необходимо написать GetLocalResourceObject(“Key”).ToString();
    • Поскольку метод возвращает Object, то при отсутствии соответствующего ключа в файлах ресурсов он возвращает Null. И узнаете Вы об этом Null только в момент выполнения (и то если вызовете ToString(), чаще же используется запись <%= GetLocalResourceObject(“Key”) %> которая просто ничего не выводит).

    Причиной этих проблем можно назвать отсутствие автоматической генерации кода. Для папки AppGlobalResources у каждого resx файла есть так же Designer.cs файл, в котом создается класс предоставляющий доступ к каждой строке. Этот класс решает проблемы отсутствующих ключей и обеспечивает intellysence решая в общем то все указанные проблемы. 

  2. Использование глобальных ресурсов. Глобальные ресурсы лишены недостатков локальных, но есть одна проблемка: невозможность разложить ресурсы по папкам и пространствам имен. Чем больше проект, тем больше строк в ресурсах, тем больше файлов. Со временем папка AppGlobalResources превращается в огромный склад, в котором довольно сложно что-либо найти. При этом генератор кода раскладывает классы вне зависимости от папок, в которых они лежат, просто в пространство имен Resources.

  3. И последняя, хотя пожалуй наиболее важная проблема. Если необходимо получить локализованную строку не в веб части проекта. Например:

    Наследование с названием типа объекта

    Есть абстрактный класс Role с абстрактным свойством DisplayName. Это очень удобно когда в интерфейсе необходимо показывать какие роли привязаны к пользователю. Обычно такие классы будут находится в модуле бизнес логики, которая не может содержать ссылки на веб приложение.

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

Но в реализации по умолчанию есть одна важная проблема – классы помечены как internal. Можно конечно изменить код вручную, но при сохранении файла .resx генератор будет запущен заново, и отменит все изменения сделанные вручную.

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

Nov 23, 2009

Continuous integration server – Cruise control .net.

Continuous Integration is a software development practice where members of a team integrate their work frequently, usually each person integrates at least daily - leading to multiple integrations per day. Each integration is verified by an automated build (including test) to detect integration errors as quickly as possible.

Это определение находится в статье Мартина Фаулера.

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

Мой первый опыт автоматизации процесса билда проекта был с использованием MsBuild. Я написал скрипт, выполняющий нужные мне действия в нужной последовательности. Файлик находился под управлением контроля версий и все было хорошо. До тех пор, пока не понадобилось обновить собственно сам скрипт. MsBuild загружает файл один раз и потом выполняет все действия указанные в нем, значит обновленную версию скрипта можно получить только после запуска устаревшего. Как результат, для получение последней версии продукта необходимо дважды выполнять процесс билда (пжервый раз со старым скриптом, который заставит обновить исходный код, второй с новой версией, который выполнит необходимые действия). Это одна из причин почему я считаю что один билд скрипт не достаточно удобен.

В этом посте будет рассмотрено как быстро настроить CI (Continuous Integration) сервер используя Cruise control .net.

По теме настройки сервера так же можно почитать следующие два туториала:

Итак начнем.

  • Установка
  • Добавляем проект
  • Настройка системы контроля версий
  • Билд
  • Тестирование
  • Уведомления

Установка

Скачать CC.NET можно тут. Для этого поста используется последняя на данный момент стабильная версия, а именно 1.5.

Во время установки CC.NET создаст виртуальный каталог в IIS, по умолчанию имя этого каталога ccnet. Таким образом попасть на страницу билд сервера можно будет попасть по адресу http://localhost/ccnet.

В папке, куда будет уставлен CC.NET будет создано 3 папки Examples, server и webdashboard.

В папке server нас будет интересовать файл ccnet.config – это обычный xml файл, в котором  хранятся настройки для проектов. Т.е. в нем описывается последовательность действий (таких как получить изменения из репозитория кода, сбилдить проект, запустить тесты и т.д.). Документацию по настройкам в этом файле можно найти тут.

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

Добавляем проект

Чтобы добавить проект добавляем в файл ccnet.config тег project, чтобы получилось примерно следующее:

<cruisecontrol xmlns:cb="urn:ccnet.config.builder">
	<project> 
	<name>Имя проекта</name> 
	<workingDirectory>Рабочая директория</workingDirectory> 
	<artifactDirectory>Артефакты</artifactDirectory> 
	<webURL>Url для веб</webURL> 
	<project>  
	<tasks> 
		Задания 
	</tasks> 
	<publishers> 
		Сообщение о результатах 
	</publishers>  
</cruisecontrol>
  • имя проекта будет отображаться на сайте, в отчетах и т.д.
  • рабочая директория – место где будут храниться временные файлы
  • артефакты – сюда попадают результирующие файлы логов и отчеты
  • Url для веб – этот адрес используется в письме cc.net, которое отсылается после билда.
  • задания – раздел где указывается какую последовательность действий требуется выполнить в процессе билда
  • сообщение о результатах – как оповещать команду о результате билда. Одной из ошибок бывает расположение рассылки о результатах в блоке tasks. Но при провале одной из последовательных задач оставшаяся очередь не выполняется, и значит никто не получит уведомления. Блок publishers не может стать причиной неудачного билда, только tasks

Настройка системы контроля версий

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

Сам раздел описывать не вижу смысла, он слишком очевиден и очень подробно описан, но есть одна особенность работы с svn – CC.NET использует команды для работы с svn вида “svn commit”. После чего говорит что не находит нужную программу для выполнения. Это происходит потому, что устанавливая turtoise svn он не содержит файла svn.exe, наличие которого предполагает CC.NET. Заставить работать удалось только при помощи установки visual svn, в котом есть файл svn.exe.

Билд

Для автоматизации билда можно использовать либо MsBuild, либо NAnt. У меня есть опыт использования обоих.

Скрипты MsBuild для меня показались очень большими и неудобными. Он генерируются вижуал студией в файлах csproj. И все хорошо пока не приходится писать эти вещи вручную. Далее становится все неудобно и слишком длинные названия начинают напрягать и раздражать. Но это лично мои впечатления. Еще одна проблема в том, что MsBuild никак не сможет сбилдить Setup project.

Теперь про NAnt. Честности ради стоит заметить что и NAnt не умеет билдить Setup project’ы, но его можно настроить использовать вижуал студию в качестве средства билда. Xml файлы для NAnt очень похожи на MsBuild и соответственно знание последнего обеспечит легкий переход, но в то же время они меньше, теги называются адекватней (опять же исключительно мое субъективное мнение).

Итак создадим первое задание – компиляция проекта. Для работы с NAnt (так же как и с MsBuild) СС.NET содержит специальный таск. Изменяем ccnet.config чтобы получилось следующее:

<tasks>
	<nant> 
		<executable>C:\nant\nant-0.86-beta1\bin\nant.exe</executable> 
		<baseDirectory>C:\builds</baseDirectory> 
		<buildFile>default.build.xml</buildFile> 
	</nant>
</tasks>

Указываем путь к nant, указываем директорию в которой лежит скрипт, указываем название самого скрипта. Содержимое default.build.xml может быть следующим:

<project default="default"> 

<property name="devEnvPath" value="C:\Program Files\Microsoft Visual Studio 9.0\Common7\IDE\devenv.com" /> <target name="compileDebug"> <exec program="${devEnvPath}"> <arg value="Путь к .sln файлу" /> <arg value="/build"/> <arg value="Debug" /> </exec> </target> <target name="default" depends="compileDebug" /> </project>

Не знаю что тут можно прокомментировать, вроде все очевидно.

Тестирование

Запуск тестов

Для тестов с использованием NUnit CC.NET содержит готовый таск. Самый простой вариант:

<nunit> 
	<path>C:\Program Files\NUnit 2.5.2\bin\net-2.0\nunit-console.exe</path> 
	<assemblies> 
		<assembly>Путь к сборке с тестами</assembly> 
	</assemblies> 
</nunit>

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

Проверка покрытия

Еще одной очень полезной вещью может быть задание на проверку покрытия кода тестами. Для того, чтобы получить эту информацию можно воспользоваться утилитой NCover. Она строит очень подробные и информативные репорты. Для интеграции в CC.NET можно использовать задание NCover Profiler. Этот таск запустит тесты и сохранит отчет о покрытии тестами. Например таск может быть таким:

<ncoverProfile> 
	<executable>C:\Program Files\NCover\NCover.Console.exe</executable> 
		<program>C:\Program Files\NUnit 2.5.2\bin\net-2.0\nunit-console.exe</program> 
		<testProject>Путь к dll с тестами</testProject> 
		<includedAssemblies>*.dll</includedAssemblies> 
	</ncoverProfile>

Это задание проверит покрытие всех dll вашего проекта.

Так же есть полезный таск NCover Reporting. C помощью него на основании результатов полученных после NCover Profiler можно генерировать html отчеты.

<ncoverReport> 
  	<executable>C:\Program Files\NCover\NCover.Reporting.exe</executable> 
	<outputDir>Reports</outputDir> 
</ncoverReport>

Чтобы увидеть результаты работы этого таска в веб интерфейсе необходимо отредактировать файл dashboard.config (находится в папке webdashboard). В разделе buildPlugins необходимо добавить следующее:

<htmlReportPlugin description="Full NCover Report" actionName="FullNCoverBuildReport" htmlFileName="NCover\fullcoveragereport.html" />

После этого в отчете по билду слева появится новая ссылка:

Full NCover Report link

Уведомления

За уведомления отвечает раздел конфигурации publishers. CC.NET содержит множество готовых способов уведомления (email, rss, cc.net tray и т.д.). Я использую email, конфигурация довольно проста:

<email from="адрес с которого посылать письмо" mailhost="smtp сервер" mailport="25" includeDetails="true" useSSL="false" mailhostUsername="имя пользователя" mailhostPassword="пароль">
	<users>
		<user name="имя пользователя" group="группа" address="адрес"/>
	</users>
	<groups>
		<group name="buildmaster" >
			<notifications>
				<notificationType>Always</notificationType>
			</notifications>
		</group>
	</groups>
	<xslFiles>
		<file>xsl\header.xsl</file>
		<file>xsl\compile.xsl</file>
		<file>xsl\unittests.xsl</file>
		<file>xsl\modifications.xsl</file>
	</xslFiles>
</email>

Результаты каждого билда будут высылаться согласно этой части конфигурационного файла.

Nov 11, 2009

aspnet_regiis на x64 windows

В своих попытках разобраться с CruiseControl.NET поставил чистую виртуалку с windows 2008 x64. После чего поставил IIS и CruiseControl.

По умолчанию IIS не регистрирует asp.net. Естественно все знают что для этого стоит выполнить утилиту aspnet_regiis с флагом –i. Но меня крайне смутило, что утилита начала выдавать ошибку типа приложение обратилось к памяти по адресу … и не может прочесть и т.д.

Поискав решение оказалось что для x64 платформ в папке Microsoft.NET есть не только папка Framework но и Framework64. Посмотрев в неё я обнаружил что regiis в этой папке работает без ошибок.

Итак для x64 платформ для регистрации asp.net в IIS выполнять нужно aspnet_regiis.exe –i находящийся в папке C:\Windows\Microsoft.NET\Framework64\v2.0.50727.

Возможно это кому то сохранит лишние 10 минут времени

Nov 6, 2009

Inversion of control. Управление зависимостями.

Данный пост является некоторым следствием работы над последним проектом. В этом проекте использовалась S#arp архитектура, которая обеспечивает отличную возможность для юнит тестирования и каркас для создания корпоративных веб приложений. Так же крайне полезно будет прочесть статью касающуюся зависимостей. Я считаю что разобраться в чем то лучше всего позволяет попытка объяснить это другому. Именно это и я и хочу сделать.

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

public class Logger 
{ 
 public void LogMessage(string message) 
 { 
  using(FileStream fileStream = new FileStream("log.txt", FileMode.Append, FileAccess.Write)) 
  { 
   StreamWriter writer = new StreamWriter(fileStream); writer.Write(message); 
    }
 } 
} 

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

Получив такое требование конечно хотелось бы добавить новый метод LogMessage(string message, bool storeInDataBase), который принимает дополнительный параметр типа bool и если он true пишет все что надо в базу.

Проблема в этом подходе я думаю уже видна – если в дальнейшем мы захотим писать не только базу данных и в файл, но в Windowns EventLog, то что, добавлять еще один флаг?

На данный момент диаграмма зависимостей будет выглядеть следующим образом:

image

Наш класс Logger напрямую зависит от файловой системы. Чтобы избавится от этой зависимости можно использовать принцип Inversion of control. Для этого выделим интерфейс, который будет отвечать за сохранение информации в лог:

public interface ILogContainer 
{ 
 void SaveMessage(string message); 
} 

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

public class FileLogContainer: ILogContainer 
{ 
 public void SaveMessage(string message) 
 { 
  using (FileStream fileStream = new FileStream("log.txt", FileMode.Append, FileAccess.Write)) 
  { 
   StreamWriter writer = new StreamWriter(fileStream); writer.Write(message); 
  } 
 } 
} 

И тогда использование будет выглядеть следующим образом:

public class Logger 
{ 
 private ILogContainer logContainer; 
 public Logger(ILogContainer logContainer) 
 { 
  this.logContainer = logContainer; 
 } 
 public void LogMessage(string message) 
 { 
  logContainer. SaveMessage(message); 
 } 
}

После этого преобразования диаграмма зависимостей будет выглядеть:

image

Из диаграммы понятно почему этот подход называется Inversion of control, получается что зависимость от файловой системы была как бы развернута в обратную сторону.

Что нам дает этот подход? Помимо того, что теперь мы можем использовать любое хранилище логов, реализующее ILogContainer, полученный результат отлично поддается тестированию. Интерфейс ILogContainer дает точку в которй можно встроить mock object, с помощью которого можно правильно проверить работу класса Logger.

Nov 5, 2009

Fluent mapping conventions. Автомаппинг.

Линки на остальные статьи

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

image

И храним все это в следующей базе:

image

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

Но это скучно и не понятно зачем, ведь имена этих свойств и так можно получить при помощи рефлексии. Так же существует четкий принцип в программировании - DRY (don’t repeat yourself). Я думаю никому не надо объяснять почему повторяющийся код это плохо. А мы при маппинге очень часто повторяем себя поскольку вероятно имена полей будут совпадать с именами колонок в таблицах и т.д. Итак чтобы этого избежать в Fluent NHibernate существует Automapping.

Чтобы воспользоваться им создадим следующую структуру файлов:

image

Собственно исходный код классов в папке (и соответственно неймспейсе) Entities можно посмотреть в примере о связях.

Интерес представляет папка mappings. Начнем по порядку. Для генерирования маппингов Fluent NHibernate использует Conventions (соглашения). Они устанавливают правила формирования названий полей объектов, названий таблиц и т.д.

CategoryMap, OrderMap, ProductMap – к ним мы обратимся позже, это классы, которые позволяют дополнять или же переопределять маппинги полученные при помощи conventions.

ModelGenerator – класс, которой собственно и соберет все воедино. Итак рассмотрим его исходный код:

public class ModelGenerator
{
 public AutoPersistenceModel Generate()
 {
  AutoPersistenceModel automap = new AutoPersistenceModel();

  automap.Conventions.AddFromAssemblyOf<ModelGenerator>();
  automap.UseOverridesFromAssemblyOf<ModelGenerator>();

automap.AddEntityAssembly(Assembly.GetAssembly(typeof (Product))) .Where(x=>x.Namespace.EndsWith("Entities")); return automap; } }
  • 5 строка – добавить все conventions из сборки, которая содержит класс ModelGenerator
  • 6 строка – взять все переопределения из сборки содержащей ModelGenerator (далее подробней)
  • 7 строка – объекты, которые необходимо замапить находятся в сборке, содержащей Product
  • 8 строка – среди объектов взять только те, неймспейс которых заканчивается на Entities

Для примера я создал 2 соглашения. 1 для идентификаторов:

public class PrimaryKeyConvention : IIdConvention
{
 public void Apply(IIdentityInstance instance)
 {
  instance.GeneratedBy.Native();
  instance.Column("Id");
 }
}

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

Соглашение для имен таблиц:

public class TableNameConvention : IClassConvention
{
 public void Apply(IClassInstance instance)
 {
  instance.Table(Inflector.Net.Inflector.Pluralize(instance.EntityType.Name)); 
 }
}

Тут используется Inflector, для того, чтобы в базе данных имена таблиц были в множественном числе. Например таблица с продуктами – Products, с категориями – Categories, в то время как объекты называются Product и Category.

Вообще говоря, если создавать базу с нуля, и следовать простым правилам, то все связи можно создавать при помощи соглашений, вообще не приходя к ручному маппингу. Но естественно не всегда есть такая возможность либо какой то из маппингов нужно изменить. Допустим по умолчанию (по соглашениям) для коллекций установлен lazy load. А с точки зрения производительности для определенного объекта его надо выключить. Это можно сделать при помощи переопределений маппингов. Именно для для этого в классе ModelGenerator указано UseOverridesFromAssemblyOf(...). Рассмотрим как это работает:

public class CategoryMap : IAutoMappingOverride<Category>
{
 public void Override(AutoMapping<Category> mapping)
 {
  mapping.HasManyToMany(x => x.Products)
   .Access.CamelCaseField()
   .Cascade.SaveUpdate()
   .AsSet()
   .ParentKeyColumn("CategoryId")
   .ChildKeyColumn("ProductId")
   .Table("Products_Categories");
 }
}

public class OrderMap : IAutoMappingOverride<Order>
{
 public void Override(AutoMapping<Order> mapping)
 {
  mapping.References(x => x.Product, "ProductId");
 }
}

public class ProductMap : IAutoMappingOverride<Product>
{
 public void Override(AutoMapping<Product> mapping)
 {
  mapping.HasManyToMany(x => x.Categories)
    .Access.CamelCaseField()
    .Table("Products_Categories")
    .ParentKeyColumn("CategoryId")
    .ChildKeyColumn("ProductId")
    .AsSet()
    .Inverse()
    .Cascade.SaveUpdate();

  mapping.HasMany(x => x.Orders)
    .KeyColumn("ProductId")
    .Inverse()
    .Cascade.AllDeleteOrphan();
 }
}

Как можно увидеть перепределение ничем не отличается от обычного Fluent маппинга с одним лишь различием – не надо упоминать каждое свойство.

Ну и в конце собственно изменения в файле Global.asax:

protected static ISessionFactory CreateSessionFactory()
{
 ModelGenerator modelGenerator = new ModelGenerator();
 var sessionFactory = Fluently.Configure()
    .Database(MsSqlConfiguration.MsSql2005.ConnectionString(@"connection string"))
    .Mappings(m =>m.AutoMappings.Add(modelGenerator.Generate()))
    .BuildSessionFactory();
 return sessionFactory;
}

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

Nov 4, 2009

NHibernate для начинающих. Часть 4 Criteria queries

Линки на остальные статьи

Основным средством построения запросов в NHibernate является Criteria api (до тех пор, пока не реализуют полноценный Linq to Hibernate). Поэтому я опишу некоторые примеры запросов, которые можно строить с их использованием. Для этого воспользуемся моделью, описанной тут.

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

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

ICriteria criteria = Global.CurrentSession.CreateCriteria(typeof(Product));

const string requiredSubstring = "prod";
criteria.Add(Restrictions.Like("Description", requiredSubstring, MatchMode.Anywhere));
criteria.SetMaxResults(10);
criteria.SetFirstResult(0);
criteria.AddOrder(NHibernate.Criterion.Order.Asc("Description"));

IList<Product> result = criteria.List<Product>();

Итак что здесь происходит:

  • 1 строка очевидно – создаем сам критерий.
  • 4 строка – в критерий добавляется условие, которое говорит о том, что объекты, которые войдут в результирующий рекорд сет в свойстве Description должны содержать requiredSubstring. Последний параметр говорит о том, где именно должна быть эта подстрока. Можно создать критерий, который говорит что название должно начинаться с указанного слова или заканчиваться на него. Прелесть этого запроса в том, что мы оперируем исключительно доменными объектами, мы вообще не заботимся о том, как NHibernate построит необходимый запрос и какие поля/таблицы затронет.
  • 5 строка – указываем, что результатов должно быть не больше 10.
  • 6 строка – выдавать результаты начиная с 0го. Т.е. например, у нас в базе есть 20 продуктов, из них 15 в названии содержит «prod». Значит данный запрос выведет 10 первых продуктов.
  • 7 строка – сортировка.

Далее рассмотрим следующий пример:

Найти все продукты, название которых содержит подстроку, и цена которых больше чем указанное число:

const string requiredSubstring = "prod";

const double requiredPrice = 5.0;
criteria.Add(new Conjunction()
  .Add(Restrictions.Like("Description", requiredSubstring, MatchMode.Anywhere)) 
  .Add(Restrictions.Gt("Price", requiredPrice))
      );

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

criteria.Add(Restrictions.Like("Description", requiredSubstring, MatchMode.Anywhere) &&
    Restrictions.Gt("Price", requiredPrice)
             );

Как Вы видите между двумя условиями стоит обычный оператор &&. Так же можно использовать и || где это необходимо (например если бы надо было найти товар у которого подстрока содержится либо в названии либо в описании).

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

ICriteria criteria = Global.CurrentSession.CreateCriteria(typeof(Product));
const string requiredSubstring = "prod";
criteria.Add(Restrictions.Like("Description", requiredSubstring, MatchMode.Anywhere));
criteria.SetProjection(Projections.RowCount());
int result = criteria.UniqueResult<int>();

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

DetachedCriteria avgCritegia = DetachedCriteria.For<Product>();
avgCritegia.SetProjection(Projections.Avg("Price"));

ICriteria criteria = Global.CurrentSession.CreateCriteria(typeof(Product));
criteria.Add(Subqueries.PropertyGt("Price", avgCritegia));

IList<Product> result = criteria.List<Product>();

NHibernate выполнит следующий запрос:

SELECT this_.Id as Id0_0_, this_.Description as Descript2_0_0_, this_.Name as Name0_0_, this_.Price as Price0_0_
FROM Products this_ 
WHERE this_.Price > (SELECT avg(cast(this_0_.Price as DOUBLE PRECISION)) as y0_ FROM Products this_0_)

Теперь посмотрим на запрос к объектам с коллекциями. Допустим надо найти все продукты, которые заказывал покупатель с определенной строкой в имени:

ICriteria criteria = Global.CurrentSession.CreateCriteria(typeof(Product));

const string requiredCustomerName = "cust";
criteria.CreateCriteria("Orders")
 .Add(Restrictions.Like("Customer", requiredCustomerName, MatchMode.Anywhere));

IList<Product> result = criteria.List<Product>();

NHibnerate выполнит следующий запрос:

SELECT this_.Id as Id0_1_,
 this_.Description as Descript2_0_1_,
 this_.Name as Name0_1_,
 this_.Price as Price0_1_,
 order1_.Id as Id3_0_,
 order1_.NumberOfItems as NumberOf2_3_0_,
 order1_.Customer as Customer3_0_,
 order1_.ProductId as ProductId3_0_
FROM Products this_ inner join Orders order1_ on this_.Id=order1_.ProductId
WHERE order1_.Customer like @p0
@p0=N'%cust%'

Естественно что в .net 3.5 уже очень не хотелось бы использовать названия свойств в виде строк. Nз-за этого приходится постоянно лазить в исходник самого объекта и смотреть там точное название необходимого ствойства. Чтобы избежать этого существует QueryOver API. Они позволяют переписать все выше упомянутые запросы, но в строго типизированном виде.

Oct 22, 2009

NHibernate mappings. Или каталог маппингов

Тут буду собирать полезные статьи относящиеся к маппингу NHibernate.
  • Наследование.
  • property – подробности про маппинг свойств.
  • component – если есть желание иметь вложенные классы в сущностях. Например чтобы у пользователя был адрес в виде объекта и обращаться  к нему можно было как user.Address.Street.
  • one-to-one – связь один к одному.
  • any – для связи таблиц, которые не связаны явно. (не знаю как это описать более точно, надо читать :) ).
  • many-to-any – расширение any маппинга.
  • join – аналог one-to-one, но позволяет замапить несколько таблиц в один объект.
  • dynamic-component – позволяет вынести набор свойств объекта в Dictionary. Возможно редко используемые свойства есть смысл туда вынести, чтобы не засорять сущность.
  • set – один из наиболее часто используемых элементов для маппинга коллекций.
  • Named queries <query/> and <sql-query/> – если нужно встраивать свой sql код в маппинги.
  • map – никогда не использовал сам. Но это еще один вариант организации связей между сущностями. Когда посмотрю возможно опишу более подробно.
  • list – главное отличие list от set – list может содержать повторяющиеся записи. Это значит что если вы попытаетесь в set добавить элемент, который уже содержится в коллекции (проверяется на равенство при помощи метода Equals()), то элемент не будет добавлен. Если же последнее действие проделать с list, то элемент может быть добавлен.

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

  1. Дерево. По поводу дерева еще один пост про то, как его эффективно вытаскивать из базы

Каталог буду пополнять по мере поступления :).

Oct 21, 2009

NHibernate transactions. Почему рекомендуют все запросы выполнять внутри транзакций

Если выполнить для доменной модели (описана тут) следующий код без транзакции:

Category category = new Category
     {
          DisplayName = "Our first category"

     };
Product product = new Product
     {
         Description = "First product description",
         Name = "First product",
         Price = 1
     };
Order order = new Order(product)
     {
         Customer = "customer"

     };
Global.CurrentSession.SaveOrUpdate(category);
Global.CurrentSession.SaveOrUpdate(product);

То SqlProfiler покажет следующее:

image

Как вы видите операции логин/логаут выполняются после каждого запроса. Если же использовать код в global.asax, как описано ранее, то получим следующий результат:

image

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

Oct 20, 2009

NHibernate для начинающих. Часть 3. Связи

Линки на остальные статьи

Изначально этот пост задумывался как описания каждого типа связи. Я собирался описать как мапить many-to-many, many-to-one, one-to-many и т.д. Но начав писать, я понял что такой информации и так достаточно в сети. Поэтому пост больше получился про то, как построить доменную модель для связанных сущностей. Данный подход наверняка можно распространить и на любое другое нормальное ORM средство.

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

  Диаграмма классов

На этой схеме изображено следующее:

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

Для хранения данной доменной модели была построена следующая база данных:

 Схема базы данных

Исходный текст классов таков:

public class Category  
{     
    private ISet<Product> products;     
    public Category()     
    {         
        products = new HashedSet<Product>();     
    }
    public virtual string DisplayName { get; set; }     
    public virtual int Id { get; protected set; }     
    public virtual ReadOnlyCollection<Product> Products     
    {        
        get { return new ReadOnlyCollection<Product>(new List<Product>(products));  }     
    }     
    public virtual void AddProduct(Product product)     
    {        
        if(product == null) throw new ArgumentNullException("product");     
        if(!products.Contains(product))         
        {
            product.AddCategory(this);            
            products.Add(product);        
        }     
    }     
    public virtual void RemoveProduct(Product product)     
    {        
        if(product == null) throw new ArgumentNullException("product");
        if(products.Contains(product))
        {            
            products.Remove(product);
            product.RemoveFrom(this);
        }
     }
}
 

Следует обратить внимание на следующие вещи:

  • Поле Id – protected. Это необходимо для защиты данных. Если нечайно поменять Id то можно обновить данные другого объекта, а не ожиемого
  • Все поля и методы должны быть виртуальными. Это необходимо для работы NHiberante
  • В конструкторе инициализируется колекция продуктов, таким образом никогда не будет NullReferenceException
  • Колекция продуктов представлена как ReadOnlyCollection. Такой подход оставляет нам возможность получить полный контроль над логикой добавления и удаления продуктов внутри категории
  • При добавлении продукта в категорию продукту автоматически добавляется эта категория. Это обеспечивает согласованность доменной модели (не может быть такого случая, что в колекции у категории продукт есть, а у продукта нет информации о том, что он содержится в категории)
  • Проверка приходящих параметров. Нельзя пробовать добавлять пустые ссылки на продукты
public class Product
{
     private ISet<Category> categories;
     public Product()
     {
        categories = new HashedSet<Category>();
        Orders = new HashedSet<Order>();
     }
     public virtual int Id { get; protected set; }
     public virtual string Name { get; set; }
     public virtual string Description { get; set; }
     public virtual double Price { get; set; }
     public virtual ReadOnlyCollection<Category> Categories
     {
        get
        {
           return new ReadOnlyCollection<Category>(new List<Category>(categories));
        }
     }
     public virtual ISet<Order> Orders { get; protected set; }
     public virtual void AddCategory(Category category)
     {
         if(category == null)
             throw new ArgumentNullException("category");
         if(!categories.Contains(category))
         {
             categories.Add(category);
             category.AddProduct(this);
         }
      }
      public virtual void RemoveFrom(Category category)
      {
         if(category == null)
             throw new ArgumentNullException("category");
         if(categories.Contains(category))
         {
             categories.Remove(category);
             category.RemoveProduct(this);
         }
       }
}  

Так же как и у категории мы обеспечиваем полный контроль над добавлением и удалением элементов из колекции. Но мы не делаем коллекцию заказов ReadOnly (но по прежнему инициализируем пустой колекцией для отсутствия NullReferenceException в нашем коде). Далее будет рассмотрено почему.

public class Order
{
      protected Order() { }
      public Order(Product product)
      {
         if(product == null)
             throw new ArgumentNullException("product");
         product.Orders.Add(this);
         Product = product;
      }
      public virtual int Id { get; protected set; }
      public virtual Product Product{get;protected set;}
      public virtual int NumberOfItems { get; set; }
      public virtual string Customer { get; set; }
}  

Важно выделить следующее

  • protected констуктор необходим для работы NHiberante
  • Тут можно увидеть почему колекция заказов осталась открытой. Чтобы добавить туда новый элемент необходимо сначала его создать. Но заказ не может существовать без продукта, что и отражено единственном открытом конструкторе
  • Product для заказа содержит protected сеттер. Это сделано для того, чтобы нельзя было у созданного заказа заменить продукт, который покупался.
  • Заказов без продукта существовать не должно. Соответственно при удалении продукта или же удалении связи между продуктом и заказом заказ должен быть удален. Как этого достичь будет показано далее

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

private ITransaction Transaction;
protected void Application_BeginRequest(object sender, EventArgs e)
{
      CurrentSession = SessionFactory.OpenSession();
      Transaction = CurrentSession.BeginTransaction();
}
protected void Application_EndRequest(object sender, EventArgs e)
{
      if (Server.GetLastError() == null)
      {
         Transaction.Commit();
      }
      else
      {
         Transaction.Rollback();
      }
      if (CurrentSession != null)
         CurrentSession.Dispose();
}   

И наконец маппинги:

public class CategoryMap: ClassMap<Category>
{
      public CategoryMap()
      {
          Table("Categories");
          Id(x => x.Id).GeneratedBy.Native();
          Map(x => x.DisplayName);
          HasManyToMany(x => x.Products)
                             .Access.CamelCaseField()
                             .Cascade.SaveUpdate()
                             .AsSet()
                             .ParentKeyColumn("CategoryId")
                             .ChildKeyColumn("ProductId")
                             .Table("[Products.Categories]");
      }
 }
public class ProductMap : ClassMap<Product>  
{
      public ProductMap()
      {
         Table("Products");
         Id(x => x.Id).GeneratedBy.Native();
         Map(x => x.Description);
         Map(x => x.Name);
         Map(x => x.Price);
         HasManyToMany(x => x.Categories)
                           .Access.CamelCaseField()
                           .Table("[Products.Categories]")
                           .ParentKeyColumn("ProductId")
                          .ChildKeyColumn("CategoryId")
                          .AsSet()
                          .Inverse()
                          .Cascade.SaveUpdate();
         HasMany(x => x.Orders)     
                      .KeyColumn("ProductId")     
                      .Inverse() 
                      .Cascade.AllDeleteOrphan();
      }
  }
  public class OrderMap:ClassMap<Order>
  {
   public OrderMap()
   {
    Table("Orders");
    Id(x => x.Id).GeneratedBy.Native();
    Map(x => x.NumberOfItems);
    Map(x => x.Customer);
    References(x => x.Product, "ProductId");
   }
  }

На что обратить внимание

  • У продукта и категории стоит Cascade.SaveUpdate() для колекции категорий и продукта соответственно. Это значит что сохранение продукта вызывает сохранение категории и наоборот. Чуть более подробно про каскадирование можно почитать тут
  • У колекций продукта стоит Inverse. Это означает что за сохранение связи между категорией и продуктом будет отвечать категория. Более подробно тут
  • У продукта на список заказов стоит Cascade.AllDeleteOrphan(). Это обязывает Nhibernate удалять записи, которые лишились родителя (дополнительно тут)
  • Для many-to-many установлен Access.CamelCaseField(). Это означает, что NHibernate будет использовать приватное поле для работы, это позволяет нам сделать коллекцию продуктов ReadOnlyCollection

Итак теперь можно поработать с полученной моделью:

Category category = new Category
    {
        DisplayName = "Our first category"
    };
Product product = new Product
    {
        Description = "First product description",
        Name = "First product",
        Price = 1
    };
category.AddProduct(product);
Global.CurrentSession.SaveOrUpdate(category); 

Эта часть кода заставит NHiberante выполнить следующий набор sql запросов:

INSERT INTO Categories (DisplayName) VALUES (@p0); select SCOPE_IDENTITY() @p0=N'Our first category'  
INSERT INTO Products (Description, Name, Price) VALUES (@p0, @p1, @p2); select SCOPE_IDENTITY() @p0=N'First product description',@p1=N'First product', @p2=1  
INSERT INTO [Products.Categories] (CategoryId, ProductId) VALUES (@p0, @p1) @p0=1,@p1=1  

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

Теперь проверим связь продуктов и заказов:

Product product = Global.CurrentSession.Get<Product>(1); 
var order = new Order(product)
 {
    NumberOfItems = 5,
    Customer = "customer"
 }; 
Global.CurrentSession.SaveOrUpdate(product); 

Это заставит NHiberante выполнить следующие запросы:

SELECT product0_.Id as Id2_0_, product0_.Description as Descript2_2_0_, product0_.Name as Name2_0_, product0_.Price as Price2_0_  
FROM Products product0_ 
WHERE product0_.Id=@p0,@p0=1  

SELECT orders0_.ProductId as ProductId1_, orders0_.Id as Id1_, orders0_.Id as Id3_0_, orders0_.NumberOfItems as NumberOf2_3_0_, orders0_.Customer as Customer3_0_, orders0_.ProductId as ProductId3_0_  
FROM Orders orders0_  
WHERE orders0_.ProductId=@p0,@p0=1  

INSERT INTO Orders (NumberOfItems, Customer, ProductId) VALUES (@p0, @p1, @p2); select SCOPE_IDENTITY(), @p0=5,@p1=N'customer',@p2=1  

(id могут быть другими).

Проверим сценарий в котором удаление связи между продуктом и заказом должно вызывать удаление заказа (это достигается при помощи all-delete-orphan). Например рассмотрим следующий код:

Product product = Global.CurrentSession.Get<Product>(1);
product.Orders.Clear(); 
Global.CurrentSession.SaveOrUpdate(product);

Он выполнит следующие запросы:

SELECT product0_.Id as Id2_0_, product0_.Description as Descript2_2_0_, product0_.Name as Name2_0_, product0_.Price as Price2_0_  
FROM Products product0_ 
WHERE product0_.Id=@p0, @p0=1  

SELECT orders0_.ProductId as ProductId1_, orders0_.Id as Id1_, orders0_.Id as Id3_0_, orders0_.NumberOfItems as NumberOf2_3_0_, orders0_.Customer as Customer3_0_, orders0_.ProductId as ProductId3_0_ 
FROM Orders orders0_  
WHERE orders0_.ProductId=@p0, @p0=1  

DELETE FROM Orders WHERE Id = @p0 @p0=2  
DELETE FROM Orders WHERE Id = @p0 @p0=3 
DELETE FROM Orders WHERE Id = @p0 @p0=4  

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

Пример кода