スマート クライアント
NHibernate および Rhino Service Bus を使用した分散アプリケーションの構築 (第 2 部)
Oren Eini
MSDN Magazine の 2010 年 7 月号から、貸出図書館向けのスマート クライアント アプリケーションを構築するプロセスについての説明を始めました。このプロジェクトには Alexandria という名前を付け、データ アクセスに NHibernate を、サーバーとの信頼性の高い通信に Rhino Service Bus を使用することに決めました。
NHibernate (nhforge.org、英語) はオブジェクト リレーショナル マッピング (O/RM) フレームワークで、Rhino Service Bus (github.com/rhino-esb/rhino-esb、英語) は Microsoft .NET Framework を基盤としたオープン ソースのサービス バス実装です。私はこれら両方のフレームワークの開発に深く関わった経験があるため、熟知しているテクノロジを使用してプロジェクトを実装すると同時に、NHibernate と Rhino Service Bus について学習したいと考えている開発者に向けて、実用的な例を紹介する機会に恵まれたと感じています。
前回は、スマート クライアント アプリケーションの基礎となる構成要素について説明しました。バックエンドを設計し、スマート クライアント アプリケーションとバックエンドとの間の通信モードを考案しました。また、バッチ処理とキャッシュ、トランザクションと NHibernate セッションの管理方法、クライアントからのメッセージを使用してそのメッセージに返信する方法、およびブートストラップにすべてをまとめる方法についても触れました。
今回は、バックエンドとスマート クライアント アプリケーションとの間でデータを送信する際のベスト プラクティスと、分散型変更管理のパターンについて説明します。今後の記事では、まだ説明していない実装の詳細について紹介し、Alexandria アプリケーション向けのクライアントを完成する予定です。
サンプル ソリューションは、github.com/ayende/alexandria (英語) からダウンロードできます。このソリューションは、バックエンド コードをホストする Alexandria.Backend、フロントエンド コードを含む Alexandria.Client、この両方に共有されるメッセージ定義を含む Alexandria.Messages という 3 つの部分から構成されています。
モデルにはルールはない
分散アプリケーションを作成するときに最もよく尋ねられる質問の 1 つは、「エンティティをクライアント アプリケーションに送信し、その後サーバー側で変更セットを適用するにはどうすればよいでしょう」というものです。
このような質問が出るということは、おそらくサーバー側は主にデータ リポジトリとして機能するモードであるとお考えなのでしょう。このようなアプリケーションを構築するのであれば、こうした作業を簡略化できるテクノロジ (WCF RIA Services や WCF Data Services など) を選択できます。ただし、ここまで説明してきたこの種のアーキテクチャを使用するとしたら、今回ネットワーク経由でエンティティを送信することについて説明することにはまったく意味がありません。実は、Alexandria アプリケーションでは、同じデータに 3 種類の異なるモデル (それぞれがアプリケーションの異なる部分に適したモデル) を使用します。
バックエンドで使用するドメイン モデルは、クエリとトランザクションの処理に使用され、NHibernate での使用に適しています (さらなる改良として、クエリの役割とトランザクション処理の役割を分割することになります)。メッセージ モデルは、ネットワーク上のメッセージを表し、ドメイン エンティティに密接にマップされるいくつかの概念を含みます (サンプル プロジェクトの BookDTO は Book のデータ クローンです)。クライアント アプリケーションでは、ビュー モデル (BookModel クラスと同様) が最適化されて、XAML にバインドされ、ユーザーの操作に対処します。
3 つのモデル (Book、BookDTO、BookModel) には一見多くの共通点があるように見えますが、それぞれ異なる役割を持っているため、すべてを 1 つのモデルに詰め込もうとすると、扱いにくく、負荷が高く、汎用的ではないモデルを作成することになります。そのため、役割に沿ってモデルを分割することで、各モデルをそれぞれの目的に合うように個別に改良できるようになり、はるかに扱いやすくなります。
考え方を変えると、用途ごとに個別のモデルを作成する理由は他にもあります。オブジェクトはデータと動作を組み合わせたものですが、ネットワーク経由でオブジェクトの送信を試みると、データしか送信されません。このことから、いくつか興味深い疑問が生じます。たとえば、バックエンド サーバーで実行すべきビジネス ロジックをどこに配置するか、そのロジックをエンティティに配置した場合にクライアントでこのロジックを実行するとどうなるかといった疑問です。
最終的には、この種のアーキテクチャでは実際のオブジェクトは使用しないことになります。代わりに、データ オブジェクト (データを保持するだけのオブジェクト) を使用し、ビジネス ロジックは、オブジェクト データを使用して実行するプロシージャーとして、他の場所に常駐します。これは好ましくありません。ロジックとコードを分散することになり、長期的な管理が難しくなるためです。どう考えても、バックエンド システムが単純なデータ リポジトリである場合を除いて、アプリケーションの各部分に異なるモデルを所有する必要があります。だとすると、当然、変更をどのように処理するかという疑問が非常に興味深くなります。
変更セットを扱うコマンド
Alexandria アプリケーションでユーザーが実行できる操作には、キューへの書籍の追加、キュー内の書籍の並べ替え、キューから全書籍の削除があります (図 1 参照)。これらの操作は、フロントエンドとバックエンドの両方に反映される必要があります。
図 1 ユーザーの書籍キューに対して可能な操作
ネットワーク経由でエンティティをシリアル化し、変更済みのエンティティを保存のためにサーバーに返送することで、これを実装しようと考えました。実際、NHibernate では、session.Merge メソッドを使用することで、まさにこのようなシナリオが明示的にサポートされます。
ただし、次のビジネス ルールがあるとします。ユーザーが推薦リストから 1 冊の書籍を自身のキューに追加すると、その書籍は推薦リストから削除され、別の推薦書籍がリストに追加されるというルールです。
ある書籍の以前の状態と現在の状態 (2 つの状態間の変更セット) を使用して、推薦リストからキューに移動されたことを検出しようとするところを想像してください。実行することはできますが、処理が困難になることは間違いありません。
私はこのようなアーキテクチャをトリガー指向のプログラミングと呼んでいます。データベースのトリガーと同じように、変更セットに基づくシステムに含まれるのは主にデータを処理するコードです。意味のあるビジネス セマンティクスを提供するには、力ずくで、かつ運よく、変更セットから変更の意味を引き出さなければなりません。
ロジックを含むトリガーはアンチパターンであると考えられるのには理由があります。トリガーはレプリケーションや純粋なデータ操作などの一部の処理には適していますが、トリガーを使用してビジネス ロジックの実装を試みることは、管理が困難なシステムにつながる複雑なプロセスです。
CRUD インターフェイスを公開し、UpdateCustomer などのメソッドにビジネス ロジックを記述できるほとんどのシステムでは、既定で、トリガー指向のプログラミングを提供しています (通常はそれしか選択肢がありません)。関連する重要なビジネス ロジックがない (システム全体が主に CRUD に関するものである) ときは、この種のアーキテクチャが意味をなしますが、ほとんどのアプリケーションには不適切で、お勧めできません。
代わりに、明示的なインターフェイス (RemoveBookFromQueue や AddBookToQueue など) を使用すると、理解し考えるのがはるかに容易なシステムになります。このように大まかなレベルで情報を交換する機能を使用すると、この先、自由度が上がり、簡単に変更を加えることができるようになります。いずれにせよ、システム内のある機能が、その機能が操作するどのデータに基づいているかを考慮する必要はありません。システムは、システム アーキテクチャに基づいて、操作が行われる場所を明確に示すことになります。
Alexandria の実装は、明示的なインターフェイスの原理に従っています。つまり、アプリケーション モデルには、それらの操作の呼び出しが含まれています (図 2 参照)。ここでいくつか興味深いことを実行してみます。これらの操作を順番に処理してみましょう。
図 2 フロントエンドでのユーザーのキューへの書籍の追加
public void AddToQueue(BookModel book) {
Recommendations.Remove(book);
if (Queue.Any(x => x.Id == book.Id) == false)
Queue.Add(book);
bus.Send(
new AddBookToQueue {
UserId = userId, BookId = book.Id
},
new MyQueueQuery {
UserId = userId
},
new MyRecommendationsQuery {
UserId = userId
});
}
まず、アプリケーション モデルを直接変更して、ユーザーの要求を即座に反映するようにします。これが可能なのは、ユーザーのキューに書籍を追加する操作は必ず成功する操作だからです。また、ユーザーのキュー内に存在する項目を推薦リストにも表示しても無意味なので、推薦リストからはその項目を削除します。
次に、メッセージ バッチをバックエンド サーバーに送信することで、書籍をユーザーのキューに追加することと、この変更が行われた後にユーザーのキューと推薦リストの状態がどのようになるかを通知します。これは、把握しておくべき重要な概念です。
この方法でコマンドとクエリを構成できるということは、変更済みのデータをユーザーに取得するために、AddBookToQueue のようなコマンドで特別な手順を実行することはないということです。代わりに、フロントエンドは同じメッセージ バッチの一部として変更済みデータを要求できるため、既存の機能を使用してこのようなデータを取得できます。
メモリに変更を加える場合でも、バックエンド サーバーからデータを要求する理由は 2 つあります。まず、バックエンド サーバーでは追加ロジック (このユーザー向けに新しい推薦書籍を見つけるなど) が実行されることがあり、フロントエンド側では認識されない変更が行われることになります。2 つ目として、バックエンド サーバーからの返信により、キャッシュが現在状態で更新されるためです。
非接続時のローカルでの状態管理
図 2 のコードには、非接続状態での作業に関して問題があることにお気付きかもしれません。メモリに変更を加えても、サーバーから返信を受け取るまでは、キャッシュされたデータに変更が反映されません。非接続状態のままアプリケーションが再起動されると、アプリケーションには古い情報が表示されます。バックエンド サーバーとの通信が再開されると、メッセージがバックエンドに送信され、最終状態はユーザーの期待どおりに解決されます。しかしそれまでは、アプリケーションにはユーザーがローカルで既に変更した情報が表示されています。
非接続状態が長期間継続することが予測されるアプリケーションでは、メッセージ キャッシュのみを使用するのではなく、各ユーザー操作の後に保存するモデルを実装します。
Alexandria アプリケーションの場合はキャッシュの規則を拡張して、図 2 のようなコマンドとクエリのメッセージ バッチに含まれる情報の有効期限がすぐに切れるようにしました。このため、最新の情報を保持しないうえ、バックエンド サーバーからの返信を受け取る前にアプリケーションが再起動された場合に、エラーのような古い情報を表示することもありません。Alexandria アプリケーションの目的からすれば、それで十分です。
バックエンドの処理
フロントエンド側からのプロセスのしくみを理解したので、次にバックエンド サーバーの観点からコードを見ていきましょう。前回の記事で紹介したクエリの処理については既にご存知でしょう。図 3 は、あるコマンドを処理するコードを示しています。
図 3 ユーザーのキューへの書籍の追加
public class AddBookToQueueConsumer :
ConsumerOf<AddBookToQueue> {
private readonly ISession session;
public AddBookToQueueConsumer(ISession session) {
this.session = session;
}
public void Consume(AddBookToQueue message) {
var user = session.Get<User>(message.UserId);
var book = session.Get<Book>(message.BookId);
Console.WriteLine("Adding {0} to {1}'s queue",
book.Name, user.Name);
user.AddToQueue(book);
}
}
実際のコードはかなり退屈なものです。関連するエンティティを読み込んでから、エンティティのメソッドを呼び出して実際のタスクを実行します。しかし、このことは皆さんが考えている以上に重要です。私が思うアーキテクトの仕事とは、プロジェクトに携わる開発者をできるだけ退屈させることです。ビジネス上の問題の多くはうんざりするものなので、システムから技術的な複雑さを取り除くことによって、開発者には興味深い技術的な問題ではなく、うんざりするようなビジネス上の問題への取り組みにより多くの時間を割いてもらいます。
Alexandria のコンテキストでは、どのような意味を持つのでしょうか。ここでは、すべてのメッセージ コンシューマーにビジネス ロジックを分散するのではなく、できるだけ多くのビジネス ロジックをエンティティに一元化します。理想的には、メッセージの使用は次のパターンに従います。
- メッセージの処理に必要なデータをすべて読み込む
- ドメイン エンティティの 1 つのメソッドを呼び出して、実際の操作を実行する
このプロセスにより、ドメイン ロジックがドメインに存在するようになります。どのようなロジックになるかは、もちろん、扱うシナリオによって異なります。次のコードにより、User.AddToQueue(book) の場合にはドメイン ロジックをどう処理するかについての考え方がつかめるはずです。
public virtual void AddToQueue(Book book) {
if (Queue.Contains(book) == false)
Queue.Add(book);
Recommendations.Remove(book);
// Any other business logic related to
// adding a book to the queue
}
ここまではフロントエンド ロジックとバックエンド ロジックが正確に対応するケースを確認したので、ここからは、両者のロジックが対応しないケースを見てみましょう。キューから書籍を削除するのはフロントエンド側では非常に単純です (図 4 参照)。実に簡単です。ローカルでキューから書籍を削除 (UI から書籍を削除) してから、メッセージ バッチをバックエンドに送信し、キューから書籍を削除してキューと推薦リストを更新するよう要求します。
図 4 キューからの書籍の削除
public void RemoveFromQueue(BookModel book) {
Queue.Remove(book);
bus.Send(
new RemoveBookFromQueue {
UserId = userId,
BookId = book.Id
},
new MyQueueQuery {
UserId = userId
},
new MyRecommendationsQuery {
UserId = userId
});
}
バックエンドで RemoveBookFromQueue メッセージを使用するには、図 3 に示すパターンに従います。つまり、エンティティを読み込み、user.RemoveFromQueue(book) メソッドを呼び出します。
public virtual void RemoveFromQueue(Book book) {
Queue.Remove(book);
// If it was on the queue, it probably means that the user
// might want to read it again, so let us recommend it
Recommendations.Add(book);
// Business logic related to removing book from queue
}
動作がフロントエンドとバックエンドで異なります。バックエンドでは削除した書籍を推薦リストに追加しますが、フロントエンドではこの処理を行っていません。このような差異の結果はどうなるのでしょう。
即時応答によってキューから書籍が削除されることになりますが、バックエンド サーバーからの返信がフロントエンドに届けばすぐに、推薦リストに追加された書籍が表示されます。おそらく、実際に違いに気付くのは、キューから書籍を削除するときにバックエンド サーバーがシャットダウンされていた場合だけでしょう。
非常にすばらしいことですが、操作を完了するためにバックエンド サーバーからの確認が実際に必要な場合はどうすればよいでしょう。
複雑な操作
ユーザーがキュー内の項目の追加、削除、または並べ替えを行うときに、操作が失敗しないことは言うまでもありません。そのため、アプリケーションでは操作をすぐに受け入れることができます。しかし、住所の編集やクレジット カードの変更のような操作については、バックエンドから操作の成功の確認を受け取るまでは操作を受け入れることができません。
Alexandria では、これを 4 段階から成るプロセスとして実装しています。大変な処理のように思えますが、実際は非常にシンプルです。図 5 に、考えられる段階を示します。
図 5 確認が必要なコマンドについて考えられる 4 つの段階
左上のスクリーンショットは、サブスクリプションの詳細を標準のビューで示しています。これは Alexandria が確認済みの変更を示す方法です。左下のスクリーンショットは、同じデータの編集画面を示しています。この画面で [保存] ボタンをクリックすると、結果は右上のスクリーンショットのようになります。これは、Alexandria が変更を "確認していない" 状態を示しています。
つまり、変更が受け入れられた (左上のスクリーンショット) か拒否された (右下のスクリーンショット) ことを示す返信をサーバーから受け取るまでは、(暫定的に) 変更を受け入れます。右下のスクリーンショットは、サーバーからのエラーを示しており、ユーザーはエラーを含む詳細を修正できます。
皆さんのお考えがどうであろうと、実装は複雑ではありません。まずはバックエンドから始めて、フロントエンドに向かって考えていきます。図 6 に、この処理に必要なバックエンド コードを示します。目新しいことは何もありません。この記事の中でほぼ同じ処理を行いました。条件付きコマンドの機能 (および複雑さ) の大半はフロントエンドにあります。
図 6 ユーザーの住所を変更するバックエンドの処理
public void Consume(UpdateAddress message) {
int result;
// pretend we call some address validation service
if (int.TryParse(message.Details.HouseNumber, out result) ==
false || result % 2 == 0) {
bus.Reply(new UpdateDetailsResult {
Success = false,
ErrorMessage = "House number must be odd number",
UserId = message.UserId
});
}
else {
var user = session.Get<User>(message.UserId);
user.ChangeAddress(
message.Details.Street,
message.Details.HouseNumber,
message.Details.City,
message.Details.Country,
message.Details.ZipCode);
bus.Reply(new UpdateDetailsResult {
Success = true,
UserId = message.UserId
});
}
}
これまでとは異なる点の 1 つは、操作の成功と失敗を明示的に表すコードを記述したことです。以前は、別のクエリでデータの更新を要求していただけでした。ただ、操作は失敗することがあるため、操作が成功したかどうかだけでなく、失敗した場合はその理由も把握したいと考えました。
Alexandria では Caliburn フレームワークを使用して、UI の管理という単調な作業の多くを処理しています。Caliburn (caliburn.codeplex.com、英語) は WPF/Silverlight フレームワークです。XAML 分離コードにコードを記述するのではなく、アプリケーション モデル内で多くのアプリケーション機能を容易に構築できるようにする表記法に大きく依存しています。
サンプル コードを見てお分かりのように、Alexandria UI のほぼすべてが Calivurn 表記法を使用し XAML を通じて組み立てられており、明確で理解しやすい XAML と、UI を直接反映する (ただし直接的には依存しない) アプリケーション モデルの両方を提供しています。これにより、コードがかなりシンプルになります。
図 7 は、これを SubscriptionDetails ビュー モデルに実装する方法に関する考え方を示しています。基本的には、SubscriptionDetails はデータのコピーを 2 つ保持します。1 つは Editable プロパティで保持し、未確認の変更の編集または表示に関連するすべてのビューを示します。もう 1 つは、Details プロパティで保持し、確認済みの変更を保持するために使用します。各モードには異なるビューがあり、モードごとにデータを表示するプロパティを選択します。
図 7 ユーザー入力に応じたビュー モード間の移動
public void BeginEdit() {
ViewMode = ViewMode.Editing;
Editable.Name = Details.Name;
Editable.Street = Details.Street;
Editable.HouseNumber = Details.HouseNumber;
Editable.City = Details.City;
Editable.ZipCode = Details.ZipCode;
Editable.Country = Details.Country;
// This field is explicitly ommitted
// Editable.CreditCard = Details.CreditCard;
ErrorMessage = null;
}
public void CancelEdit() {
ViewMode = ViewMode.Confirmed;
Editable = new ContactInfo();
ErrorMessage = null;
}
XAML では、ViewMode バインディングを構成して、モードごとに表示する適切なビューを選択します。つまり、モードが編集中に切り替わると、Views.SubscriptionDetails.Editing.xaml ビューが選択され、オブジェクトの編集画面が表示されます。
ただし、最も興味深いのは、保存と確認のプロセスです。次に、保存の処理方法を示します。
public void Save() {
ViewMode = ViewMode.ChangesPending;
// Add logic to handle credit card changes
bus.Send(new UpdateAddress {
UserId = userId,
Details = new AddressDTO {
Street = Editable.Street,
HouseNumber = Editable.HouseNumber,
City = Editable.City,
ZipCode = Editable.ZipCode,
Country = Editable.Country,
}
});
}
ここで実際に行っていることは、メッセージを送信し、ビューを編集不可能なビューに切り替えることだけです。その際、それらの変更がまだ認められていないことを示すマーカーを表示しています。図 8 には、確認または拒否のコードを示します。全体から見ると、このような機能を実装するコードはごく少量で、今後同様の機能を実装する際の基盤となります。
図 8 返信の使用と結果の処理
public class UpdateAddressResultConsumer :
ConsumerOf<UpdateAddressResult> {
private readonly ApplicationModel applicationModel;
public UpdateAddressResultConsumer(
ApplicationModel applicationModel) {
this.applicationModel = applicationModel;
}
public void Consume(UpdateAddressResult message) {
if(message.Success) {
applicationModel.SubscriptionDetails.CompleteEdit();
}
else {
applicationModel.SubscriptionDetails.ErrorEdit(
message.ErrorMessage);
}
}
}
//from SubscriptionDetails
public void CompleteEdit() {
Details = Editable;
Editable = new ContactInfo();
ErrorMessage = null;
ViewMode = ViewMode.Confirmed;
}
public void ErrorEdit(string theErrorMessage) {
ViewMode = ViewMode.Error;
ErrorMessage = theErrorMessage;
}
また、カタログの検索など、従来の要求/応答の呼び出しを検討することも必要です。このような呼び出しでの通信は一方向のメッセージを使って実現されるため、バックエンド サーバーからの応答が到着するまでは、バックエンドで処理中であることを示すように UI を変更する必要があります。こうしたプロセスを詳しく取り上げることはしませんが、それを実行するコードはサンプル アプリケーションに含まれています。
まとめ
このプロジェクトは、アプリケーションの構築時に直面すると予想した目標と課題を示すことから始めました。対処しようとした主な課題は、データの同期、分散コンピューティングに関する誤った想定、および不定期接続型クライアントの処理でした。振り返ってみると、Alexandria ではこの目標を果たし、課題を克服するすばらしい仕事を成し遂げたと思います。
フロントエンド アプリケーションは WPF に基づいており、Caliburn 表記法を大いに活用してアプリケーション モデルの実際のコードを減らしています。モデルは XAML ビュー、およびアプリケーションへの呼び出しを行うフロントエンド メッセージ コンシューマーの小さなセットにバインドされています。
一方向のメッセージングの処理、インフラストラクチャ層でのメッセージのキャッシュ、およびバックエンドの承認を必要とする操作が実際に完了したとみなされる前に非接続状態での作業を可能にすることについて説明しました。
バックエンドでは、Rhino Service Bus と NHibernate に基づくメッセージベースのアプリケーションを構築しました。セッションとトランザクションの有効期間の管理と、メッセージ バッチを使用して NHibernate の最初のレベルのキャッシュを活用する方法を説明しました。バックエンド側のメッセージ コンシューマーは、単純なクエリ、またはドメイン オブジェクト上の適切なメソッドへのデリゲーターとして機能し、実際にはビジネス ロジックの大半がここに存在します。
単純な CRUD インターフェイスではなく、明示的なコマンドを強制的に使用すると、コードがわかりやすくなります。これにより、アーキテクチャ全体がアプリケーションの各部分の役割と各部分を構築する方法を明確に定義することに重点を置くことになるため、コードを簡単に変更できます。最終的には、明確に役割分担され、非常に構造化された製品になります。
本格的な分散アプリケーション アーキテクチャのガイダンスを数回の短い記事に押し込めるのは困難です。特に一度にいくつか新しい概念を紹介しようとしているなら、なおさらです。それでも、ここで示した事例を適用すれば、従来の RPC または CRUD ベースのアーキテクチャよりも現実的に作業しやすいアプリケーションになることはおわかりいたただけると思います。
Oren Eini (Ayende Rahien というハンドルネームでも活躍しています) は、いくつかのオープン ソース プロジェクト (NHibernate、Castle など) の現役メンバーであり、その他多数のプロジェクト (Rhino Mocks、NHibernate Query Analyzer、Rhino Commons など) の発起人でもあります。また、NHibernate 用のビジュアル デバッガーである NHibernate Profiler (nhprof.com、英語) の責任者です。仕事の状況については、ayende.com/Blog (英語) を参照してください。