クライアントコールバックを利用した Ajax

Ajax とは…、というのは解からない人は調べてもらうとして、簡単に書くと

  • ブラウザのページ全体を GET/POST することなく
  • 非同期で XML などのデータをブラウザとサーバ間でやりとりして
  • そのデータを元に Javascript などでページを動的に書き換える

といった動作を指す。データのやりとりは、通常はブラウザ側から xmlHttpRequest を利用し、サーバ側に用意されたデータ取得に必要なリクエストを発行する。
サーバ側で用意するのは REST-API なり XML-RPC なり、SOAPWebService なり、application/json 形式なり、どのようなもので用意してもよい。ブラウザ側の Javascript が受け取って処理できれば何でもよいのである。
全体としては、

の4つの部品が必要になることになり、「Ajax に便利なライブラリ」というと、上記の4つの機能を実現するのを助けるライブラリになる。
ASP.NET 2.0 では、クライアントコールバックと呼ばれる機能が、「Ajax に便利なライブラリ」に相当している。具体的には、

が提供されている。
クライアントコールバックでは、扱えるデータは「文字列1つ」、シンプルには、ブラウザ上で、

send("my name");

みたいな Javascript を呼び出すと、Web Control 側で、

string recv(string name)
{
  return "こんにちは、" + name
}

みたいなメソッドが呼び出され、ブラウザ上で

function update_label(message)
{
  $("message").value = message;
}

みたいな感じでページを更新することができる、ということになる。複雑なデータを送受信したいのであれば、文字列の内容を XML や application/json 形式で保存することにすれば、.NET 側も Javascript 側もライブラリが揃っていて楽なのではないかと思います。

サーバ側のメソッド作成

まずは、最も簡単なサーバ側のメソッド作成。
前述の recv() メソッドをそのまま利用することはできない、これは ASP.NET のリクエスト処理シーケンスの都合で、HttpModule の各イベントを途中にはさむ必要があるため、「文字列を受け取るメソッド」と「文字列を返すメソッド」は別に用意しなければならない。
これらのメソッドは System.Web.UI.ICallbackEventHandler で定義されているので、このインターフェスを実装するだけでクライアントコールバック対応の Web Control が完成する。とりあえず、通常の aspx ファイルは、Page の派生クラスであり、Web Control なので、Page 自身が ICallbackEventHandler を実装してテストする。
Web Form を新規作成すると、

public partial class CallbackTest : System.Web.UI.Page
{
  ...
}

というような分離コードが生成されるので、

public partial class CallbackTest : System.Web.UI.Page, 
                                    System.Web.UI.ICallbackEventHandler
{

と、ICallbackEventHandler を追加する。先程書いたように「文字列を受け取るメソッド」と「文字列を返すメソッド」の2つが存在するので、実装を行う。

    private string name = null;

    void ICallbackEventHandler.RaiseCallbackEvent(string eventArgument)
    {
        this.name = eventArgument;
    }

    string ICallbackEventHandler.GetCallbackResult()
    {
        return "こんにちは、" + (this.name ?? string.Empty);
    }

なお、分離コードではなく、aspx ファイルへの埋め込みの場合は、<%@Implements %> ディレクティブを使用する。(埋め込みでやってる人はわかってるだろうから、具体例は書かない)

Javascript の生成

xmlHttpRequest に関連する Javascript は、ClientScriptManager の GetCallbackEventReference メソッドで作成することができる。aspx のコードの上からであれば、ClientScriptManager は this.ClientScript で取得ができるのだが、この GetCallbackEventReference メソッドの引数は、ドキュメントを見ただけではちょっと難解である。

   string script = this.ClientScript.GetCallbackEventReference(
       this,        // ICallbackEventHandler を実装した Web Control
       "argument",  // サーバへ送る文字列を評価する Javascript
       "callback1", // 結果を受け取る Javascript の関数名
       "context",   // 任意のデータを評価する Javascript
       "callback2", // サーバ側で発生した例外を受け取る Javascript の関数名
       true         // 同期なら false, 非同期なら true
   );

としてスクリプトを取得できる。callback2 は省略できるオーバーロードが用意されていて、省略するとサーバ側で例外が発生した場合に、黙って何もしないことになる。
さて、 上のコメントだけでは意味がわからないかもしれないので、もうすこしこのメソッドについて書いておきます。
まず、一番わかりにくいのは「評価する Javascript」を文字列で指定するところかもしれません。この、GetCallbackEventReference メソッドは Javascriptソースコードを生成するので、たとえば、

result = {1} + {3};

のような Javascript が生成されるとして、この {1} や {3} の部分に埋め込む内容を指定しているようなかんじです。仮に、上記の内容でパラメータがそのまま string.Format() に流れるとすると、

.GetCallbackEventReference(this, "dog", "callback1", "cat", "callback2", true);
  ↓
result = dog + cat;

.GetCallbackEventReference(this, @"""dog""", "callback1", "cat", "callback2", true);
  ↓
result = "dog" + cat;

.GetCallbackEventReference(this, "1", "callback1", "2", "callback2", true);
  ↓
result = 1 + 2;

.GetCallbackEventReference(this, "getValue()", "callback1", "null", "callback2", true);
  ↓
result = getValue() + null;

のような感じでスクリプトが生成されることになります。
最初の例は、単純に dog と cat という文字列を指定したことで、生成された Javascript は dot という変数と cat という変数を足し合わせるというものになります。
次の例は、dog をクオートで囲んだため、生成された Javascript では dog が文字列定数になっています。同様に次は整数定数として Javascript が生成されています。
最後の例は、関数呼び出しやキーワード null すら指定できることを示しています。
callback1, callback2 は Javascript の関数名です、それぞれ、

function callback1(result, context)
{
  // リクエストが成功したときに呼び出される
  // result  : サーバから得られた文字列
  // context : GetCallbackEventReference で指定した context の評価値
}

function callback2(message, context)
{
  // リクエストが失敗したときに呼び出される
  // message : Exception.ToString() の結果
  // context : GetCallbackEventReference で指定した context の評価値
}

たとえば、

.GetCallbackEventReference(this, @"""my name""", "update_label", "null", true);

という呼び出しで作成されたメソッドは最初に書いたものと似た、

send("my name", null);

という呼び出しになります。これを受け取る update_label は、

function update_label(result, context)
{
  // result  == "こんにちは、my name"
  // context == null
  document.getElementByName("Label1").value = result;
}

となり、context には常に null が入っています。
さて、GetCallbackEventReference で手に入れたスクリプトは、勝手にレスポンスに追加されたりはしません。ClientScriptManager.RegisterClientScriptBlock() を使用して登録しなければなりません。
このメソッドは、過去に WebResource の記事でも登場しましたが、

this.ClientScript.RegisterClientScriptBlock(
        this.GetType(),     // key1
        "unique_key",       // key2
        script,             // スクリプト
        true                // script 全体を <SCRIPT> で囲むかどうか
);

のようなかんじで呼び出します。通常、key1 は RegisterClientScriptBlock を呼び出すクラス自身ですので this.GetType()*1 を指定し、key2 にはスクリプトの名前などを指定する。
RegisterClientScriptBlock は key1 と key2 の組み合わせを記録し、まったく同じ組み合わせで2回以上呼び出された場合に何も行わないようになっている。たとえば、

this.ClientScript.RegisterClientScriptBlock(
        typeof(Page), "test", "alert('hello.');", true);
this.ClientScript.RegisterClientScriptBlock(
        typeof(Page), "test", "alert('hello.');", true);

などと、key1, key2 が同じ組み合わせで複数回呼び出しても、生成される HTML は、

<SCRIPT>
alert('hello.');
</SCRIPT>

と1行だけになる。単純な aspx ページから呼び出す場合には、同じスクリプトを2回登録することはありえないが、UserControl や Web Control から RegisterClientScriptXXX を呼び出す場合、同じコントロールを2つ置いたら、RegisterClientScriptXXX は2回呼び出されてしまうので、非常に重要な機能になる。
さて、

string script = this.ClientScript.GetCallbackEventReference(
        this, @"""my name""", "update_label", "null", true);

this.ClientScript.RegisterClientScriptBlock(
        typeof(CallbackTest), "callback_script", script, true);

これでスクリプトの生成と登録は完了に見えるが、Ajax な呼び出しを行う場合にはコレでは不十分である。先に書いたように、変数 script に保持されている内容は、

send("my name", null);

であるため、生成される HTML は、

<SCRIPT>
send("my name", null);
</SCRIPT>

となります。これではページを読み込んだとたんに send() 関数が呼ばれてしまうため、意味がない。*2aspx のページにラベル(Label1)とボタン(Button1)を配置し、Button1 を押した際に、ページの再読み込みなしにラベルの内容が書き換わるようにしてみよう。
まず、send() がページの読み込み時ではなく、ボタンを押したときまで実行されないようにしなければならない。どうすればよいかというと、send() のコードを関数内に移動する。つまり、出力される HTML を、

<SCRIPT>

function Button1_Click()
{
  send("my name", null);
}

</SCRIPT>

に変更すればよい、というわけである。(何を当たり前のことを、と思われるかもしれない)そのためのコードは、

string script = this.ClientScript.GetCallbackEventReference(
        this, @"""my name""", "update_label", "null", true);

this.ClientScript.RegisterClientScriptBlock(
        typeof(CallbackTest), "Button1_Click", 
        "function Button1_Click() { " + script + " }", true);

当然、このようになる。これで、Button1_Click 関数ができたので、Button1.onclick を Button1_Click に設定する。これで完成。

もう1歩

自分の名前を入力して渡せるようにするなら、たとえば Button1_Click() の実装を、

function Button1_Click()
{
  var name = $("nameInputBox").value;

  send(name, null);
}

などとすればよいかもしれません。このようなスクリプトを生成するためには、まず GetCallbackEventReference で send() がローカル変数 name を参照するように

string send = this.ClientScript.GetCallbackEventReference(
        this, "name", "update_label", "null", true);

としておき、

this.ClientScript.RegisterClientScriptBlock(
        typeof(CallbackTest), "Button1_Click",
        @"
function Button1_Click() 
{
  var name = $('nameInputBox').value;

  " + send + " 
}", true);

などと登録すれば OK ですね?

context の使い方を少し

任意のデータが保存できる場所です。どのような使い方もできます。
たとえば、名前を入力する TextBox が5個並んでいるとして、

.GetCallbackEventReference(
        this, "textbox.value", "update_label", "textbox", true);

として、生成したスクリプト

function CheckAllTextBox()
{
  for (int i = 0; i < 5; i++)
  {
    var textbox = ...( i 番目の TextBox を取得 )...

    {0}
  }
}

という文字列の {0} の位置に埋め込んだとすると、

function CheckAllTextBox()
{
  for (int i = 0; i < 5; i++)
  {
    var textbox = ...( i 番目の TextBox を取得 )...

    // TextBox の内容をサーバへ送信し、context として textbox を保存する
    send(textbox.value, textbox);
  }
}

となりますね。サーバ側が渡された文字列を検査して 1(OK) と 0(NG) を返すとして、コールバックで

function SetCheckResult(result, textbox)
{
  // サーバから受け取った結果を、保存しておいたtextbox の内容としてセットする
  // → 2番目の TextBox の結果は2番目の TextBox へ、
  //   3番目の TextBox の結果は3番目の TextBox へセットされる
  if (result == 1)
  {
    textbox.value = "OK";
  }
  else
  {
    textbox.value = "NG";
  }
}

とすると、5つのテキストボックスの内容を順番に検査することができます。
また、非同期呼び出しであれば、5つのチェックボックスのうち、3番目ぐらいが検査に非常に時間がかかる場合でも、他の4つは即座に検査が終われば、検査が終わった順で OK か NG に変化していくように動作します。

*1:this.GetType() は派生クラスが生成される場合に問題になる場合がある。typeof(ページ名) を指定してコンパイル時に解決するのがベター。この日記の例なら typeof(CallbackTest) を指定する

*2:それが期待する動作であれば問題ないし、そのような設計も十分ありえる。