2009年2月2日月曜日

ASP.NET MVCで非同期リクエスト

Improve scalability in ASP.NET MVC using Asynchronous requests « Steve Sanderson’s blog まずは↑。

非同期のIHttpAsyncHandlerをMVCでも使おうよ、という話。 ソースは部分的。

で、このたびリリースされたASP.NET MVC RC(Refreshはお早めに)。 の、FuturesにAsyncControllerが含まれてる。

そりゃ~、もう気になって仕方ないよね。 とりあえずは、ソースを確認して、どういう構成で非同期実装してるのかを見ることにしたんだけど、どうにも要領を得ないな。

AsyncManager.RegisterTast

上記エントリだとアクション内でRegisterAsyncTaskを読んで、Begin/Endそれぞれのdelegateを登録するという流れなので、 RC Futuresのソースを眺めてて、パッと目につくのがAsyncManager.RegisterTast()。 名前からしてタスクを登録するメソッド。

public IAsyncResult RegisterTask(Func beginDelegate, AsyncCallback endDelegate) 

こんな宣言なのを見ると、上記エントリと同じ使い方でいいんじゃないかと思えるんだけど。 試しに書いたコードが↓。

    public void Image(string fileName)
    {
      AsyncManager.RegisterTask(cb => {
        Debug.WriteLine(string.Format("request thread:{0}={1}({2})",
          new object[] { Thread.CurrentThread.ManagedThreadId, Thread.CurrentThread.IsThreadPoolThread, fileName }));

        Thread.Sleep(3000);
        cb(null);
      }, delegate(IAsyncResult result) {
        Debug.WriteLine(string.Format("response thread:{0}={1}({2})",
          new object[] { Thread.CurrentThread.ManagedThreadId, Thread.CurrentThread.IsThreadPoolThread, fileName }));

        var dir = Server.MapPath("~/App_Data/Images");
        var path = string.Format("{0}\\{1}", dir, fileName);

        if (System.IO.File.Exists(path))
        {
          File(path, "image/png").ExecuteResult(ControllerContext);
        }
        else
          Response.StatusCode = 404;
      });
    }

Add_Data/ImagesにPNG画像ファイルを入れて、そのファイル名を指定するとファイルを返すという簡単なもの。 動くのは動く。普通に。 でも、スレッドIDはbegin/endどっちも同じものしか使われてない様子。 開発環境だからかな~。なんでかな~。 レスポンスを返す部分で、ActionResult.ExecuteResult()を呼んで、その場でResponseしちゃうっていうのはちょっと違う気がしなくもない。

Action/ActionCompleted

次にソースを追っかけて気がついた。「// Is this the Foo() / FooCompleted() pattern?」なんてコメント発見。普通にアクションを書いて、同じ名前のアクション名+Completedっていうアクションを定義する方法。わかりにく!

    public void Image2(string fileName)
    {
      Thread.Sleep(3000);

      HttpContext.Items["params"] = fileName;
      Debug.WriteLine(string.Format("request thread:{0}={1}({2})",
        new object[] { Thread.CurrentThread.ManagedThreadId, Thread.CurrentThread.IsThreadPoolThread, fileName}));
    }

    public ActionResult Image2Completed()
    {
      Debug.WriteLine(string.Format("response thread:{0}={1}({2})",
      
new object[] { Thread.CurrentThread.ManagedThreadId,
Thread.CurrentThread.IsThreadPoolThread, HttpContext.Items["params"]
}));

      var dir = Server.MapPath("~/App_Data/Images");
      var path = string.Format("{0}\\{1}", dir, HttpContext.Items["params"]);

      if (System.IO.File.Exists(path))
        return File(path, "image/png");
     
      Response.StatusCode = 404;
      return new EmptyResult();
    }

ようするに↑こうなんだけど。 んで、これだと、スレッドIDが変わることがあるから、なんか上手く行ってる気がしなくもない。 なんせ、リクエストを受け付けたときのパラメータがCompletedの方には渡されないから、HttpContext.Itemに入れてるのが、かなり自信ない。 でも、この書き方だと、Completedの戻り値がActionResultだから分かりやすいんじゃないかと思える。 スレッドIDも違うし。

BeginAction/EndAction

さらにコードを追いかけてると、今度は「// Is this the BeginFoo() / EndFoo() pattern?」と書かれてる。 ってことは、アクション名にBeginなんちゃら/Endなんちゃらってかいておくと、それを呼び出してくれるのかなと。ソースもAsyncActionMethodSelectorだし。 で、試した。 今度はBegin/Endのパラメータ指定に制限があって、Beginは戻り値IAsyncResultで引数AsyncCallback callback, object stateが必須。EndにはIAsyncResult resultが必須。

    public IAsyncResult BeginImage3(AsyncCallback callback, object state)
    {
      Debug.WriteLine(string.Format("request thread:{0}={1}({2})",
        new object[] { Thread.CurrentThread.ManagedThreadId, Thread.CurrentThread.IsThreadPoolThread, "test" }));

      var web = WebRequest.Create("http://farm1.static.flickr.com/131/353753310_1ed04f694c_m.jpg");
      HttpContext.Items["web"] = web;
      return web.BeginGetResponse(callback, state);
    }

    public void EndImage3(IAsyncResult result)
    {
      Debug.WriteLine(string.Format("response thread:{0}={1}({2})",
        new object[] { Thread.CurrentThread.ManagedThreadId, Thread.CurrentThread.IsThreadPoolThread, "test" }));

      var web = HttpContext.Items["web"] as WebRequest;
      WebResponse res = web.EndGetResponse(result);

      Response.ContentType = res.ContentType;

      var reader = new BinaryReader(res.GetResponseStream());
      Response.BinaryWrite(reader.ReadBytes((int)res.ContentLength));
    } 

追記 ここはHttpContext.Item使わなくてもBeginGetResponseにstateの代わりにweb渡せばEndAsync3でresult.AsyncStateでとれますね。

今度はFlickrの画像をWebRequestで取得するように変更。 戻り値が必要だし。 んで、デバッグ出力のスレッドIDはBeginとEndでちゃんと違う。 そんなこんなで、どうやって書くのが正しいのかいまいちよく分からないAsyncController。 ちなみにRouteは↓こんな感じで書いてます。

      routes.MapAsyncRoute(null,
        "Async3/{*url}",
        new { controller = "AsyncImages", action = "Image3" }
      );

      routes.MapAsyncRoute(null,
        "Async2/{fileName}",
        new { controller = "AsyncImages", action = "Image2" }
      );

      routes.MapAsyncRoute(null,
        "Async/{fileName}",
        new { controller = "AsyncImages", action = "Image" }
      ); 

悩ましいな~。なんて思ってた所に新たな非同期エントリ登場。

Extend ASP.NET MVC for Asynchronous Action - Happy Coding

これまた全然違う作り方してるんだよね。

Controllerはそのままに、AsyncMvcHandler実装(他にもいろいろあるけど)で乗り切る方法。 これだとControllerFactoryもいじる必要無いし、アクション単位でAsyncAction属性指定で非同期判別。 戻り値もActionResultだし。

サンプルコードが↓こんなの。

        [AsyncAction]
        public ActionResult AsyncAction(AsyncCallback asyncCallback, [AsyncState]object asyncState)
        {
          
SqlConnection conn = new SqlConnection("Data
Source=.\\sqlexpress;Initial Catalog=master;Integrated
Security=True;Asynchronous Processing=true");
            SqlCommand cmd = new SqlCommand("WAITFOR DELAY '00:00:03';", conn);
            conn.Open();
           
            return this.Async(
                cmd.BeginExecuteNonQuery(asyncCallback, asyncState),
                (ar) =>
                {
                    int value = cmd.EndExecuteNonQuery(ar);
                    conn.Close();
                    return this.View();
                });
        }

this.AsyncっていうのがControllerの拡張メソッド。これでEndProcessRequestの時に呼び出すdelegateを指定。スゴイよね。 コード量も少ないし。delegateの戻りがそのままアクションの戻りとして使われるし。 なんかコードみてるとasyncCallbackとasyncStateをCallContextに入れるようになってる。初めて見た。 コレだとスレッド単位のコンテキストオブジェクト管理が出来るっぽい。

ActionResultなんかはHttpContext.Itemに入れて、リクエストコンテキストで管理。 ちなみにコレのプロジェクトにベンチマーク用のコードが入っててこれいいじゃん!みたいな。 PowerShellとIIS6リソースキットに含まれるTinyGetを使って時間を計測。 シンプルで簡単なテストだから試してみた。

試すに当たって使った同期版のコードは↓。

      public ActionResult Sync(string fileName)
      {
        Thread.Sleep(3000);

        var dir = Server.MapPath("~/App_Data/Images");
        var path = string.Format("{0}\\{1}", dir, fileName);

        if (System.IO.File.Exists(path))
          return File(path, "image/png");

        return new EmptyResult();
      }

      public ActionResult Sync2(string url)
      {
        var web = new WebClient();

        var bytes = web.DownloadData("http://farm1.static.flickr.com/131/353753310_1ed04f694c_m.jpg");
        return File(bytes, web.ResponseHeaders["Content-Type"]);
      } 

Sync vs Async(Imageアクション)

Measure-Command {.\tinyget -srv:localhost -r:53976 -uri:/Sync/Cart.png -threads:50 -loop:1} [System.Threading.Thread]::Sleep(2000) Measure-Command {.\tinyget -srv:localhost -r:53976 -uri:/Async/Cart.png -threads:50 -loop:1} [System.Threading.Thread]::Sleep(2000) Sync 21.33秒、Async 14.68秒。

Sync vs Async2(Image2アクション)

Measure-Command {.\tinyget -srv:localhost -r:53976 -uri:/Sync/Cart.png -threads:50 -loop:1} [System.Threading.Thread]::Sleep(2000) Measure-Command {.\tinyget -srv:localhost -r:53976 -uri:/Async2/Cart.png -threads:50 -loop:1} [System.Threading.Thread]::Sleep(2000) Sync 21.40秒、Async 12.70秒。

ここまでの2個のテストではそれぞれ3秒のウェイトを入れてこの時間だから、非同期でリクエストスレッドを早く開放したほうが次のリクエストを続々と受け入れることが出来るようになるんだから、最終的にこのくらいの差が出てくるのも納得(だよね?)。

Sync2vs Async3(Image3アクション):Flickrから取得

Measure-Command {.\tinyget -srv:localhost -r:53976 -uri:/Sync2/Cart.png -threads:50 -loop:1} [System.Threading.Thread]::Sleep(2000) Measure-Command {.\tinyget -srv:localhost -r:53976 -uri:/Async3/Cart.png -threads:50 -loop:1} [System.Threading.Thread]::Sleep(2000) Sync 3.84秒、Async 2.16秒。

コレに関してはあんまり意味ないね。Flickrからの取得にかかる時間が毎回一定なわけじゃないし。 Any good ideas to build an async action for scalability improvement? - ASP.NET Forums ここで、それぞれの設計について話をしてる。

OnAction~フィルターの動作も非同期にしないとっていう話? ふむ~。 じきにAsyncControllerの使い方を公開するってことなんでそれまで待つのがいいかもね。