PHPでWebSocket

f:id:y_d:20111228012332p:image

去年PHPで実装するWebSocketサーバーについて書きましたが、ブラウザのバージョンが上がり、内容が古くなってきたので、2012年1月2日今現在のブラウザで動くよう改めて書いてみようと思います。

前回とブラウザ以外は変わりませんが、今回はこんな環境で動かします。

※サーバー側のWebsocket用ポート開放を忘れずに。

今回の最終目標は上記ブラウザすべてで動作するリアルタイムお絵かきツールを作る事にします。

まずは動かしてみる

手順

ライブラリなどを配置してひとまずデモ用のチャットアプリケーションを動かしてみます。こんな手順で進めていきます。

  1. Websocketソケットサーバーを起動する。
  2. HTMLとjavascriptでクライアントサイドを作成する。
  3. ブラウザから実行する。
手順1.Websocketソケットサーバーを起動する


■ 手順1-1.ダウンロード
PHPのWebsocketサーバーライブラリ「php-websocket」を次のURLからダウンロード。
https://github.com/lemmingzshadow/php-websocket
※前回はこちらを使用しましたが今回はhybi-10対応版を使用します。

f:id:y_d:20111120050957p:image


■ 手順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と表示されていれば成功です。
f:id:y_d:20120102192737p:image:w640
このデモアプリケーションはチャットみたいな事ができます。actionに『Echo』、dataに適当な文字列を入力してSendボタンをクリックしてみると、WebSocketサーバーを経由してServer-Response:に表示されます。
f:id:y_d:20120102192738p:image:w640


■status.html
こちらはWebSocketサーバーを監視するアプリケーションのようです。
緑色でonlineと表示されていれば成功です。
f:id:y_d:20120102192739p:image:w640

ここまででひとまず動作するようになりましたが、ここまでの手順ではiPhoneでは動作しません(offlineになっています)。
f:id:y_d:20120102192740p:image:w320


iPhoneでも動くようにする

WebSocketのバージョンについて

ひとまずサンプルが動いたところで、WebSocketのプロトコル仕様についてまとめてみようかと思います。

WebSocketのプロトコル仕様は今も規格策定中で、各ブラウザごとに実装状況が異なります。前回の記事を書いた時点でChrome devはdraft76を実装していましたが、今現在hybi17を実装しています。他のブラウザはというと、Firefoxはhybi10で、SafariiPhoneのmobile Safariはdraft76のようです(勝手にhybi10と呼んでいますが正式なドラフト名はdraft-ietf-hybi-thewebsocketprotocol-10です)。

前回の記事で使用したphp-websocketはdraft75と76に対応していましたが、hybi10に対応したphp-websocketがforkされてgithubで公開されています。今回の説明ではこちらを使用してているのですが、ChromeFirefoxで動作するもののiPhonesafariでは動作しません。そこで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と表示されています)。

f:id:y_d:20120102203847p:image:w360


新しいアプリケーションを作成してみる

ここまででデモアプリケーションを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>

こんな感じで動作すると思います。
f:id:y_d:20120102215351p:image


リアルタイムお絵かきアプリケーションを作成してみる

今回の最終目標であるリアルタイムお絵かきアプリケーションを作成してみようと思います。

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);
};
完成

大体こんな感じで完成しました。下記URLで動いているところを確認できます。

http://demouth.net/sketch/079/

ChromefirefoxsafariiPhonesafariなどで動きます。

f:id:y_d:20120102220714p:image:w640

f:id:y_d:20120102220715p:image:w320