しばやん雑記

Azure とメイドさんが大好きなフリーランスのプログラマーのブログ

ASP.NET MVC 3 を使ってプッシュ型アプリケーションを作る

プッシュ型のアプリケーションと言っていますが、このブログで何回か話している Comet を ASP.NET MVC で実装したというレベルの話です。

Comet に関しては Comet - Wikipedia を参照してください。要するに XHR で作成した HTTP コネクションに対して、情報の準備が完了するまでレスポンスを返さないようにすることで擬似的にプッシュを実現する方法です。

それでは今回作成したアプリケーションの画像を先にぺたり。

よくあるチャットタイプのアプリケーションですが、違うのは Comet を使っているので、誰かが投稿すると即座に接続しているクライアント全員に発言が届けられる点です。

上の画像にある IE9 のネットワークキャプチャ画面にある、いくつかのコネクションを見ると面白いかもしれません。

それでは Comet の実装ですが、基本的な考え方は非同期コントローラを使ってレスポンスを返すのを遅らせます。非同期コントローラを使っているので、他のユーザーからのイベントを受け取ってレスポンスを返すという作業が非常に楽になりますし、ASP.NET のコネクションプールをいたずらに消費することもありません。

今回は Comet で必要になってくる、とあるユーザーからのイベントを接続している全員に通知するための機能を CometController という形でまとめました。静的オブジェクトを使っているのが非常に怖い感じですが、仕方ないと思ってあきらめました。

[AsyncTimeout(60000)]
public abstract class CometController : AsyncController
{
    private static readonly object _syncLock = new object();
    private static readonly Dictionary<object, WeakReference> _clients = new Dictionary<object, WeakReference>();

    protected void AddObserver(object key, Action callback)
    {
        lock (_syncLock)
        {
            var reference = new WeakReference(callback);

            if (_clients.ContainsKey(key))
            {
                _clients[key] = reference;
            }
            else
            {
                _clients.Add(key, reference);
            }
        }
    }

    protected void Notify()
    {
        lock (_clients)
        {
            foreach (var client in _clients)
            {
                var reference = client.Value;

                if (reference.IsAlive)
                {
                    var callback = (Action)reference.Target;

                    if (callback != null)
                    {
                        callback();
                    }
                }
            }
        }
    }

    protected void RemoveObserver(object key)
    {
        lock (_syncLock)
        {
            _clients.Remove(key);
        }
    }
}

結構な手抜き実装なのですが、デリゲートは弱参照で持つようにして多少の心優しさをアピールします。通知してほしい場合は AddObserver メソッドを呼び出してデリゲートを登録します。削除は RemoveObserver メソッドを使いますが、これはレスポンスを返し終わった時に呼ぶべきですね。

そして登録されているオブサーバーにイベントを通知するのが Notify メソッドですが、単純に呼び出すのではなくて、ちゃんと参照が生きているか確認するようにしています。当たり前のことですが…。

ちなみに非同期アクション自体は 60 秒でタイムアウトするようにしています。たとえタイムアウトしても JavaScript 側で再接続するコードを書いてあげれば問題ないですよね。

それでは発言をデータベースに登録するアクションです。このアクションでは他のクライアントへの通知を行うために Notify メソッドを呼び出しています。これは自分自身への通知も行うので、非常にシンプルな実装になっています。

public void Create(string text, string username)
{
    var status = new Status
    {
        Text = text,
        Username = username,
        CreatedAt = DateTime.Now,
    };

    _context.Statuses.Add(status);
    _context.SaveChanges();

    Notify();
}

そして肝心の非同期アクションですが、何となく Twitter API 風に since_id を引数に付けて実装しました。0 の時にはデータベースに存在するすべての発言を JSON 形式で即座に返すようにするために、OutstandingOperations の数を増やさずに同期的に処理をするようにしています。

public void ReceiveAsync(int since_id = 0)
{
    if (since_id == 0)
    {
        return;
    }

    AsyncManager.OutstandingOperations.Increment();

    AddObserver(HttpContext.Request.UserHostAddress, () =>
    {
        AsyncManager.Parameters["since_id"] = since_id;
        AsyncManager.OutstandingOperations.Decrement();
    });
}

public ActionResult ReceiveCompleted(int since_id = 0)
{
    RemoveObserver(HttpContext.Request.UserHostAddress);

    var statuses =
        _context.Statuses.Where(p => p.StatusId > since_id).OrderBy(p => p.CreatedAt).ToArray()
            .Select(p => new
            {
                StatusId = p.StatusId,
                Username = p.Username,
                Text = p.Text,
                CreatedAt = p.CreatedAt.ToString()
            }).ToArray();

    return Json(statuses);
}

今回はクライアントを識別するためのキーを IP アドレスにしてしまったので、テストには複数のクライアントが必要になります。実際にはユニークな ID を生成してクッキーにでも入れておけばいいと思います。

最後にクライアントサイドのコードですが、jQuery を使ってさくっと Ajax コードを書きました。クライアント側からはレスポンスが遅いだけにしか見えないので、特に説明は要らないと思います。

var callback = function (since_id) {
    $.ajax({
        type: "POST",
        url: "/Home/Receive",
        data: "since_id=" + since_id,
        dataType: "json",
        success: function (msg) {
            jQuery.each(msg, function () {
                $("#tl tr:first-child").after("<tr><td>" + this.Username + "</td><td>" + this.Text + "</td><td>" + this.CreatedAt + "</td></tr>");
            });

            callback(msg[msg.length - 1].StatusId);
        },
        error: function (e) {
            callback(since_id);
        }
    });
};

callback(0);

特徴としては success と error に指定した関数オブジェクトが呼ばれたときには、Ajax での接続処理を行う関数オブジェクト自体を再度呼び出して再接続している点ぐらいですね。これで正常なレスポンスでも、エラーが返ってきても無限に接続を維持できますね。

これで準備は全て完了したのでいつも通り動作検証を行おうと思うのですが、画像では Comet のリアルタイム性が伝わらないので珍しくサンプルプロジェクトを用意しました。

SQL Server Compact Edition 4.0 が必要になっているので、Web PI などでインストールしてあげてください。

ダウンロード:MvcCometChat.zip 直

たまに発言が反映されないことがありますが、大体はブラウザリロードで直ります。サンプルなので許してくださいね。