次の方法で共有


得られた教訓

大規模な Software Plus Services アプリケーションを最適化する

Udi Dahan

この記事では、次の内容について説明します。
  • WCF と 3 層サービス モデル
  • 随時接続の処理
  • スレッドとユーザビリティ
  • 同期ドメイン
この記事では、次のテクノロジを使用しています。
WCF、Software Plus Services

目次

常に最新の情報
2 層での問題
WCF と 3 層
スケーラビリティの問題
つかの間の満足
随時接続がサーバーに与える影響
スレッドとユーザビリティ
クライアント側の WCF
オブジェクト グラフ依存性
同期ドメイン
ビューとコントローラのやり取り
スレッド セーフなデータ バインド
双方向データ バインド
クライアント側リポジトリ
パフォーマンス
得られた教訓

現在、リッチな対話型デスクトップ クライアント ソフトウェアをデザインすることは、これまでになく簡単になっています。Visual Studio、Windows Presentation Foundation (WPF)、および Composite Application Guidance for WPF (Prism) といった、今までで最も充実したツールを開発者は手にしています。

ただし、それだけでは十分ではありません。

大規模なリッチ クライアント Software Plus Services アプリケーションの開発に従事していた私たちのチームは、さまざまな苦労を重ねた結果、デッドロックを引き起こしたりデータ競合によってガベージを生成したりしない、随時接続型のマルチスレッド スマート クライアントを開発するということが、決して簡単ではないという事実を知りました。ただし、それはツールセットに欠けているものがあるからではありません。応答性と堅牢性を兼ね備えたシステムを構築するには、非常に特殊なパターンでこれらのツールを使用する必要があるのです。

この記事では、私たちが直面したいくつかの問題に焦点を当て、それらをどのように克服したかについて説明します。読者が独自の Software Plus Services アプリケーションを構築する際に、私たちと同じ過ちを犯すことなく、これらのツールを最大限に活用できるようになるために、この記事の情報が役立つことを期待します。

常に最新の情報

私たちのチームが最初に直面した問題の 1 つは、ユーザーに対して、他のユーザーが行っていることすべて、および他のシステムで発生しているイベントについて、常に最新の情報を提供する必要があることでした。このようなリアルタイムのシステム動作により、ユーザーは大量のデータに対して、イベントが発生した時点で適切に対処できるようになります。以前のバージョンのシステムでは、情報の検索、並べ替え、およびフィルタ処理は可能でしたが、そのような情報に対するリアルタイムの対応はできませんでした。

ユーザー エクスペリエンスをデザインする過程で、以前のバージョンのシステムで使用されていたグリッドの多くを、ユーザーに重要なイベントを通知する Messenger 風のポップアップ メッセージ ("トースト" と呼ばれます) に置き換えました。他にも変更を加え、ユーザーはエンティティのプロパティに各種のしきい値を設定できるようになり、その値を超えるとトースト メッセージがポップアップされます。多くの場合、トースト メッセージにはリンクが表示され、リンクをクリックすると、メッセージの内容によって影響を受けるエンティティを示すフォームが開きます (場合によっては、影響の前後のビューも示されます)。

過去に使用経験のある通常の要求/応答パターンを拡張して適用しようと方法を検討している最中に、主要な関係者の 1 人が現れ、次のことを指摘しました。"世界中からのイベントをリアルタイムでユーザーに通知するこの機能は、私たちが構築している、共同作業を重視したリアルタイム エンタープライズをサポートするうえで、ナレッジ ワーカーにとって不可欠なものです。"

簡単に言えば、何らかのパブリッシュ/サブスクライブ機能が必要であることは明らかでした。何百人ものユーザーが中央のデータベースにポーリングしている状態で、イベント発生後 2 秒でユーザーに通知しようとする試みは、単純に機能しませんでした (ユーザー数 60 人ほどで破綻しました)。

それに加えて、対象となるユーザー層はモバイル ユーザーでした。Wi-Fi ゾーンに出入りしながら数秒間ごとに切断と再接続を繰り返すユーザーや、在宅勤務で 1 ~ 2 時間にわたってオフラインで作業するユーザー、最大 1 週間にわたって集中的にオフライン作業を行うユーザーなどです。

随時接続、データ同期、およびパブリッシュ/サブスクライブを全部一度に扱う必要があったのです。私たちは、クライアント側でもサーバー側でもすべての問題を解決することはできず、統合されたアプローチが必要であることに気付きました。一方の側に変更を加えると、もう一方の側でも対応した変更が必要になるからです。この記事では、サーバー側からクライアント側へという順序で、このプロセスを説明してゆきます。

2 層での問題

最初に採用した展開モデルは、多くのリッチ クライアント アプリケーションで見られる従来型の 2 層モデルでした。このモデルで難しかったことは、データベースからクライアントへの通知の送信です。

試したオプションの 1 つは、データベースとのやり取りをすべてストアド プロシージャ経由で行い、トランザクションのコミット後にこのストアド プロシージャから、データベース サーバーでホストされる Web サービスを呼び出すというものでした。何が起こったかをクライアントに通知するのはこの Web サービスの役割であり、そのためにこの Web サービスは、呼び出されたストアド プロシージャの名前とストアド プロシージャに渡された引数とをクライアントに渡します。

この方法は、あまりうまくいきませんでした。このアプローチには、2 つの重要な問題がありました。1 つは論理的な問題で、もう 1 つは物理的な問題です。

論理的な問題は、1 つのストアド プロシージャがコード内のあらゆる種類のパスから呼び出される可能性があることで、これは異なる時点で異なる動作が行われることを意味します。クライアントがストアド プロシージャの起動通知を受信したときには、それが何を意味し、どのトーストをユーザーにポップアップすればよいかが常に明確とは限りませんでした。

物理的な問題は、各クライアントがそれぞれ独自の Web サービスを公開する必要があり、それらの Web サービスがデータベース サーバー上の Web サービスから呼び出されるということです。これは、大きなセキュリティ ホールだとしてセキュリティ担当者からすぐに拒否されました。また、必ずしも発信元を追跡できないデータに基づいてユーザーがリアルタイムで意思決定を行う、ということに (妥当な) 懸念を抱いていた規制監査担当者からも、同様に拒否されました。

WCF と 3 層

2 層ソリューションの論理的な問題を解決するためには、サービス コントラクトに関する情報をより明確にする必要がありました。実行されたストアド プロシージャの名前とそのパラメータを記述することで、何が起こったかについては明確になりますが、その理由については必ずしも明確にはなりません。

Windows Communication Foundation (WCF) サービス コントラクトのデザインを開始したときに、サービス メソッドの名前付けと範囲指定を適切に行うだけでは不十分であることがはっきりしました。通知のコントラクト、つまりサーバーがどのようにしてクライアントにコールバックしたのか、に関しても明確にする必要がありました。WCF のコールバック コントラクトでは、サーバー/クライアント間のメッセージに対して、通常のサービス コントラクトの場合と同じ程度の処理を行うことができます。WCF のコールバック コントラクトの詳細については、2006 年 10 月号の Juval Lowy の記事「一方向呼び出し、コールバック、およびイベントに関して知っておく必要のあること」を参照してください。

コマンド/イベントの各ペアは、次のようにモデル化されました。

[ServiceContract(CallbackContract = 
  typeof(ISomethingContractCallback))] 
interface ISomethingContract {
  [OperationContract(IsOneWay = true)] 
  void DoSomething();
}

interface ISomethingContractCallback {
  [OperationContract(IsOneWay = true)] 
  void SomethingWasDone();
}

現在のメソッドを呼び出したクライアント以外のクライアントに WCF サービスがコールバックできるようにするには、そのクライアントとは別のプロセスでサービスをホストし、そのプロセスを独自のサーバーに展開することにより、本質的に 3 層展開に移行する必要がありました。

サービスをデータベースと同じ場所に保持することが求められる一方で、データベース管理者は、サービスが貴重なリソースを奪ってしまい、データベースのパフォーマンスを低下させるのではないかと懸念を抱きました。このステートレスな WCF サービスのために別の層を用意することで得られる利点は、データベース サーバーに手を加えることなく、より多数のコンピュータへと簡単にスケールアウトできることです。

しかし残念ながら、それは思ったほど簡単なことではなかったのです。

スケーラビリティの問題

私たちは、スケーラビリティに関して 2 種類の問題に直面しました。やはり、論理的な問題と物理的な問題です。

スケーラビリティの論理的な問題は、システムの機能が増えるにつれて表面化してきました。最初に開発を開始したときには、すべてのメソッドを含む 1 つのサーバー コントラクトと、すべての対応するメソッドを含む 1 つのコールバック コントラクトしかありませんでした。やがてすぐに、あまりに多くの開発者が同じものを使用している、ということが問題になりました。各コマンド/イベント ペアをそれぞれ独自のサービス/コールバック コントラクトのペアに移動することで、その問題は解決しました。しかし、この解決策により、別のさらに厄介な問題が生じたのです。

サービス/コールバック コントラクトのペアを複数使用することを決定したときに、主に問題になると考えたのは、管理できない数のコントラクトが存在することでした。実際には、アプリケーションのビジネス ドメイン間のモジュール性のおかげで、その点はそれほど大きな問題とはなりませんでした。問題となったのは、"ペア" そのものでした。

1 つのコマンドが複数の種類の通知を発生させる可能性があり、また、1 つの種類の通知が複数の種類のコマンドから発生する可能性がありました。従来の明確な 1 対 1 マッピングでは単純すぎるため、多対多のマッピングに拡張する必要がありました。システム内のコマンドの数と通知の数を拡張するだけでなく、それらの間の関係の数も拡張する必要があります。WCF のコールバック コントラクトでサポートされる 1 対 1 表現では、先に進めないように思えました。

スケーラビリティの物理的な問題に直面したのは、WCF サービス層をスケールアウトしようとしたときです。クライアント コールバック コントラクトのサブスクライバの一覧を何らかの方法で共有する必要があったのです。MSDN Magazine 2006 年 10 月号の Juval Lowey の記事で示されたガイダンスに従って作業しながら、パブリッシュ/サブスクライブ サービスを含む、関連するインフラストラクチャ部分を作成しました。1 つのパブリッシュ/サブスクライブ サービスでは、すべてのパブリッシャからすべてのサブスクライバに送信される通知の量を処理しきれず、本質的にパブリッシャ/サブスクライバ間でボトルネックが形成されることがわかりました。また、各通知はそれぞれ優先順位が異なり、それをサポートするようインフラストラクチャを修正することは困難でした。

この問題について何度か試行錯誤した末、私たちが考えた最適なパブリッシュ/サブスクライブ サービスの構造は、論理ビジネス ドメインごとに 1 つのパブリッシュ/サブスクライブ インフラストラクチャを用意するというものです。各ドメインにはそれぞれ独自のコマンドと通知のセットが関連付けられていたため、それが明確な境界としてうまく機能しました。この分割により、以前に抱えていた優先順位問題の多くが解決されると同時に、パブリッシュ/サブスクライブ インフラストラクチャのスケールアウトの第 1 段階が自動的に実現されたのです。

つかの間の満足

WCF の利点の 1 つは、HTTP や TCP などさまざまなテクノロジに対して複数のバインドをサポートしていることです。私たちは最初に、最も機能が豊富であるという理由で WS HTTP を使用することに決めましたが、その決定が意味することをすべて考慮していたわけではありませんでした。

システムをユーザーにロールアウトした時点では、すべてがうまくいっているように見えましたが、それもつかの間のことでした。約 1 時間後に、ヘルプ デスクの担当者から、"サーバーが接続を拒否している" という連絡が来始めました。

すぐに確認すると、サーバーはすべて稼働していることがわかりましたが、あまり多くの動作は行われていないようでした。CPU 使用率は低く、メモリ、I/O、その他すべてについても同様でした。データベースのデッドロックや他の問題を調べましたが、何も問題はありませんでした。

そこで、デバッグ モードで別のサーバー プロセスを開始し、何が起こるか観察を始めました。問題が現れるまで、それほど長くはかかりませんでした。

10 ~ 20 秒ごとに、クライアントにイベントを発行していたスレッドが、約 30 秒間ハングしたのです。これがスケーラビリティにどのような影響を及ぼしたかおわかりでしょう。サポートしているクライアント数に対する推奨スレッド プール サイズにかなり近づき、スレッドが使い果たされていました。その時点で、WCF はクライアントからの新しい接続を拒否し始めたのです。

私たちは、クライアント アプリケーションにバグがあってサーバーをブロックしているのかもしれないと考えました。

そこで、(サーバー ログから得られた IP アドレスを使用して) 問題を引き起こしている特定のクライアント コンピュータを探しました。それらのコンピュータのユーザーに連絡し、サーバーに問題が発生した時間帯に、アプリケーションにおかしな挙動がなかったかどうかを確認しました。特に変わったことは報告されませんでした。しかし、1 人のユーザーが、興味深い観察を行っていました。"WiFi 接続が切断されるときに、アプリケーションの動作が Outlook と同様に途切れることが多くあります。"

随時接続がサーバーに与える影響

私たちはいくつかのクライアント コンピュータおよびサーバー コンピュータからログの収集を始め、同じ現象を何度も見つけました。クライアントがオフラインになるたびに、そのクライアントへの呼び出しが 30 秒間ブロックされていたのです。

これは、OperationContract の IsOneWay 属性について私たちが理解していることと矛盾します。すべてのイベント通知メソッドは void を返しており、サーバー スレッドがブロックされる理由はなかったのです。

WsHttpBinding の動作を詳しく調べるうちに、一方向動作のコントラクトでどのような処理が行われていたのかがわかってきました。サーバーがプロキシで一方向メソッドを呼び出してクライアントに通知しようとするときに、このプロキシが以前に接続されたことがあり、基になるチャネル オブジェクトがすべてキャッシュ内に保持されている場合には、それらのオブジェクトを利用して、HTTP 経由でクライアントを呼び出そうとします。呼び出しが一方向であっても、基になるチャネルでは、HTTP 接続が確立されて HTTP 要求を送信できるようになるまで待ちます。HTTP 応答は待ちません。不運にも、クライアントが何かの理由でオフラインであった場合には、HTTP 接続の確立を断念するまでの間、既定の HTTP タイムアウト時間 (30 秒間) だけ待つことになります。

この動作は修正できないことがわかったため、クライアントが切断されても堅牢性を保てるような、別のバインドを見つける必要がありました。その答えは、Microsoft Message Queuing (MSMQ) のバインド、具体的には NetMsmqBinding の中に見つかりました。幸運なことに、WCF では、1 つのバインドから別のバインドへの入れ替えが比較的容易です。

クライアントの接続が切断されて復帰したときにも、サーバー スレッドがブロックされなくなったことを確認したので、視点を再びクライアントに戻しました。

スレッドとユーザビリティ

要求/応答方式でサーバーとやり取りするリッチ クライアントをデザインするときに、スレッド セーフについてそれほど心配したことはありませんでした。開発したサーバーが機能を増やしてより強力になると、応答にかかる時間も長くなります。これは、サーバーが応答するまでアプリケーションがハングすることで、ユーザビリティの問題につながることがわかっています。一般的な解決策は、サーバーに対して非同期に呼び出しを行うようクライアントを修正することです。その時点で開発者は、サーバーからのコールバックがバックグラウンド スレッドで処理されるときに、UI コントロールで例外が発生する傾向があることに気付きます。最終的には、サーバーのコールバックがフォアグラウンド スレッドに切り替わってすべてが再度動作するように、コードが変更されます。

問題は、クライアントが複数のサーバーから際限なく通知を受け取るときに、上記のパターンが同じようには機能しないということです。

クライアントに到着するすべての通知がフォアグラウンド スレッドで処理され、すべての UI 処理もフォアグラウンド スレッドで行われるため、この 2 つのタスクは継続的に制御を奪い合う結果となります。実際に、マウスを動かそうとすると、画面上でポインタが不安定に飛び跳ねるのが見えるかもしれません。テキスト ボックスへの文字入力も、同じように不安定になります。

私は、ロード テスト ラボでの作業中にこのことに気付きました。それまでは、クライアントがさまざまな通知をすべてリアルタイムでポップアップさせるのを眺めながら、アーキテクチャの出来栄えに対して満足感にひたっていたのです。そして、1 つのトースト メッセージにあるリンクをクリックしようと、マウスを手に取りましたが、ポインタを動そうとしても固まったままです。まるで、バッテリの切れたワイヤレス マウスのようでした。しかし、それは通常の有線 USB マウスなのです。

私は頭を抱えました。リリースまであと 2 週間しかありません。機能テストはすべて問題なく完了し、ロード テストでもすべてを処理できていました。唯一の問題は、負荷のかかった状態でシステムが厳密には機能していなかったことです。"ユーザビリティの悪夢" とでもいった状態でした。

クライアント側の WCF

クライアントに関して最初の段階まで立ち戻る前に、緊急の書き直しを防ぐことのできる方法が何かないかと考えました。スマート クライアント環境で WCF を使用する方法に関する MSDN のガイダンス (「Windows Communication Foundation を使用してスマート クライアントを記述する」を参照) を熟読し、これまでにないほど多くの概念実証用コードを記述しましたが、すべてを解決するような方法は見当たりません。それは、デッドロックとユーザビリティとの間に挟まれて身動きができないような感覚でした。

見込みがありそうに思えた戦略の 1 つは、Windows Forms の同期コンテキスト (参照) とのやり取りをカプセル化する安全なコントロールに基づくものでした。これらのコントロールがバックグラウンド スレッドからアクセスされても、その呼び出しを UI スレッドにマーシャリングするというものです。

クライアントに到着するすべての通知で UI が更新されるわけではないため、ほとんどのクライアント側通知処理をバックグラウンド スレッドで実行することにより、ユーザビリティを大幅に向上できました。通知が UI の更新を伴うときには、安全なコントロールによって、実際の UI レンダリングが正しいスレッドで行われるようになります。これは、技術的には堅実なソリューションに見えました。

そのまま作業を進め、必要となる安全なコントロールおよびフォームを実装した後、ロード テストをバックグラウンドで実行しながら、テスト ラボでかなりの時間、機能テストを行いました。すべてのログをクライアント上で記録し、UI の速度を低下させずに高いログ レベルを保持できるようログを非同期にしました。

この財務アプリケーションでは、複数のトレーダーが 1 つの投資ポートフォリオに対して共同で作業し、リスク、手数料、および収益に関してさまざまな目標の実現を目指します。ときには、1 つの取引を共同で担当する場合もあります。何人かのテスト担当者がこの動作をシミュレートしたときに、ある 1 つの実行で収益がひどく悪い結果になりました。それ自体は必ずしも驚くことではありませんが (実際、テスト担当者が本当にトレーダーの役目を果たせたとしたら、テスト担当者などしていないでょう)、他の実行の結果と大きく異なるという事実が私たちの注意を引きました。以前にこのシステムを開発した経験から学んだこととして、何か通常と異なることが起こった場合、それはプログラミングで仮定した何かが誤っていたことを意味しています。

私たちは大量のログをふるいわけながら、その 1 つの実行の中で収益が落ち込んだ瞬間を探しました。驚いたことに、それは非常に明快でした。収益が赤字になった 1 つの明確な時点があったのです。しかし、その周辺のログ エントリを調べても、何も見えてきません。すべてが、収益が黒字のときとほとんど変わらないように見えました。

ベテランの UNIX ハッカーである DBA の 1 人が、ちょっとした正規表現を使えば 1 時間で根本的な違いを見つけられると約束しました。私たちは既に 3 時間も費やしていたため、その申し出をすぐに受け入れ、そして 45 分後、彼はにやりとしながら戻ってきました。

原因はコンテキスト スイッチでした。そして、考えられる最も悪いタイミングで、データ競合が起こっていたのです。1 つのスレッドが取引を (約) 9,000 万円に設定し、もう 1 つのスレッドがそれを 100 万米ドルに設定するときに、一度に 1 つのプロパティについて処理が行われました。不運なことに、その取引が 100 万円 (11,000 米ドル) という状態になってしまったのです。これが、収益が急落した理由でした。

複数のスレッドが同時に同じオブジェクトを操作することを防ぐのは、難しいことではありません。単に各スレッドが、操作の前にオブジェクトをロックすれば済みます (図 1 を参照)。必要なものがすべてロックされていることを確認するには、多くのクライアント コードを徹底的に見直す必要がありました。そして、過度にロックが行われてデッドロックが生じるのを防ぐために、かなりの量のテストを行う必要がありました。

図 1 オブジェクトをロックする

//Thread 1
void UserWantsToDoSomethingToTrade(
    Guid tradeId, double value, Currency c) {
  Trade t = InMemoryStore.Get(tradeId);
  lock(t) {
    t.Value = value;
    t.Currency = c;
  }
}

//Thread 2
void ReceivedNotificationAboutTrade(
    Guid tradeId, TradeNotification tn) {
  Trade t = InMemoryStore.Get(tradeId);
  lock(t) {
    t.Value = tn.Value;
    t.Currency = tn.Currency;
  }
}

私たちが (非常に不本意ながら) 断念したことの 1 つは、メモリ内オブジェクトをユーザーが編集可能なビューにデータ バインドすることでした。フォームが開いている期間中、ユーザーにオブジェクトをロックさせることはできません。そのオブジェクトを待っているバックグラウンド スレッドが、他のすべての通知を処理できなくなる可能性があるためです。

かなりの動揺を抱えながら、私たちはこれまでどおり一連のテストをシステムに対して実行し、"何かまた他に" プロジェクトが暗礁に乗り上げるような予想外の避けられない問題が起こるのではないかと身構えていました。それは、やはり起こりました。

テスト担当のトレーダーがシステムを動作させると、前の問題は解消されたように見えました。より深く複雑なシナリオが実行され、より多くの種類の多数のユーザーが同じ投資ポートフォリオにアクセスし、リスク プロファイルを変更し、仮定による見積もりや過去の比較などを実行しました。テスト担当者は実際、システムに対してあらゆる方向から攻撃を加え、システムは持ちこたえました。これが本当かどうかを半分疑いながら、私たちは良いニュースを携えて顧客の元に戻ったのです。

顧客は必ずしも私たちを信じたわけではありません。つまり、その時点で私たちの追跡記録は特に印象的なものではなかったのです。120% の納期遅延という事実は、そのように信頼に影を落とすものです。しかし、彼らは新しいシステムを緊急に欲していたので、私たちは火曜日にベータ版を構築しました。

そして木曜日に、再びロールアウトしました。

オブジェクト グラフ依存性

1 回のテスト実行での収益が他のテスト実行のときと大きく異なっていれば、開発者やテスト担当者のような投資の素人でも、違いに気が付くでしょう。投資や取引に関して細かいルール違反があり、それがすぐに大きな影響を及ぼしたりしない場合であれば、素人では気が付きません。ただし、専門家は違います。

ここでは本質的な財務上のルールには触れませんが、技術的な問題は、1 つのオブジェクトをロックするという戦略が、複数のオブジェクトが関与するルールに対してはあまりに単純すぎたということです。

これは厄介な問題でした。

呼び出し側のコードが 1 つのオブジェクトをロックして更新したときに、そのオブジェクトがイベントを生成し、そのイベントが他の複数のオブジェクトによって処理される場合があります。さらに、これらの各オブジェクトが自分自身を更新して独自のイベントを生成し、それが次々と波及して多くのオブジェクトに影響を与える可能性があります。もちろん、呼び出し側コードではこのさざ波がどこまで達するかを知ることはできないため、そのような他のオブジェクトはロックされていません。またもや、複数のスレッドがロックなしで同じオブジェクトを更新していたのです。

ある程度のスレッド セーフを実現するために、イベントや他の疎結合通信メカニズムを断念しようという話も出ましたが、(長い時間をかけて調整してきた) すべての複雑なルールを .NET の力を借りずに再実装するという考えは、受け入れ難いものでした。

また、ドメイン オブジェクトだけではなかったのです。コントローラ オブジェクトもコールバックされる可能性があり、やはりその状態が変化して、ツール バーのボタンやメニューを無効または有効にしたり、トーストをポップアップさせたりします。

単一のグローバル ロックが必要でしたが、使用できない UI の記憶が、そのような解決策に対して私たちを慎重にさせていました。また、そのようなロックが存在したとしても、ロックの設定と解除が適切に行われるようシステム内のコードのすべての行を見直す必要がありました。このバージョンのソフトウェアだけでなく、以降すべてのメンテナンス リリースおよび更新プログラムに対してもです。

まさに、行くも地獄、戻るも地獄です。

そして、私たちが同期ドメインを発見したのはそのときでした。

同期ドメイン

同期ドメインを使用すると、オブジェクトへのスレッド アクセスを自動的に同期する機能を宣言的に実現できます。このクラスは、.NET のリモート処理をサポートするインフラストラクチャの一部として導入されたものです。同期された状態のオブジェクトにアクセスするようクラスを設定するには、クラスが ContextBoundObject から継承するようにし、次のように SynchronizationAttribute でマークする必要があります。

[Synchronization]
public class MyController : ContextBoundObject {
  /// All access to objects of this type will be intercepted
  /// and a check will be performed that no other threads
  /// are currently in this object's synchronization domain.
}

予想したとおり、これを行うと、オブジェクトの作成とアクセスで多少パフォーマンス上のオーバーヘッドが生じました。もう 1 つ面倒な注意点は、同期ドメインに含まれるクラスにはジェネリック メソッドやジェネリック プロパティを含めることができない一方、他のクラスのジェネリックを呼び出したり使用したりはできるということです。

プロジェクトのこの段階で、他の選択肢が実質的にすべて消えていたため、この方法を試すことになりました。

1 つの同期ドメインを使用し、その中ですべてのロジックを実行することにしました。これは、コントローラ オブジェクト、ドメイン オブジェクト、およびクライアント側 WCF オブジェクトをすべて同期ドメインに含める必要があることを意味します。実際、同期ドメインの外部に置く必要があったオブジェクトは、Windows フォームそれ自体とそのコントロール、および他のビジュアル GUI 要素だけでした。

そこで発見した興味深いことは、必ずしも同期ドメイン内のすべてのオブジェクトが ContextBoundObject から継承しなくともよく、すべてのオブジェクトに SynchronizationAttribute が適用されなくともよい、ということです。それが必要なのは、同期ドメインの境界に位置するオブジェクトだけでした。これは、すべてのドメイン オブジェクトを以前の状態に保持できることを意味し、パフォーマンスの大きな向上につながりました。

コントローラ クラスに対しては、もう少し注意が必要でした。

ビューとコントローラのやり取り

Model-View-Controller (MVC) パターンを使用すると、コントローラがビューとやり取りするのは UI スレッド上だけです。これは前に述べた安全なコントロールとは異なるアプローチです。安全なコントロールでは、コントローラはバックグラウンド スレッドでもビューを呼び出すことができました。

また、サーバーから受信されたすべての通知が UI の更新を必要とするわけではないこともわかっており、その決定を行うのはコントローラ オブジェクトの役割でした。そのため、コントローラは通知をバックグラウンド スレッドで処理する必要があり、UI の更新が必要な場合には、スレッドを切り替える必要があります。

しかし、1 つちょっとした注意点があります。スレッドは常に非同期で切り替える必要があるということです。そうしないと、システムにデッドロックが発生したり、パフォーマンスに深刻な問題が生じたりします (経験から言えることです)。

間もなく、私たちは、スレッドと呼び出しをカプセル化してアプリケーションのコードを単純に保つためのコントローラ基本クラスを作成しました (図 2 を参照)。

図 2 コントローラ基本クラス

[Synchronization]
public class MyController : ContextBoundObject {
  // this method runs on the background thread
  public void HandleServerNotificationCorrectly() 
   {
  // RIGHT: switching threads asynchronously
  Invoker.BeginInvoke( () => CustomerView.Refresh() );

  // other code can continue to run here in the background

  // when this method completes, and the thread exits the
  // synchronization domain, the UI thread in the Refresh
  // method will be able enter this or any other synchronized object.
   }

   // this method runs on the background thread
   public void HandleServerNotificationIncorrectly()
   {
  // WRONG: switching threads synchronously
  Invoker.Invoke( () => CustomerView.Refresh() );

  // code here will NOT be run until Refresh is complete

  // DANGER! If Refresh tries to call into a controller or any other
  //         synchronized object, we will have a deadlock.
   }

   // have the main form injected into this property
   public ISynchronizeInvoke Invoker { get; set; }

   // the view we want to refresh on server notification
   public ICustomerView CustomerView { get; set; }
}

しかし、本当に取り戻す必要があったものの 1 つは、データ バインドでした。モデルが文字列や倍精度浮動小数点数や整数の集まりであったら、MVC を実行しているとは言えません。

スレッド セーフなデータ バインド

以前にデータ バインドで直面した問題は、ビュー オブジェクトがモデル オブジェクトへの参照を保持することで、UI スレッドが、バックグラウンド スレッドで更新しているのと同じオブジェクトを更新できてしまうことでした。ViewModel パターンを取り入れることで、多くのことがずっと単純になりました。これらのオブジェクトの構造を独立して制御できるというだけで、それまでと大きく違ったのです。

データ バインドを復活させるための次のステップは、コントローラ オブジェクトがそれぞれのビューに対して、次のように複製を渡すようにすることです。

public class CustomerController : BaseController {
  // this method runs on the background thread
  public void CustomerOverdrawn(Customer c) {
    ICustomerOverdrawnView v = this.CreateCustomerOverdrawnView();

    v.Customer = c.Clone(); // always remember to clone

    this.CallOnUiThread( () => v.Show() );
  }
}

この手法は機能しましたが、いくつかの問題がありました。第 1 の問題は、保守性です。すべての開発者が、ドメイン オブジェクトをビューに渡す前に複製することを忘れないようにするには、どうすればよいでしょうか。第 2 の問題は、より技術的なものです。ドメイン オブジェクトは互いに参照し合うため、1 つのオブジェクトを複製しただけでは、他のオブジェクトも複製されたことにはなりません。

手短に言えば、ドメイン オブジェクトがビューに渡されるときには、ディープ クローンを行う必要があるということです。また、この複雑さをビュー自体には一切持ち込まないことも重要でした。必要なことは、CreateCustomerOverdrawnView でビューの汎用プロキシを作成し、そのプロキシで、すべてのメソッド呼び出しとプロパティ setter について、ドメイン オブジェクトであるパラメータを検査し、ディープ クローンを実行して、その複製をビュー自体に渡すことです。

このプロキシ処理を実行するためのテクノロジは多数あり、それぞれ異なる方法で実行します。アスペクト指向プログラミング手法を用いるものもあれば、もっと単純明快な技法を用いるものもありますが、技法自体は重要ではありません。プロキシを作成する必要があるとだけ覚えておいてください。プロキシには、ディープ クローン用のメソッドと、複製の作業用セットを保持する付随辞書を含めます。図 3 に、採用した解決策を示します。

図 3 ビューに対してオブジェクトを複製する

// dictionary of references from source objects to their clones
// so that we always return the same clone for the same source object.
private IDictionary<object, object> sourceToClone = 
  new Dictionary<object, object>();

// performs a deep clone of the given entity
public object Clone(object entity) {
  if (entity.GetType().IsValueType)
  return entity;

  if (entity is string)
  return (entity as string).Clone();

  if (entity is IEnumerable) {
    object list = 
      Activator.CreateInstance(entity.GetType()) as IEnumerable;
    MethodInfo addMethod = entity.GetType().GetMethod("Add");

    foreach (object o in (entity as IEnumerable))
      addMethod.Invoke(list, new object[] {Clone(o)});

    return list;
  }

  if (sourceToClone.ContainsKey(entity))
    return sourceToClone[entity];

  object result = Activator.CreateInstance(entity.GetType());
  sourceToClone[entity] = result;

  foreach(FieldInfo field in 
    entity.GetType().GetFields(BindingFlags.Instance | 
    BindingFlags.FlattenHierarchy | BindingFlags.NonPublic | 
    BindingFlags.Public)) field.SetValue(result, 
    Clone(field.GetValue(entity)));

  return result;
}

このメソッドは、オブジェクトのすべてのプロパティとフィールドを検査して、必要であれば複製します。既に複製したオブジェクトに対する参照を辞書内に見つけた場合は、再度複製せずに、最初の複製を返します。このようなやり方で、メソッドは与えられたオブジェクト グラフのミラー イメージを作成し、バックグラウンド スレッドで使用されている可能性のあるオブジェクトにビューがアクセスするのを防ぎます。

これがすべて完了した後は、与えられたドメイン オブジェクトにビューを自由にバインドできるようになります。ただし、ドメイン オブジェクトの双方向データ バインドによる、バインドされたビューの自動更新は、もはや行われません。

双方向データ バインド

通常のデータ バインドのシナリオでは、バインドされたオブジェクトが変更されると、イベントが発生して、更新が必要であることをビューに通知します。これにより、サーバーとの通信を処理する部分では、ドメイン オブジェクトの更新時にビューを更新する必要がないため、システムの分離が容易になります。この新しいスレッド セーフ アーキテクチャでは、クライアント側の WCF オブジェクトが、ビューにバインドされているものと同じドメイン オブジェクトのインスタンスを更新しないため、双方向データ バインドの利点のいくつかが失われます。

マルチスレッド環境では、双方向データ バインドを断念しなければならないことを理解する必要があります。もしも UI にバインドされたドメイン オブジェクトがバックグラウンド スレッドで更新された場合、INotifyPropertyChanged (参照) の動作により、バックグラウンド スレッドが UI を直接更新することになり、アプリケーションがクラッシュします。

コントローラは、どのビューをいつ更新すればよいのかを把握している必要があります。たとえば、ユーザーがフォームを開いて、保留中の取引の詳細を表示しているときに、その取引の変更に関する通知をクライアントが受信した場合には、関連するコントローラがフォームを更新する必要があります。次に、それを最初にどのように実行したかを示します。

public class TradeController : BaseController {
  public void Init() {
    Commands.Open<Trade>.Activated += (args => {
      TradeForm f = OpenTradeForm(args.Trade);

      args.Trade.Updated += this.CallOnUiThread(
        () =>  f.TradeUpdated(args.Trade)
      );
    });
  }
}

このコードは、フォームを開いて、要求された取引を表示することにより、汎用の open コマンドを処理します。また、取引が更新されたときには、更新された取引をフォームに渡すよう指定しています。

私たちは、この明確で単純で直観的なコードに満足していました。

ただし、バグがあることに気付くまでの間です。

ユーザーが 2 番目の取引 (またはそれ以降の任意の取引) を開いたときに、前の取引に更新がある場合には、ユーザーがその取引に既に関心を持っていなくても、フォームには更新された取引が表示されます。つまり、コールバックの破棄について、もう少し関心を払う必要がありました。

public class TradeController : BaseController {
  public void Init() {
    Commands.Open<Trade>.Activated += (args => {
      TradeForm f = OpenTradeForm(args.Trade);

      Delegate tradeUpdated = this.CallOnUiThread(
        () =>  f.TradeUpdated(args.Trade)
      );

      args.Trade.Updated += tradeUpdated;

      f.Closed += () => args.Trade -= tradeUpdated;
    });
  }
}

前のコードとの違いは、フォームが閉じられたときに取引の更新に対するサブスクライブを解除できるように、デリゲートの参照を維持していることです。バグは修正されました。

ただし、1 つ小さな問題があります。

このコードは、シングルスレッドのシステムでは正しく動作しますが、マルチスレッド クライアントで大量の複製が行われる環境では、参照が常に思ったとおりになるとは限りません。

イベント引数内の取引オブジェクトは、何らかのアクションによって提供されますが、具体的にはコマンドの起動によって提供されます。コマンドは、ユーザーが何かを行った結果として、UI スレッドで起動されます。ここでは、グリッド内の取引をダブルクリックした結果です。しかし、取引がグリッドに表示されている場合、それはコントローラがそれをビューに渡す必要があったこと、およびその過程で取引が複製されていることを意味します。

簡単に言うと、この取引に関するサーバーからの通知は、グリッド内の複製されたオブジェクトに対して直接は作用しないため、コントローラがサブスクライブしている Updated イベントは、発生することがありません。そのため、取引の詳細を表示しているフォームは更新されません。

この解決策には何かが足りないのです。

クライアント側リポジトリ

これらのコントローラには、エンティティの特定のインスタンスが更新されたことを、オブジェクト参照に頼らずに認識する何らかの方法が必要です。

すべてのエンティティに識別子があり、これらのエンティティのメモリ内リポジトリがクライアント上にあって照会可能であれば、コントローラはそれを利用して、UI から得られる識別子に基づきエンティティの正式な参照を取得できます。

リポジトリを使用した取引コントローラは次のようになります。

public class TradeController : BaseController {
  public void Init() {
    Commands.Open<Trade>.Activated += (args => {
      TradeForm f = OpenTradeForm(args.Trade);
      Delegate tradeUpdated = this.CallOnUiThread(
        (trade) =>  f.TradeUpdated(trade) );
      this.Repository<Trade>.When((t => t.Id == args.Trade.Id))
       .Subscribe(tradeUpdated);
      f.Closed += 
       () => this.Repository<Trade>.Unsubscribe(tradeUpdated);
    });
  }
}

これにより、コントローラはリポジトリを利用して、UI によって提供される識別子に基づき、特定の取引インスタンスの変更に対してサブスクライブします。パズルの最後のピースは、単純に、クライアント側の WCF オブジェクトがこの同じリポジトリを使用して、クライアント側のドメイン オブジェクトを更新するようにすることです。

パフォーマンス

すべての要素を適切に配置し、クライアント アプリケーションのかなりの部分を書き直し、サポート フレームワークを導入して、パフォーマンス テストを実施した後で、まだ問題が残っていることを発見しました。

負荷が重く、多くのオブジェクトがほぼ同時に更新されるような状況では、クライアント側で大量の通知が処理されるため、UI の更新が停滞します。インストルメンテーションを行った結果、通知の量が多いほど、バックグラウンド スレッドによって同期ドメインが保持される時間が長くなることがわかり、その間ユーザーは、コントローラ ロジックを必要とする操作を実行できなくなります。

そこで、クライアント側コードを最適化したり、グラフが小さくなって複製にかかる時間が短くなるようドメイン オブジェクトをリファクタリングしたり、その他にも思いつくすべてのことを試してみました。しかし、クライアントで試したことは何も役に立ちませんでした。

そのとき、若いサーバー開発者の 1 人が (多少遠慮気味に) 口を開き、複数の通知メッセージを発行するようにサーバー コードを変更したらどうかと提案したのです。変更したエンティティをすべて 1 つのメッセージに収める代わりに、メッセージごとに 1 つのエンティティとしたり、または他の数でもいいですし、構成可能にすることもできます。

その結果、決定的な改善が見られました。

1 つの通知を処理してから次の通知を処理するまでの間、クライアント上のバックグラウンド スレッドを同期ドメインから外すことにより、UI スレッドが割り込んでユーザーのために何か処理を行うことが可能になりました。大きな通知に対してクライアントで一度に 1 つのメッセージを処理すると多少長い時間がかかりますが、それは完全に許容範囲内でした。

得られた教訓

このプロジェクトの開発を開始したときには、他のリッチ クライアント/データベース システムと同じようなものと想定していました。ここでアーキテクチャ的および技術的に直面した問題は、それまでに経験したどのようなものよりも大きな問題でした。サーバーとクライアントの両方で低レベルのスレッド問題を処理し、随時接続がスケーラビリティに与える影響について理解し、デッドロックや競合を避けながら許容レベルのユーザビリティを維持する、という具合に、すべてをうまく動作させるために適切に構成しなければならない要素が数多くありました。それらを支援するテクノロジはすべて既に存在し、それをどう組み合わせるかがすべてでした。

しかし、最終的に、世界中のユーザーがリアルタイムで共同作業するようすを見たとき、他の方法は考えられなかったことを理解しました。ユーザーが情報を引き出し、並べ替え、グループ化することに依存しているようなシステムでは、企業が競争を勝ち抜くことはできません。Software Plus Services、随時接続クライアント、およびマルチコアの革新は、企業が躍進を遂げるために必要な、これまでにない大きな生産性と収益性をもたらします。

あの関係者の発言は正しかったのです。世界中からのイベントをリアルタイムでユーザーに通知するこの機能は、共同作業を重視したリアルタイム エンタープライズをサポートする上で、ナレッジ ワーカーにとって本当に不可欠なものです。

プロジェクトの完了後、私は次のような不安を抱きました。より基幹業務的なアプリケーション開発に移行したときには、ここで取り上げたすべてのパターンや手法がまったく機能しないのではないか、と。その結果は、快い驚きでした。

個人的なアドバイスとして、アプリケーションに関して熱弁をふるう基幹業務マネージャほど、開発者のキャリアに利益をもたらすものはないと言えます。そして、その開発はそれ自体、さらにずっと興味深いものです。ぜひ試してみてください。

Udi Dahan は、ソフトウェア シンプリストです。また、MVP であり、WCF、WindowsWF、および "Oslo" を担当する接続型テクノロジのアドバイザでもあります。彼は、サービス指向でスケーラブルな、セキュリティ保護された .NET アーキテクチャ設計を専門としており、トレーニング、メンタリング、およびハイエンド アーキテクチャ コンサルティング サービスを提供しています。Udi には、彼のブログ Udi@UdiDahan.com から連絡することができます。