at posts/single.html

リアルタイムなチャット (2)

興味があったので、先日の日記に書いた AJA Chat のソースを読んでみた。

基本構造

AJA Chat

  • index.cgi / typing.cgi: ログイン画面およびチャット画面を表示する。特に処理はやっていない。
  • serv.cgi: XMLHttpRequest() からの接続を受けつける。発言が更新されたらレスポンスを返す。30秒以内に発言が更新されなければ(write.cgiからシグナルがこなければ)タイムアウトとする。
  • write.cgi: XMLHttpRequest() 経由で発言を受信する。受信したら serv.cgi へシグナルを送る。

index.cgi

処理順にソースを眺めていく。

    25   u = @cgi['u'].to_s
    26   if (u == '')
    27     print %Q(
    28 </head>
    29 <body>
       # 以下ログイン画面の HTML が続く
    40 )
    41   else
    42     require 'read'
    43     print %Q(
    44 <script type="text/javascript">
       # 以下チャット画面の JavaScript と HTML が続く

ログイン処理とチャット画面の表示を担当。 ログインしていなければ (パラメータ u が無ければ) ログイン画面を表示し、ログインしていればチャット画面を表示する。

以下はクライアント側の処理。 チャット画面では、埋め込まれている JavaScript から serv.cgi と write.cgi を呼ぶようになっている(後述)。

   114 <body onLoad="reload();">

まず、画面の読み込みが完了すると reload() 関数を実行する。

    95 function reload() {
    96   var xmlhttp = createXmlHttp();
    97   user = encodeURIComponent("#{u}");
    98   xmlhttp.open("GET", "serv.cgi?"+user, true);
    99   xmlhttp.onreadystatechange=function() {
         // サーバから応答があった場合の処理
   109   }
   110   xmlhttp.send(null);
   111 }

reload() 関数では XMLHttpRequest を使って serv.cgi とのコネクションを確立する。 サーバから応答があった場合の処理は以下の通り。

   101       if (xmlhttp.status == 200) {
   102         res = xmlhttp.responseText;
   103         if (res.substring(0, 2) != "no") {
   104           document.getElementById("log").innerHTML = res;
   105         }
   106       }
   107       reload();

応答内容でチャット画面を書き換えて、さらに再度 reload() を呼んでいる。 時間内に誰も発言しなかったら "no" という文字列が返ってくるので、その場合は画面を書き換えないようになっている。 個人的には "no" という文字列ではなくて、 304 Not Modified を返してもいいと思う。

ここまでは読み込み処理で、次は発言時の書き込み処理になる。

   117 <form name="f" onSubmit="talk(); return false;">
   118 発言: <input name="msg" size="40">
   119 </form>

talk() 関数を呼びだしている。 return false; とすることで、(JavaScript ではなく)ブラウザ自体が Submit 処理を実行することを防止している。

    65 function talk() {
    66   if (document.f.msg.value == "") return;
    67   xmlhttp = createXmlHttp();
    68   msg = encodeURIComponent("#{u}: " + document.f.msg.value);
    69   document.f.msg.value = "";
    70   xmlhttp.open("GET", "write.cgi?msg="+msg, true);
    71   xmlhttp.onreadystatechange=function() {
    72     if (xmlhttp.readyState == 4 && xmlhttp.status == 200) {
    73       res = xmlhttp.responseText;
    74       document.getElementById("log").innerHTML = res;
    75     }
    76   }
    77   xmlhttp.send(null);
    78 }

発言内容をサーバに送っているだけで、特に難しいところは無い。 強いて言えば、文字化け対策のために encodeURIComponent() で URI エンコーディングしているくらいかな。

serv.cgi

次は serv.cgi 。これはクライアントの reload() 関数から XMLHttpRequest 経由で起動される。 起動すると、まず sync 関数を実行する。 引数はクエリストリング経由で取得したユーザ名。 この方式だと、悪戯で他のユーザになれちゃいそうだけど、そこはサンプルということで。(実用するならセッション ID を使ったほうがいい)

    13 def sync(id)
    14   @ifile = File.open("#{id}.sync", 'w')
    15   @ifile.flock(File::LOCK_EX)
    16   File.open("#{$$}.pid", 'w') do |pidf|
    17     pidf.puts(id)
    18   end
    19
    25   sleep(30)
    26   if (@ifile)
    27     File.unlink("#{$$}.pid")
    28     @ifile.flock(File::LOCK_UN)
    29     @ifile.close
    30     response('no')
    31   end
    32 end

「ユーザ名.sync」と「プロセスID.pid」の2つのファイルを作成し、30秒間スリープしている。 「ユーザ名.sync」は参加者一覧の取得に、「プロセスID.pid」は write.cgi からの更新通知に使われている。 26行目以降は、30秒経過したとき(タイムアウト時)の処理。 ファイルの後片付けをして、"no"という文字列を返している。

では、誰かが発言したときはどうしているのか。 それが以降の処理になる。

    44 trap(:USR1) do
    45   File.unlink("#{$$}.pid")
    46   @ifile.flock(File::LOCK_UN)
    47   @ifile.close
    48   @ifile = nil
    49   require 'read'
    50   response(read_chat())
    51   exit 0
    52 end

trap 自体は起動直後(sync メソッドが実行される前)に実行される。 ここでは、 USR1 というシグナルを受信した場合の処理を定義している。

sync メソッド内で 30 秒間スリープしている間に USR1 シグナルを受信すると 45 行目から 51 行目が実行され、クライアントに発言内容を返すようになっている。 USR1 シグナルは誰かが発言した瞬間に受信するようになっているので、発言内容がリアルタイムでブラウザの画面に表示されるというわけ。

read.rb

前述の serv.cgi のシグナル受信時に呼ばれる。

     1 def read_chat
     2   return '' if (!File.exists?('dat.db'))
     3   ret = ''
     4   File.open('dat.db', 'r') do |i|
     5     i.flock(File::LOCK_SH)
     6     ret = i.read
     7     i.flock(File::LOCK_UN)
     8   end
     9   ret
    10 end

実質的には dat.db の中身を返しているだけ。

write.cgi

次は write.cgi 。これはクライアントの talk() 関数から XMLHttpRequest 経由で起動される。 メインの処理は users.db, topic.db, chat.db というファイルに書き込み処理をやっている。 ここは本題と外れるので、ひとまず省略する。 書き込み処理が終わると、以下のように mkdat メソッドを実行する。

    63 require 'mkdat'
    64 mkdat()

mkdat.rb

まず、 write.cgi が作成した users.db, topic.db, chat.db から dat.db を生成する。ここもひとまず省略。 dat.db を生成すると、他の serv.cgi へ更新を通知する。

    59   Dir.glob('*.pid').each do |pid|
    60     pidi = pid.sub(/.pid$/, '').to_i
    61     begin
    62       Process.kill(:USR1, pidi)
    63     rescue
    64       File.unlink(pid)
    65     end
    66   end

serv.cgi が「プロセスID.pid」というファイルを作っているので、そこからプロセスIDを取得する。 そして、各プロセスに対して、 USR1 シグナルを送信する。 USR1 シグナル受信後の serv.cgi の処理は前述の通り。

補足

サンプルで公開されている AJA Chat には、入力中の文字列も他の人の画面に表示されるようになっている。 これは、 index.cgi の代わりに typing.cgi で実現している。 今回興味を持ったのは、シグナルを使ってリアルタイムを実現するところだったので、 typing.cgi については省略。 こっちは定期的 (800ミリ秒ごと) にテキストボックスの内容をチェックして、変更があればサーバに送ることをやっているみたい。

関連する日記