WebSocketsでチャットを作ってみる!

WebSocketsってなにができるの?

WebSocketsは双方向ストリーム通信ができる規格。

最もメジャーなHTTPは同期型といい、

  1. クライアントからサーバーへつなぎに行く。
  2. クライアントからサーバへデータを送信する。
  3. サーバーからクライアントへデータを返信する。
  4. 接続を切断する。

以上をセットで繰り返し、データの交換を行ないます。 HTTPではこの手順が規定されているので、手順3.に進んでから手順2.をやりたかったら 手順4.まで進めて最初から手順を踏んでいかなければなりません。 手順3.だけを繰り返したい場合も基本のこの手順セットを繰り返す必要があります。

これが双方向ストリーム通信の場合、手順1.を予め実行し繋ぎっぱなしにします。 手順2.や手順3.は随時好きなタイミングで実施できます。 これは手順4.を実施するまで任意の回数手順2.や手順3.を実行できます。

データが欲しい側からリクエストする通信をPULL型、 データを送りたい側から送信する通信をPUSH型と言います。

これまでのWebのプロトコルはあくまで「PULL型」と言われる、 クライアント側からのアクションで通信を行うものが主流でした。 最近はRTMPやXMPPなどを筆頭に「PUSH型」の役割も大きくなってきています。 「PUSH型」では、サーバ主導のタイミングでクライアントにメッセージを 送信できます。

タイミングよくクライアントにデータを送るのに「PULL型」を使うと 「ポーリング」という周期的にPULLすることになるし、 コネクションの接続・切断に一定量のデータ(ヘッダ情報など)転送を行ないます。 クライアントもサーバもかなり無駄にリソースを使ってしまう。 そこのところを改善しようとCOMETなどの技術が生まれたりしましたが、 汎用性が低かったり、安定性が悪かったりであまりメジャーになれていません。

HTTPプロトコルの場合、PULL的な通信しかできませんが、 WebSocketsを使うとあたかも、TCPソケットを繋いだかのような双方向通信が出来ちゃうので、 PUSH型の通信やPULL型の通信が容易に実現できるようになっているのです。

ルータ(プロキシ)越え

ものすごくHTTPライクなコネクション確率手順なので、 接続確立までは問題は出にくいようです。

しかし、NAT環境から外のWebSocketsサーバへつなぐ場合には、 いろいろと問題が出るようです。 その多くは、ルータがコネクションをキープしてくれないので、 つながった直後すぐにタイムアウトして切断されてしまうのです。

注釈

どうやら、タイムアウト時間以内に通信をなにか行うことでコネクションをキープできるみたいです!

あらかじめ必要なもの

  • aptitude install libevent-dev python-dev python-setuptools # for ubunutu
  • easy_install gevent
  • easy_install gevent-websocket

geventサーバの紹介

geventはWSGIサーバです。 グリーンスレッドっていう協調型軽量スレッドで動きます。 詳しいことはまだわかっていませんが、 あのTornadeより大量のコネクションをさばけるらしい。

参考:

チャットデモ

以下のコードを実行すればWebSocketサーバに成ります。

chat_sample.py:
import os
import random
from geventwebsocket.handler import WebSocketHandler
from gevent import pywsgi, sleep

ws_list = set()

def chat_handle(environ, start_response):
    global cnt
    ws = environ['wsgi.websocket']
    ws_list.add(ws)
    print 'enter!', len(ws_list)
    while 1:
        msg = ws.wait()
        if msg is None:
            break
        remove = set()
        for s in ws_list:
            try:
                s.send(msg)
            except Exception:
                remove.add(s)
        for s in remove:
            ws_list.remove(s)
    print 'exit!', len(ws_list)

def myapp(environ, start_response):  
    path = environ["PATH_INFO"]
    print path
    if path == "/": 
        start_response("200 OK", [("Content-Type", "text/html")])  
        return open('./chat_sample.html').read()
    elif path == "/chat":  
        return chat_handle(environ, start_response)
    raise Exception('Not found.')

server = pywsgi.WSGIServer(('0.0.0.0', 8080), myapp, handler_class=WebSocketHandler)

server.serve_forever()

以下のHTMLをブラウザで開けば、チャットルームに入ります。

chat_sample.html:
<!DOCTYPE html>
<html>
<head>
<script src="http://ajax.googleapis.com/ajax/libs/jquery/1.4.2/jquery.min.js"></script>
<script>
$(function() {
    var data = {};
    ws = new WebSocket("ws://192.168.66.106:8080/chat");
    ws.onopen = function() {
        ws.send('hi');
    };
    ws.onmessage = function(e) {
      $("#holder").append($('<p>'+e.data+'</p>'));
    };
    $('#sender').append($('<button/>').text('send').click(function(){
        ws.send($('#message').val());
    }));
});
</script>
</head>
<body>
<div id="sender">
<input type="text" id="message" value="text" />
</div>
<h3>Messages</h3>
<div id="holder"></div>
</body>
</html>

参加中はWebSocketsがサーバとブラウザ間が接続が維持されます。 新しいタブで複数開けば別人としてチャットに参加します。 ページを閉じれば、WebSocketは切断されます。

まとめ

geventが1コネクションに1擬似スレッドを当てて捌いてくれるおかげで、 めっさシンプルにWebSocketsのチャットサーバが実現できます。

他のWebサーバの場合、PythonのGILが障害になったり、 ネイティブスレッドのリソース消費が気になって仕方がありませんね。

追記

handlerの引数からwsがなくなっています。 environの中で渡す仕様に変更されました。

このほうがWSGI標準のやりかたっぽいですね!