ASP.NET

Microsoft .NET Framework でシンプルな Comet アプリケーションをビルドする

Derrick Lau

コード サンプルをダウンロードする

 

Comet とは、長時間維持される AJAX 接続を使用し、明示的な要求を行わないで、コンテンツを Web サーバーからブラウザーにプッシュする技術です。これにより、より対話型のユーザー エクスペリエンスになり、使用される帯域幅は、より多くのデータを受信しながらも、ページ ポストバックによってトリガーされる一般的なサーバーのラウンドトリップよりも少なくなります。利用できる Comet 実装はたくさんありますが、そのほとんどが Java ベースです。今回は、code.google.com/p/cometbox (英語) からコード サンプルを入手できる cometbox をベースにした C# サービスをビルドすることに重点を置いて説明します。

新しい手法として、WebSocket やサーバー側のイベントといった HTML5 機能を使用して同様の動作を実装できますが、こういった手法を使用できるのは最新バージョンのブラウザーのみです。古いブラウザーをサポートしなければならない場合、最も互換性の高いソリューションは Comet です。それでも、ブラウザーが xmlHttpRequest オブジェクトを実装し、AJAX をサポートしていなければなりません。そうでなければ、Comet 形式の通信はサポートされません。

アーキテクチャの概要

図 1 に基本的な Comet 型の通信を、図 2 に今回の例のアーキテクチャを示します。Comet では、AJAX 通信に欠かせないブラウザーの xmlHttpRequest オブジェクトを使用して、サーバーとの間で長時間維持される HTTP 接続を確立します。サーバーは接続を開いたままにして、必要に応じてコンテンツをブラウザーにプッシュします。

Comet-Style Communication
図 1 Comet 型通信

Architecture of the Comet Application
図 2 Comet アプリケーションのアーキテクチャ

ブラウザーとサーバーのインターフェイスとなるのがプロキシ ページです。このプロキシ ページは、クライアント コードを含む Web ページと同じ Web アプリケーション パスに置き、ブラウザーからサーバーへ、およびサーバーからブラウザーへメッセージを送信するためだけに使用されます。プロキシ ページが必要な理由はいずれ簡単に説明します。

最初のステップでは、ブラウザーとサーバーとの間でメッセージを交換する形式を、JSON、XML、カスタム形式などから選択します。今回はわかりやすくするため、JSON を選択しました。JSON は、JavaScript、jQuery、および Microsoft .NET Framework で自然にサポートされ、XML と同じ量のデータを、少ないバイト数と帯域幅で転送できます。

Comet 型通信を設定するには、サーバーへの AJAX 接続を開きます。最も簡単な方法は、jQuery を使うことです。jQuery は複数のブラウザーをサポートし、$.ajax などの便利なラッパー関数が用意されています。この関数は、本質的には各ブラウザーの xmlHttpRequest オブジェクトのラッパーで、サーバーから着信するメッセージの処理に実装できるイベント ハンドラーが用意されています。

接続を開始する前に、送信するメッセージのインスタンスを作成します。そのためには、図 3 のように、変数を宣言し、JSON.stringify を使用してデータを JSON メッセージの形式にします。

図 3 データを JSON メッセージ形式にする

function getResponse() {
  var currentDate = new Date();
  var sendMessage = JSON.stringify({
    SendTimestamp: currentDate,
    Message: "Message 1"
  });
  $.ajaxSetup({
    url: "CometProxy.aspx",
    type: "POST",
    async: true,
    global: true,
    timeout: 600000
  });

次に、接続先 URL、通信に使用する HTTP メソッド、通信方式、接続タイムアウトなどのパラメーターを指定して関数を初期化します。jQuery では、ajaxSetup というライブラリ呼び出しでこの機能が提供されます。今回の例は、概念実証のソリューションをビルドするだけなので、タイムアウトを 10 分に設定しました。必要に応じて、タイムアウトの設定を変更してもかまいません。

この時点で、success イベント ハンドラーの定義を唯一のパラメーターとして指定する jQuery の $.ajax メソッドを使用し、サーバーとの接続を開きます。

$.ajax({
  success: function (msg) {
    // Alert("ajax.success().");
    if (msg == null || msg.Message == null) {
      getResponse();
      return;
    }

このハンドラーは、返されたメッセージ オブジェクトを解析する前に、有効な情報が含まれているかどうかをテストします。エラー コードが返されると、jQuery が機能せず、ユーザーに未定義のメッセージが表示されるため、このテストは不可欠です。メッセージが null のときは、ハンドラーから AJAX 関数が再帰的に呼び出されてから戻るため、コードが繰り返し実行されないように return ステートメントを追加しています。メッセージに問題がなければ、単純にメッセージを読み取り、コンテンツをページに書き込みます。

$("#_receivedMsgLabel").append(msg.Message + "<br/>");
getResponse();
return;
    }
  });

これで、Comet 型通信のしくみ示し、パフォーマンス テストとスケーラビリティ テストを実行する手段を備えたシンプルなクライアントが作成されます。今回の例では、getResponse という JavaScript コードを Web ユーザー コントロールに配置して、それを分離コードで登録して、コントロールが ASP.NET ページに読み込まれるときに AJAX 接続が即時に開かれるようにしています。

public partial class JqueryJsonCometClientControl :
  System.Web.UI.UserControl
{
  protected void Page_Load(object sender, EventArgs e)
  {
    string getResponseScript =
      @"<script type=text/javascript>getResponse();</script>";
    Page.ClientScript.RegisterStartupScript(GetType(),
      "GetResponseKey", getResponseScript);
  }
}

サーバー

メッセージを送受信するクライアントが完成したので、次はメッセージを受信して応答を返すサービスをビルドします。

ASP.NET ページと HTTP ハンドラーの使用を含め、Comet 型通信にさまざまな技術を実装してみましたが、どれもうまくいきませんでした。特に難しかったのが、1 つのメッセージを複数のクライアントにブロードキャストすることでした。さいわい、数々の調査の結果、cometbox プロジェクトを見つけ、それが最も簡単なアプローチだとわかりました。いろいろ試して Windows サービスとして実行して簡単に使用できるようにし、長時間接続したままコンテンツをブラウザーにプッシュできるようにしました (残念ながら、その過程でプラットフォーム間の互換性が一部失われました)。最後に、JSON と独自の HTTP コンテンツ メッセージ タイプを追加しました。

まず、Visual Studio ソリューション内に Windows サービス プロジェクトを作成し、サービス インストーラー コンポーネント (使用方法は https://msdn.microsoft.com/ja-jp/library/ddhy0byf(v=vs.100).aspx で確認できます) を追加して、コントロール パネルの管理ツールの [サービス] から、作成したサービスのオンとオフを切り替えられるようにします。これが終わったら、スレッドを 2 つ作成します。1 つは、TCP ポートにバインドしてメッセージを送受信するスレッドで、もう 1 つは、メッセージを受信したときのみコンテンツを送信するようにメッセージ キューをブロックするスレッドです。

最初に、TCP ポートで新しいメッセージをリッスンして、応答を返すクラスを作成する必要があります。実装できる Comet 通信にはいくつかの種類がありますが、今回の実装では、通信を抽象化する Server クラス (サンプル コードの Comet_Win_Service HTTP\Server.cs 参照) を用意します。ただし、わかりやすくなるように、必要条件である、HTTP 経由で JSON メッセージを受信するというごく基本的なことと、コンテンツをプッシュして返すまで接続したままにすることに重点を置いています。

Server クラスに、Server オブジェクトからアクセスする必要のあるオブジェクトを保持する保護されたメンバーを作成します。作成するメンバーは、HTTP 接続用 TCP ポートにバインドしてリッスンするスレッド、セマフォ、クライアント オブジェクトのリストなどで、それぞれが、サーバーへの 1 つの接続を表します。重要なのは、_isListenerShutDown をパブリック プロパティとして公開し、サービスの Stop イベントで変更できるようにすることです。

次に、コンストラクターで、TCP リスナー オブジェクトのインスタンスを作成し、ポートを排他的に使用するよう設定して、開始します。次に、受信用スレッドを開始して、TCP リスナーに接続するクライアントに対応します。

クライアント接続をリッスンするスレッドには、サービスの Stop イベントが発生したかどうかを示すフラグを絶えずリセットする while ループを含めます (図 4 参照)。このループの最初の部分をミューテックスに設定し、リッスンしているすべてのスレッドをブロックして、サービスの Stop イベントが発生したかどうかを確認します。イベントが発生すると、_isListenerShutDown のプロパティが true になります。確認が完了したら、ミューテックスを解放し、サービスが引き続き実行されている場合は、TcpListener.AcceptTcpClient を呼び出して、TcpClient オブジェクトを返します。オプションとして、既存のクライアントを追加しないように、既存の TcpClients を確認します。ただし、想定するクライアント数に応じて、サービスで一意 ID を生成し、この ID をブラウザー クライアントに送信するシステムに置き換えることもできます。置き換える処理では、1 つの接続だけが保持されるようにサーバーと通信するたびに毎回 ID を記憶して応答します。しかし、サービスでエラーが発生すると、この処理が問題を生む可能性があります。つまり、ID カウンターがリセットされ、既に使用されている ID が新しいクライアントに付与される可能性があります。

図 4 クライアント接続のリッスン

private void Loop()
{
  try
  {
    while (true)
    {
      TcpClient client = null;
      bool isServerStopped = false;
      _listenerMutex.WaitOne();
      isServerStopped = _isListenerShutDown;
      _listenerMutex.ReleaseMutex();
      if (!isServerStopped)
      {
        client = listener.AcceptTcpClient();
      }
    else
    {
      continue;
    }
    Trace.WriteLineIf(_traceSwitch.TraceInfo, "TCP client accepted.",
      "COMET Server");
    bool addClientFlag = true;
    Client dc = new Client(client, this, authconfig, _currentClientId);
    _currentClientId++;
    foreach (Client currentClient in clients)
    {
      if (dc.TCPClient == currentClient.TCPClient)
      {
        lock (_lockObj)
        {
          addClientFlag = false;
        }
      }
    }
    if (addClientFlag)
    {
      lock (_lockObj)
      {
        clients.Add(dc);
      }
    }

最後に、スレッドではクライアントのリストを調べ、機能していないクライアントを削除します。わかりやすいように、TCP リスナーがクライアント接続を受け付けたときに呼び出されるメソッドにこのコードを 置きましたが、このようにするとクライアント数が数十万になるとパフォーマンスに影響する場合があります。これを一般に公開する Web アプリケーションで使用する場合は、短い間隔のタイマーを追加して、クライアントのリストをクリーンアップをすることをお勧めします。

TcpClient オブジェクトが Server クラスの Loop メソッドに返されると、このオブジェクトを使用して、ブラウザー クライアントを示すクライアント オブジェクトを作成します。各クライアント オブジェクトは、サーバー コンストラクターと同様に、一意のスレッドに作成されるため、クライアント クラス コンストラクターでは、続行する前にクライアントが閉じられていないことを確認するミューテックスを待機する必要があります。後から、TCP ストリームを確認して読み取りを始め、読み取りが終了したら実行されるようにコールバック ハンドラーを登録します。コールバック ハンドラーでは、バイト列を読み取り、ParseInput メソッドを使用して解析します。このメソッドは、今回の記事に添えたサンプル コードで確認できます。

Client クラスの ParseInput メソッドでは、一般的な HTTP メッセージのさまざまな部分に対応するメンバーを持つ Request オブジェクトを構築し、そのメンバーを適切に設定します。まず、HTTP ヘッダーの形式からヘッダー情報を判断して、"\r\n" などのトークン文字列を検索してヘッダー情報を解析します。次に、ParseRequestContent メソッドを呼び出し、HTTP メッセージの本文を取得します。ParseInput の最初のステップは、使用された HTTP 通信のメソッドと要求が送られた URL を特定することです。次に、HTTP メッセージのヘッダーを抽出し、Request オブジェクトの Headers プロパティに格納します。このプロパティは、Dictionary 型で、ヘッダーの種類と値を持ちます。これも、ダウンロード用サンプル コードでしくみを確認してください。最後に、要求のコンテンツを Request オブジェクトの Body プロパティに読み込みます。このプロパティは、コンテンツのすべてのバイト列を格納する単なる文字列変数です。この時点では、まだコンテンツは解析されていません。最後に、クライアントから受信した HTTP 要求に問題がある場合は、適切なエラー応答メッセージを送信します。

プレーン テキスト、XML、JSON など、さまざまなメッセージ型のサポートを追加できるように、HTTP 要求のコンテンツを解析するメソッドは別に作成しました。

public void ParseRequestContent()
{
  if (String.IsNullOrEmpty(request.Body))
  {
    Trace.WriteLineIf(_traceSwitch.TraceVerbose,
      "No content in the body of the request!");
    return;
  }
  try
  {

まず、コンテンツを MemoryStream に書き込みます。ストリームを処理できるのは特定のデシリアライザーのみのため、必要な場合には要求の Content-Type に応じたオブジェクト型にシリアル化解除します。

MemoryStream mem = new MemoryStream();
mem.Write(System.Text.Encoding.ASCII.GetBytes(request.Body), 0,
  request.Body.Length);
mem.Seek(0, 0);
if (!request.Headers.ContainsKey("Content-Type"))
{
  _lastUpdate = DateTime.Now;
  _messageFormat = MessageFormat.json;
}
else
{

XML が依然としてよく使われる形式なので、図 5 のように、既定の動作は XML 形式のメッセージを処理するようにしました。

図 5 既定の XML メッセージ ハンドラー

if (request.Headers["Content-Type"].Contains("xml"))
{
  Trace.WriteLineIf(_traceSwitch.TraceVerbose, 
    "Received XML content from client.");
  _messageFormat = MessageFormat.xml;
  #region Process HTTP message as XML
  try
  {
    // Picks up message from HTTP
    XmlSerializer s = new XmlSerializer(typeof(Derrick.Web.SIServer.SIRequest));
    // Loads message into object for processing
    Derrick.Web.SIServer.SIRequest data =
      (Derrick.Web.SIServer.SIRequest)s.Deserialize(mem);
  }
  catch (Exception ex)
  {
    Trace.WriteLineIf(_traceSwitch.TraceVerbose,
      "During parse of client XML request got this exception: " + 
        ex.ToString());
  }
  #endregion Process HTTP message as XML
}

ただし、Web アプリケーションに関しては、メッセージを JSON 形式にすることを強くお勧めします。なぜなら、XML とは異なり、JSON には開始タグと終了タグのオーバーヘッドがなく、JavaScript でネイティブでにサポートされるためです。メッセージが JSON で送られたかどうかを示すために HTTP 要求の Content-Type ヘッダーを使用し、名前空間 System.Web.Script.Serialization の JavaScriptSerializer クラスでシリアル化解除しています。このクラスにより、JSON メッセージの C# オブジェクトへのシリアル化解除が非常に簡単になります (図 6 参照)。

図 6 JSON メッセージのシリアル化解除

else if (request.Headers["Content-Type"].Contains("json"))
{
  Trace.WriteLineIf(_traceSwitch.TraceVerbose,
    "Received json content from client.");
  _messageFormat = MessageFormat.json;
  #region Process HTTP message as JSON
  try
  {
    JavaScriptSerializer jsonSerializer = new JavaScriptSerializer();
    ClientMessage3 clientMessage =
      jsonSerializer.Deserialize<ClientMessage3>(request.Body);
    _lastUpdate = clientMessage.SendTimestamp;
    Trace.WriteLineIf(_traceSwitch.TraceVerbose,
      "Received the following message: ");
    Trace.WriteLineIf(_traceSwitch.TraceVerbose, "SendTimestamp: " +
      clientMessage.SendTimestamp.ToString());
    Trace.WriteLineIf(_traceSwitch.TraceVerbose, "Browser: " +
      clientMessage.Browser);
    Trace.WriteLineIf(_traceSwitch.TraceVerbose, "Message: " +
      clientMessage.Message);
  }
  catch (Exception ex)
  {
    Trace.WriteLineIf(_traceSwitch.TraceVerbose,
      "Error deserializing JSON message: " + ex.ToString());
  }
  #endregion Process HTTP message as JSON
}

最後に、テスト用に、PING という単語のみを含むテキスト HTTP 応答で応答する ping Content-Type を追加しました。このように、Content-Type "ping" の JSON メッセージを送信して Comet サーバーが実行されているかを簡単にテストして確認できます (図 7 参照)。

図 7 Content-Type "ping"

else if (request.Headers["Content-Type"].Contains("ping"))
{
  string msg = request.Body;
  Trace.WriteLineIf(_traceSwitch.TraceVerbose, "Ping received.");
  if (msg.Equals("PING"))
  {
    SendMessageEventArgs args = new SendMessageEventArgs();
    args.Client = this;
    args.Message = "PING";
    args.Request = request;
    args.Timestamp = DateTime.Now;
    SendResponse(args);
  }
}

結局、ParseRequestContent は単なる文字列解析メソッドで、それ以上でも以下でもありません。おわかりのとおり、XML データの解析は、コンテンツをまず MemoryStream に書き込み、XmlSerializer クラスを使用してクライアントからのメッセージを表すために作成するクラスにシリアル化解除しているため、やや複雑です。

ソース コードを効率よく整理するため、図 8 のように Request クラスを作成しました。このクラスには、ヘッダーなど HTTP 要求で送信される情報を保持するメンバーのみを、サービスから簡単にアクセスできる形で含めます。必要に応じて、要求にコンテンツがあるかないかを判断するヘルパー メソッドや、認証確認を追加することもできます。ただし、今回はサービスをシンプルにして、実装を簡単にするため、追加していません。

図 8 Request クラス

public class Request
{
  public string Method;
  public string Url;
  public string Version;
  public string Body;
  public int ContentLength;
  public Dictionary<string, string> Headers = 
    new Dictionary<string, string>();
  public bool HasContent()
  {
    if (Headers.ContainsKey("Content-Length"))
    {
      ContentLength = int.Parse(Headers["Content-Length"]);
      return true;
    }
    return false;
  }

Response クラスは、Request クラスと同様、C# Windows サービスから簡単にアクセスできる形で HTTP 応答情報を格納するメソッドを含んでいます。SendResponse メソッドでは、Cross-Origin Resource Sharing (CORS) に必要なため、カスタム HTTP ヘッダーをアタッチするロジックを追加し、ヘッダーを簡単に修正できるように構成ファイルから読み込むようにしました。また、Response クラスには、200、401、404、405、500 などの一般的な HTTP ステータスに対して、メッセージを出力するメソッドも含んでいます。

Response クラスの SendResponse メンバーは、HTTP 応答ストリームにメッセージを単純に書き込みます。HTTP 応答ストリームは、クライアントで設定されたタイムアウトが非常に長い (10 分) ので、ずっと有効なままです。

public void SendResponse(NetworkStream stream, Client client)
{

図 9 に示すように、W3C の CORS 仕様に準拠するよう、適切なヘッダーを HTTP 応答に追加します。わかりやすくするため、ヘッダーのコンテンツを簡単に変更できるように、ヘッダーを構成ファイルから読み込みます。

次に、標準 HTTP 応答ヘッダーとコンテンツを追加します (図 10 参照)。

図 9 CORS ヘッダーの追加

if (client.Request.Headers.ContainsKey("Origin"))
{
  AddHeader("Access-Control-Allow-Origin", client.Request.Headers["Origin"]);
  Trace.WriteLineIf(_traceSwitch.TraceVerbose,
    "Access-Control-Allow-Origin from client: " +
    client.Request.Headers["Origin"]);
}
else
{
  AddHeader("Access-Control-Allow-Origin",
    ConfigurationManager.AppSettings["RequestOriginUrl"]);
  Trace.WriteLineIf(_traceSwitch.TraceVerbose,
    "Access-Control-Allow-Origin from config: " +
    ConfigurationManager.AppSettings["RequestOriginUrl"]);
}
AddHeader("Access-Control-Allow-Methods", "POST, GET, OPTIONS");
AddHeader("Access-Control-Max-Age", "1000");
// AddHeader("Access-Control-Allow-Headers", "Content-Type");
string allowHeaders = ConfigurationManager.AppSettings["AllowHeaders"];
// AddHeader("Access-Control-Allow-Headers", "Content-Type, x-requested-with");
AddHeader("Access-Control-Allow-Headers", allowHeaders);
StringBuilder r = new StringBuilder();

図 10 標準 HTTP 応答ヘッダーの追加

r.Append("HTTP/1.1 " + GetStatusString(Status) + "\r\n");
r.Append("Server: Derrick Comet\r\n");
r.Append("Date: " + DateTime.Now.ToUniversalTime().ToString(
  "ddd, dd MMM yyyy HH':'mm':'ss 'GMT'") + "\r\n");
r.Append("Accept-Ranges: none\r\n");
foreach (KeyValuePair<string, string> header in Headers)
{
  r.Append(header.Key + ": " + header.Value + "\r\n");
}
if (File != null)
{
  r.Append("Content-Type: " + Mime + "\r\n");
  r.Append("Content-Length: " + File.Length + "\r\n");
}
else if (Body.Length > 0)
{
  r.Append("Content-Type: " + Mime + "\r\n");
  r.Append("Content-Length: " + Body.Length + "\r\n");
}
r.Append("\r\n");

ここでは、文字列として構築された HTTP 応答メッセージ全体を、HTTP 応答ストリームに書き込み、パラメーターとして SendResponse に渡します。

byte[] htext = Encoding.ASCII.GetBytes(r.ToString());
stream.Write(htext, 0, htext.Length);

メッセージの送信

メッセージを送信するスレッドは、本質的には、Microsoft メッセージ キューをブロックする While ループにすぎません。スレッドには、スレッドがキューからメッセージを取得するときに発生する SendMessage イベントがあります。このイベントはサーバー オブジェクトのメソッドによって処理され、基本的には各クライアントの SendResponse メソッドを呼び出し、接続しているすべてのブラウザーにメッセージをブロードキャストします。

スレッドは、クライアントにブロードキャストするコンテンツがサーバーにあることを示すメッセージが適切なメッセージ キューに置かれるまで待機しています。

Message msg = _intranetBannerQueue.Receive(); 
// Holds thread until message received
Trace.WriteLineIf(_traceSwitch.TraceInfo,
  "Message retrieved from the message queue.");
SendMessageEventArgs args = new SendMessageEventArgs();
args.Timestamp = DateTime.Now.ToUniversalTime();

このメッセージを受信すると、必要なオブジェクト型に変換します。

msg.Formatter = new XmlMessageFormatter(new Type[] { typeof(string) });
string cometMsg = msg.Body.ToString();
args.Message = cometMsg;

クライアントに送信する内容が決まると、ブロードキャストするメッセージがあることを示す Windows イベントをサーバーで発生させます。

if (SendMessageEvent != null)
{
  SendMessageEvent(this, args);
  Trace.WriteLineIf(_traceSwitch.TraceVerbose,
    "Message loop raised SendMessage event.");
}

次に、実際の HTTP 応答本文 (サーバーがすべてのクライアントにブロードキャストするメッセージのコンテンツ) を作成するメソッドが必要です。先ほどのメッセージでは、Microsoft メッセージ キューにダンプされたメッセージのコンテンツを取得して、HTTP 応答メッセージでクライアントに送信するために JSON オブジェクト形式にします (図 11 参照)

図 11 HTTP 応答本文の作成

public void SendResponse(SendMessageEventArgs args)
{
  Trace.WriteLineIf(_traceSwitch.TraceVerbose,
    "Client.SendResponse(args) called...");
  if (args == null || args.Timestamp == null)
  {
    return;
  }
  if (_lastUpdate > args.Timestamp)
  {
    return;
  }
  bool errorInSendResponse = false;
  JavaScriptSerializer jsonSerializer = null;

次に、メッセージのコンテンツを JSON 形式にする JavaScriptSerializer オブジェクトのインスタンスを作成します。JavaScriptSerializer オブジェクトのインスタンスを作成することが難しい場合があるので、次のように try/catch のエラー処理を追加します。

try
{
  jsonSerializer = new JavaScriptSerializer();
}
catch (Exception ex)
{
  errorInSendResponse = true;
  Trace.WriteLine("Cannot instantiate JSON serializer: " + 
    ex.ToString());
}

続いて、JSON 形式メッセージとこの JSON メッセージを送信する Response クラスのインスタンスを保持する文字列変数を作成します。

有効な HTTP 要求を処理しているか確認するために、すぐに基本的なエラー チェックを行います。今回の Comet サービスでは、サーバー オブジェクトと同様に TCP クライアントごとにスレッドを作成するため、安全性の確認を頻繁に行って、デバッグを容易にするのが最も安全だと考えました。

有効な要求を確認すると、JSON メッセージをまとめて HTTP 応答ストリームに送信します。JSON メッセージを作成し、シリアル化し、それを使用して HTML 応答メッセージを作成するだけです。

if (request.HasContent())
{
  if (_messageFormat == MessageFormat.json)
  {
    ClientMessage3 jsonObjectToSend = new ClientMessage3();
    jsonObjectToSend.SendTimestamp = args.Timestamp;
    jsonObjectToSend.Message = args.Message;
    jsonMessageToSend = jsonSerializer.Serialize(jsonObjectToSend);
    response = Response.GetHtmlResponse(jsonMessageToSend,
      args.Timestamp, _messageFormat);
    response.SendResponse(stream, this);
  }

まとめてフックするために、まずサービスの Start イベント中に、メッセージ ループ オブジェクトとサーバー ループ オブジェクトのインスタンスを作成します。これらのオブジェクトでは、このサービスのクラスのメンバーが他のサービスのイベント中に呼び出されてもよいように、クラスのメンバーを保護します。これで、メッセージ ループのメッセージ送信イベントがサーバー オブジェクトの BroadcastMessage メソッドにより処理されます。

public override void BroadcastMessage(Object sender, 
  SendMessageEventArgs args)
{
  // Throw new NotImplementedException();
  Trace.WriteLineIf(_traceSwitch.TraceVerbose,
    "Broadcasting message [" + args.Message + "] to all clients.");
  int numOfClients = clients.Count;
  for (int i = 0; i < numOfClients; i++)
  {
    clients[i].SendResponse(args);
  }
}

BroadcastMessage は、単純にすべてのクライアントに同じメッセージを送信します。必要に応じて、希望するクライアントだけにメッセージを送信するように変更してかまいません。この方法でこのサービスを使用して、たとえば、複数のオンライン チャット ルームを処理できます。

OnStop メソッドは、このサービスが停止したときに呼び出されます。呼び出されたら、サーバー オブジェクトの Shutdown メソッドを呼び出します。このメソッドでは、依然有効なクライアント オブジェクトのリストが調査され、それらがシャットダウンされます。

これで、適切に機能する Comet サービスになりました。このサービスは、コマンド プロンプトで installutil コマンド (詳細については https://msdn.microsoft.com/ja-jp/library/sd8zc8ha(v=vs.100).aspx を参照) を使用してサービス アプレットにインストールできます。また、既にサービス インストーラー コンポーネントをサービス プロジェクトに追加している場合は、独自の Windows インストーラーを作成して展開することもできます。

機能しないのは CORS の問題

では、ブラウザー クライアントの $.ajax 呼び出しの URL に Comet サービス URL を指定するように設定してみます。Comet サービスを開始し、Firefox のブラウザー クライアントを開きます。Firefox ブラウザーには、Firebug 拡張機能がインストールされている必要があります。Firebug を起動し、ページを更新すると、コンソールの出力領域に "Access denied" (アクセスが拒否されました) というエラーが表示されます。この原因は CROS にあり、セキュリティ上の理由で、JavaScript では同じ Web アプリケーションとそれをホストするページがある仮想ディレクトリ以外のリソースにはアクセスできません。たとえば、ブラウザー クライアント ページが http://www.somedomain.com/somedir1/somedir2/client.aspx にある場合、このページでのすべての AJAX 呼び出しは同じ仮想ディレクトリまたはサブディレクトリのリソースに対してのみ行われます。別のページまたは Web アプリケーション内の HTTP ハンドラーを呼び出す場合は問題ありませんが、同じメッセージをすべてのクライアントに送信するときにページやハンドラーによってメッセージ キューがブロックされないようにしたいので、Windows Comet サービスの使用と CORS の制限を回避する手段が必要です。

そのためには、ブラウザー クライアントからの HTTP メッセージをインターセプトして、関連するヘッダーとコンテンツを抽出し、Comet サービスに接続する別の HTTP 要求オブジェクトを作成する機能だけを持つプロキシ ページを、同じ仮想ディレクトリに作成することをお勧めします。サーバーでこの接続が完了するので、CORS による影響はありません。このように、プロキシを使用して、ブラウザー クライアントと Comet サービスとの間に長時間開かれたままになる接続を維持できます。さらに、1 つのメッセージがメッセージ キューに届くと同時に、接続しているすべてのブラウザー クライアントに送信できます。

まず、HTTP 要求を取得し、その要求をバイト配列にストリーミングして、新しい HTTP 要求オブジェクトに渡して簡単にインスタンスを作成します。

byte[] bytes;
using (Stream reader = Request.GetBufferlessInputStream())
{
  bytes = new byte[reader.Length];
  reader.Read(bytes, 0, (int)reader.Length);
}

次に、新しい HttpWebRequest オブジェクトを作成し、Comet サーバーを指します。この URL は後から簡単に変更できるように web.config ファイルに記述します。

string newUrl = ConfigurationManager.AppSettings["CometServer"];
HttpWebRequest cometRequest = (HttpWebRequest)HttpWebRequest.Create(newUrl);

これにより、ユーザーごとに Comet サーバーとの接続が作成されますが、同じメッセージが各ユーザーにブロードキャストされるため、二重にロックされたシングルトンの cometRequest オブジェクトをカプセル化して Comet サーバーの接続負荷を軽減し、IIS により接続負荷が分散されるようにすることのみ可能です。

続けて、jQuery クライアントから受け取ったものと同じ値を HttpWebRequest ヘッダーに設定します。特に、KeepAlive プロパティを true に設定して、長時間開かれたままになる HTTP 接続を維持します。これは、Comet 型通信の基本技術です。

ここで、Origin ヘッダーを確認します。このヘッダーは、W3C の仕様により、CORS 関連の問題に対処するときに必要とされています。

for (int i = 0; i < Request.Headers.Count; i++)
{
  if (Request.Headers.GetKey(i).Equals("Origin"))
  {
    containsOriginHeader = true;
    break;
  }
}

次に、Comet サーバーが受け取るように、Origin ヘッダーを HttpWebRequest に渡します。

if (containsOriginHeader)
{
  // cometRequest.Headers["Origin"] = Request.Headers["Origin"];
  cometRequest.Headers.Set("Origin", Request.Headers["Origin"]);
}
else
{
  cometRequest.Headers.Add("Origin", Request.Url.AbsoluteUri);
}
System.Diagnostics.Trace.WriteLineIf(_proxySwitch.TraceVerbose,
  "Adding Origin header.");

次に、jQuery クライアントからの HTTP 要求のコンテンツからバイト列を取得して、HttpWebRequest の要求ストリームに書き込みます。これは Comet サーバーに送信されます (図 12 参照)。

図 12 HttpWebRequest ストリームに書き込む

Stream stream = null;
if (cometRequest.ContentLength > 0 && 
  !cometRequest.Method.Equals("OPTIONS"))
{
  stream = cometRequest.GetRequestStream();
  stream.Write(bytes, 0, bytes.Length);
}
if (stream != null)
{
  stream.Close();
}
// Console.WriteLine(System.Text.Encoding.ASCII.GetString(bytes));
System.Diagnostics.Trace.WriteLineIf(_proxySwitch.TraceVerbose, 
  "Forwarding message: " 
  + System.Text.Encoding.ASCII.GetString(bytes));

メッセージを Comet サーバーに送信した後、HttpWebRequest オブジェクトの GetResponse メソッドを呼び出します。このメソッドは、サーバーの応答を処理するための HttpWebResponse オブジェクトを提供します。また、メッセージと一緒にクライアントに送り返す、必須の HTTP ヘッダーを追加します。

try
{
  Response.ClearHeaders();
  HttpWebResponse res = (HttpWebResponse)cometRequest.GetResponse();
  for (int i = 0; i < res.Headers.Count; i++)
  {
    string headerName = res.Headers.GetKey(i);
    // Response.Headers.Set(headerName, res.Headers[headerName]);
    Response.AddHeader(headerName, res.Headers[headerName]);
  }
  System.Diagnostics.Trace.WriteLineIf(_proxySwitch.TraceVerbose,
    "Added headers.");

サーバーの応答を待ちます。

Stream s = res.GetResponseStream();

Comet サーバーのメッセージを受信すると、それを元の HTTP 要求の応答ストリームに書き込み、クライアントが受け取れるようにします (図 13 参照)。

図 13 HTTP 応答ストリームにサーバー メッセージを書き込む

string msgSizeStr = ConfigurationManager.AppSettings["MessageSize"];
int messageSize = Convert.ToInt32(msgSizeStr);
byte[] read = new byte[messageSize];
// Reads 256 characters at a time
int count = s.Read(read, 0, messageSize);
while (count > 0)
{
  // Dumps the 256 characters on a string and displays the string to the console
  byte[] actualBytes = new byte[count];
  Array.Copy(read, actualBytes, count);
  string cometResponseStream = Encoding.ASCII.GetString(actualBytes);
  Response.Write(cometResponseStream);
  count = s.Read(read, 0, messageSize);
}
Response.End();
System.Diagnostics.Trace.WriteLineIf(_proxySwitch.TraceVerbose, 
  "Sent Message.");
s.Close();
}

アプリケーションのテスト

アプリケーションをテストするために、サンプル アプリケーションのページを保持する Web サイトを作成します。Windows サービスの URL が正確で、メッセージ キューを適切に構成して使用できる状態にします。サービスを開始し、1 つのブラウザーで Comet クライアント ページを開き、別のブラウザーでメッセージを送信するページを開きます。メッセージを入力し、[send] (送信) をクリックします。およそ 10 ミリ秒後、別のブラウザー ウィンドウにメッセージが表示されます。これをさまざまなブラウザー (特に、いくつかの古いブラウザー) で試します。xmlHttpRequest オブジェクトをサポートするブラウザーであれば、機能します。これにより、ほぼリアルタイムの Web 動作 (en.wikipedia.org/wiki/Real-time_web、英語) が提供され、ユーザーからの要求なしにブラウザーにほぼ瞬時にコンテンツがプッシュされます。

新しいアプリケーションを展開する前に、パフォーマンスと負荷をテストしておく必要があります。これを行うには、まず集めるメトリックを特定します。応答時間とデータ転送サイズの両方の使用負荷を測定することをお勧めします。それに加えて、Comet に関連した使用シナリオ (特に、単一のメッセージを複数のクライアントにポストバックなしでブロードキャストするシナリオ) をテストします。

テストのために、Comet サーバーに接続された複数のスレッドを開いてサーバーが応答を送信するまで待機するユーティリティを構築しました。このテスト ユーティリティでは、Comet サーバーに接続する合計ユーザー数と接続 (通常はサーバー応答送信後に接続は閉じられます) を再度開く回数など、いくつかのパラメーターを設定できます。

次に、メイン画面のテキスト フィールドでバイト数を設定して x バイトのメッセージをメッセージ キューにダンプするユーティリティと、サーバーから送信されるメッセージを待機する時間 (ミリ秒) を設定するテキスト フィールドを作成しました。これは、クライアントにテスト メッセージを送り返すために使用します。続いて、テスト クライアントを起動し、ユーザー数とクライアントが Comet 接続を再度開く回数を指定し、スレッドでサーバーに対する接続を開きました。すべての接続が開くのを数秒待ち、メッセージ送信ユーティリティに移動して、一定のバイト数を送信します。これを、合計ユーザー数、合計接続再開回数、およびメッセージ サイズのさまざまな組み合わせに対して繰り返します。

最初のデータ サンプリングでは、単独のユーザーで接続再開回数を増やしていきましたが、テスト中の応答メッセージは一貫した (小さい) サイズにしました。図 14 に示すように、接続再開回数はシステムのパフォーマンスや信頼性に影響を与えるように見えません。

図 14 さまざまなユーザー数

ユーザー数 接続再開回数 メッセージ サイズ (バイト) 応答時間 (ミリ秒)
1,000 10 512 2.56
5,000 10 512 4.404
10,000 10 512 18.406
15,000 10 512 26.368
20,000 10 512 36.612
25,000 10 512 48.674
30,000 10 512 64.016
35,000 10 512 79.972
40,000 10 512 99.49
45,000 10 512 122.777
50,000 10 512 137.434

時間は線形的に一定の割合で徐々に増加しています。つまり、Comet サーバーのコードが全体的に堅牢だということです。図 15 は、ユーザー数と、512 バイトのメッセージに対する応答時間をグラフにしたものです。図 16 には 1,024 バイトのメッセージ サイズに関する統計を示しました。最後の 図 17 は、図 16 をグラフにしたものです。これらのテストはすべて、8 GB の RAM と 2.4 GHz Intel Core i3 CPU を搭載した 1 台のノート パソコンで行いました。

Response Times for Varying Numbers of Users for a 512-Byte Message
図 15 さまざまなユーザー数に対して 512 バイトのメッセージを送信した場合の応答時間

図 16 1,024 バイトのメッセージ サイズのテスト

ユーザー数 接続再開回数 応答時間 (ミリ秒)
1,000 10 144.227
5,000 10 169.648
10,000 10 233.031
15,000 10 272.919
20,000 10 279.701
25,000 10 220.209
30,000 10 271.799
35,000 10 230.114
40,000 10 381.29
45,000 10 344.129
50,000 10 342.452

User Load vs Response Time for a 1KB Message
図 17 1 KB のメッセージに対するユーザー数と応答時間の関係

応答時間が、ある程度の範囲内で、1 KB までのメッセージ サイズであれば 1 秒未満に収まっている以外、数字に特定の傾向は見られません。帯域幅は、メッセージの形式に影響されるため、追跡する必要はありませんでした。また、すべてのテストを 1 台のパソコンで行ったので、ネットワーク待ち時間は要因から除外されました。ホーム ネットワークで試すこともできましたが、ワイヤレス ルーターとケーブル モデムの設定よりも、パブリック インターネットの方がはるかに複雑なので、意義があるとは考えませんでした。しかし、Comet 通信技術の重要な点は、更新時にサーバーからコンテンツをプッシュしてサーバーのラウンドトリップを減らすことにあります。理論的には、Comet 技術を使用することで、ネットワーク帯域幅使用が半分軽減されます。

まとめ

これで無事に独自の Comet 型アプリケーションを実装して活用でき、効率的にネットワーク帯域幅を減らして Web サイト アプリケーションのパフォーマンスが向上したらさいわいです。もちろん、Comet に代わる WebSockets (https://msdn.microsoft.com/ja-jp/magazine/hh975342.aspx) や Server-Sent Events (SSE) (bit.ly/UVMhoD、英語) などの、HTML5 に含まれる新しいテクノロジについて調べることも考えるでしょう。これらのテクノロジは、ブラウザーにコンテンツをプッシュする簡単な方法が用意されており有望ですが、ユーザーは HTML5 をサポートするブラウザーを使用する必要があります。古いブラウザーを使用しているユーザーを今後もサポートする必要がある場合、Comet 型通信は引き続き最善の選択肢です。

Derrick Lau は、およそ 15 年の経験を持つ、ベテランのソフトウェア開発チームのリーダーです。金融会社や政府の IT 部門のほか、テクノロジ重視の企業のソフトウェア開発部門での仕事に携わってきました。EMC 開発コンテストでは、2010 年に大賞を受賞し、2011 年には最終選考まで進みました。また、MCSD および EMC コンテンツ管理開発者として認定を受けています。

この記事のレビューに協力してくれた技術スタッフの Francis Cheung に心より感謝いたします。