はじめに
Linux環境でサーバーアプリを作った事は今までありましたが、Windows上で開発する時に仮想環境を作ったり、もろもろのサーバーアプリをインストールしたりするのが面倒だったので、Xampp環境だけでWebsocketサーバーをたてられないか検証してみたいと思います。
それにどうせだったらLaravelのフレームワーク環境を使ってクライアントサイドとサーバーサイドのオールインワン環境で進められないか確認してみたいと思います。
この記事では検証作業をメインとしていますのでXamppやLaravel等のツールはインストール済である前提で話を進めます。参考にさせて頂いた資料等はリンク先のサイトをご覧ください。
環境
- プラットフォーム
- Windows10
- 統合環境
- Xampp v3.3.0
- フレームワーク
- Laravel v10.30.1
ソケットライブラリの準備
サーバーサイドはPHP製のソケット通信ライブラリ(自作)を使う予定ですが、PHPのsockets拡張モジュールを使用しているので、まずはPHPの設定変更を行います。
php.ini内のモジュール定義がコメントアウトされている場合はコメントをはずして有効にします。
以下のコマンドを実行してenabledになっている事を確認。
> php -i | Select-String -pattern 'Sockets Support'
Sockets Support => enabled
これで拡張モジュールであるsocketsが有効になりました。
Laravelのバッチ実装の要領でプロセスを作ってみる
今回は一番オーソドックスなチャット(エコー)サーバーを作るつもりなので、まずは下記のコマンドを実行してチャットサーバー用のコマンドファイルを作成します。
> php artisan make:command ChatServer
メインの処理はこんな感じでいたってシンプルです。
public function handle()
{
$port_no = $this->argument('port_no');
//--------------------------------------------------------------------------
// リッスンポートで待ち受ける
//--------------------------------------------------------------------------
$ret = $this->manager->listen('localhost', $port_no);
if($ret === false)
{
goto finish; // リッスン失敗
}
//--------------------------------------------------------------------------
// ノンブロッキングループ
//--------------------------------------------------------------------------
$timeout = get_config('const.cycle_driven_blocking_time');
while(true)
{
// 周期ドリブン
$ret = $this->manager->cycleDriven($timeout);
if($ret === false)
{
goto finish;
}
}
finish:
// 全接続クローズ
$this->manager->closeAll();
}
- ①リッスンポートで待ち受ける
- バッチ実行時に受け取ったポート番号のパラメータ”$port_no”を使ってポートをリッスン状態にして待ち受けます。
- ②ノンブロッキングループ
- 1つのプロセス内でブロッキングモードにすると、1つの接続で処理を占有してしまうので並行処理が行えなくなります。今回使用しているライブラリではノンブロッキングモードを使って周期ドリブンで処理を振り分けているので、同プロセス内で複数の接続を制御できます。
managerインスタンスはコンストラクタで生成しています。
Websocketのプロトコルについて知っておこう
少し難しい話にはなりますがWebsocketサーバーを自作する場合、基本的なネットワークの知識と下記のようにopeningハンドシェイクとフレーム構造を理解しておく必要があります。
これらの内容を説明しているサイトは多々ありますので、ここでは簡単に説明しておきます。
openingハンドシェイク
リッスンポートから接続をアクセプトするとクライアントサイドから下記のようなHTTPヘッダーぽいものが送られてきます。
その後サイバーサイドからは下記のようなHTTPヘッダーぽいものを送り返してあげる必要があり、それがクライアントサイドに届くと接続が確立します。
フレーム構造
openingハンドシェイクが完了すると、あとは生のデータをやり取りするだけですが、その際に必要になってくるのが下記のフレーム構造です。
上記のイメージはWebsocketの仕様を調べていると色んなサイトで紹介されていると思いますが、この構造でデータを送信したり受信したりしないと通信相手は受け付けてくれません。
詳細は本家のページで
少し難しいと感じたかもしれませんが、逆に言えばこの2つをある程度理解していれば比較的簡単に作る事ができますので、他のプロトコルに比べればまだ扱いやすいと思います。
詳細は今回参考にさせて頂いたページや本家のページでも是非確認してみてください。
Laravelでチャットページを作る
まずは下記のコマンドを実行してコントローラーを作成します。
> php artisan make:controller ChatController
成功したらapp/Http/Controllers/ChatController.php
にファイルが作成されるので下記のように修正します。
<?php
namespace App\Http\Controllers;
use Illuminate\Http\Request;
class ChatController extends Controller
{
public function index()
{
return view('chat');
}
}
やっている事はindex
メソッドを追加してchat
ブレードを返しているだけですが、ここではJavascript上でWebsocketしか使わないのでこれで十分です。
あとはこれをapp/routes/web.php
にルーティングとして設定します。
Route::get('chat/index', 'App\Http\Controllers\ChatController@index');
チャットページのテンプレートをapp/resources/views/chat.blade.php
に作成します。
内容はこんな感じであくまでサンプルとして簡易的に作成しています。
{{-- ハンドルネーム入力欄 --}}
<div class="handle-div">
<input class="handle" type="text" name="handle_name" value="" placeholder="ハンドルネーム"><button id="connect_button" style="cursor: pointer;">参加する</button>
</div>
{{-- 参加人数表示欄 --}}
<div class="count-div">
<p id="user-count" class="user-count">--</p><p class="count-unit">人</p>
</div>
{{-- チャット履歴欄 --}}
<div class="table_box">
<table>
<tbody id="history">
</tbody>
</table>
</div>
{{-- コメント入力欄 --}}
<div class="comment-div">
<input class="comment" type="text" name="comment" value="" placeholder="コメント"><button id="send_button" style="cursor: pointer;">ポチる</button>
</div>
{{-- 非表示エリア --}}
<table style="display: none;">
<tbody id="template">
<tr><td class="partition" colspan="2"></td></tr>
<tr>
<td class="datetime"></td>
<td class="handle"></td>
</tr>
<tr>
<td class="comment" colspan="2"></td>
</tr>
</tbody>
</table>
ページのイメージはこんな感じ。
以下画面構成を簡単に説明しておきます。
- ①[参加する]ボタン
- ハンドルネームを入力後このボタンを押す事でチャットに参加でき”--人”の部分に参加人数が表示されます。オンライン状態になったら[退出する]ボタンに切り替わります。
- ②チャット履歴
- 画面中央の色が濃い部分に投稿されたチャット履歴が表示されます。
- ③[ポチる]ボタン
- コメント欄に入力後このボタンを押す事で投稿が送信されてチャット履歴に表示されます。
Websocket処理(Javascript)の中身
クライアントサイドの実装部分を大まかに分けると下記の6種類になります。
- new WebSocket()でサーバーに繋ぎにいく
- onopenイベントで接続完了時の処理を書く
- sendメソッドでデータを送信する
- onmessageイベントでデータ受信時の処理を書く
- oncloseイベントで切断時の処理を書く
- onerrorイベントでエラー発生時の処理を書く
closeメソッドを使えばクライアントサイドで任意の切断フレームを送信できます。
切断フレームについての詳細は以下の記事をご覧ください。
以降では今回のサンプルを使って各々見ていきます。
接続([参加する]ボタン押下)時の処理
// 接続
let websocket = new WebSocket("ws://localhost:10000");
// 接続完了
websocket.onopen = function(e)
{
// ボタン名変更
$('#connect_button').text('退出する');
// ハンドルネーム入力を禁止
$('input[name="handle_name"]').prop('disabled', true);
// 入室メッセージを送信
let data =
{
'cmd': 'message'
, 'user': $('input[name="handle_name"]').val()
, 'body': '<p class="entrance">入室しました</p>'
};
websocket.send(JSON.stringify(data));
};
- ①接続
- ”localhost”アドレスの”10000”ポートへ接続します。
- ②接続完了
- ボタンを[退出する]へ変更しハンドルネーム入力を禁止します。また、入室時のメッセージをsendメソッドを使ってJSON形式に変換してから送信しています。
サーバーサイドでチャットメッセージとして認識させる為に"cmd"パラメータで"message"を設定しています。この指定がないと無視されるように実装しています。
切断([退出する]ボタン押下)時の処理
// 退出メッセージを送信
let data =
{
'cmd': 'message'
, 'user': $('input[name="handle_name"]').val()
, 'body': '<p class="entrance">退出しました</p>'
};
websocket.send(JSON.stringify(data));
// 切断中フラグをたてる
flg_closing = true;
- ①退出メッセージを送信
- ハンドルネームと退出メッセージをJSON形式に変換してからsendメソッドで送信しています。
- ②切断中フラグをたてる
- 切断中フラグにtrueを設定しています。
切断中フラグは下記の「データ受信時の処理」で使っているのでそちらを参照してください。
[ポチる]ボタン押下時の処理
let data =
{
'cmd': 'message'
, 'user': $('input[name="handle_name"]').val()
, 'body': $('input[name="comment"]').val()
};
websocket.send(JSON.stringify(data));
ハンドルネームとコメント欄のデータをJSON形式に変換してからsendメソッドで送信しています。
データ受信時の処理
// データ受信
websocket.onmessage = function(event)
{
// JSON形式にパース
let data = JSON.parse(event.data);
// ハンドルネームを取得
let name = '<p class="noname">no name</p>';
if(data.user.length > 0)
{
name = data.user;
}
// ユーザー数の設定
$('#user-count').text(data.count);
// テンプレートへ値を設定してアペンド
$('#template .datetime').text(data.datetime);
$('#template .handle').html(name);
$('#template .comment').html(data.body);
let html = $('#template').html();
$('#history').append(html);
// スクロールバー移動
let top = $('#history').outerHeight(true);
$('.table_box').scrollTop(top);
if(flg_closing === true)
{
// Websocketを閉じる
websocket.close();
// ボタン名変更
$('#connect_button').text('参加する');
// ユーザー数の設定
$('#user-count').text('--');
// ハンドルネーム入力を許可
$('input[name="handle_name"]').prop('disabled', false);
flg_closing = false;
}
};
- ①JSON形式にパース
- 受信したデータをJSON形式にパースしています。
- ②ハンドルネームを取得
- 受信したハンドルネームを判定し空の場合は”no name”へ変換しています。
- ③ユーザー数の設定
- 参加人数を表示用に設定しています。
- ④テンプレートへ値を設定してアペンド
- 投稿日時・ハンドルネーム・コメントをテンプレートへ設定してからチャット履歴へ追加しています。
- ⑤スクロールバー移動
- 最下部へスクロールさせて最新の履歴が見れるようにしています。
- ⑥切断中の処理
- ユーザーが切断した場合に退室メッセージを送っているので”flg_closing”がたっていた場合はチャット履歴への追加が済んでから切断処理や[参加する]ボタンへ変更するなどの終了処理を行っています。
サーバーからは参加人数と投稿日時が付与されて送られてきます。
切断時の処理
websocket.onclose = function(event)
{
if(event.wasClean)
{
console.log(`Websocket切断[code=${event.code} reason=${event.reason}]`);
}
else
{
console.log('Websocket切断');
}
websocket = null;
}
ログを出力してWebsocketのインスタンスを初期化しています。
エラー発生時の処理
websocket.onerror = function(error) {
console.log(`エラー発生[${error.message}]`);
// ボタン名変更
$('#connect_button').text('参加する');
// ユーザー数の設定
$('#user-count').text('--');
websocket = null;
};
ボタンを[参加する]に変更するなどの終了処理を行ってからWebsocketのインスタンスを初期化しています。
チャットサーバーの実装
サーバーサイドのアプリケーション部分の処理は下記のみです。
この関数をLaravelのヘルパー関数として定義した上でソケット通信ライブラリへ登録しています。
function command_message(ICycleDrivenParameter $p_param): ?string
{
// 受信データを取得
$msg = $p_param->command()->getRecvData();
// 現在日時を設定
$msg['datetime'] = now()->format('Y/m/d H:i:s');
// 現在のユーザー数を設定
$msg['count'] = $p_param->command()->getUserCount();
// 全コネクションへ配信
$p_param->command()->setSendStackAll($msg);
// チャットをログに残す
log_write('chat-log', 'info', 'CHAT-LOG', $msg);
return null;
}
- ①受信データを取得
- 受信データを"$msg"変数へ格納。
- ②現在日時を設定
- サーバーサイドの現在日時を受信したデータへ付与。
- ③現在のユーザー数を設定
- 参加人数を受信したデータへ付与。
- ④全コネクションへ配信
- 作成したデータを全ユーザーへ配信
- ⑤チャットをログに残す
- チャット履歴をLaravelログへ残す。
実行してみよう!
まずは下記コマンドを実行して今回実装したWebsocketサーバーを10000ポートでLaravelバッチとして起動します。
> php artisan app:chat-server 10000
次にLaravelで作成したチャットページをブラウザで開きます。
ハンドルネームを入力して[参加する]ボタンを押下します。
[参加する]ボタンが[退出する]ボタンに切り替わり、参加人数が1人となって入室メッセージも表示されています。
次にもう1人参加させてみましょう。
村人Yが参加した事で人数が2人になり入室メッセージもお互いに表示されていますね。
それでは何か投稿してみます。
お互いの投稿が表示されているのが確認できました。
それでは村人Xを退出させてみます。
村人X側の[退出ボタン]ボタンが[参加する]に切り替わり退出メッセージも表示されました。
サーバーサイドのログもみてみます。
ちゃんとLaravelのログとして生成されています。
ログの内容も下記の通り保存できています。
[2024-02-18 18:37:47] local.INFO: CHAT-LOG {"cmd":"message","user":"","body":"<p class=\"entrance\">入室しました</p>","datetime":"2024/02/18 18:37:47","count":1}
[2024-02-18 18:37:50] local.INFO: CHAT-LOG {"cmd":"message","user":"","body":"","datetime":"2024/02/18 18:37:50","count":1}
[2024-02-18 18:37:54] local.INFO: CHAT-LOG {"cmd":"message","user":"","body":"aaa","datetime":"2024/02/18 18:37:54","count":1}
[2024-02-18 18:37:57] local.INFO: CHAT-LOG {"cmd":"message","user":"","body":"<p class=\"entrance\">退出しました</p>","datetime":"2024/02/18 18:37:57","count":1}
[2024-02-18 21:15:54] local.INFO: CHAT-LOG {"cmd":"message","user":"村人X","body":"<p class=\"entrance\">入室しました</p>","datetime":"2024/02/18 21:15:54","count":1}
[2024-02-18 21:19:31] local.INFO: CHAT-LOG {"cmd":"message","user":"村人Y","body":"<p class=\"entrance\">入室しました</p>","datetime":"2024/02/18 21:19:31","count":2}
[2024-02-18 21:22:30] local.INFO: CHAT-LOG {"cmd":"message","user":"村人X","body":"私が村人Xだ!","datetime":"2024/02/18 21:22:30","count":2}
[2024-02-18 21:22:46] local.INFO: CHAT-LOG {"cmd":"message","user":"村人Y","body":"私が村人Yだ!","datetime":"2024/02/18 21:22:46","count":2}
[2024-02-18 21:23:27] local.INFO: CHAT-LOG {"cmd":"message","user":"村人X","body":"なが~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~い文章","datetime":"2024/02/18 21:23:27","count":2}
[2024-02-18 21:24:01] local.INFO: CHAT-LOG {"cmd":"message","user":"村人Y","body":"とてもなが~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~い文章","datetime":"2024/02/18 21:24:01","count":2}
[2024-02-18 21:24:50] local.INFO: CHAT-LOG {"cmd":"message","user":"村人X","body":"そろそろ退出しま~す!","datetime":"2024/02/18 21:24:50","count":2}
[2024-02-18 21:27:40] local.INFO: CHAT-LOG {"cmd":"message","user":"村人X","body":"<p class=\"entrance\">退出しました</p>","datetime":"2024/02/18 21:27:40","count":2}
[2024-02-18 21:34:24] local.INFO: CHAT-LOG {"cmd":"message","user":"村人Y","body":"さよなら~","datetime":"2024/02/18 21:34:24","count":1}
さて、ここまでで1点不具合を発見してしまったのですがご覧の皆さんはお気づきでしょうか?
これは今後の課題として残しておきますw
まとめ
私が今まで携わった現場ではWindows上で開発する事が多かったのでLinuxも含めたクロスプラットフォームで開発する事が多かったのですが、インストールするアプリケーションが多かったりターミナルソフトで状況を確認したりしなければならず、いつもその事に煩わしさを感じていました。
今回の検証ではサーバーサイドとクライアントサイドも含めて設定値の確認やログの確認が同じフレームワーク内でできる事には非常にメリットがあると感じていますし、ソースをGIT等を使ってバージョン管理しておけば、サーバー側への展開も1回で済むので非常に楽ちんで作業もはかどりそうです。
今回の大まかな作業手順は次の通りです。
- Xamppのインストール
- フレームワーク(Laravel)のインストール
- ソケット通信ライブラリをバッチ処理として実装
- クライアントページを同じフレームワーク上で作成
【追記】6/12
ここで不具合の答え合わせをしておきたいと思います。
実は村人Xが退室した時の参加人数に不具合がありました。
↓の赤枠の部分が本来1件になっていないといけないところが2件になっていました。
これはその時点での接続数を返していたのでそうなっていたのですが、本来であればカウントダウンした数字を返すべきでした。
画面上の参加人数が1件と表示されていたのは、村人Xが退室した後に村人Yが「さよなら~」とコメントしていたせいです。このコメントを送信したタイミングでの接続数は1件だったので、画面上では違和感なく見えているだけでした。
また、この記事掲載時点で使用していたサーバーサイドの処理は以下の記事のようにフレームワーク化しました。
この記事の検証内容を基にLaravel連携に発展させた内容が以下の記事になります。