風柳メモ

ソフトウェア・プログラミング関連の覚書が中心

Google App Engine/PythonのChannel APIを使ってみた

Google App Engine/Pythonで、サーバ側からクライアント側へのpush型通信*1が出来るChannel APIというのが追加されたので、『れとろ・ちゃっと*2に実装してみました。
そのついでのメモ代わりの記事です。
Java版は既に@さんが、実際の動作サンプルと一緒に記事にされています

12/19現在、どうもクライアントで読み込むGAEのスクリプト(JavaScript)に不具合があるっぽいですが(後述)
クライアント側のスクリプトが一緒なら、Java版でも同様の不具合が出ると思うんですが、どうなのかな?→やっぱり出るみたいですね。

サーバ側でやること

1. Channel APIのimport

Pythonのソースの最初の方に

from google.appengine.api import channel

を追加します。
Googleのドキュメントからはこの記述抜けている気がする…。

2. チャネルを作成し、tokenをクライアントに渡す

クライアント毎に有効なチャネルを作成して対応するtokenを取得し、これをクライアント側に渡す必要があります。
チャネル作成&token取得は、以下のようにcreate_channel()を使って行ないます。

channel_token = channel.create_channel( client_id )

client_idはサーバ側でクライアントを識別できる、ASCII文字列(64文字以内)である必要があります*3
ちなみに、同一の client_id であっても、返されるtokenは create_channel()をコールする度に違うものになります。
従って、その都度クライアントに渡す必要があります。

クライアントに渡す方法ですが、この時点ではpush通信が確立していませんので、クライアント側からの要求(HTTP GETなり)に対して返すことになります。


『れとろ・ちゃっと』を例にすると、

  • チャットルームに入室時点(チャットルームのHTMLのGET要求)で、チャネルを作成し、tokenをHTMLに埋めこんで(FORMのHIDDENなINPUT要素として)渡す。
  • クライアントから定期的に(15分)ポーリング(POST)し、tokenの有効期限が切れていたら、チャネルを作成しなおし、tokenを応答(JSON)の中に入れて渡す。

のようにしています。
ちなみに、本来のtokenの有効期限は2時間ですが、『れとろ・ちゃっと』では1時間30分で期限切れとみなし*4、これ以降のクライアントからのポーリングの際にtokenを取得しなおして渡しています。

3. イベントが発生したら、各クライアントに対してメッセージを送る

イベント*5が発生したら、各クライアントに対して*6メッセージを送ります。
メッセージ送信は、以下のようにsend_message()を使って行ないます。

try:
  channel.send_message( client_id , message )
except Exception, s:
  pass # 本来はエラー時の処理を入れる

try: 〜 except: で括ってあるのは例外対策です。
ちなみにローカルでテストしていた際は、messageが空文字(u'')だと、InvalidMessageErrorが発生していました。
引数はclient_id*7とmessage*8だけで、tokenはありません。
普通に考えると、tokenの有効期限が切れた宛先に送ったりしたら、例外が発生したりするのではないかと推測されますが、どうなんでしょうね。
試していないので、誰かやってみて下さい。ちなみにテスト環境だと、例外も発生せず、ただクライアント側にmessageが届かないだけでした…。

クライアント側でやること

記述はほとんどhttp://code.google.com/intl/en/appengine/docs/python/channel/javascript.htmlの焼き直しです。

1. Channel API用のスクリプト読み込み

<head>〜</head>内に、

<script src="/_ah/channel/jsapi"></script>

のような記述を入れ、Channel API用のスクリプトを読み込みます。

2. tokenの取得と、channel object作成

サーバ側で作成されたtokenを何らかの(サーバ側で用意された取得手順に応じた)形で取得し、<body>〜</body>内のscript要素(JavaScript)で、

var channel = new goog.appengine.Channel( channel_token );

の形で、channel object を作成します。

3. ソケットを開き、コールバック関数を設定

2. で得られた channel object のメソッドである open() をコールし、ソケットを開き、コールバック関数を指定します。

var socket = channel.open({
    onopen      :   function(){
        //  ソケットopen完了時(受信可となったタイミング)にコールされる処理
    }
,   onmessage   :   function(message) {
        //  メッセージを受信したときにコールされる処理
        //  message.data が受信した文字列
    }
,   onerror     :   function(error) {
        //  ソケットで何らかの異常が発生したときにコールされる処理
        //  error.codeにエラーコード、error.descriptionに理由が入る
        //  ※token の有効期限が切れた際にも呼ばれる
    }
,   onclose     :   function(){
        //  ソケットが何らかの理由でクローズされたときにコールされる処理
        //  ※token の有効期限が切れた際にも呼ばれる
        //  ※試した範囲では、自分で socket.close()を呼んでもコールされなかった
    }
});

/*
  上記のように引数として指定するほかにも、
  var socket = channel.open()
  とした後で、
  socket.onopen = function(){...};
  のような形でコールバック関数を指定することも出来る。
*/

不具合っぽいもの

token の本来の有効期限(2時間)が切れる前(onerrorがコールされる前)に、

// 既存のソケットを閉じる
socket.close();

// サーバから新規取得した token でチャネル作成
channel = new goog.appengine.Channel( new_channel_token );

// ソケット作成(引数省略、実際には上記と同様にコールバックを指定)
socket = channel.open(...);
 

のように、新規tokenでソケットを開きなおそうとすると、onopenがコールされず、通信も出来なくなってしまいます。
そのまま放っておくと、古いtokenの有効期限が切れたタイミングで、onerror、oncloseが呼ばれます。

ちょっと調べたところ、どうもクライアント側のスクリプトに不具合があるようで、ソケットを開いたときに(多分通信用の)隠しIFRAME要素(id="wcs-iframe"、name="wcs-iframe")が作られるのですが、これが socket.close() でも除去されずにそのまま残り、新規tokenでソケットを開くと(同じid、nameを持つ)IFRAMEが新たに作られてしまう、という現象が発生していました。

試しに、socket.close()とchannel = new goog.appengine.Channel(...)の間に、

var iframes = document.getElementsByTagName('iframe'), wcs_iframes=[];
for (var ci=0,len=iframes.length; ci<len; ci++) {
    var iframe=iframes[ci];
    if (iframe.id=='wcs-iframe' || iframe.name=='wcs-iframe') wcs_iframes[wcs_iframes.length]=iframe;
}
for (var ci=0,len=wcs_iframes.length; ci<len; ci++) {
    wcs_iframes[ci].parentNode.removeChild(wcs_iframes[ci]);
}

のような処理を入れて、古いIFRAMEを除去してやると、うまく動作するようになりました。
多分、不具合だと思うんだけれどなぁ……私のクラス/関数の使い方が何かおかしいのかも知れませんが。

*1:通常のウェブで見られる、クライアントからの要求に対してサーバ側が応答する形(pull型通信)ではなく、サーバ側のイベントを即時クライアント側に通知できる形の通信…といいつつ、厳密には、Cometと同様のロングポーリング(非同期ポーリング)を使ったpushとpullの混合方式だと思われますが

*2:これまではクライアントを待機させる(ロングポーリング)処理だけ自宅サーバで代用していました

*3:『れとろ・ちゃっと』では、ルームのオーナID+ルーム番号+Twitterのscreen_nameをclient_idとして使用

*4:memcacheでtokenを保存し、有効期限を90分(timeに60*90設定)にしてタイマ代わりにしています

*5:これはアプリケーションに応じて様々でしょう。『れとろ・ちゃっと』では誰かが入室/退室する、話す、離席/着席する、といったものをイベントとして扱っています

*6:Channel APIでは一斉通知のような方法は用意されておらず、個別通知になります

*7:create_channel()で指定したのと同じもの

*8:実際にクライアントに送られる文字列。クライアントにはPOSTメッセージとして送られるようで、32KBまで。JSONでエンコードされた物を設定することが推奨されています