豆腐食べたい

プログラミングとかその他もろもろとかの技術の摂取録。あと豆腐食べたい。

PHPでWrenchによるWebSocketを使ってチャットデモを作る

タイトルの通りですけど、PHPでWebSocketを使ったチャットデモを作りました。この記事ではそのために自分が取った生まれたての小鹿のようにたどたどしい行動の記録を残そうというわけであります。

先にやったことをまとめときますと、PHP製のWebSokcetサーバーであるWrenchを用いて、ユーザー名とチャットルーム名を入力してチャットができるタイプのチャットアプリケーションを作りました。

そして先に断っときますと、自分は以前、というか前の記事でメール送信フォームを作ったのがほぼ初のweb開発体験&PHP体験ですので、これはつまり2度目のPHP体験です。ですから、きちんとPHPを触ってきた方には一体何書いとんじゃこいつって感じの死ぬ前の牡鹿のように弱々しい行動をとっていたりするかもしれませんが、そのときはコメント欄で指摘してくださるか、そっと心の中でだけ死ねやハゲとかつぶやいてくだされば幸いです。

では以下やったことです。

1.Wrenchの導入 〜 デモを動かす

1.1-Wrenchについて

まず、PHPでWebSocektを実現するためにWrench (https://github.com/varspool/Wrench) を用いました。これはvarspool氏が「DO WHAT THE FUCK YOU WANT TO」パブリックライセンスに基づいて公開していらっしゃるプロダクトです。PHPでWebSocketをいじった方々のブログを見るとlemmingzshadow氏のphp-websocekt (https://github.com/lemmingzshadow/php-websocket) を紹介していらっしゃる方々が多いようですが、このphp-websocketはwrenchからforkされたもののようでしたので、事情はよく把握できていないのですがこちらのものを使ってみることにしました。(あと被forkとstarの数も多かったので・・・)

ちなみにphpでwebsocketを使えるようにするためのものとしてはこの他にもmazhack氏のphp-websocket (https://github.com/mazhack/php-websocket) と nicokaiser氏の php-websocket (https://github.com/nicokaiser/php-websocket) がありまして、それぞれ有名なようです。そしてWrenchのREADMEのAuthors節では

Authors
The original maintainer and author was @nicokaiser. Plentiful improvements were contributed by @lemmingzshadow and @mazhack. Parts of the Socket class were written by Moritz Wutz. The server is licensed under the WTFPL, a free software compatible license.

となっていまして、複雑に入り組んでいて正直どれを使うのが一番メジャーなのかわかりません。詳しい方がいたら教えていただきたいですね。。
また、WrenchはREADMEにも書いてあるとおりRFC 6455に対応していますので、各最新版のブラウザならだいたい対応していくはずです。WebSocketのバージョンについて詳しくは
WebSocket - Wikipedia
などでどうぞ

1.2-ダウンロード

まずは、WebSocektが動く様を同梱されているデモで確かめます
https://github.com/varspool/Wrench/ からDownloadを経由してZipをダウンロードするかもしくはgit cloneでもするとWrenchのデータが入手できると思います。すると中にディレクトリdoc, examples, libがあります。まずはapacheなりなんなりのサーバー公開ディレクトリに「Wrench」ディレクトリでも作り、そのなかにそのlibとexamplesをコピーします。*1

1.3-動くように改変

次にexamplesの中のserver.phpを環境に最適化しましょう。Server.phpの以下の部分("?php"ははてなのシンタックスハイライトを有効にするために書いたもので、以下の部分は先頭にはあるわけではないです)

<?php
$server = new \Wrench\Server('ws://localhost:8000/', array(
    'allowed_origins'            => array(
        'mysite.localhost'
    ),

の"mysite.localhost"を自分のサーバーに適したものに変更してください。私は単にlocalhostに変更しました。8000ポートがすでに使われている場合はこれも変更してもいいのですが、その場合クライアント側のjavascriptにもポートの変更が必要になります。

さて、普通なら改変はこんなものですむかな〜とか思うわけですけど、現実は厳しい、これからそれなりに改変が必要となります。初期状態じゃechoデモは全然動きません。なんでこんなことになったんだろう。

やることは ・coffeescriptを動くようにする ・jquery,json2.jsを設置する ・いくつかの不整合を修正する
の3点です。素直に他ブログで紹介されてた通りのWebSocketライブラリ導入すれば良かったとかは生まれたての小鹿なので考えつきませんでした。

1.3.1-coffeescriptを動くようにする

まずexamplesの中のcoffeescriptディレクトリを開いた中にあるindex.htmlをエディタで開きますと

<script src="lib/coffeescript/jsmaker.php?f=client.coffee"></script>

という部分があり、引数で渡したcoffeescriptをjavascriptに変換してくれることになっているようですが、社会は厳しい、そんなPHPスクリプトは同梱されてません。そこですでにcoffeescriptの開発環境を導入済みの方はそのようなPHPスクリプトを用意するか、coffeeをコンパイルしてjsにしたものを直接設置しましょう。(私はこれでやりましたがcoffeeの導入で色々あって苦労しました・・・)
coffeeを導入していない方で、デモを動かせればいいから導入もしなくていいやって方は

CoffeeScriptはインストールしなくてもブラウザ上で実行できるよという話 - ariyasacca

さんを参考にしてcoffeescriptをブラウザで直接動かせるようにしましょう。具体的には上記の部分を

<script type="text/coffeescript" src="coffee/client.coffee"></script>
<script type="text/javascript" src="http://jashkenas.github.com/coffee-script/extras/coffee-script.js"></script> 

に修正すれば万事OKです。(2012-10-1現在)

1.3.2-jquery,json2.jsを設置する

次にindex.htmlではjqueryとjson2.jsを要求していますが、やはりデモには同梱されていません。

http://jquery.com/
および
https://github.com/douglascrockford/JSON-js
からそれぞれ入手して、jsディレクトリを作ってそこに配置しましょう。

1.3.3-不整合を修正する

ラストです。これをすませばecho demoが動くようになります。
と言ってもやることは簡単、coffeeディレクトリの中にあるclient.coffeeを開いて

    serverUrl = 'ws://127.0.0.1:8000/demo

となっているところを

    serverUrl = 'ws://127.0.0.1:8000/echo

に変更しましょう。
必要な修正は以上です。

1.4-echoデモを動かす

では早速echoデモを動かしましょう
まずはexamplesディレクトリでターミナルを開いたら

    $php server.php

としてWebSocektサーバーを開始してください。

    info: Server Initialized

と表示されれば成功です。
これが表示されたならwebブラウザで http://localhost/Wrench/examples/coffeescript/index.html にアクセスしましょう(URLを環境に合わせて適宜変更してください)
下の画像のようにconnectedとでれば成功です。適当にメッセージを送ってみましょう

f:id:tofu_head:20121001185323p:plain

これで無事にPHPでWrenchによるWebSocketが動きました

2.チャットアプリケーションを作る

では同梱のShinyなデモの話は終わり、うって変わって怪しいチャットデモを作る話です。同時に自作アプリケーションを作るレクチャーにもなるかもしれません。ならないかもしれません。

2.1-自作アプリケーションをWrenchサーバーに登録する

まずは先ほどのデモの段階で作成したexamplesを複製し、"chatdemo"にでもリネームしてください。ここにこのデモをベースとしてチャットデモを作ります。

次にexamplesを抜け、lib/Wrench/Applicationを見てください。Application.phpとEchoApplication.phpがあるはずです。すべてのWrenchサーバー用のアプリケーションは、このうちのひとつApplication.phpを継承する単一のクラスで表現されます。ではこのEchoApplication.phpを複製し、ChatApplication.phpにでもリネームしてください。そして中身をエディタで開き、EchoApplicationクラスをChatApplicationクラスに書き換えましょう。これで自作アプリケーションをWrenchに登録する準備ができました。

次に先ほど作ったchatdemoディレクトリを作り、server.phpを開きます。コメント部分を無視すると下記のようになっていますね

#!/usr/bin/env php
<?php

ini_set('display_errors', 1);
error_reporting(E_ALL);

require(__DIR__ . '/../lib/SplClassLoader.php');

$classLoader = new SplClassLoader('Wrench', __DIR__ . '/../lib');
$classLoader->register();

$server = new \Wrench\Server('ws://localhost:8000/', array(
    'allowed_origins'            => array(
        'localhost'
    ),
));

$server->registerApplication('echo', new \Wrench\Application\EchoApplication());
$server->run();

これの

<?php
$server->registerApplication('echo', new \Wrench\Application\EchoApplication());

<?php
$server->registerApplication('chat', new \Wrench\Application\ChatApplication());

に書き換えましょう。このregisterApplicationメソッドこそが、Wrenchにアプリケーションを登録するためのメソッドです。
登録できたかどうかブラウザからアクセスして確かめましょう。chatdemoの配下のclient.coffeeを開き、

serverUrl = 'ws://127.0.0.1:8000/chat'

へと書き換えたなら、 echoデモの時と同じようにchatdemoのサーバーを起動し、http://localhost/Wrench/chatdemo/coffeescript/index.htmlへとアクセスしましょう。EchoApplicationがきちんと動いていたら、アプリケーションの登録が成功したということです。

ところで自分は、このregisterApplicationに渡している\Wrench\Application\ホニャララと言う識別子に面食らった情報弱者なのですが、これはphpにおける名前空間の表し方のようです。ちなみに先頭の\はグローバル名前空間を表します。名前空間の詳細についてはマニュアルを参照するのが一番いい気がします。またこれもちなみになんですが、マニュアルを読めばわかるようにphp名前空間はディレクトリ構造に対応する必要はありません。これは、spl_autoload_register関数で、名前空間で修飾されたクラス名を渡すとそのクラスをrequireするような関数を登録することで、名前空間の解決を行うからです。phpでは名前空間からどのようにクラスをロードするかの仕組みがユーザーに丸なげされてるわけですね。でもまぁ結局はディレクトリに対応した名前空間の仕組みが慣れ親しまれているからか、Wrenchのデモでは、ディレクトリ&ファイル名と名前空間を対応付けるSplClassLoaderクラスを用いて名前空間を解決しています。

2.1-アプリケーションを作り変える

すべての基本はApplicationクラスを継承したこのChatApplication.phpです。
何ができるかはドキュメントとApplication.phpを覗いてみましょう・・・と言いたいところですが、どちらもあまり整備されていないようですのでコアのConnection.phpを見たほうがいいようです。現在

onConnect, onDisconnect, onData, onBinaryData

メソッドを実装できるようです。何が起こるかはメソッド名から推測できるでしょう。
こうしてWebSocketを使った自作のアプリケーションができるというわけです。

2.2-チャットデモを作る

さて、では早速ChatApplication.phpを書き換えましょう。といってもチャットの作り方講座なんてするわけではないので、タイトル通りの作ったものを垂れ流します。

まずはChatApplication.phpは下のようになりました

<?php

namespace Wrench\Application;

use Wrench\Application\Application;
use Wrench\Application\ChatApplication\ChatRoom;
use Wrench\Application\ChatApplication\UserNotFoundException;
use Wrench\Application\ChatApplication\ConnectionAlreadyEstablishedException;

class ChatApplication extends Application
{
    private $clienId2room = array(); //clientとチャットルームのインスタンスのテーブル
    private $roomName2room = array(); //チャットルームの名前とその名前のチャットルームのインスタンスのテーブル
    
    private function sendError($errorMessage, $client) {
        $client->send(json_encode(array("type" => "error", "message" => $errorMessage)));
    }        
    
    // $connection: connection型    
    public function onDisconnect($connection) {
        if (isset($this->clienId2room[$connection->getId()])) {
            $this->clienId2room[$connection->getId()]->logoutUser($connection);
        }
    }

    // $client: connection型
    public function onData($json, $client)
    {
        $data = json_decode($json, true);
        if ($data === NULL || !(isset($data['type'])) || (!isset($this->clienId2room[$client->getId()]) && $data['type'] !== "participate")){
            $this->sendError("不正なデータを受信しました:無意味なメッセージ", $client);
            return;
        }

        try {
            switch ($data['type']) {
            case "participate":
                
                if (!isset($data['roomId']) || !isset($data['userId'])) {
                    $this->sendError("ログインに必要な情報がたりません", $client);
                    return;
                }
                $data['roomId'] = trim($data['roomId']);
                $data['userId'] = trim($data['userId']);
                if (!isset($this->roomName2room[$data['roomId']])) {
                    $this->roomName2room[$data['roomId']] = new ChatRoom();
                }
                $this->clienId2room[$client->getId()] = $this->roomName2room[$data['roomId']];
                $this->clienId2room[$client->getId()]->loginUser($client, $data['userId']);
                    
                break;
            case "message":
                if (!isset($data['body'])) {
                    $this->sendError("不正なデータを受信しました:内容のないチャット送信", $client);
                    return;
                }
                $this->clienId2room[$client->getId()]->sendMessage($client, $data['body']);
                break;
            case "logout":
                $this->clienId2room[$client->getId()]->logoutUser($client);
                unset($this->clienId2room[$client->getId()]);
                break;
                
                
            default:
                sendError("不正なデータを受信しました:無効な種類のメッセージ", $client);
                return;
            }

        } catch (UserNotFoundException $e) {
            $this->sendError($e->getMessage(), $client);
            return;
        } catch (ConnectionAlreadyEstablishedException $e) {
            $this->sendError($e->getMessage(), $client);
            return;
        }




    }
}

次にこの中でロードされているChatRoom.phpはこんな感じです

<?php 

namespace Wrench\Application\ChatApplication;

class ChatRoom {

    private $userTable = array();

    public function loginUser($client, $username) {
        if (isset($this->userTable[$client->getId()])) {
            throw new ConnectionAlreadyEstablishedException();
        }

        $this->userTable[$client->getId()]['username'] = $username;
        $this->userTable[$client->getId()]['client'] = $client;

        $loginNotification = json_encode(array("type" => "login notify", "username" => $username));
        $usernames = array();
        foreach ($this->userTable as $user) {
            array_push($usernames, ($user['username']));
            if ($user['client'] !== $client) {
                $user['client']->send($loginNotification);
            }
        }
        $client->send(json_encode(array("type" => "login accept", "usernames" => $usernames)));
        return true;

    }

    public function logoutUser($client) {
        if (!isset($this->userTable[$client->getId()])) {
            throw new UserNotFoundException();
        }
        $logoutNotification = json_encode(array("type" => "logout notify", "username" => $this->userTable[$client->getId()]['username']));
        unset($this->userTable[$client->getId()]);
        foreach ($this->userTable as $user) {
            $user['client']->send($logoutNotification);
        }
        return true;
    }

    public function sendMessage($client, $message) {
        if (!isset($this->userTable[$client->getId()])) {
            throw new UserNotFoundException();
        }
        $data = json_encode(array("type" => "message", "username" => $this->userTable[$client->getId()]['username'], "body" => $message));
        foreach($this->userTable as $user) {
            $user['client']->send($data);
        }
        return true;
    }
}

ソースをこれ以上いちいち書き上げるわけにもいかないのでgithubに公開しておきました。あとindex.html, client.coffeeも改変され、2,3個の例外クラスが追加されています。

https://github.com/nullpo-head/Wrench-ChatApplicationDemo

ダウンロードして公開フォルダにでも設置してくれれば動くようになるはずです。

2.3-注意点

チャットデモを作っていて気づいたことや学んだことはphp初心者erなので星の数の星の数乗ほどあるわけですが、その中で一つ書き留めていた方がいいかなということがあります。それは、FatalErrorがプログラム中で一回でも出れば、Wrench自体が停止してサーバーが落ちてしまうことです。
apache上でPHPを動かしているときは、ユーザーがアクセスしてきてエラーが出たとしてもそのユーザーのページがきちんと表示されないくらいですみますが、Wrenchだとサーバー自体が落っこちて、以後再起動させるまでアクセスできなくなります。適切にエラーハンドリングをしなければいけませんね。ちなみに私のチャットデモではもちろんそんなことはできていませんからご安心ください。

2.4-動画

チャットデモはもちろんgithubからダウンロードすれば動かせるわけですけど、そんなのめんどくさいって方のためと自分の自己満足のために動作動画を上げておきます。ブラウザでしっかりプッシュがされている様がわかるのではないでしょうか。




*1:WebSocketサーバ本体であるlibは公開ディレクトリに本来は置くべきではありませんし、examplesの中のserver.phpも同様ですが、便宜のために一つにまとめておくよう説明しています。もちろんこのままwebに公開したりしないでくださいね