10 июля 2010 г.

Совершенный код №2. Пример реализации Unit Of Work с NHibernate

В комментариях и письмах к статье Совершенный код №2. Реализация Unit Of Work вы предложили довольно мало реализаций. Я опишу принципы своего видения реализации и учту ваши предложения и исправления к моему коду в предыдущих статьях.

Простое, очевидное и неправильное решение

Рассмотрим бизнес-сценарий №5 из статьи про корень агрегации. Там я привел пример реализации, который использует UnitOfWork. Об этом мы поговорим дальше, а сейчас рассмотрим решение без UnitOfWork:

public class ClientService
{
private readonly IAccountRepository accountRepository;
private readonly IClientRepository clientRepository;
private readonly IOrderRepository orderRepository;

public ClientService(IClientRepository clientRepository, IOrderRepository orderRepository, IAccountRepository accountRepository)
{
  this.clientRepository = clientRepository;
  this.orderRepository = orderRepository;
  this.accountRepository = accountRepository;
}

public void LockClient(int clientId)
{
  using (var transactionScope = new TransactionScope())
  {
      // этот метод подгрузил в клиента поля Orders и Account
      Client client = clientRepository.GetById(clientId);

      IEnumerable<Order> notApprovedOrders = client.Orders.Where(o => o.IsApproved == false);

      foreach (Order order in notApprovedOrders)
      {
          client.RemoveOrder(order);
          orderRepository.Remove(order);
      }

      client.Account.Disable();

      accountRepository.Save(client.Account);

      transactionScope.Complete();
  }
}
}

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

  • Это код доменной логики и он должен находится в корне агрегации - доменном объекте Client. Но в таком виде этот код нельзя переносить в объект Client, т.к. придется передать в него и интерфейсы репозиториев
  • В коде есть явные вызовы репозиториев. Если выбирать/удалять/сохранять приходится много объектов - это создает дополнительный код с вызовами соответствующих репозиториев, а также дополнительную связность объектов. В данном случае 3 интерфейса связаны с классом ClientService
  • Мы дублируем доменную логику с вызовами соответствующих репозиториев. Например, при удалении заказов вызывается сначала client.RemoveOrder, а потом удаление из БД orderRepository.Remove
  • Есть шанс забыть вызвать репозиторий для одного из измененных объектов, тогда не все изменения попадут в БД

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

Идея шаблона Unit Of Work

Шаблон UnitOfWork - призван отслеживать все изменения данных, которые мы производим с доменной моделью в рамках бизнес-транзакции. После того, как бизнес-транзакция закрывается, все изменения попадают в БД в виде единой транзакции.

Аналог бизнес-транзации - это транзация БД, которая выполняет все SQL-запросы как единую операцию, а при завершении транзации, либо они все проходят успешно, либо ни один SQL-запрос не выполняется. Таким же образом бизнес-транзакция отслеживает все операции, которые происходят с доменными объектами в памяти и после завершения генерирует необходимые запросы к БД.

Схема работы

При использовании UnitOfWork получаем код:
public class ClientController : Controller
{
private readonly IClientRepository clientRepository;
private readonly IUnitOfWorkFactory unitOfWorkFactory;

public ClientController(IUnitOfWorkFactory unitOfWorkFactory, IClientRepository clientRepository)
{
   this.unitOfWorkFactory = unitOfWorkFactory;
   this.clientRepository = clientRepository;
}

public ActionResult LockClient(int clientId)
{
   using (IUnitOfWork unitOfWork = unitOfWorkFactory.Create())
   {
       Client client = clientRepository.GetById(clientId);

       client.Lock();

       unitOfWork.Commit();
   }

   return View();
}
}

Реализация функции Lock приведена в статье. При вызове Commit все изменения объектов Order, Client и Account попадут в БД. Таким образом, мы инкапсулировали всю логику отслеживания изменений данных в одном объекте - IUnitOfWork.

Основные принципы реализации

API получилось довольно красивое, теперь разберемся как же это всё работает?

Для создания единицы работы наше приложение будет использовать интерфейс IUnitOfWorkFactory. Его метод Create будет возвращать IUnitOfWork. Мы разделили интерфейсы, которые использует наше приложение напрямую и реализации этих интерфейсов исходя из принципа инверсии зависимости.

Чтобы подставить в нашем проекте конкретную реализацию вместо IUnitOfWorkFactory, мы будем использовать IoC-контейнер.

Конкретные реализации

Переходим от интерфейсов к конкретным реализациям шаблона UnitOfWork. Эти реализации могут быть двух видов:

  1. Ручная реализация
  2. Готовые решения из различных ORM - NHibernate (Session), Linq2Sql (DataContext) , Entity Framework (ObjectContext), LLBLGen (UnitOfWork) и т.д.

Я ещё не видел ни одной ручной реализации, которая приближались бы по качеству к UnitOfWork из готовой библиотеки. Если у вас есть пример, напиши в комментарий. Поэтому в качестве примера я выбрал готовое решение от NHibernate.

Реализация для NHibernate

Понятно, что наша обертка в виде интерфейса IUnitOfWork будет скрывать объект Session в NHibernate. Наиболее интересный момент - это связь репозиториев и текущей единицей работы.

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

Скачать исходный код Visual Studio 2010/C# (github)

Связь компонентов:

Вам остается только унаследовать свои репозитории от BaseRepository, реализовать свой INHibernateInitializer и проставить соответствие всех интерфейсов в IoC-контейнере.

Пример реализации INHibernateInitializer:

public class FluentInitializer : INHibernateInitializer
{
   #region INHibernateInitializer Members

   public Configuration GetConfiguration()
   {
       MsSqlConfiguration persistenceConfigurer = MsSqlConfiguration
               .MsSql2005
               .ConnectionString(connectionStringBuilder => connectionStringBuilder.FromAppSetting("ConnectionString"))
               .ProxyFactoryFactory<ProxyFactoryFactory>()
               .CurrentSessionContext<ThreadStaticSessionContext>()
               .DoNot.ShowSql();

       FluentConfiguration cfg = Fluently.Configure()
               .Database(persistenceConfigurer)
               .Mappings(m => m.FluentMappings.AddFromAssemblyOf<TaskMap>());

       return cfg.BuildConfiguration();
   }

   #endregion
}

Ваша реализация

Ваш проект знает только об интерфейсах IUnitOfWork и IUnitOfWorkFactory. Значит ничего не мешает заменить предложенную мой реализацию с NHibernate на любую другую. Например, если вы используете Entity Framework, то могу посоветовать статью Using Repository and Unit of Work patterns with Entity Framework 4.0. Будьте внимательны, автор в этом примере не уделяет внимания обертке с интерфейсами и использует код специфический для Entity Framework напрямую. Это опасно тем, что при смене Entity Framework на другую ORM, вам придется менять довольно много кода во всех ваших проектах.


Ссылки

P of EAA: Unit Of Work

MSDN Magazine: The Unit Of Work Pattern And Persistence Ignorance

LosTechies: Wither the Repository

32 комментария:

  1. Как вариант в самом экшене можно убрать вызов UoW, заменив его на атрибут MvcTransaction из mvccontrib.

    ОтветитьУдалить
  2. @headachy
    Да, если приложение на ASP.NET MVC можно обернуть UoW в аттрибут.

    ОтветитьУдалить
  3. Я в итоге пришел к такому же набору интерфейсов. Спасибо большое за статью!

    ОтветитьУдалить
  4. Вот интересно, как вы реализовали эту самую связь между репозиториями и текущей единицей работы? В коде в статье unitOfWork никуда не передается, как репозиторий понимает, откуда его брать?

    ОтветитьУдалить
  5. Видимо как-то через ISession эта связь происходит, только вот я не очень хорошо знаком с Nhibernate, чтобы понять, что это за сессия...

    ОтветитьУдалить
  6. ага, вот оно где видимо: CurrentSessionContext() =)

    ОтветитьУдалить
  7. @Андрей
    Ответить не успел ты сам все нашел :)

    > не очень хорошо знаком с Nhibernate, чтобы понять, что это за сессия

    Если ты работал хотя бы с одной ORM, то должен знать, что это:

    "...NHibernate (Session), Linq2Sql (DataContext) , Entity Framework (ObjectContext), LLBLGen (UnitOfWork)"

    ОтветитьУдалить
  8. Никак не могу понять, как устроены зависимости проектов в солюшене. Infrastructure.NHibernate ссылается на Infrastructure.Common (IoC) для разрешения INHibernateInitializer. Для того, чтобы IoC могло разрешить данный интерфейс нужна обратная ссылка. Получаем циклическую зависимость проектов, и студия референсы добавить не дает. Что я недопонял?

    ОтветитьУдалить
  9. @Alexander

    > Infrastructure.NHibernate ссылается на Infrastructure.Common (IoC) для разрешения INHibernateInitializer

    Infrastructure.NHibernate не нужно разрешать зависимости. В этой сборке декларируется интерфейс INHibernateInitializer. Этот интерфейс будет реализован уже в конкретном проекте (обратите внимание, что Infrastructure.* это общие сборки для всех проектов).

    > Для того, чтобы IoC могло разрешить данный интерфейс нужна обратная ссылка

    В вашем проекте (например, в Global.asax.cs в проекте ASP.NET MVC) ссылка будет на IoC и на INHibernateInitializer, но они друг на друга не будут ссылаться.

    ОтветитьУдалить
  10. @Александр Бындю

    В классе OS.Infrastructure.NHibernate.UnitOfWorkAware.NHibernateHelper свойство Configuration пытается использовать класс IoC:
    configuration = IoC.Resolve().GetConfiguration();
    Сделать инъекцию INHibernateInitializer в конструкторе в NHibernateHelper не получится, т.к. класс статический.
    Как быть?

    ОтветитьУдалить
  11. @Alexander
    Нам не надо делать инъекции зависимостей в NHibernateHelper.

    На старте вашего приложения вы подключите 2 сборки Infrastructure.NHibernate и Infrastructure.NHibernate.

    И выставите маппинг на старте приложения (Global.asax.cs для Web, функция Main() в консольном приложении и т.п.):

    IoC.Register(m => m.Bind().To());

    MyNHibernateInitializer будет определен в вашем проекте, не в инфраструктуре.

    ОтветитьУдалить
  12. @Александр Бындю
    Понял, огромное спасибо!

    ОтветитьУдалить
  13. Александр, вот такой ньюанс ещё остался. Насчёт отсутствия явных вызовов репозиториев (типа Add, Save, Delete) в коде бизнес-транзакции.

    Вот тот красивый код:

    Client client = clientRepository.GetById(clientId);

    client.Lock();

    unitOfWork.Commit();

    Вопрос, как узнает unitOfWork при коммите,
    а) что этот client стал Changed, и его нужно обновить
    б) что некоторые orders стали Deleted и их нужно удалить соответственно?

    В том посте, где классы описывались, они были чистыми POCO, без какого-либо Self-Tracking. И внутри Lock соответственно никаких вызовов repository.Add/Delete тоже нету (что и правильно по идее). Так где же вызовы? Или предполагается тут, что это Self-Tracking сущности?

    ОтветитьУдалить
  14. Большое спасибо за статью, как раз сейчас разбираюсь с UnitOfWwork...
    Посмотрел исходники и есть вопрос по транзакциям. NHibernateUnitOfWork открывает её в конструкторе, и делает ролбэк на диспозе, если не вызван комит. Соответственно, при операциях чтения так и будет - это нормально? Или надо тоже вызывать Comit?

    ОтветитьУдалить
  15. И ещё вопросик назрел... Если делать INSERT или DELETE, то данные когда в базу подадут при вызове Comit (как у LinqToSql на SubmitChanges) или при вызове Comit только транзакция комитится?

    ОтветитьУдалить
  16. @Андрей

    > Так где же вызовы?

    Спасибо за вопрос. Я видимо его упустил.

    Дело в том, что когда мы берем данные методом GetById, то Client фактически выбирается из объекта Session. И живет client на самом деле находится в сессии, которая отслеживает все его изменения. В момент коммита сессия генерирует все необходимые запросы.

    ОтветитьУдалить
  17. @Andy

    > Соответственно, при операциях чтения так и будет - это нормально? Или надо тоже вызывать Comit?

    При чтении Comit делать не надо.

    > при вызове Comit только транзакция комитится?
    При вызове Comit у UoW формируются все необходимые запросы к БД (не раньше), выполняются на БД внутри одной транзакции и потом происходит Comit транзакции.

    ОтветитьУдалить
  18. @Александр Бындю

    Большое спасибо!

    ОтветитьУдалить
  19. Александр, по поводу того, что все операции к БД будут только на комите... Вы уверены? Я попробовал, у меня при вставке новой записи идет инсерт не дожидаясь комита (прямо на Save) - проверил профайлером.

    ОтветитьУдалить
  20. @Andy
    Странно. Ну в любом случае, они должны быть внутри одной транзакции

    ОтветитьУдалить
  21. @Andy, @Александр
    Это зависит от настройки FlushMode.

    ОтветитьУдалить
  22. >Странно. Ну в любом случае, они должны быть внутри одной транзакции
    Такое бывает если поле ID вставляемого элемента есть Identity(MS-SQL)

    ОтветитьУдалить
  23. Я извиняюсь, но немного запутался. Пытаюсь попробовать вашу схему. У меня в проекте ASP.NET MVC в качестве ioc используется castle. В 
    Global.asax.cs  в Application_Start() прописано ControllerBuilder.Current.SetControllerFactory(new WindsorControllerFactory());. Где 
    WindsorControllerFactory класс который разрешает зависимости контроллеров. Компоненты описаны в web.config.
    Сейчас добавил в проект 
    Infrastructure.NHibernate. В  Domain.NHibernate описал конкретную реализацию MyNHibernateInitializer  и на этом застопорился. Вопрос как теперь мне использовать IoC.Resolve() в 
    Global.asax.cs. вместе с  WindsorControllerFactory. Кстати вы написали 
    IoC.Register(m => m.Bind().To()); а не 
    IoC.Resolve(m => m.Bind().To()); Это не опечатка? а то тоже вводит в заблуждение.

    ОтветитьУдалить
  24. WindsorControllerFactory будет создавать корневой элемент для инжектирования зависимостей - *ControllerВ вашем IoC-контейнере нужно прописать зависимость интерфейса INHibernateInitializer от вашей реализации - MyNHibernateInitializer.
    Я для примера написал, что это будет выглядеть так:
    IoC.Register(m => m.Bind().To());

    Соответственно, когда в фабрике контроллеров создастся контроллер, дальше по цепочке, в него подставится реализация UoW, в которую подставится реализация INHibernateInitializer.

    ОтветитьУдалить
  25. Сергей Борисенко2 февраля 2012 г., 2:54

    BaseRepository имеет следующий конструктор - BaseRepository(ISessionProvider sessionProvider, ILinqProvider linqProvider) 

    Стало совсем не понятно, как теперь пользоваться данной библиотекой.

    ОтветитьУдалить
  26. Не могу понять для чего класс BaseRepository абстрактный?

    ОтветитьУдалить
  27. Ух, сколько воды утекло с тех пор :) У нас уже вся инфраструктура поменялась.

    Воспринимайте приведенный в статье код, как пример использовать NHibernate, не более.

    ОтветитьУдалить
  28. public static class IoC - принято называть ServiceLocator

    ОтветитьУдалить
  29. Можете обновить пример с учетом вашей измененной архитектуры? Будет очень интересно посмотреть, что изменилось, и в какую сторону. Я сейчас разбираюсь с данным вопросом на проекте, будет очень кстати.

    ОтветитьУдалить
  30. Как использовать BaseMap в этом примере?

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

    ОтветитьУдалить
  32. Было бы интересно узнать, что поменялось и главное почему

    ОтветитьУдалить