Доступ к данным

Создание настольного приложения с применением NHibernate

Орен Эйни (Oren Eini)

Загрузка примера кода

NHibernate — это средство объектно-реляционного сопоставления (Object Relational Mapper, OR/M), предназначенное сделать работу с базой данных такой же простой, как и с объектами в памяти. Это одна из наиболее популярных инфраструктур OR/M при разработке под Microsoft .NET Framework. Но большинство пользуется NHibernate в контексте веб-приложений, поэтому информации о создании настольных приложений на основе NHibernate сравнительно мало.

Используя NHibernate в веб-приложении, я часто прибегаю к стилю «один запрос — один сеанс», что влечет за собой массу последствий, которые легко упустить. При этом мне безразлично, что сеанс удерживает ссылки на загруженные сущности, так как ожидаю, что сеанс очень быстро завершится. Да и об обработке ошибок особо не переживаю, поскольку могу просто отменить текущий запрос и связанный с ним сеанс, если вдруг возникнет какая-то ошибка.

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

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

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

  • управление областью видимости единиц работы;
  • уменьшение длительности открытых соединений с базой данных;
  • распространение изменений в сущностях на все части приложения;
  • поддержка двухстороннего связывания с данными;
  • ускорение запуска;
  • предотвращение блокировки UI-потока на время доступа к базе данных;
  • разрешение конфликтов при параллельной обработке.

Хотя решения, которые я даю, относятся исключительно к NHibernate, как минимум большая их часть применима и к другим технологиям доступа к данным. Одна из проблем, общая для всех известных мне технологий доступа к данным, — как управлять областью видимости единицы работы приложения, или, в терминах NHibernate, жизненным циклом сеанса.

Управление сеансами

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

Теперь вопрос обработки ошибок. Если вы получаете исключение (например StaleObjectStateException из-за конфликта при параллельной обработке), ваш сеанс и загруженные им сущности попадают прямиком в ад, потому что при использовании NHibernate исключение, сгенерированное в сеансе, переводит его в неопределенное состояние. Вы теряете возможность использовать этот сеанс и любые загруженные им сущности. Если у вас только один глобальный сеанс, значит, вам придется перезапускать приложение, что вряд ли кому-то понравится.

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

Такая же скверная и, увы, почти настолько же распространенная практика в работе с NHibernate — слишком детализированное управление сеансом. Типичный пример — вот такой фрагмент кода:

public void Save(ToDoAction action) {
  using(var session = sessionFactory.OpenSession())
  using(var tx = session.BeginTransaction()) {
    session.SaveOrUpdate(action);

    tx.Commit();
  }
}

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

Сеанс не следует хранить открытым слишком долго, но и открывать его на чересчур короткое время тоже не надо, если вы хотите использовать возможности NHibernate. Как правило, старайтесь подогнать жизненный цикл сеанса под длительность операции, выполняемой системой.

Рекомендуемая практика для настольных приложений — использовать один сеанс на форму, чтобы у каждой формы в приложении был собственный сеанс. Форма обычно представляет некую порцию работы, которую пользователь хотел бы выполнить, поэтому подстройка жизненного цикла сеанса под таковой для формы дает на практике весьма неплохие результаты. Дополнительный выигрыш заключается в том, что у вас больше нет проблемы с утечкой памяти, потому что при закрытии формы в приложении вы автоматически закрываете соответствующий сеанс. А это делает все сущности, загруженные сеансом, доступными для сбора мусора (garbage collection, GC).

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

Хотя подобный стиль управления жизненным циклом сеанса описывается как один сеанс на каждую форму, на практике вы обычно имеете дело с сеансом на каждый презентатор. Код на рис. 1 взят из базового класса презентатора (presenter).

Рис. 1. Управление сеансом презентатора

protected ISession Session {
  get {
    if (session == null)
      session = sessionFactory.OpenSession();
    return session;
  }
}

protected IStatelessSession StatelessSession {
  get {
    if (statelessSession == null)
      statelessSession = sessionFactory.OpenStatelessSession();
    return statelessSession;
  }
}

public virtual void Dispose() {
  if (session != null)
    session.Dispose();
  if (statelessSession != null)
    statelessSession.Dispose();
}

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

Управление соединениями

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

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

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

В случае NHibernate ситуация во много схожая с тем исключением, что в NHibernate есть несколько средств, упрощающих явное соблюдение многих вещей. В сеансе NHibernate нет сопоставления «один к одному» с соединением с базой данных. Вместо этого NHibernate на внутреннем уровне управляет соединением с базой данных, открывая и закрывая его по мере необходимости. То есть вам не требуется поддерживать какой-либо вид состояния в приложении для отключения и повторного подключения к базе данных, если в том возникает нужда. По умолчанию NHibernate минимизирует длительность поддержания соединения в открытом состоянии.

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

Обычно требуется открывать отдельные транзакции для каждой выполняемой операции. Рассмотрим пример формы, на которой показывается список текущих дел (to-do list) (имитация экранного снимка показана на рис. 2). Код для обработки этой формы довольно прост, как видно из листинга на рис. 3.

Приложение «список текущих дел»

Рис. 2 Приложение «список текущих дел»

Рис. 3 Создание формы ToDo

public void OnLoaded() {
  LoadPage(0);
}

public void OnMoveNext() {
  LoadPage(CurrentPage + 1);
}

public void OnMovePrev() {
  LoadPage(CurrentPage - 1);
}

private void LoadPage(int page) {
  using (var tx = StatelessSession.BeginTransaction()) {
    var actions = StatelessSession.CreateCriteria<ToDoAction>()
      .SetFirstResult(page * PageSize)
      .SetMaxResults(PageSize)
      .List<ToDoAction>();

    var total = StatelessSession.CreateCriteria<ToDoAction>()
      .SetProjection(Projections.RowCount())
      .UniqueResult<int>();

    this.NumberOfPages.Value = total / PageSize + 
               (total % PageSize == 0 ? 0 : 1);
    this.Model = new Model {
      Actions = new ObservableCollection<ToDoAction>(actions),
      NumberOfPages = NumberOfPages,
      CurrentPage = CurrentPage + 1
    };
    this.CurrentPage.Value = page;

    tx.Commit();
  }
}

У меня три операции: первая загрузка формы, отображение первой страницы и пролистывание записей вперед-назад.

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

Сеансы без состояний

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

Сеанс без состояния в отличие от обычного сеанса не удерживает ссылки на загруженные сущности. Он отлично подходит для загрузки сущностей просто для отображения. Для такого рода задач вы, как правило, загружаете сущности из базы данных, показываете их на форме и забываете о них. Сеанс без состояния — как раз то, что надо в этом случае.

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

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

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

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

вы просто закрываете текущий сеанс и открываете новый.

Манипулирование данными

На рис. 4 показана имитация экранного снимка окна для редактирования. Какие проблемы ждут вас, если вам понадобится редактировать сущности?

Редактирование сущностей

Рис. 4. Редактирование сущностей

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

С первой задачей справиться достаточно легко. Надо лишь использовать сеанс, сопоставленный с формой, — вот и все. Соответствующий код показан на рис. 5.

Рис. 5. Редактирование сущности в сеансе

public void Initialize(long id) {
  ToDoAction action;
  using (var tx = Session.BeginTransaction()) {
    action = Session.Get<ToDoAction>(id);
    tx.Commit();
  }

  if(action == null)
    throw new InvalidOperationException(
      "Action " + id + " does not exists");

  this.Model = new Model {
    Action = action
  };
}

public void OnSave() {
  using (var tx = Session.BeginTransaction()) {
    // this isn't strictly necessary, NHibernate will 
    // automatically do it for us, but it make things
    // more explicit
    Session.Update(Model.Action);

    tx.Commit();
  }

  EventPublisher.Publish(new ActionUpdated {
    Id = Model.Action.Id
  }, this);

  View.Close();
}

Вы получаете сущность из базы данных в методе Initialize(id), а обновляете ее в методе OnSave. Заметьте, что вы делаете это в двух раздельных транзакциях, а не в одной, чтобы не удерживать одну транзакции. слишком долго. И еще этот странный вызов EventPublisher. Что он означает? Сейчас расскажу.

Здесь EventPublisher решает еще одну задачу: когда у каждой формы свой сеанс, они получают разные экземпляры сущностей. На первый взгляд это кажется лишним. Зачем загружать одно и то же несколько раз?

На деле такое разделение между формами значительно упрощает приложение. Рассмотрим, что было бы, если бы вы использовали общие экземпляры сущностей. В этом случае вы могли бы столкнуться с любым сценарием ложного редактирования. Например, вы отображаете одну сущность на двух формах и позволяете ее редактировать. Если вы вносите одни изменения в сущность на одной форме (скажем, в сетке), другие — на второй (отображающей, допустим, не только родительские, но и дочерние элементы), а сохраняете изменения только на второй форме, то что будет с изменениями на первой форме?

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

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

Публикация событий

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

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

EventPublisher является механизмом публикации-подписки, с помощью которого я отделяю издателя от подписчика. Единственная связь между ними — класс EventPublisher. Я использую тип события (ActionUpdated на рис. 5), чтобы решить, кому сообщить о событии.

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

public Presenter() {
  EventPublisher.Register<ActionUpdated>(
    RefreshCurrentPage);
}

private void RefreshCurrentPage(
  ActionUpdated actionUpdated) {
  LoadPage(CurrentPage);
}

При запуске я регистрирую метод RefreshCurrentPage в событии ActionUpdated. После этого при каждом появлении данного события я просто обновляю текущую страницу вызовом LoadPage, с которым вы уже знакомы.

Реализация получается на самом деле весьма «ленивой». Меня не волнует, показывается ли на текущей странице отредактированная сущность, — все равно я обновляю ее в любом случае. Более сложная (и эффективная) реализация должна была бы обновлять данные в сетке, только если на этой странице отображается измененная сущность.

Основное преимущество такого применения механизма публикации-подписки — отделение издателя и подписчиков. В основной форме меня не интересует, что форма редактирования публикует событие ActionUpdated. Идея публикации событий и механизм публикации-подписки — краеугольные камни для построения слабо сопряженных UI; она детально описана в руководстве «Composite Application Guidance» (msdn.microsoft.com/library/cc707819) от группы Microsoft Patterns & Practices.

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

Следующий код взят из презентатора формы редактирования:

public Presenter() {
  EventPublisher.Register<ActionUpdated>(RefreshAction);
}

private void RefreshAction(ActionUpdated actionUpdated) {
  if(actionUpdated.Id != Model.Action.Id)
    return;
  Session.Refresh(Model.Action);
}

Этот код регистрируется на событие ActionUpdated, и, если вы редактируете сущность, запрашивает NHibernate обновить ее из базы данных.

Явная модель обновления сущности из базы данных также дает вам шанс на принятие решений о том, что должно происходить далее. Обновлять автоматически, стирая все изменения, внесенные пользователем? Или вывести запрос пользователю? Или попытаться «молча» объединить изменения? Этот выбор теперь доступен вам и реализуется достаточно прямолинейно.

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

Хотя этот код обновления сущности действительно изменяет значения экземпляра сущности, как вы собираетесь заставить UI реагировать на это изменение? Значения сущности связаны с полями на форме, но вам нужно как-то сообщить UI, что эти значения были модифицированы.

Microsoft .NET Framework предоставляет интерфейс INotifyPropertyChanged, который понимает большинство UI-инфраструктур и знает, как с ним работать. Вот определение INotifyPropertyChanged:

public delegate void PropertyChangedEventHandler(
  object sender, PropertyChangedEventArgs e);

public class PropertyChangedEventArgs : EventArgs {
  public PropertyChangedEventArgs(string propertyName);
  public virtual string PropertyName { get; }
}

public interface INotifyPropertyChanged {
  event PropertyChangedEventHandler PropertyChanged;
}

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

Реализация этого довольно проста:

public class Action : INotifyPropertyChanged {
  private string title;
  public virtual string Title {
    get { return title; }
    set {
      title = value;
      PropertyChanged(this, 
        new PropertyChangedEventArgs("Title"));
    }
  }

  public event PropertyChangedEventHandler 
    PropertyChanged = delegate { };
}

Несмотря на свою простоту, этот код повторяется довольно часто и необходим лишь для удовлетворения требований UI-инфраструктуры.

Перехват создания сущности

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

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

В частности, это позволит вам использовать преимущества ключевого слова virtual для встраивания собственной функциональности. Для этого применяется методика под названием аспектно-ориентированное программирование (Aspect-Oriented Programming, AOP). По сути, вы берете класс и в период выполнение включаете в него дополнительные поведения. Описание реализации этого механизма выходит за рамки моей статьи, но она инкапсулируется в классе DataBindingFactory, определение которого выглядит так:

public static class DataBindingFactory {
  public static T Create<T>();
  public static object Create(Type type);
}

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

ToDoAction action = DataBindingFactory.Create<ToDoAction>();
string changedProp = null;
((INotifyPropertyChanged)action).PropertyChanged 
  += (sender, args) => changedProp = args.PropertyName;
action.Title = "new val";
Assert.Equal("Title", changedProp);

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

Но одна проблема все же сохраняется. Хотя вы можете создавать новые экземпляры сущностей с помощью DataBindingFactory, по большей части вы имеете дело с экземплярами, созданными NHibernate. Очевидно, NHibernate ничего не известно о вашем DataBindingFactory, и эта инфраструктура не может его задействовать. Но не унывайте: вы можете использовать одну из самых полезных точек расширения NHibernate — Interceptor. Interceptor в NHibernate, по сути, позволяет вам брать на себя часть функций, которые NHibernate выполняет на внутреннем уровне.

Одна из таких функций — создание новых экземпляров сущностей. На рис. 6 показан Interceptor, который делает это с помощью DataBindingFactory.

Рис. 6. Перехват создания сущности

public class DataBindingInterceptor : EmptyInterceptor {
  public ISessionFactory SessionFactory { set; get; }

  public override object Instantiate(string clazz, 
    EntityMode entityMode, object id) {

    if(entityMode == EntityMode.Poco) {
      Type type = Type.GetType(clazz);
      if (type != null) {
        var instance = DataBindingFactory.Create(type);
        SessionFactory.GetClassMetadata(clazz)
          .SetIdentifier(instance, id, entityMode);
        return instance;
      }
    }
    return base.Instantiate(clazz, entityMode, id);
  }

  public override string GetEntityName(object entity) {
    var markerInterface = entity as
      DataBindingFactory.IMarkerInterface;
    if (markerInterface != null)
      return markerInterface.TypeName;
    return base.GetEntityName(entity);
  }
}

Вы переопределяете метод Instantiate и обрабатываете случай, где получаете сущность с известным вам типом. Затем создаете экземпляр класса и настраиваете его свойство-идентификатор. Кроме того, вам нужно научить NHibernate распознавать, к какому типу относится экземпляр, созданный через DataBindingFactory, что делается в методе GetEntityName объекта intercepter.

Теперь осталось лишь установить в NHibernate новый Interceptor. Следующий фрагмент взят из класса BootStrapper, отвечающего за настройку приложения:

public static void Initialize() {
  Configuration = LoadConfigurationFromFile();
  if(Configuration == null) {
    Configuration = new Configuration()
      .Configure("hibernate.cfg.xml");
    SaveConfigurationToFile(Configuration);
  }
  var intercepter = new DataBindingIntercepter();
  SessionFactory = Configuration
    .SetInterceptor(intercepter)
    .BuildSessionFactory();
  intercepter.SessionFactory = SessionFactory;
}

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

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

Наверняка найдутся те, кто скажут, что выбор такого решения создает проблему с точки зрения производительности по сравнению с реализацией, требующей больших усилий в кодировании. На практике это совершенно не так. Утилита, которой я пользуюсь (Castle Dynamic Proxy) для динамического расширения классов, специально оптимизирована для обеспечения максимальной производительности.

Решение проблемы производительности

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

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

Первый — запускать NHibernate в фоновом потоке. Хотя в этом случае UI будет появляться гораздо быстрее, такой способ вносит осложнения, так как вы не сможете ничего показывать пользователю из базы данных до тех пор, пока не закончите запуск фабрики сеансов.

Другой вариант — сериализация NHibernate-класса Configuration. Большая часть издержек запуска NHibernate связана с проверкой информации, передаваемой классу Configuration. Это сериализуемый класс, поэтому вы можете заплатить полную цену только раз, а после этого резко сократить издержки, загружая уже проверенный экземпляр из хранилища.

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

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

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

Такие обязанности часто переносятся в фоновый поток, и вы можете делать то же самое при использовании NHibernate — только помните, что сеанс NHibernate не является безопасным в многопоточной среде. Хотя сеанс можно использовать в нескольких потоках (привязки к определенному потоку нет), но никогда не делайте этого параллельно (это же относится и к сущностям). Другими словами, использовать сеанс в фоновом потоке можно без проблем, но при этом нужно упорядочивать доступ к сеансу и не допускать параллельных обращений к нему. Использование сеанса из нескольких потоков параллельно приведет к неопределенному поведению, а значит, вы должны избегать этого.

К счастью, можно предпринять несколько сравнительно простых мер, чтобы обеспечить упорядоченный доступ к сеансу. Класс System.ComponentModel.BackgroundWorker был специально разработан для решения подобных задач. Он позволяет выполнять задачу в фоновом потоке и уведомляет вас о завершении ее обработки, беря на себя синхронизацию с UI-потоком, столь важную в настольных приложениях.

Вы уже видели, как управлять редактированием имеющейся сущности, что делалось непосредственно в UI-потоке. Теперь давайте сохраним новую сущность в фоновом потоке. Следующий код отвечает за инициализацию презентатора Create New:

private readonly BackgroundWorker saveBackgroundWorker;

public Presenter() {
  saveBackgroundWorker = new BackgroundWorker();
  saveBackgroundWorker.DoWork += 
    (sender, args) => PerformActualSave();
  saveBackgroundWorker.RunWorkerCompleted += 
    (sender, args) => CompleteSave();
  Model = new Model {
    Action = DataBindingFactory.Create<ToDoAction>(),
    AllowEditing = new Observable<bool>(true)
  };
}

Реальное сохранение выполняется классом BackgroundWorker; этот процесс разделен на два этапа. Не считая этого, в остальном все очень похоже на то, что я делал в сценарии редактирования. Еще одно, на что нужно обратить внимание, — свойство Allow Editing; оно используется для блокировки UI в форме, когда вы выполняете операцию сохранения. Тем самым можно безопасно использовать сеанс в другом потоке, зная, что параллельного доступа к сеансу или любой из его сущностей с этой формы не будет.

Сам процесс сохранения должен быть уже хорошо знаком вам. Рассмотрим сначала метод OnSave:

public void OnSave() {
  Model.AllowEditing.Value = false;
  saveBackgroundWorker.RunWorkerAsync();
}

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

private void PerformActualSave() {
  using(var tx = Session.BeginTransaction()) {
    Model.Action.CreatedAt = DateTime.Now;
    
    Session.Save(Model.Action);
    tx.Commit();
  }
}

Когда сохранение изменений в базу данных заканчивается, BackgroundWorker запускает CompleteSave в UI-потоке:

private void CompleteSave() {
  Model.AllowEditing.Value = true;
  EventPublisher.Publish(new ActionUpdated {
    Id = Model.Action.Id
  }, this);

  View.Close();
}

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

Благодаря этой методике вы можете использовать преимущества фоновой обработки без нарушения контракта потоков для экземпляров сеанса. Как всегда, многопоточность — отличный способ повышения «отзывчивости» приложения, но к многопоточному программированию нельзя относиться легкомысленно, так что применяйте эту методику с осторожностью.

Параллельная обработка

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

Это называют конфликтом параллельной обработки, и NHibernate позволяет обнаруживать такие конфликты множеством способов. Сущность ToDoAction имеет поле <version/>, которое сообщает NHibernate явным образом выполнять проверки параллельного доступа с нежесткой блокировкой. Полное обсуждение вариантов параллельной обработки, поддерживаемых NHibernate, см. в моем блоге в статье по ссылке ayende.com/Blog/archive/2009/04/15/nhibernate-mapping-concurrency.aspx.

Фактически решения в области параллельной обработки делятся на две широкие категории.

  • Параллельный доступ с жесткой блокировкой (pessimistic concurrency), при котором вы должны удерживать блокировки в базе данных и оставлять транзакцию открытой на весьма продолжительное время. Как я уже говорил, это не лучшая идея в настольном приложении.
  • Параллельный доступ с нежесткой блокировкой (optimistic concurrency), при котором вы можете закрыть соединение с базой данных на время, пока пользователь размышляет о дальнейших действиях. Большинство вариантов, поддерживаемых NHibernate, относятся именно к этой категории; конфликты можно обнаруживать, используя несколько стратегий.

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

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

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

Одна интересная проблема, с которой вы сталкиваетесь почти сразу же, — исключение, генерируемое в сеансе, означает, что данный сеанс больше не применим. Любой конфликт параллельной обработки проявляется в NHibernate как исключение. Единственное, что можно сделать с сеансом после генерации исключения, — вызвать Dispose для этого сеанса; любая другая операция приведет к неопределенному поведению.

Сейчас я вернусь к примеру с экраном редактирования и реализую для него параллельную обработку. На этот экран я добавлю кнопку Create Concurrency Conflict, при щелчке которой будет выполняться такой код:

public void OnCreateConcurrencyConflict() {
  using(var session = SessionFactory.OpenSession())
  using(var tx = session.BeginTransaction()) {
    var anotherActionInstance = 
      session.Get<ToDoAction>(Model.Action.Id);
    anotherActionInstance.Title = 
      anotherActionInstance.Title + " -";
    tx.Commit();
  }
MessageBox.Show("Concurrency conflict created");
}

В результате создается новый сеанс и модифицируется свойство Title. А это вызывает конфликт параллельной обработки, когда я пытаюсь сохранить сущность, показываемую на форме, поскольку сеанс, связанный с этой формой, не знает о выполненных изменениях. На рис. 7 показано, как я обрабатываю такую ситуацию.

Я просто обернул код, сохраняющий изменения в базу данных, в блок try/catch и обрабатываю исключение, информируя пользователя об обнаружении конфликта параллельной обработки. После чего я заменяю сеанс.

Рис 7. Обработка конфликтов параллельной обработки

public void OnSave() {
  bool successfulSave;
  try {
    using (var tx = Session.BeginTransaction()) {
      Session.Update(Model.Action);

      tx.Commit();
    }
    successfulSave = true;
  }
  catch (StaleObjectStateException) {
    successfulSave = false;
    MessageBox.Show(
      @"Another user already edited the action before you had a chance to do so. The application will now reload the new data from the database, please retry your changes and save again.");

    ReplaceSessionAfterError();
  }

  EventPublisher.Publish(new ActionUpdated {
    Id = Model.Action.Id
  }, this);

  if (successfulSave)
    View.Close();
}

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

Наконец, если сохранение изменений в базе данных прошло успешно, я просто закрываю форму. Но мне еще нужно позаботиться о замене сеанса и сущности (рис. 8).

Рис. 8. Обновление сеансов и сущностей

protected void ReplaceSessionAfterError() {
  if(session!=null) {
    session.Dispose();
    session = sessionFactory.OpenSession();
    ReplaceEntitiesLoadedByFaultedSession();
  }
  if(statelessSession!=null) {
    statelessSession.Dispose();
    statelessSession = sessionFactory.OpenStatelessSession();
  }
}

protected override void 
  ReplaceEntitiesLoadedByFaultedSession() {
  Initialize(Model.Action.Id);
}

Как видите, я заменяю сеанс его удалением и открытием нового. В случае сеанса я также указываю презентатору заменить все сущности, которые были загружены в проблемном сеансе. Сущности NHibernate тесно связаны со своим сеансом, и, когда сеанс становится непригодным для дальнейшего использования, как правило, лучше всего заменить и сущности. Это не обязательное требование — сущности не перестают работать, — но такие вещи, как отложенная загрузка, больше не будут функционировать. Я предпочитаю заплатить дополнительную цену за замену сущностей, чем потом пытаться определять, можно или нельзя проходить по графу объектов в конкретных случаях.

В данном случае замена сущности реализуется простым вызовом метода Initialize. Это тот же метод Initialize, который обсуждался в случае формы редактирования. Он просто получает сущность из базы данных и присваивает ее свойству Model — ничего особенного он не делает. В более сложных сценариях он может заменять несколько экземпляров сущности, используемых на одной форме.

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

Управление конфликтами

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

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

UI для управления конфликтами изменений

Рис. 9. UI для управления конфликтами изменений

Для экрана редактирования измените код, отвечающий за решение конфликта параллельной обработки, следующим образом:

catch (StaleObjectStateException) {
  var mergeResult = 
    Presenters.ShowDialog<MergeResult?>(
    "Merge", Model.Action);
  successfulSave = mergeResult != null;

  ReplaceSessionAfterError();
}

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

Презентатор диалога объединения достаточно прост:

public void Initialize(ToDoAction userVersion) {
  using(var tx = Session.BeginTransaction()) {
    Model = new Model {
      UserVersion = userVersion,
      DatabaseVersion = 
        Session.Get<ToDoAction>(userVersion.Id),
        AllowEditing = new Observable<bool>(false)
    };  

    tx.Commit();
  }
}

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

public void OnAcceptDatabaseVersion() {
  // nothing to do
  Result = MergeResult.AcceptDatabaseVersion;
  View.Close();
}

Если же пользователь выбирает свою версию, с моей стороны требуется лишь чуть больше усилий:

public void OnForceUserVersion() {
  using(var tx = Session.BeginTransaction()) {
    //updating the object version to the current one
    Model.UserVersion.Version = 
      Model.DatabaseVersion.Version;
    Session.Merge(Model.UserVersion);
    tx.Commit();
  }
  Result = MergeResult.ForceDatabaseVersion; 
  View.Close();
}

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

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

Заметьте, что до слияния я присваиваю свойство Version пользовательской версии одноименному свойству версии из базы данных. Это необходимо потому, что в данном случае мне требуется перезапись версии явным образом.

Мой код не пытается обрабатывать рекурсивные конфликты параллельной обработки — это упражнение я оставляю вам.

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

Орен Эйни (Oren Eini ) (работает под псевдонимом Эйенд Рахайн (Ayende Rahien)) — участник нескольких проектов по разработке технологий с открытым исходным кодом (среди них NHibernate и Castle); основатель многих других проектов (в том числе Rhino Mocks, NHibernate Query Analyzer и Rhino Commons). Эйни также отвечает за NHibernate Profiler (nhprof.com), визуализированный отладчик для NHibernate. С ним можно связаться через блог ayende.com/Blog..

Выражаю благодарность за рецензирование данной статьи экспертам Говарду Дайеркингу (Howard Dierking)