Google App Engine/Pythonで、サーバ側からクライアント側へのpush型通信*1が出来るChannel APIというのが追加されたので、『れとろ・ちゃっと』*2に実装してみました。
そのついでのメモ代わりの記事です。
Java版は既に@ts_3156さんが、実際の動作サンプルと一緒に記事にされています。
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でエンコードされた物を設定することが推奨されています