FuelPHPでWebsocketサーバを立ち上げ、ユーザ同士でリアルタイムに情報交換させる機能を実装しました。
最初にWebsocketについて、これを読んでいる人には説明不要かもしれませんが、軽く説明しておきます。
通常のHTTPでの通信というのはリクエストとレスポンスの2つからなります。クライアントがリクエストをサーバにかけて、サーバ側がレスポンスする。
これの弱点というのは、サーバ側から情報発信のスタートをすることができない点です。
例えば掲示板は今までのHTTP通信の仕組みでできるけど、チャットはできないのです。
数秒ごとにリクエストをかけ続ける(ポーリング)という手法で擬似的にチャットを実現させる手法もあるにはあるのですが、動作がもっさりしてしまいます。
そうゆう場合はWebsocketという、HTTP通信の派生版を使用します。そうすると、あたかもクライアントとサーバが直でリアルタイムに繋がっているような使用感が得られます。
今回、FuelPHPでその実現をすることになり、ググった結果Ratchetが引っかかりました。
ただコレ、ググればキータだけでなく、他のサイトでもちょちょい出てくるのですが、どの記事の真似をしても、なんかうまくいかなくて。。。。結局元ネタのgithubのサンプル通りにやったらうまく行きました。
キータより何より、まずは1次情報が大事!!
ということで、最新のRatchetのソースでの実装を紹介します。
あ、それとRatchetのサンプルだけだと、繋がっている人全員にデータ送信してしまうので、1対1の個人通信の仕方も紹介します。
ついでにリバプロについても軽く説明します。どの記事もlocalhostで繋がってオシマイなので、実践的じゃないと思ったので。
多分、他の記事でも説明されていると思いますが、まずはRatchetを入れます。
php composer.phar require cboden/ratchet
他の記事だと、composer.jsonに名前空間の設定をする記載が多く載ってますが、私の場合はFuelPHPのフレームワークの中で実装したためやっていません。必要に応じて対応してください。キータだとこの記事あたり。
https://qiita.com/TakashiOshikawa/items/c2d8083b3fec5db185e2
で、Ratchetを使ってWebソケットサーバを立ち上げます。今回の仕事ではFuelPHPを使っているので、Taskのバッチ処理でWebソケットサーバを構築しました。
何はともあれRatchetのソースはこちら
https://github.com/ratchetphp/Ratchet
まずはgitに書いてあるサンプルをそのまま転記します。
<?php
use Ratchet\MessageComponentInterface;
use Ratchet\ConnectionInterface;
// Make sure composer dependencies have been installed
require __DIR__ . '/vendor/autoload.php';
//autoload.phpの位置は、お使いのフレームワークなりに合わせて変更します。
/**
* chat.php
* Send any incoming messages to all connected clients (except sender)
*/
class MyChat implements MessageComponentInterface {
protected $clients;
public function __construct() {
$this->clients = new \SplObjectStorage;
}
public function onOpen(ConnectionInterface $conn) {
$this->clients->attach($conn);
}
public function onMessage(ConnectionInterface $from, $msg) {
foreach ($this->clients as $client) {
if ($from != $client) {
$client->send($msg);
}
}
}
public function onClose(ConnectionInterface $conn) {
$this->clients->detach($conn);
}
public function onError(ConnectionInterface $conn, \Exception $e) {
$conn->close();
}
}
//サーバの起動の方法がちょっと新しくなったのか?他の一般記事では起動方法が異なっております。
$app = new Ratchet\App('localhost', 8080);
$app->route('/chat', new MyChat, array('*'));
$app->route('/echo', new Ratchet\Server\EchoServer, array('*'));
$app->run();
$ php chat.php
上記ソースをFuelの中でTaskとして動かすように改造し、また1対1の個人通信を実現するために行なったコードが以下になります。
<?php
namespace Fuel\Tasks;
use \Ratchet\MessageComponentInterface;
use \Ratchet\ConnectionInterface;
require __DIR__.'/../../vendor/autoload.php';
/**
* Send any incoming messages to all connected clients (except sender)
*/
class MyChat implements MessageComponentInterface {
protected $clients;
private $subscriptions;
private $users;
public function __construct() {
$this->clients = new \SplObjectStorage;
$this->subscriptions = [];
$this->users = [];
}
public function onOpen(ConnectionInterface $conn) {
$this->clients->attach($conn);
$this->users[$conn->resourceId] = $conn;
}
public function onMessage(ConnectionInterface $conn, $msg) {
$data = json_decode($msg);
switch ($data->command) {
case "subscribe":
$this->subscriptions[$conn->resourceId] = $data->channel;
break;
case "message":
if (isset($this->subscriptions[$conn->resourceId])) {
$target = $this->subscriptions[$conn->resourceId];
foreach ($this->subscriptions as $id=>$channel) {
if ($channel == $target && $id != $conn->resourceId) {
$this->users[$id]->send($data->message);
}
}
}
}
}
public function onClose(ConnectionInterface $conn) {
$this->clients->detach($conn);
unset($this->users[$conn->resourceId]);
unset($this->subscriptions[$conn->resourceId]);
}
public function onError(ConnectionInterface $conn, \Exception $e) {
$conn->close();
}
}
class SocketServer
{
public static function run()
{
$app = new \Ratchet\App('localhost', 8080);
$app->route('/chat', new MyChat, array('*'));
$app->route('/echo', new \Ratchet\Server\EchoServer, array('*'));
$app->run();
}
}
?>
これは元ネタがあって、海外の誰かさんが、未テストでソースを公開していたものをパクってます。
https://stackoverflow.com/questions/30953610/how-to-send-messages-to-particular-users-ratchet-php-websocket
Note: This code is untested I wrote it on the fly from my experience with Ratchet. Good luck :)
未テストだそうなので、実際にコピペして動かしてみたら一発で動きました。ありがとう!
ちょっとポイントだけ解説すると、接続ごとに $conn->resourceId でコネクション固有のIDを取ることができるので、それをサーバ内に格納します。
$this->users[$conn->resourceId] = $conn;
で、通話させたい相手の$conn->resourceIdを選択してメッセージを指定送信
$this->users[$id]->send($data->message);
subscriptionsというのは何かというと、会話の部屋番号(channel)を格納するものです。コネクション接続時に部屋を割り当てるイメージ。channelは通信を特定のメンバーだけに絞るための値なのでRatchetとかWebsocketとかとは本質的には関係ないものです。
subscriptionsをforeachで回しているのがイケテナイと思いますが、要件的にそこまで人数が多くない&実際の実装ではこまめに不要リソースを解放する処理を加えたのでOKとしました。 (後日改造しましたがビジネスロジック混ざるので非公開)
次がクライアント側の方ですが、これはJSで適当に書いてください。
以下は1対1での通信のサンプルになります。
//通信開始
var conn = new WebSocket('wss://ドメイン/api/websocket'); //リバプロ使ってポートは隠す
conn.onopen = function(e) {
console.log("Connection established!");
//部屋番号を割り当て
conn.send(JSON.stringify({command: "subscribe", channel: 部屋番号の数字をサーバで払い出し));
};
//メッセージの受信
conn.onmessage = function(e) {
console.log(e.data);
};
//メッセージの送信
conn.send(JSON.stringify({command: "message", message: メッセージの内容}));
まだ作業がある
先のJSだと通信先のポート指定はしていません。本来ならWebsocketのポート番号で通信する必要があります。
ここは案件次第ですが、私の場合は外部からAPACHE経由で、通常のFuelのサイトとWebsocketを一緒に管理したかったのでリバースプロキシを設定しました。
APACHEで以下のような設定をVHOSTに行いました。(nginx使えというクレーム来そうですが、キータの1記事のためにわざわざ環境構築まではしないです)
<IfModule !proxy_wstunnel_module>
LoadModule proxy_wstunnel_module /usr/lib/apache2/modules/mod_proxy_wstunnel.so
</IfModule>
# websocket
ProxyPass /api/websocket ws://localhost:8080/chat
ProxyPassReverse /api/websocket ws://localhost:8080/chat
専用サーバじゃなかったので他のサービスに迷惑がかからないようにVHOST内しかいじれませんでした。が、たまたまmod_proxy_wstunnelが入っているAPACHEだったので、一番楽な方法をとってます。
ここは環境次第だと思うので、各自頑張ってください!
おわり