1枚psgiファイルでchatのようなもの
前々から同時接続中のクライアントにサーバから通知というのがものすごーくやりたかったのだけどこのたびTwiggyとTatumakiのegとぶつかり稽古を重ねてようやくできた。
多分最近だとこういうのやるのにWebSocketとかでやるのかもしれないけどそれだとブラウザが対応してなかったりするとアレなのとTwiggyのegと全く同じになってしまう気がするのでjquery.ev.js使ってlong-poll(というのかしら)でやってますです。
use strict; use warnings; use Encode; use JSON; use Data::Section::Simple; use Text::Xslate; use Plack::Request; use Plack::Builder; #$ENV{TWIGGY_DEBUG} = 1; my $vpath = Data::Section::Simple->new()->get_data_section(); my $tx = Text::Xslate->new(path => [$vpath]); my $timer; my %clients; my $app = sub { my $env = shift; my $req = Plack::Request->new($env); if ($req->path eq '/') { my $html = $tx->render('index.tx'); return [ 200, ['Content-Type' => 'text/html', 'Content-Length' => length $html ], [ $html ] ]; } elsif ($req->path eq '/post') { my $message = decode_utf8( $req->param('message') ); my $name = decode_utf8( $req->param('name') ); my $current_time = time(); # 全てのクライアントにレスポンスを返す for my $cv (values %clients) { undef $timer; $cv->send([ 200, ['Content-Type' => 'text/plain'], [ encode_json [ { type => 'message', message => $message, name => "${name}[$env->{REMOTE_ADDR}]", current_time => $current_time, }, ] ] ]); } return [ 200, ['Content-Type' => 'text/plain'], ['success'] ]; } elsif ($req->path eq '/poll') { my $client_id = $req->param('_'); my $fh = $env->{'psgix.io'} or return [500, ['Content-Type' => 'text/plain'], ['something wrong']]; my $cv = AnyEvent->condvar; $clients{ $client_id } = $cv; return sub { my $responder = shift; # callbackの中でrecvしないと× $cv->cb(sub { # $cv->send()するとここに戻ってくる。$resには$->send()に渡した引数が返ってくる。 my $res = $cv->recv; my $w = $responder->($res); }); # イベント待ちタイマー $timer = AnyEvent->timer( after => 30, interval => 0, cb => sub { undef $timer; delete $clients{ $client_id }; $cv->send( [200, ['Content-Type' => 'text/plain'], [ encode_json +[] ] ] ); } ); }; } else { return [ 404, ['Content-Type' => 'text/plain'], ['not found'] ]; } }; builder { mount '/chat' => builder { enable "Static", path => sub { s!^/static/!! }, root => './htdocs/static'; $app; }; }; __DATA__ @@ index.tx <html> <head> <title>chat</title> <script src="http://code.jquery.com/jquery-1.4.4.min.js" type="text/javascript"></script> <script src="/chat/static/js/jquery.ev.js" type="text/javascript"></script> <script type="text/javascript"> function get_duration(millisecond) { // milli => sec var second = parseInt( millisecond / 1000 ); var r_milli = millisecond % 1000; // sec => min var minute = parseInt( second / 60 ); var r_sec = second % 60; // min => hour var hour = parseInt( minute / 60 ); var r_min = minute % 60; // hour => day var day = parseInt( hour / 24 ); var r_hour = hour % 24; if (day) { return day + 'days ago'; } else if (hour) { return hour + 'hours and ' + r_min + 'minutes ago'; } else if (minute){ return minute + ' minutes and ' + r_sec + ' seconds ago'; } else { return second + ' seconds ago'; } } function replace_duration(current_time) { $(".duration").each(function() { var post_time = $(this).attr('title'); if (current_time === post_time) { return true; } var time = current_time - post_time; $(this).text( get_duration(time * 1000) ); }); } $(document).ready(function() { $("#message").keyup(function(e) { if (e.keyCode === 13 && $(this).val()) { $.ajax({ url : '/chat/post', data: { message : $(this).val(), name : $("#name").val() || 'unknown' }, success: function(data) {} }); } }); $.ev.loop('/chat/poll', { message : function(m) { var date = new Date(); var duration = get_duration(m.current_time * 1000 - date.getTime()); $("#messages").prepend( $('<tr/>').append( $('<td/>').addClass('name').append(m.name) ) .append( $('<td/>').addClass('message').append(m.message) ) .append( $('<td/>').addClass('duration').append(duration).attr('title', m.current_time) ) ); replace_duration(m.current_time); } }); }); </script> <style type="text/css"> h1 { font-size: 50px; } hr { border-top: 1px solid gray; } table { white-space:nowrap; table-layout: fixed; width: 100%; } #messages { font-size: small; } .name { width: 15%; font-weight: bold; } .message { width:70%; text-align:left; float:left; } .duration { text-align:right; font-size:xx-small; color: gray; vertical-align: bottom; } </style> </head> <body> <h1>chat modoki</h1> <lable>name:</label> <input type="text" name="name" id="name" size="15" /> <lable>message:</lable> <input type="text" name="message" id="message" size="70" /> <hr /> <table id="messages"></table> </body> </html>
Twiggy使って非同期なアプリを書くときに自分なりに考えた要点
AE使いたいアクションの場合はコードリファレンスを返してやる。
レスポンスとしてコードリファレンスを返すとTwiggyがそのコードリファレンスの第一引数にコードリファレンスを渡してくれる。そしてこの渡されたコードリファレンスにPSGIレスポンスを渡して実行してやるとクライアントにレスポンスが返る感じになる。
$cv->send()に渡した値が$cv->recv()の返り値となる
なのでAEのコールバックで$cv->send()するときにクライアントに返したいPSGIレスポンスを渡してやる。そうすると$cv->recv()に戻ってくるので後はその返り値を受け取ってTwiggyから渡されたコードリファレンスに渡してやればいい。