Friction River Software

  • お問い合わせ

CakePHP5入門【CakePHP5実用編⑤】WebSocketとRatchet

B美

今回、ようやくチャットサービスの中核たる「WebSocketウェブソケット」を実装していくよ

C菜

楽しみです~

A子

やっとかよ(苦笑)

B美

んじゃ、まずは二つのライブラリをインストールしよう

cd html/authapp[Enter]
composer require cboden/ratchet react/socket[Enter]

これで(WebSocketウェブソケットライブラリである)「Ratchetラチェット」が導入されるからね
(「react/socket」のほうは、ソケット通信には必須らしい…よく知らんけど)



B美

次は、WebSocketサーバとして働くCakePHPのCommandクラスを作るよ

bin/cake bake command WebSocketServer[Enter]

双方向通信を行うためには、このサーバが必要だからね

C菜

できました~

B美

それじゃ「src/Command」の中に作られた「WebSocketServerCommand.php」を編集していくよ

まず、先頭には

use Ratchet\MessageComponentInterface;
use Ratchet\ConnectionInterface;
use Ratchet\Http\HttpServer;
use Ratchet\Server\IoServer;
use Ratchet\WebSocket\WsServer;
use React\EventLoop\Factory as LoopFactory;
use React\Socket\Server as ReactServer;

を追加して、クラス定義には「MessageComponentInterface」をインプリメントしてね

class WebSocketServerCommand extends Command implements MessageComponentInterface

A子

い、いんぷり…何だって?

C菜

extends」が「継承」を意味するというのは習いましたけど、「implements」は習ってませんよ~

B美

インターフェースの「実装」と訳されることが多いわね

簡単に言っちゃうと…
「MessageComponentInterface」の中に定義されているメソッド(中身は無くて、宣言だけ有る)の「実装」を強制する仕組みね

A子

どこが「簡単」なんだよ!

さっぱり分からんがな(怒)

C菜

インタフェースには「メソッドの宣言だけ」が記述されてるってことでしょうか~?

B美

「だけ」ってことはないんだけど、まぁぶっちゃけ「そのようなもの」と思っておけば良いわ

これはオブジェクト指向における「多態性たたいせい(ポリモーフィズム)」にも関わってくるんだけど、(難しいから)あまり気にしなくても大丈夫よ
(「WebSocketの機能を実現するための約束事」とでも思っておけばOK)

ちなみに、WebSocketだけじゃなく、今後色々なところで出現する(かもしれない)けどね(苦笑)

A子

「気にすんな」ってのが結論か…

B美

そういうこと

それじゃ、続きね
「WebSocketServerCommand」クラスの中に、二つの配列フィールド(「$clients」及び「$rooms」)と「initialize」メソッドを追加するよ
(なお、「protected」というのは、前回出てきた「アクセス修飾子」の一つね)

class WebSocketServerCommand extends Command implements MessageComponentInterface
{
    protected array $clients = [];
    protected array $rooms = [];

    public function initialize(): void
    {
        parent::initialize();

        $this->clients = [];
        $this->rooms = [];
    }

A子

意味はよく分からんけど、コピペすれば良いんだね?

B美

まぁ、それでも良いわ(苦笑)
んじゃ、いよいよ処理の中核である「execute」メソッドを実装していくよ

public function execute(Arguments $args, ConsoleIo $io): void
{
    $loop = LoopFactory::create();
    $socket = new ReactServer(WEBSOCKET_IP.':'.WEBSOCKET_PORT, $loop);
    $server = new IoServer(new HttpServer(new WsServer($this)), $socket, $loop);

    $io->out('WebSocketサーバを起動しました。');
    $server->run();
}

A子

ん?
WEBSOCKET_IP」と「WEBSOCKET_PORT」って、定数定義かな?

B美

ええ、そうよ
「config」ディレクトリの中にある「const.php」には以下の二行を追加してね

define("WEBSOCKET_IP", "192.168.1.205");    //WebSocketサーバのIPアドレス
define("WEBSOCKET_PORT", "50080");    //WebSocketサーバのポート番号

あ、IPアドレスは各人の開発環境に合わせてね

それと、ポート番号をここでは「50080」番にしているけど、これは(使用していない)別のポート番号に変更してもOKだからね
(もちろん「0~65535」の範囲内で、かつ「ウェルノウンポート」ではないものに…)

A子

ウェルノウンポート」って、小さい番号のことだよね?

C菜

たしか「0~1023」の範囲内だったと思います~

B美

二人とも正解よ
Well Knownウェルノウン」ポート以外にも「レジスタード」ポート(または「ユーザ」ポート)である「1024~49151」や「ダイナミック」ポート(または「プライベート」ポート)である「49152~65535」もあるけどね

要するに「個人で新たなポート番号を利用する場合、でかい数字にしとけ!」…ってこと
(具体的には、「ざっくり50000番以降を使えば、既存のアプリケーションとかぶることはない」…って覚えておけば良いわ)

A子

なんだか難しい話だけど、「execute」メソッドの中身自体はめっちゃシンプルね

これで終わり?

B美

いいえ、もう少しよ
(さっき出てきた「インタフェースの実装」がまだだからね)

public function onOpen(ConnectionInterface $conn): void
{
    $conn->roomId = null;
    $this->clients[$conn->resourceId] = $conn;
}

public function onMessage(ConnectionInterface $from, $msg): void
{
    $data = json_decode($msg, true);

    if (!isset($data['type']) || !isset($data['room_id'])) {
        return;
    }

    $roomId = $data['room_id'];

    if ($data['type'] === 'join') {
        $from->roomId = $roomId;
        if (!isset($this->rooms[$roomId])) {
            $this->rooms[$roomId] = [];
        }
        $this->rooms[$roomId][$from->resourceId] = $from;
    } elseif ($data['type'] === 'message' && $from->roomId !== null) {
        $messageData = [
            'room_id' => $roomId,
            'user_id' => $data['user_id'],
            'user' => $data['user'],
            'message' => $data['message']
        ];

        foreach ($this->rooms[$roomId] as $client) {
            $client->send(json_encode($messageData));
        }
    }
}

public function onClose(ConnectionInterface $conn): void
{
    if ($conn->roomId !== null && isset($this->rooms[$conn->roomId][$conn->resourceId])) {
        unset($this->rooms[$conn->roomId][$conn->resourceId]);
    }
    unset($this->clients[$conn->resourceId]);
}

public function onError(ConnectionInterface $conn, \Exception $e): void
{
    $conn->close();
}

これら四つのメソッドを追記すれば完了よ
(処理の中核となるのは「onMessage」メソッドね)




C菜

できました~

もうこのコマンドクラスを実行しちゃっても大丈夫ですか~?

B美

そうね
テスト実行してみましょうか

bin/cake web_socket_server[Enter]

A子

エラーは出なかったから、ちゃんと動いてるんだろうけど…

なんか入力待ち状態($が出た状態)に戻らないんだけど…(苦笑)

B美

そりゃ、サーバだからね

当然、無限ループ状態になってるわよ

A子

入力待ちに復帰するには、どうすりゃいいのさ

B美

[Ctrl]+[c]で処理を中断できるわよ
([Ctrl]キーと[c]キーを同時に押すってこと)

もちろん、それをやるとWebSocketサーバが止まっちゃうんだけど…

C菜

それでは「MATE端末」を閉じることもできなくなるわけですよね~?

なんとかならないんですか~?

B美

簡単よ
実行時のコマンドの末尾に「&」を付け加えるだけ

bin/cake web_socket_server &[Enter]

一見さっきと同じく止まってるように見えるけど、[Enter]キーを押すと復帰するわ
(もちろん、「MATE端末」を閉じてもWebSocketサーバが停止することはないから…)



A子

それじゃ、このWebSocketサーバを停止させるにはどうしたら良いの?
([Ctrl]+[c]じゃダメだよね?)

B美

さっきの例では、実行後に「29310」という数字が表示されたでしょ?

あれってLinuxの「プロセス番号」なのよ
(プログラムを実行すると、そのプログラムには重複しない「プロセス番号」が自動的に割り当てられるの)

C菜

その番号を使ってプログラム(WebSocketサーバ)を強制終了させる…ってことでしょうか~?

B美

C菜正解!
あ、ただ「プロセス番号を覚えてないといけない」とか、「忘れちゃったらどうしよう」…なんて不安になるわよね

でも大丈夫!
ポート番号からプロセス番号を調べる」こともできるのよ

su[Enter]
lsof -i :50080[Enter]

これで50080番ポートを使っているプログラムの「プロセス番号」が判明するわ

B美

そうしたら、ここで判明したプロセス番号を指定して「kill」すればOKってわけ

kill 29310[Enter]
exit[Enter]

もちろん、29310というのは決まった数字じゃない(常に変わる)わよ
(言うまでもないだろうけど…)

A子

面倒くせぇ
(あと「kill」って物騒な言葉よね(苦笑))

てか、これってシェルスクリプトで自動化することはできないの?

B美

まったくもう、仕方ないわねぇ

#!/bin/sh

PORT=50080

PID=$(lsof -i :$PORT -t)

if [ -n "$PID" ]; then
    echo "WebSocketサーバを終了します..."
    kill "$PID"
    echo "終了しました。"
else
    echo "ポート $PORT を使用しているプロセスは見つかりませんでした。"
fi

これをrootユーザで作って、実行権を与えておきなさい

A子

もちっと詳しく頼むよ(苦笑)

C菜

MATE端末」を開いて、「su -」でrootユーザになってから「nano」でファイルを作って、そのファイルのパーミッションを変更すれば良いんですよね~?
(ファイル名は「websocket_close.sh」にしますね)

su -[Enter]
nano websocket_close.sh[Enter]
(上で示したスクリプトをコピペして上書き保存)
chmod +x websocket_close.sh[Enter]
exit[Enter]

で良いと思います~

で、このスクリプトを実行(WebSocketサーバを終了)する際は

su -[Enter]
./websocket_close.sh[Enter]
exit[Enter]

ということになりますね~



A子

さすがはC菜、よく覚えてるわね(汗)

B美

のんきなこと言ってんじゃないわよ

あなたもきちんと復習しておきなさいね

A子

うへぇ

まぁ、それはともかく…
まだチャットサービスの実装は終わってないんでしょ?

B美

もちろん!

あとはControllerコントローラーViewビューを作っていきましょう

C菜

bakeで作っても良いでしょうか~?

B美

OKよ

まぁ、かなり中身を削除することになるんだけど…(苦笑)
(必要なのは「index」メソッドだけだし)

A子

分かったよ
それじゃ、やってみよう

bin/cake bake controller chat[Enter]

これで「ChatController.php」ができるはず…
あと、「templates」の中に「Chat」ディレクトリを新規作成しておこう


B美

うん、いいね

「src/Controller」の中に作られた「ChatController.php」だけど、「index」メソッド以外は全て削除してね
んで、「index」メソッドの中身は以下のように書き換えるの

public function index($room_id = null)
{
    //引数がなければトップページへリダイレクト
    if (is_null($room_id)) {
        return $this->redirect(['controller' => 'Top', 'action' => 'index']);
    }

    $this->set(compact('room_id'));
}

ポイントは「URLにチャットルーム番号を指定して、それをViewビュー側へと渡す」ってだけ

C菜

了解です~

B美

さて、あとはViewビューファイルなんだけど、長くなっちゃったから次回にしましょうか

A子

ほっ…

まじで今回は大変だった(苦笑)