December 2009
Volume 24 Number 12
データ アクセス - NHibernate によるデスクトップ タスク アプリケーションの作成
Oren Eini | December 2009
NHibernate は、オブジェクト リレーショナル マッピング (OR/M) の 1 つで、メモリ内のオブジェクトを扱うぐらい簡単にデータベースを操作できるようにすることを目的としています。NHibernate は、Microsoft .NET Framework 開発用の OR/M フレームワークの中で最もよく使用されるフレームワークの 1 つです。しかし、NHibernate のほとんどのユーザーが Web アプリケーションで使用しているため、デスクトップでの NHibernate アプリケーションの作成についての情報はあまりありません。
Web アプリケーションで NHibernate を使用する場合、私は 1 要求につき 1 セッションの形式を使用することが多くなります。この方法であれば、簡単に解消できる課題が多数あります。読み込んだエンティティへの参照を維持するセッションはすぐに破棄されるため、気にかける必要はありません。エラーが発生した場合でも現在の要求とこれに関連付けられているセッションを単純に中止できるため、エラー処理についても心配する必要はありません。
セッションの有効期間が明確です。自分が施した変更について他のセッションを更新する必要はありません。セッションは 1 つの要求が処理されている間しか存続しないため、データベースで長時間トランザクションを保持することも、接続を長時間開いたままにしておくことも、心配する必要はありません。
ご想像のとおり、上記のことは、デスクトップ アプリケーションでは問題になります。誤解がないように記しておくと、ここで話題にしているのは、データベースと直接通信するアプリケーションです。なんらかのリモート サービスを使用するアプリケーションは、1 要求を単位とするシナリオのもとで NHibernate をリモート サーバー上で使用しているため、この記事では詳しく説明しません。この記事では随時接続型のシナリオについては取り上げませんが、内容の多くは随時接続型のシナリオにも当てはまります。
NHibernate ベースのデスクトップ アプリケーションを作成することは、他の永続化テクノロジを使用するデスクトップ アプリケーションを作成することと大差ありません。この記事で説明する以下の課題の多くは、すべてのデータ アクセス テクノロジに共通しています。
- 作業単位のスコープを管理する
- データベース接続を開いたままにする時間を短縮する
- エンティティの変更をアプリケーションのすべての部分に伝達する
- 双方向のデータ バインドをサポートする
- 起動時間を短縮する
- データベースへのアクセス中に UI スレッドをブロックしない
- 同時実行の競合を処理および解決する
ここで提供するソリューションは NHibernate のみを使用していますが、少なくとも上記の大半は他のデータ アクセス テクノロジにも当てはまります。私の認識ではすべてのデータ アクセス テクノロジに共通するこのような課題の 1 つは、アプリケーションの作業単位のスコープ、つまり、NHibernate の用語ではセッションの有効期間をどのように管理するかです。
セッションを管理する
NHibernate デスクトップ アプリケーションによくある不適切な処理の 1 つは、アプリケーション全体で 1 つのグローバル セッションを使用することです。これが問題である理由はたくさんありますが、中でも最も重要な理由が 3 つあります。まず、セッションは読み込んだすべてのエンティティへの参照を保持することです。アプリケーションのユーザーはエンティティを読み込み、そのエンティティを少し操作したら、そのエンティティについて忘れてしまいますが、グローバル セッションが 1 つしかなければそのエンティティへの参照が維持されるため、エンティティは決して解放されません。結果として、アプリケーションでメモリ リークが発生します。
次に、エラー処理の問題があります。例外 (同時実行の競合による StaleObjectStateException など) が発生すると、セッションとそのセッションに読み込まれているエンティティは利用できなくなります。NHibernate を使用する場合は、例外をスローしたセッションが未定義状態に移行されるためです。そのセッションも読み込まれていたエンティティも使用できなくなります。1 つのグローバル セッションしかなければ、おそらくアプリケーションの再起動が必要になりますが、これは良い考えとは言えないでしょう。
最後に、トランザクションと接続の処理の問題があります。セッションを開くことと、データベース接続を開くことは同じではありませんが、1 つのセッションのみを使用するということは、必要以上にトランザクションと接続を保持する可能性がきわめて高くなります。
同じように不適切であり、残念なことに 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 の機能を利用するには、開いている時間が短すぎるのも問題です。基本的に、セッションの有効期間とシステムによって実行される実際の操作とが見合うようにします。
デスクトップ アプリケーションに推奨される処理は、フォームごとにセッションを使用する方法です。アプリケーションの各フォームに専用のセッションが使用されるようにします。通常、各フォームは、ユーザーが実行するある特定の作業を表すため、セッションの有効期間とフォームの有効期間を一致させることは、実際非常に有効です。さらに、アプリケーションのフォームを閉じると、セッションも破棄されるため、メモリ リークの問題がなくなるというメリットもあります。この方法であれば、セッションによって読み込まれたすべてのエンティティを、ガーベージ コレクター (GC) による回収の対象にできるでしょう。
フォームごとのセッションが好ましい理由は他にもあります。まず、NHibernate の変更管理機能を利用できることです。したがって、データベースに対するすべての変更は、トランザクションをコミットした時点でフラッシュされます。また、異なるフォーム間を分離する境界も作られるため、単一のエンティティに対して変更をコミットでき、他のフォームに表示されているその他のエンティティが変更される心配がありません。
このようなセッションの有効期限の管理方法は、フォームごとのセッションとして説明されますが、実際には、プレゼンダーごとにセッションを管理するのが一般的です。図 1 のコードは、プレゼンター基本クラスからの抜粋です。
図 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 セッションには、データベース接続が 1 対 1 に関連付けられません。NHibernate では、データベース接続を内部で管理し、必要に応じて開閉します。つまり、必要に応じてデータベースを切断および再接続するために、アプリケーションでなんらかの状態を保持しておく必要がありません。NHibernate は、既定では、接続を開いている期間が最大限短くなるように処理します。
ただし、開発者は、トランザクションができる限り小さくなるように配慮する必要があります。具体的に避けたいことの 1 つは、フォームの有効期間の最初から最後までトランザクションを開いたままにすることです。この場合、NHibernate が、トランザクションの最初から最後まで、接続を開いたままにしなければならなくなります。フォームの有効期間は人間の応答時間によって決まるため、実際に適切な期間よりも長くトランザクションと接続が保持されることになるでしょう。
通常は、実行する操作ごとに個別のトランザクションを開きます。私がサンプル アプリケーションとして選んだ、簡単なタスク リストを表示するフォームのモックアップを見てみましょう (図 2 参照)。このフォームを処理するコードは、非常に簡単です (図 3 参照)。
図 2 タスク リスト アプリケーション
図 3 タスク フォームの作成
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();
}
}
ここでは、フォームの初期読み込み、先頭ページの表示、および前のレコード ページまたは次のレコード ページへの移動という 3 つの操作があります。
操作ごとに、個別のトランザクションを開始してコミットしています。この方法であれば、データベース上でリソースを消費する必要も、長時間トランザクションが開いたままになることを心配する必要もありません。新しいトランザクションを開始すると、NHibernate によって自動的にデータベースへの接続が開かれ、トランザクションが完了したら接続は閉じられます。
ステートレス セッション
もう 1 つ細かいことですが、指摘しておくことがあります。それは、ISession を使用していないことです。代わりに IStatelessSession を使用して、データを読み込んでいます。ステートレス セッションは、通常、一括データ操作で使用されますが、ここでは、メモリ消費の問題を解決するために、ステートレス セッションを使用しています。
ステートレス セッションは、文字どおり、ステートレスです (状態が保持されません)。ステートレス セッションでは、通常のセッションと異なり、読み込んだエンティティへの参照が維持されません。このため、表示のみが目的であるエンティティの読み込みには最適です。このようなタスクでは、一般に、データベースからエンティティを読み込み、それをフォームに渡しておしまいです。ステートレス セッションは、このような場合にまさに必要とされるセッションです。
しかし、ステートセス セッションには、一連の制限があります。ここで主に問題になるのは、ステートレス セッションでは遅延読み込みがサポートされないこと、通常の NHibernate イベント モードに組み込まれないこと、および NHibernate のキャッシュ機能を利用しないことです。
このため、私は、基本的に、ユーザーに対して情報を表示するだけで複雑な操作を行わない簡単なクエリに、ステートレス セッションを使用します。編集目的でエンティティを表示する場合でも、ステートレス セッションを使用できますが、ステートレス セッションではなく、通常のセッションを使用するようにしています。
メインのアプリケーション フォームでは、すべてのデータをなんとか表示専用にして、ステートレス セッションのみを利用しようとしています。メインのフォームは、アプリケーションが開かれている間維持されるので、ステートフル セッションでは問題になります。これは、読み込まれたエンティティへの参照が維持されるためだけでなく、セッションが例外をスローした場合に問題になる可能性が高いためでもあります。
NHibernate では、例外をスローしたセッションは未定義状態であると見なされます (この場合、定義済みの動作は Dispose のみです)。このため、セッションを置き換える必要があります。また、ステートフル セッションによって読み込まれた各エンティティは各自への参照を維持するため、使用できなくなったセッションによって読み込まれているすべてのエンティティをクリアする必要があります。このような状況では、ステートレス セッションを使用する方がはるかに簡単です。
ステートレス セッションによって読み込まれたエンティティでは、セッション状態が意識されません。したがって、ステートレス セッションの場合は、現在のステートレス セッションを閉じて新しいセッションを開くだけで、エラーから回復できます。
データを操作する
図 4 は、編集画面のモックアップです。エンティティを編集する必要があるときに、直面する課題はなんでしょう。
図 4 エンティティの編集
さて、ここでは実際に 2 種類の課題があります。1 つは NHibernate の変更管理機能を利用して、エンティティ (またはエンティティ オブジェクト グラフ) を表示し、作業が完了するまで NHibernate がその表示状態を固定できるようにすることです。2 つ目は、エンティティを保存したら、このエンティティを表示するすべてのフォームに、新しい値が反映されるようにすることです。
1 つ目の課題は、実はかなり簡単に対応できます。必要な作業は、フォームにセッションを関連付けて使用するだけです。図 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 メソッドでそのエンティティを更新します。1 つのトランザクションを長時間維持するのではなく、これらの処理を 2 つのトランザクションに分けて実行していることに注目してください。また、このコードには奇妙な EventPublisher 呼び出しがあります。これは何でしょう。
EventPublisher は、また別の課題を処理するためにここで使用されています。各フォームに専用のセッションが用意されるのであれば、操作対象のエンティティのインスタンスもフォームごとに用意されます。一見すると、これは無駄なように思えます。なぜ同じアクションを何度も読み込む必要があるのでしょう。
実際には、フォーム間をこのように分離することで、アプリケーションは大幅に単純になります。エンティティのインスタンスをアプリケーション全体で共有すると、どのようなことが起きるでしょう。この場合、考えられる限りのあらゆる編集シナリオで問題になるでしょう。 あるエンティティを 2 つのフォームに表示し、それらのフォームからそのエンティティを編集できるとしたら、どうなるでしょう。たとえば、編集可能なグリッドと、詳細な編集フォームが表示されるとします。グリッドでエンティティを変更してから、詳細編集フォームを開き、同じエンティティを保存した場合、編集可能グリッド上で実行した変更はどうなるでしょう。
アプリケーション全体で 1 つのエンティティ インスタンスしか使用しない場合、詳細フォームを保存するときに、同時にグリッドを使用して実行した変更も保存されることになるでしょう。これは、望ましい動作ではありません。また、エンティティ インスタンスを共有すると、編集フォームを取り消して、未保存の変更をすべて破棄するような処理は、大幅に難しくなります。
このような問題は、フォームごとに 1 つのエンティティ インスタンスを使用すれば起こりません。これは、フォームごとのセッションの方法を使用する場合は事実上必須であり、適切な処置です。
イベントをパブリッシュする
しかし、まだ EventPublisher の目的についてすべて説明したことにはなりません。実はとても簡単なことです。アプリケーション全体でエンティティのインスタンスを 1 つ使用するのではなく、多数のエンティティを使用することができます。ただし、(エンティティが適切に保存されたら) そのエンティティを表示するすべてのフォーム上で、更新されたエンティティを表示する必要があるでしょう。
今回の例では、これを明示的に処理しています。エンティティを保存するたびに、エンティティを保存したことと、どのエンティティを保存したかを通知するイベントをパブリッシュしています。これは標準の .NET イベントではありません。.NET イベントには、.NET イベントを直接サブスクライブするクラスが必要です。これは、この場合の通知には適しているとは言えません。理由は、各フォームから他のすべてのフォームのイベントに対して登録を行う必要があるためです。これを管理しようとするだけでも、悪夢のような事態になるでしょう。
EventPublisher は、ここではパブリッシャーとサブスクライバーを分離するために使用しているパブリッシュ/サブスクライブ メカニズムです。パブリッシャーとサブスクライバーが共通して使用する唯一のクラスは、EventPublisher クラスです。イベントの種類 (図 5 の ActionUpdated) を使用して、イベントについて通知する主体を決定しています。
今度は、逆の立場に立って考えてみましょう。タスクを更新した場合、更新された値が、メインのフォームに表示されるようにしたいと考えています。このメインのフォームは、タスクのグリッドを表示します。以下は、そのフォーム プレゼンターから抜粋した関連コードです。
public Presenter() {
EventPublisher.Register<ActionUpdated>(
RefreshCurrentPage);
}
private void RefreshCurrentPage(
ActionUpdated actionUpdated) {
LoadPage(CurrentPage);
}
起動時に、RefreshCurrentPage メソッドを ActionUpdated イベントに登録しています。そこで、このイベントが発生するたびに、既におなじみの LoadPage を呼び出して、現在のページを更新します。
これは、実際にはかなりの遅延実装です。ここでは、現在のページが編集されたエンティティを表示するかどうかは気にせず、とにかくページを更新します。より複雑 (で効率的) な実装では、更新されたエンティティがそのページに表示されている場合のみグリッド データを更新することになるでしょう。
この方法でパブリッシュ/サブスクライブ メカニズムを使用する最大のメリットは、パブリッシャーとサブスクライバーを分離することです。メインのフォームでは、編集フォームが ActionUpdated イベントをパブリッシュするかどうかは考慮していません。イベントのパブリッシュおよびパブリッシュ/サブスクライブの概念は、疎結合ユーザー インターフェイスを作成するうえで基盤となる概念です。詳細については、Microsoft patterns & practices による「複合クライアント アプリケーション ガイダンス」(msdn.microsoft.com/library/cc707819、英語) を参照してください。
もう 1 つ、考えてみる価値がある状況があります。同じエンティティに対して 2 つの編集フォームを同時に開いている場合はどうなるでしょう。データベースから新しい値を取得して、ユーザーに表示するにはどうしたらよいでしょう。
次のコードは、編集フォーム プレゼンターからの抜粋です。
public Presenter() {
EventPublisher.Register<ActionUpdated>(RefreshAction);
}
private void RefreshAction(ActionUpdated actionUpdated) {
if(actionUpdated.Id != Model.Action.Id)
return;
Session.Refresh(Model.Action);
}
このコードでは、ActionUpdated イベントへの登録を行い、それが編集対象のエンティティだった場合は、NHibernate にデータベースから新しいエンティティを取得して更新するように指示しています。
明示的にエンティティをデータベースから取得して更新するこのモデルでは、ここで実行する動作を指定することもできます。自動的に更新するか、ユーザーの変更をすべて消去するか、ユーザーに確認メッセージを表示するか、変更を暗黙的にマージするかなど、これらはすべて、この時点で、開発者の裁量により、簡単に処理を決定できます。
ただし、ほとんどの場合は、1 つのエンティティを (少なくとも複数のユーザーによって) 並行して更新することは通常許可しないので、単純にエンティティを更新するだけで十分です。
このエンティティの更新コードは実際にエンティティ インスタンスの値を更新しますが、この変更に対して UI はどのように処理しますか。エンティティ値をフォーム フィールドにデータ バインドしていますが、UI にこれらの値が変更されたことを通知するなんらかの方法が必要です。
Microsoft .NET Framework では、ほとんどの UI フレームワークが理解し、操作方法を認識している INotifyPropertyChanged インターフェイスを提供しています。以下は、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 の要件の 1 つは、使用するクラスのすべてのプロパティとメソッドを仮想化することです。NHibernate では、遅延読み込みの問題を適切に処理するためにこの処理が必要ですが、他の目的にもこの要件を利用できます。
実行できることの 1 つは、仮想キーワードを利用して、独自の動作をクラスに挿入することです。これには、アスペクト指向プログラミング (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 についてまったく認識しておらず、DataBindingFactory を利用できません。しかし、絶望しなくても大丈夫です。NHibernate の最も便利な拡張ポイントであるインターセプターを利用できます。NHibernate のインターセプターは、実質的に、NHibernate が内部で実行する機能の一部を引き継ぐことができます。
インターセプターによって引き継ぐことができる機能の 1 つが、エンティティのインスタンスの新規作成です。図 6 は、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 メソッドで実行します。
残された作業は後 1 つで、NHibernate に新しいインターセプターを設定することです。次のコードは 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;
}
ここでは、構成のセマンティクスについては無視します。これについてはすぐに説明します。重要な点はインターセプターを作成し、構成にこのインターセプターを設定して、セッション ファクトリを作成することです。そして、最後に、このセッション ファクトリをインターセプターに設定します。確かに、これは少しスマートではありませんが、適切なセッション ファクトリをインターセプターに設定する最も簡単な方法です。
インターセプターの設定が済んだら、開発者は何もしなくても、NHibernate によって作成されるすべてのエンティティで、INotifyPropertyChanged の通知がサポートされるようになります。これは、この問題に対する非常に洗練されたソリューションだと思います。
このようなソリューションを選択するのは、実装をハード コーディングする場合と比べてパフォーマンスの面で問題だといわれる方もいます。実際には、それは思い違いです。このクラスの実行時の拡張に私が使用しているツール (Castle Dynamic Proxy) では、最適なパフォーマンスが確実に得られるように、かなりの最適化が行われています。
パフォーマンスの問題に対処する
パフォーマンスと言えば、Web アプリケーションにはない、デスクトップ アプリケーション特有の問題として起動時間があります。Web アプリケーションでは、要求のパフォーマンスを向上するために、起動時間が長くなる方を優先することはきわめて一般的です。一方、デスクトップ アプリケーションでは、起動時間をできる限り短縮するようにします。実際、デスクトップ アプリケーションでよく利用される技法の 1 つは、アプリケーションの起動が完了するまで、アプリケーションのスクリーン ショットをユーザーに表示だけしておくという方法です。
残念ながら、NHibernate の起動にはやや時間がかかります。これは、主に NHibernate では、通常の操作中の実行速度を上げるために、起動時にさまざまな初期化とチェックを実行するためです。この問題の対処によく使われる方法が 2 つあります。
1 つは、NHibernate をバックグラウンド スレッドで起動することです。これで、UI が格段に速く表示されるようになりますが、同時に、セッション ファクトリの起動が完了するまで、データベースからのデータを何もユーザーに表示できないため、アプリケーションにとっては副作用も生まれます。
もう 1 つの方法は、NHibernate の Configuration クラスをシリアル化することです。NHibernate の起動に関連するコストの大半は、Configuration クラスに渡される情報の検証にかかるコストに関連しています。Configuration クラスは、シリアル化可能なクラスであるため、この代償を一度払っておけば、その後は既に検証済みのインスタンスを永続ストレージから読み込んで、このコストを省くことができます。
これが、LoadConfigurationFromFile と SaveConfigurationToFile の目的で、NHibernate の構成のシリアル化とシリアル化解除を行います。これらを使用することで、アプリケーションの起動時には構成を作成するだけでよくなります。ただし、注意が必要な小さな落とし穴があります。エンティティ アセンブリまたは NHibernate の構成ファイルが変更されている場合は、キャッシュされている構成を無効にする必要があります。
今回のサンプル コードには、この問題を意識し、エンティティまたは構成が変更されている場合にキャッシュされたファイルを無効にする完全な実装が含まれています。
他にも取り組まなければならないパフォーマンスの問題があります。データベースの呼び出しは、アプリケーションが実行する操作の中で負荷が高い操作の 1 つです。したがって、アプリケーション UI スレッドでの実行は避けたい操作です。
このような操作はバックグラウンド スレッドに委ねられることが多く、NHibernate についても同様に処理できますが、NHibernate セッションはスレッド セーフでないことに留意してください。複数のスレッドから 1 つのセッションを使用できますが (スレッド関係はありません)、セッション (またはエンティティ) を複数のスレッドで並列に使用してはなりません。つまり、セッションをバックグラウンド スレッドで使用することにはまったく問題ありませんが、セッションへのアクセスをシリアル化して、同時実行アクセスを防ぐ必要があります。複数のスレッドからセッションを並列に使用すると、未定義の動作になります。したがって、これは避ける必要があります。
さいわい、セッションへのアクセスを確実にシリアル化するために実行できる比較的簡単な方法がいくつかあります。System.ComponentModel.BackgroundWorker クラスは、特にこのようなタスクを処理するために設計されています。このクラスを使用すると、タスクをバックグラウンド スレッドで実行できます。また、タスクの完了を通知できるため、デスクトップ アプリケーションでは非常に重要な UI スレッドの同期の問題にも対応できます。
前に、既存のエンティティの編集を管理する方法を説明しました。そのときは、UI スレッド上で直接作業しました。ここでは、新しいエンティティをバックグラウンド スレッドに保存しましょう。次のコードは、新規作成のプレゼンターの初期化コードです。
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 は、実際の保存プロセスを実行するために使用されています。このプロセスは 2 つの部分に分けられています。このように分割されているほかは、編集シナリオでの処理方法と非常によく似ています。他に注意が必要な興味深い部分は AllowEditing プロパティです。このプロパティは、保存操作を実行するときに、フォーム内の 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 は、UI スレッド内のプロセスの CompleteSave パーツを実行します。
private void CompleteSave() {
Model.AllowEditing.Value = true;
EventPublisher.Publish(new ActionUpdated {
Id = Model.Action.Id
}, this);
View.Close();
}
フォームを再び有効にし、アクションが更新されたことを示す通知を発行し (関連する画面も更新されます)、最終的にフォームを閉じます。UI の有効化は厳密には必要ないと思いますが、万全を期してコードに含めました。
この手法を使用すると、セッションのインスタンスに対するスレッド コントラクトに違反することなく、バックグラウンド処理を利用できます。いつものことながら、スレッド処理は応答性の高いアプリケーションを作成する優れた手段ですが、マルチスレッド プログラミングは安易に手が出せる作業ではないので、この手法は注意して使用してください。
同時実行に対処する
同時実行は、どんなにしても複雑なトピックであり、スレッド処理以外の問題もあります。同じエンティティを同時に編集する 2 名のユーザーがいる場合を考えてみます。そのうちの 1 人が先に保存ボタンを押して、変更をデータベースに保存するとします。問題は、2 人目のユーザーが保存ボタンを押した場合にどうなるかです。
これは同時実行の競合と呼ばれ、NHibernate にはこのような競合を検出するための手段がかなり多くあります。ToDoAction エンティティには、NHibernate に明示的にオプティミスティックな同時実行のチェックを実行する必要があることを示す <version/> フィールドがあります。NHibernate が提供する同時実行オプションの完全な説明については、私のブログ記事 (ayende.com/Blog/archive/2009/04/15/nhibernate-mapping-concurrency.aspx、英語) を参照してください。
基本的に、同時実行ソリューションは、2 つのカテゴリに大別できます。
- ペシミスティック同時実行制御: データベースに対するロックを維持し、トランザクションを一定の時間開いておく必要があります。前述のとおり、これはデスクトップ アプリケーションにはふさわしくありません。
- オプティミスティック同時実行制御: ユーザーが "考えている間" にデータベース接続を閉じることができます。NHibernate が提供する必要があるオプションのほとんどは、オプティミスティックに分類されるもので、いくつかの方法で競合を検出できます。
ペシミスティック同時実行制御には、このように多大なパフォーマンス コストが伴うため、通常は許容できません。したがって、オプティミスティック同時実行制御を優先することになります。オプティミスティック同時実行では、データを通常どおり保存しますが、データが別のユーザーによって変更されている場合に対応する処理が用意されています。
NHibernate は、保存またはコミット プロセス中に、StaleObjectStateException を使用してこの状況を通知します。アプリケーションは、その例外をキャッチし、それに従って動作する必要があります。通常、このような場合は、なんらかのメッセージをユーザーに対して表示し、エンティティが別のユーザーによって編集されているので、変更をやり直す必要があることを伝えます。場合によっては、たとえば情報をマージするオプションを提供したり、どのバージョンを維持するかをユーザーが決定できるようにしたり、より複雑な操作を実行する必要があります。
メッセージを表示し、ユーザーに変更をやり直してもらうという 1 つ目のオプションは、非常によく使用されるため、これを NHibernate を使用して実装する方法を説明し、その後、もう 1 つのソリューションを実装する方法を簡単に説明します。
この場合、すぐに興味深い問題に直面します。つまり、セッションから例外が生成された場合、そのセッションが使用できない状態になっているということです。同時実行の競合は、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 ブロックでラップし、同時実行の競合が検出されたことをユーザーに通知して、StaleObjectStateException に対処しています。その後、セッションを置き換えます。
図 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 プロパティにエンティティを設定するだけのもので、何も大きなことはしていません。より複雑なシナリオでは、1 つのフォームで使用されているいくつかのエンティティ インスタンスを置き換える場合もあります。
さらに言えば、同時実行の競合だけでなく、NHibernate のセッションで発生するあらゆるエラーに、同じ方法を使用できます。エラーが発生したら、セッションを置き換える必要があります。そして、セッションを置き換える場合、おそらく念のため、古いセッションを使用して読み込んだエンティティを新しいセッションに再読み込みする必要があるでしょう。
競合の管理
今回触れておきたい最後のトピックは、より複雑な同時実行の競合の管理手法についてです。基本的に、利用できる方法は 1 つしかありません。データベース内のバージョンと、ユーザーが変更したばかりのバージョンのどちらを選択するかをユーザーが決定できるようにすることです。
図 9 は、マージ画面のモックアップです。ご覧のとおり、ここではユーザーに両方のオプションを提示して、どちらを残すかを選択するように求めています。同時実行の競合のソリューションはどれも、何かしらこうした考え方を基盤にしています。別の方法で表現する必要がある場合も考えられますが、このようなしくみになっているので、これを基にソリューションを考えるとよいでしょう。
図 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 機能を使用して、ユーザーのバージョンに含まれるすべての保持対象値を取得し、現在のセッション内のエンティティ インスタンスにそれらの値をコピーします。実際には、2 つのインスタンスをマージし、データベースの値にユーザーの値を強制的に上書きします。
これは、実は、Merge メソッドのコントラクトによって遅延読み込みされたアソシエーションがスキャンされないように制御されているため、相手のセッションが終了されている場合でも安全に実行できます。
マージを実行する前に、ユーザーのバージョン プロパティをデータベースのバージョン プロパティに設定していることに注意してください。これは、ここでは明示的にデータベースのバージョンを上書きするために実行されています。
このコードでは、再帰的な同時実行の競合 (つまり、ある同時実行の競合を解決した結果発生した競合) については対応しません。その方法は読者の方に課題として残しておきましょう。
今回はさまざまな課題について説明しましたが、NHibernate デスクトップ アプリケーションを作成することは、NHibernate Web アプリケーションを作成することと比べて難しくありません。どちらの場合も、NHibernate を使用することで、開発の手間が省かれ、アプリケーションの堅牢性が増し、全体的にシステムを変更および操作しやすくなると思います。
Oren Eini* (Ayende Rahien というハンドル ネームでも活躍しています) は、いくつかのオープン ソース プロジェクト (NHibernate、Castle など) の現役メンバーであり、その他多数のプロジェクトの発起人 (Rhino Mocks、NHibernate Query Analyzer、Rhino Commons など) でもあります。また、NHibernate 用のビジュアル デバッガーである NHibernate Profiler (nhprof.com、英語) の責任者です。仕事の状況については、ayende.com/Blog (英語) を参照してください。*
この記事のレビューに協力してくれた技術スタッフの Howard Dierking に心より感謝いたします。