PHPでWebSocket
去年PHPで実装するWebSocketサーバーについて書きましたが、ブラウザのバージョンが上がり、内容が古くなってきたので、2012年1月2日今現在のブラウザで動くよう改めて書いてみようと思います。
前回とブラウザ以外は変わりませんが、今回はこんな環境で動かします。
- さくらのVPS CentOS
- PHP 5.3.6
- Chrome 17.0.942.0 dev-m
- Firefox 8.0 release channel
- Safari 5.0.2
- iPhoneのmobile Safari iOS5.0.1
※サーバー側のWebsocket用ポート開放を忘れずに。
今回の最終目標は上記ブラウザすべてで動作するリアルタイムお絵かきツールを作る事にします。
まずは動かしてみる
手順
ライブラリなどを配置してひとまずデモ用のチャットアプリケーションを動かしてみます。こんな手順で進めていきます。
- Websocketソケットサーバーを起動する。
- HTMLとjavascriptでクライアントサイドを作成する。
- ブラウザから実行する。
手順1.Websocketソケットサーバーを起動する
■ 手順1-1.ダウンロード
PHPのWebsocketサーバーライブラリ「php-websocket」を次のURLからダウンロード。
https://github.com/lemmingzshadow/php-websocket
※前回はこちらを使用しましたが今回はhybi-10対応版を使用します。
■ 手順1-2.サーバーに配置
ダウンロードしたソースをサーバーの適当な場所に解凍して配置します。
■ 手順1-3.環境に合わせてソースをちょっとだけ修正
lemmingzshadow-php-websocket/server/server.php を2行書き換えます。
<?php //・・・省略・・・ //環境に合わせてドメインとポート番号を指定する。 //ご自身の環境に合わせて適切なものに変更してください。 //(私の環境ではドメインがdemouth.netでポートが8000番です。) $server = new \WebSocket\Server('demouth.net', 8000); //$server = new \WebSocket\Server('localhost', 8000); //・・・省略・・・ //接続を許可するドメインを変更する。 //ここで指定したドメイン以外からの接続はhandshakeの際にはじかれます。 //このメソッドを何度か呼ぶことでドメインを複数設定する事が可能です。 //(私の環境ではdemouth.netと指定します。) $server->setAllowedOrigin('demouth.net'); //$server->setAllowedOrigin('foo.lh'); //・・・省略・・・
この変更で下のような感じになります。
<?php /* This program is free software. It comes without any warranty, to * the extent permitted by applicable law. You can redistribute it * and/or modify it under the terms of the Do What The F*ck You Want * To Public License, Version 2, as published by Sam Hocevar. See * http://sam.zoy.org/wtfpl/COPYING for more details. */ ini_set('display_errors', 1); error_reporting(E_ALL); require(__DIR__ . '/lib/SplClassLoader.php'); $classLoader = new SplClassLoader('WebSocket', __DIR__ . '/lib'); $classLoader->register(); $server = new \WebSocket\Server('demouth.net', 8000); // server settings: $server->setMaxClients(20); $server->setCheckOrigin(true); $server->setAllowedOrigin('demouth.net'); $server->setMaxConnectionsPerIp(5); $server->setMaxRequestsPerMinute(50); $server->registerApplication('status', \WebSocket\Application\StatusApplication::getInstance()); $server->registerApplication('demo', \WebSocket\Application\DemoApplication::getInstance()); $server->run();
■ 手順1-4.websocketサーバーを起動
先ほど修正したphpファイルを実行します。
# php lemmingzshadow-php-websocket/server/server.php
実行すると次のような感じで表示されるかと思います。
2011-11-20 04:28:27 [info] Server created
WebSocketサーバーが起動しましたのでjavascriptから接続する準備は整いました。サーバー側はこれで完成です。
手順2.HTMLとjavascriptでクライアントサイドを作成する。
手順1-1でダウンロードした
lemmingzshadow-php-websocket/client
の中身をサーバー上に配置します。
配置したら、次のファイルの
lemmingzshadow-php-websocket/client/js/client.js lemmingzshadow-php-websocket/client/js/status.js
下記のMozWebSocketとWebSocketをnewしている部分の引数を書き換えます(私の環境ではドメインが「demouth.net」でポートが「8000」番。ご自身の環境に合わせて適切なものに変更してください。)。
socket = new MozWebSocket('ws://demouth.net:8000/demo'); //socket = new MozWebSocket('ws://localhost:8000/demo');
socket = new WebSocket('ws://demouth.net:8000/demo'); //socket = new WebSocket('ws://localhost:8000/demo');
socket = new WebSocket('ws://demouth.net:8000/status'); //socket = new WebSocket('ws://localhost:8000/status');
socket = new WebSocket('ws://demouth.net:8000/status'); //socket = new WebSocket('ws://localhost:8000/status');
これですべて準備は整いました。あとはブラウザからアクセスしてみましょう。
手順3.ブラウザで表示する。
■index.html
緑色でonlineと表示されていれば成功です。
このデモアプリケーションはチャットみたいな事ができます。actionに『Echo』、dataに適当な文字列を入力してSendボタンをクリックしてみると、WebSocketサーバーを経由してServer-Response:に表示されます。
■status.html
こちらはWebSocketサーバーを監視するアプリケーションのようです。
緑色でonlineと表示されていれば成功です。
ここまででひとまず動作するようになりましたが、ここまでの手順ではiPhoneでは動作しません(offlineになっています)。
iPhoneでも動くようにする
WebSocketのバージョンについて
ひとまずサンプルが動いたところで、WebSocketのプロトコル仕様についてまとめてみようかと思います。
WebSocketのプロトコル仕様は今も規格策定中で、各ブラウザごとに実装状況が異なります。前回の記事を書いた時点でChrome devはdraft76を実装していましたが、今現在hybi17を実装しています。他のブラウザはというと、Firefoxはhybi10で、SafariとiPhoneのmobile Safariはdraft76のようです(勝手にhybi10と呼んでいますが正式なドラフト名はdraft-ietf-hybi-thewebsocketprotocol-10です)。
前回の記事で使用したphp-websocketはdraft75と76に対応していましたが、hybi10に対応したphp-websocketがforkされてgithubで公開されています。今回の説明ではこちらを使用してているのですが、ChromeやFirefoxで動作するもののiPhoneやsafariでは動作しません。そこでphp-websocketを書き換えてdraft75と76、hybi10に対応させてみようかと思います(セキュリティホールが存在するバージョンに対応させるのもどうかと思いますが、今回は勉強という意味で)。
Connection.phpを変更する
こちらのソースを参考に、ソースの一部を書き換えてみます。書き換えたソースは下記のURLになります。このURLからConnection.phpのソースをダウンロードして、サーバーのConnection.phpに上書きしてください。
https://gist.github.com/1542601
ちなみにソースの主な変更箇所を抜粋するとこんな感じです。
<?php //省略 private function handshake($data) { $this->log('Performing handshake'); $lines = preg_split("/\r\n/", $data); // check for valid http-header: if(!preg_match('/\AGET (\S+) HTTP\/1.1\z/', $lines[0], $matches)) { $this->log('Invalid request: ' . $lines[0]); $this->sendHttpResponse(400); socket_close($this->socket); return false; } // check for valid application: $path = $matches[1]; $this->application = $this->server->getApplication(substr($path, 1)); if(!$this->application) { $this->log('Invalid application: ' . $path); $this->sendHttpResponse(404); socket_close($this->socket); $this->server->removeClientOnError($this); return false; } // generate headers array: $headers = array(); foreach($lines as $line) { $line = chop($line); if(preg_match('/\A(\S+): (.*)\z/', $line, $matches)) { $headers[$matches[1]] = $matches[2]; } } // check origin: if($this->server->getCheckOrigin() === true) { $origin = (isset($headers['Sec-WebSocket-Origin'])) ? $headers['Sec-WebSocket-Origin'] : false; $origin = (isset($headers['Origin'])) ? $headers['Origin'] : $origin; if($origin === false) { $this->log('No origin provided.'); $this->sendHttpResponse(401); socket_close($this->socket); $this->server->removeClientOnError($this); return false; } if(empty($origin)) { $this->log('Empty origin provided.'); $this->sendHttpResponse(401); socket_close($this->socket); $this->server->removeClientOnError($this); return false; } if($this->server->checkOrigin($origin) === false) { $this->log('Invalid origin provided.'); $this->sendHttpResponse(401); socket_close($this->socket); $this->server->removeClientOnError($this); return false; } } // check for supported websocket version: if(!isset($headers['Sec-WebSocket-Version']) || $headers['Sec-WebSocket-Version'] < 6) { $key3 = ''; preg_match("#\r\n(.*?)\$#", $data, $match) && $key3 = $match[1]; $host = isset($headers['Host']) ? $headers['Host'] :false; if($host === false) { $this->log('Unsupported websocket version.'); $this->sendHttpResponse(501); socket_close($this->socket); $this->server->removeClientOnError($this); return false; } $this->notHybi = true; $status = '101 Web Socket Protocol Handshake'; if (array_key_exists('Sec-WebSocket-Key1', $headers)) { // draft-76 $def_header = array( 'Sec-WebSocket-Origin' => $origin, 'Sec-WebSocket-Location' => "ws://{$host}{$path}" ); $digest = $this->securityDigest($headers['Sec-WebSocket-Key1'], $headers['Sec-WebSocket-Key2'], $key3); } else { // draft-75 $def_header = array( 'WebSocket-Origin' => $origin, 'WebSocket-Location' => "ws://{$host}{$path}" ); $digest = ''; } $header_str = ''; foreach ($def_header as $key => $value) { $header_str .= $key . ': ' . $value . "\r\n"; } $upgrade = "HTTP/1.1 ${status}\r\n" . "Upgrade: WebSocket\r\n" . "Connection: Upgrade\r\n" . "${header_str}\r\n$digest"; socket_write($this->socket, $upgrade, strlen($upgrade)); $this->handshaked = true; $this->log('Handshake sent'); $this->application->onConnect($this); return true; } // do handyshake: (hybi-10) $secKey = $headers['Sec-WebSocket-Key']; $secAccept = base64_encode(pack('H*', sha1($secKey . '258EAFA5-E914-47DA-95CA-C5AB0DC85B11'))); $response = "HTTP/1.1 101 Switching Protocols\r\n"; $response.= "Upgrade: websocket\r\n"; $response.= "Connection: Upgrade\r\n"; $response.= "Sec-WebSocket-Accept: " . $secAccept . "\r\n"; $response.= "Sec-WebSocket-Protocol: " . substr($path, 1) . "\r\n\r\n"; socket_write($this->socket, $response, strlen($response)); $this->handshaked = true; $this->log('Handshake sent'); $this->application->onConnect($this); // trigger status application: if($this->server->getApplication('status') !== false) { $this->server->getApplication('status')->clientConnected($this->ip, $this->port); } return true; } //省略 private function handle($data) { if ($this->notHybi){ $chunks = explode(chr(255), $data); for ($i = 0; $i < count($chunks) - 1; $i++) { $chunk = $chunks[$i]; if (substr($chunk, 0, 1) != chr(0)) { $this->log('Data incorrectly framed. Dropping connection'); socket_close($this->socket); return false; } $this->application->onData(substr($chunk, 1), $this); } return true; }else{ $decodedData = $this->hybi10Decode($data); switch($decodedData['type']) { case 'text': $this->application->onData($decodedData['payload'], $this); break; case 'ping': $this->send($decodedData['payload'], 'pong', false); $this->log('Ping? Pong!'); break; case 'pong': // server currently not sending pings, so no pong should be received. break; case 'close': $this->close(); $this->log('Disconnected'); break; } return true; } } public function send($payload, $type = 'text', $masked = true) { if($this->notHybi){ if (! @socket_write($this->socket, chr(0) . $payload . chr(255), strlen($payload) + 2)) { @socket_close($this->socket); $this->socket = false; } }else{ $encodedData = $this->hybi10Encode($payload, $type, $masked); if(!socket_write($this->socket, $encodedData, strlen($encodedData))) { socket_close($this->socket); $this->socket = false; } } } //省略
変更を適用したらserver.phpを再起動します。
# php lemmingzshadow-php-websocket/server/server.php
この変更を反映した事でiPhoneでも動作するようになりました(緑色でonlineと表示されています)。
新しいアプリケーションを作成してみる
ここまででデモアプリケーションをPC向けブラウザとiPhoneで動作させる事ができました。今度は新しいアプリケーションの作成方法について紹介します。例としてエコーサーバーアプリケーション(誰かがサーバーに送信した内容を、そのまま全ユーザーに送信するアプリ)を作成してみます。
手順1.Applicationを継承したクラスを作成する(PHP)
新しいアプリケーションを作成する際は、
lemmingzshadow-php-websocket/server/lib/WebSocket/Application/Application.php
のApplicationクラスを継承してアプリケーションを作成します。
最低限の機能で実装したエコーサーバーアプリケーションの場合、こんな感じになると思います。
<?php namespace WebSocket\Application; class EchoApplication extends Application { private $_clients = array(); public function onConnect($client) { $id = $client->getClientId(); $this->_clients[$id] = $client; } public function onDisconnect($client) { $id = $client->getClientId(); unset($this->_clients[$id]); } public function onData($data, $client) { foreach($this->_clients as $sendto) { $sendto->send($data); } } }
このクラスを下記ディレクトリに保存します。
lemmingzshadow-php-websocket/server/lib/WebSocket/Application/EchoApplication.php
手順2.server.phpにアプリケーションを登録する
先ほど変更したserver.phpを再び変更し、作成したエコーサーバーアプリケーション登録する処理を1行追加します。
※ちなみにSplClassLoaderを使用しているのでクラスをrequireしたりする必要はありません。
<?php /* This program is free software. It comes without any warranty, to * the extent permitted by applicable law. You can redistribute it * and/or modify it under the terms of the Do What The F*ck You Want * To Public License, Version 2, as published by Sam Hocevar. See * http://sam.zoy.org/wtfpl/COPYING for more details. */ ini_set('display_errors', 1); error_reporting(E_ALL); require(__DIR__ . '/lib/SplClassLoader.php'); $classLoader = new SplClassLoader('WebSocket', __DIR__ . '/lib'); $classLoader->register(); $server = new \WebSocket\Server('demouth.net', 8000); // server settings: $server->setMaxClients(20); $server->setCheckOrigin(true); $server->setAllowedOrigin('demouth.net'); $server->setMaxConnectionsPerIp(5); $server->setMaxRequestsPerMinute(50); $server->registerApplication('status', \WebSocket\Application\StatusApplication::getInstance()); $server->registerApplication('demo', \WebSocket\Application\DemoApplication::getInstance()); //アプリケーションの登録 $server->registerApplication('echo', \WebSocket\Application\EchoApplication::getInstance()); $server->run();
手順3.server.phpを実行する
あとは先ほどと同じようにserver.phpを実行します。
# php lemmingzshadow-php-websocket/server/server.php
実行すると次のような感じで表示されるかと思います。
2011-11-20 04:28:27 [info] Server created
手順4.HTMLを作成する
あとはechoアプリケーションに接続する次のようなHTMLを作成してアクセスすると、
<!DOCTYPE html> <html> <head> <meta charset="UTF-8" /> <script src="http://ajax.googleapis.com/ajax/libs/jquery/1.7.1/jquery.min.js"></script> <script> jQuery(function($) { var socket; if ( $.browser.mozilla ){ socket = new MozWebSocket('ws://demouth.net:8000/echo'); }else{ socket = new WebSocket('ws://demouth.net:8000/echo'); } socket.onopen = function(msg){ $('#status').text('online'); }; socket.onmessage = function(msg){ $('#res').text( $('#res').text() + msg.data ); }; socket.onclose = function(msg){ $('#status').text('offline'); }; $('#button').click(function(){ socket.send($('#mes').val()); }); }); </script> </head> <body> <div id="status"></div> <input type="text" id="mes"> <input type="button" id="button" value="send"> <div id="res"></div> </body> </html>
リアルタイムお絵かきアプリケーションを作成してみる
今回の最終目標であるリアルタイムお絵かきアプリケーションを作成してみようと思います。
Applicationクラス
誰かがお絵かきをした後に、他のユーザーが遷移してきた場合でも絵を復元させられるように、Applicationクラスにペンの移動履歴を保持するようにしてみました。
大体こんな感じのソースになっています。
<?php namespace WebSocket\Application; class DrawingApplication extends Application { private $_clients = array(); private $_dataList = array(); const NUM_HISTORY = 1000; public function onConnect($client) { $id = $client->getClientId(); $this->_clients[$id] = $client; $this->_sendHistoryTo($id); } public function onDisconnect($client) { $id = $client->getClientId(); unset($this->_clients[$id]); } public function onData($data, $client) { $this->_sendAll($data); return true; } private function _sendAll($data) { $this->_pushData($data); foreach(array_keys($this->_clients) as $clientId) { $this->_clients[$clientId]->send($data); } } private function _sendTo($idClient, $data) { $this->_clients[$idClient]->send($data); } private function _sendHistoryTo($idClient) { $client = $this->_clients[$idClient]; foreach($this->_dataList as $data) { $client->send($data); } } private function _pushData($data) { if (count($this->_dataList) >= self::NUM_HISTORY) { array_splice($this->_dataList,0,1); } $this->_dataList[] = $data; } }
js側の実装
javascript側では、WebSocketサーバーに送信する際JSONから文字列に変換し、
var command = {}; command.x = this._x; command.y = this._y; command = JSON.stringify(command); socket.send(command);
WebSocketから受信する際にStringからJSONにパースするようにしました。
socket.onmessage = function(msg){ var command = JSON.parse(commandStr); };