PoCo::HTTP で Comet チャットサーバを作る

せっかくのイベントドリブンフレームワークな POE ですから Comet でチャットをやってみようかと。ありきたりですが。

POE::Component::* でウェブサーバを立ち上げられるのは現在のところ,

  1. POE::Component::Server::HTTPServer
  2. POE::Component::Server::SimpleHTTP
  3. POE::Component::Server::HTTP

の3つがあります。1番目は最近 inactive なのでパス。2番目は「イベントフレームワーク」への馴染みはいいんですが「Simple」じゃなくなってます。3番目はコールバック関数を登録するというお気楽スタイルなんですがちょっと雑な感じ。

今回は楽をするために3番目の PoCo::HTTP を使ってみたいと思います。id:naoya 氏も最近紹介してましたし。


URI の設計指針は,

  • チャット内容の取得は /view という URI に対するリクエストを Long-poll で
  • 自分の発言内容の投稿は /write という URI に対する POST リクエストで,パラメータ名は「body」

サーバ側のプログラムは以下のとおりです。当初コンテンツ自体は Apache で出力し,AJAX 通信部分は POE で別ポートに対して出力しようと思ってました。しかし Firefox でセキュリティチェックにひっかかってしまったんで(IEOpera なら大丈夫),コンテンツ自体も自力で出力する必要があり冗長(sub view_file_maker の部分)になってしまいました。

#!/usr/bin/perl

use strict;
use warnings;

use POE qw( Component::Server::HTTP );
use HTTP::Status;
use Scalar::Util qw( refaddr );

my $server = POE::Component::Server::HTTP->new(
    Port => 8888,
    ContentHandler => {
        '/'            => view_file_maker('index.html'),
        
        '/view'        => \&view_content,
        '/write'       => \&write_content,
    },
    PostHandler => {
        '/view'        => [ \&view_final ],
    },
    ErrorHandler => {
        '/view'        => \&view_final,
    },
);

POE::Kernel->run();

exit();

my %receiver;

sub view_content {
    ### view_content...
    my ($req, $res) = @_;
    
    $receiver{refaddr $res} = $res;
    
    $req->headers->header(Connection => 'close');  # B.K.
    
    return RC_WAIT;
}

sub view_final {
    ### view_final...
    my ($req, $res) = @_;
    
    delete $receiver{refaddr $res};
}

sub write_content {
    ### write_content...
    my ($req, $res) = @_;
    
    use CGI;
    my $q = CGI->new($req->content);
    
    broadcast_message($q->param('body'));
    
    $req->headers->header(Connection => 'close');
    
    standard_response($res, 'text/plain');
    $res->content("sent\n");
    
    return RC_OK;
}

sub broadcast_message {
    ### broadcast_message...
    my ($message) = @_;
    
    foreach my $res (values %receiver) {
        standard_response($res, 'text/plain');
        $res->content($message . "\n");
        $res->continue();
    }
}

sub standard_response {
    my ($res, $content_type) = @_;
    
    $res->code(RC_OK);
    $res->content_type($content_type);
    $res->headers->header(Pragma => 'no-cache');
}

sub RELOAD_FILE() { 1 }

sub view_file_maker {
    my ($file_name, $content_type) = @_;
    $content_type ||= 'text/html; charset=UTF-8';
    
    my $content;
    return sub {
        ### view_html: ${file_name}
        my ($req, $res) = @_;
        
        if (RELOAD_FILE || ! $content) {
            open(F, $file_name) or die $!;
            local $/ = undef;
            $content = <F>;
            close(F);
        }
        
        $req->headers->header(Connection => 'close');
        
        standard_response($res, $content_type);
        $res->content($content);
        
        return RC_OK;
    };
}

流れは,

  • /view へのリクエストがきた場合,レスポンスオブジェクトをグローバルに保存しつつ RC_WAIT でとりあえずコールバックから戻る
  • /write へのリクエストがきた場合,保存されたレスポンスオブジェクトに対して発言内容をブロードキャストする

というものです。

non keep-alive な通信をしたいのですが,IE だとコネクションを握って離してくれないので,切断するためにちょっと変なことをしています。

    $req->headers->header(Connection => 'close');

の部分です。詳しくは POD に書いてあります。

ブラウザ側の実装ですが,「http://www.tsujita.jp/blojsom/blog/default/Ajax/2006/09/13/Comet%E3%82%92%E5%AE%9F%E8%A3%85%E3%81%97%E3%81%A6%E3%81%BF%E3%82%8B%EF%BC%9F.html」が非常に参考になります。というか自力でうまくできなかったんでかなりパクってしまいました。すいません。なお prototype.js ではなく Mini AJAX を使ってみました。

<html>
    <head>
        <meta http-equiv="Content-Type" content="text/html; charset=UTF-8">
        <title>comet chat</title>
        <script type="text/javascript"><!--

// ここに Mini AJAX
//      http://www.bigbold.com/snippets/posts/show/2025
// の内容をそのままコピペすること

function want_message() {
    ajax.get('/view', arrive_message);
}

function arrive_message(text) {
    if (text == '')
        return;

    $('messages').value = text + $('messages').value;

    want_message();
}

function write_message() {
    var params = 'body=' + encodeURIComponent($('message').value);

    ajax.post('/write', function (){}, params);

    $('message').value = '';
}

        // --></script>
    </head>
    <body onload="want_message()">

<textarea id="messages" cols="50" rows="10"></textarea>
<br>

<input type="text" id="message" size="30">
<input type="button" value="送信" onclick="write_message()">

    </body>
</html>

この内容を index.html という内容でサーバスクリプトと同じ場所に置くと,ドキュメントルートとして表示してくれます。

あとは,サーバスクリプトを起動して

http://サーバ名:8888/

と叩いてやるとチャットがたちあがります(かなり手抜きですが)。

サーバプログラムのほうは PoCo::HTTP のおかげでかなりイベントフレームワークっぽくない感じになってしまいましたが,あのようにブロードキャストメソッドを雑に書いてもきちんと並列に動いてくれるところが POE の美点かな,と。

追記 2006/11/21

これ試したら、IEでStack Overflowになったから、何かと思ったら2回目のレスポンスが即座に来てた。

IE だとそもそも PoCo::HTTP の挙動も安定しなかったんでいろいろためしているときに Pragma: no-cache を入れてました。あくまで HTTP/1.0 サーバであるという意思表示でしたが,Cache-Control ヘッダと Expires ヘッダを入れる方が確実ですね。

arrive_message 内から want_message を直接呼んでいる件については,恥ずかしながら setTimeout() というものを知らなかったので(笑)。なるほど,勉強になりました。