13
16

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 5 years have passed since last update.

PHPでソケットを使った単一のソケットサーバー VS 複数のソケットクライアントの双方向チャット。

Last updated at Posted at 2018-06-24

毎度ふと思いたった時にSocketを使ったなにか動くものを作りたくなるので備忘録を含めちゃんとまとめようと思います。
まずソケットでよく言われるセッションを一度つなげたまま、双方向?のやり取りができるようやってみます。なかなかソケットで実現可能という記事は目にするのですが、実際複数クライアント対単一ソケットサーバーの実装まで取り組んでいる記事が見当たらなかったのでトレーニングも兼ねてトライしました。

1.既存のWEBサーバに対して任意のソケットを作成し、HTTPリクエストを実施する

まず複数台のクライアントなどを一切考えずにクライアントを実装してみます。

qiita_1.php

<?php
    //動くけどなんかしっくりこない
    $destination = "tcp://yahoo.co.jp:80";
    $socket = stream_socket_client($destination);

    if ($socket ===false) {
        print("ソケットの確立に失敗");
        exit();
    } else {
        $message = "GET / HTTP/1.1\r\n";
        $message .= "\r\n";
        fwrite($socket, $message, strlen($message));
        while(true) {
            $read = fread($socket, 1024);
            if (strlen($read) === 0) {
                break;
            }
            print($read);
        }
    }

上記のソースをコンソールでたたくと
yahoo.co.jpのトップページのソースコードをGETしてくる・・・・が、
上記の状態ではGETリクエストを送信した後、レスポンス取得し終えるまでにどうにも数秒の
ディレイがかかる。

そこで

qiita_1_2.php
<?php
    //ちゃんと動いてしっくりくる
    $destination = "tcp://yahoo.co.jp:80";
    $socket = stream_socket_client($destination);
    if ($socket ===false) {
        print("ソケットの確立に失敗");
        exit();
    } else {
        $message = "GET / HTTP/1.1\r\n";
        $message .= "\r\n";
        fwrite($socket, $message, strlen($message));
        //(1)ソケットの書き込みに対するタイムアウトを記述する
        stream_set_timeout($socket, 1);
        while(true) {
            $read = fread($socket, 1024);
            if (strlen($read) === 0) {
                break;
            }
            print($read);
        }
    }

と上記のように、(1)のクライント側から任意のサーバー側に対する

stream_set_timeout($socket, 1);

ソケットに対してクライアント側からの書き込み終了のタイムアウトを指定する。
このようにすることでスムーズにリモート側からレスポンスが返却される。

クライアント側のソケットのタイムアウトとは?
そもそもソケットを用いた通信時、クライアント側からの送信内容を
ソケットサーバー側で受信する際に、

fread($stream , 1024);
fgets($stream);

上記のようなストリームの読み込み関数を用いるが、このときソケットサーバー側は
当該のストリームからなんからのデータを読み取れるまでプログラムを完全にブロックすることになる。
クライアント側でのタイムアウトとは、サーバー側やクライアント側含めたストリームを読み込むまで
の時間を指定するものである。
つまり短く設定すればするほど読み取りによりブロックされる時間は早くなるが、
ストリームの読み取りに不良が発生する。

2.次に任意の文字列をエコーする、いわゆるエコーサーバー(※サーバー1台に対してクライアント1接続)

次に、よくウェブ上で確認できるソケットを使ったエコーサーバを作成
クライアント側のソースは前述のコードと大差はありません。

クライアント側

qiita_client.php
<?php
    $destination = "tcp://localhost:11180";
    $socket = stream_socket_client($destination);

    if ($socket ===false) {
        print("ソケットの確立に失敗");
        exit();
    } else {
        // ソケットへの接続時お名前を入力する
        $yourName = readline(">>> Input your name ");
        while (true) {
            print("{$yourName}さん");
            $input = readline(">>> ");
            $sendingMessage = "{$yourName}:{$input}";
            fwrite($socket, $sendingMessage, strlen($sendingMessage));
            stream_set_timeout($socket, 1);
            // サーバー側からのレスポンスを取得
            while(true) {
                $read = fread($socket, 1024);
                print($read.PHP_EOL);
                if (strlen($read) === 0) {
                    break;
                }
            }
        }
    }

クライアント側に関しては特筆すべきことはないと思います。
あえて上げるとすれば、クライアント側のストリームがプログラムの終了するか、あるいは
意図的に

fclose($stream);

といった関数でストリームを閉じるとサーバー側は即時にストリームのfeofがtrueになってしまいます。
そのため、クライアント<->サーバー間の関係を双方向にするためには、常にクライアント側、サーバー側ともに
ストリームが閉じることがないようにする必要があります。


        while (true) {
            print("{$yourName}さん");
            $input = readline(">>> ");
            $sendingMessage = "{$yourName}:{$input}";
            fwrite($socket, $sendingMessage, strlen($sendingMessage));
            stream_set_timeout($socket, 1);
            // サーバー側からのレスポンスを取得
            while(true) {
                $read = fread($socket, 1024);
                print($read.PHP_EOL);
                if (strlen($read) === 0) {
                    break;
                }
            }
        }

上記の無限ループがソケットを永続的に利用できるようにするための処理です。
まず、クライアント側という性質上何らかのリクエストに対して、レスポンスを得るということが必要になるため永続的にソケットに書き込むというループの中に、さらにサーバー側からのレスポンスを読み取るというループを設けておきます。
これにより、
リクエストに対して->レスポンスを得る
というクライアント・サーバー型の、クライアントの責務を果たすことができます。

次に、大事なこのクライアントからの接続を待ち受けるサーバのソースコードです。

qiita_server.php

<?php

    $socketHole = "tcp://localhost:11180";
    $socket = stream_socket_server($socketHole);
    if ($socket === false) {
        print("ソケットの確立に失敗しました。");
        exit();
    } else {
        while(true) {
            // 何らかのクライアント側からこのサーバーまで
            // 接続されるために待ち受けを行う
            $stream = stream_socket_accept($socket, -1);
            if ($stream !== false) {
                // ソケットの確立が成功した場合
                $clientName = stream_socket_get_name($stream, true);  //・・・・・(1)
                print($clientName."が接続しました".PHP_EOL);
                $successMessage = "[$clientName]:I have solved a connection from your client:".date("Y-m-d H:i:s").PHP_EOL;
                fwrite($stream, $successMessage, strlen($successMessage));
                while(true) {
                    $temp = fread($stream, 1024);
                    // サーバー側のコンソールにログとして出力する
                    $log = "[$clientName]:サーバー側が[$temp]というメッセージを受信".date("Y-m-d H:i:s").PHP_EOL;
                    print($log);
                    fwrite($stream, $log, strlen($log));
                    if (strlen($temp) === 0) {
                        break;
                    }
                }
            }
        }
    }
    fclose($socket);

上記がサーバー側のソースとなります。
一点、留意する点としてソース上の(1)にある

stream_socket_get_name($stream, true);

という箇所でしょうか。
これは何をしているかというと、当該のソケットサーバー(と命名します)に接続してきたクライアントを
当該のソケットサーバー内でユニークに識別するための名前を発行してくれます。
どのようなフォーマットで名前を取得できるかというと

::1:57328

上記のようなフォーマットで接続してきたクライアントを識別できる識別子を取得できます。
単一のクライアントだけでなぜこのような名前が必要かというと、この次に実施する1台のソケットサーバに対して
複数のクライアントからの接続を受け入れた場合、受信したメッセージをどのように振り分けるかをこれで
判別することが可能になります。(※実装次第ですが。)

また、クライアント側で双方向を実現するために、ソケットを永続させるためのループ処理をつくると述べましたが、サーバー側に関しては、リクエストを受けとって対応するレスポンスを返却するという責務を全うするため

                while(true) {
                    $temp = fread($stream, 1024);
                    // サーバー側のコンソールにログとして出力する
                    $log = "[$clientName]:サーバー側が[$temp]というメッセージを受信".date("Y-m-d H:i:s").PHP_EOL;
                    print($log);
                    fwrite($stream, $log, strlen($log));
                    if (strlen($temp) === 0) {
                        break;
                    }
                }

上記のように、読み込みを行うループ処理の中で、その読み込まれた内容にマッチする内容を返却するため
上記のようなループ処理となっています。

3.次に複数のクライアントからの接続を受け入れる。

このとき、例えば3つのクライアントAAA、BBB、CCCという3つの接続があった場合

例えば、
AAA>>>こんにちわ
と発言した場合
「AAAがこんにちわ」と発言した旨をBBBとCCCが受信するのが望ましい。
また
BBB>>> 明日何時から?
CCC>>> 21時からだよ
と上記の二人がこのように発言した場合
AAAにはこの両者のメッセージが発言順に画面に表示されるのが望ましい。
では、これを前例のソースコードを改修して実現します。

まずはクライアント側

qiita_client.php

<?php
    $destination = "tcp://localhost:11180";
    $socket = stream_socket_client($destination);

    if ($socket === false) {
        print("ソケットの確立に失敗");
        exit();
    } else {
        // ソケットへの接続時お名前を入力する
        $yourName = readline(">>> あなたのお名前は?");
        while (true) {
            print("{$yourName}さん");
            $input = readline(">>> ");
            if (strlen($input) !== 0) {
                $sendingMessage = "{$yourName}:{$input}";
            } else {
                $sendingMessage = "";
            }
            fwrite($socket, $sendingMessage, strlen($sendingMessage));
            stream_set_timeout($socket, 1);
            // サーバー側からのレスポンスを取得
            while(true) {
                $read = fread($socket, 1024);
                print($read.PHP_EOL);
                if (strlen($read) === 0) {
                    break;
                }
            }
        }
    }

次にサーバ側ソース

qiita_server.php
<?php

    $socketHole = "tcp://localhost:11180";
    $errorNunmber = null;
    $errorMessage = null;
    $socket = stream_socket_server($socketHole, $errorNunmber, $errorMessage);
    if ($socket === false) {
        print("ソケットの確立に失敗しました。");
        exit();
    } else {
        // このソケットサーバーに接続してきた全クライアントを
        // 監視用の配列に確保する
        $wrapper = array();
        while(true) {
            // 何らかのクライアント側からこのサーバーまで
            // 接続されるために待ち受けを行う(※これは初回接続時のみ)
            $stream = @stream_socket_accept($socket, 5);
            if ($stream !== false) {
                // クライアントの初回接続時のみ
                $clientName = stream_socket_get_name($stream, true);
                print($clientName."がサーバーに接続しました".PHP_EOL);
                $successMessage = "[$clientName]:サーバーへの接続を許可しました。:".date("Y-m-d H:i:s").PHP_EOL;
                fwrite($stream, $successMessage, strlen($successMessage));
                // ストリーム監視用の配列に確保
                $wrapper[$clientName] = $stream;
            }
            // 本ソケットサーバー側に接続している
            // クライアントが0の状態を予防
            if (count($wrapper) === 0) {
                continue;
            }
            // (1) 同時接続されたソケットを集中管理できる処理を書く
            $read = $write = $error = $wrapper;
            $number = stream_select($read, $write, $error, 3);
            if ($number > 0) {
                foreach ($read as $key => $fp) {
                    $temp = fread($fp, 1024);
                    if (strlen($temp) === 0) {
                        break;
                    }
                    // サーバー側のコンソールにログとして出力する
                    $readClientName = stream_socket_get_name($fp, true);
                    $serverLog = "[$readClientName]:サーバー側が[$temp]というメッセージを受信".date("Y-m-d H:i:s").PHP_EOL;
                    print($serverLog);
                    $sendingLog = "[$readClientName]:$temp".date("Y-m-d H:i:s").PHP_EOL;
                    foreach ($write as $innerKey => $innerFp) {
                        $writeClientName = stream_socket_get_name($innerFp, true);
                        if ($readClientName === $writeClientName) {
                            // メッセージ送信者のメッセージを本人に送る必要はない
                            continue;
                        }
                        fwrite($wrapper[$writeClientName], $sendingLog, strlen($sendingLog));
                    }
                }
            }
        }
    }
    fclose($socket);

以上が
サーバー側の処理。

4.解説

特筆すべき点は


            // (1) 同時接続されたソケットを集中管理できる処理を書く
            $read = $write = $error = $wrapper;
            $number = stream_select($read, $write, $error, 3);

これは、接続中のストリームを配列にいれてクライアントからの読み込みが可能なストリーム、
あるいは書き込みが可能なストリームのどれかが実行可能になった場合にint型の戻り値を返す関数。
このとき、各種配列をループにかけて全ストリームに総当たりで読み込みを行いかつ、そのまま他のクライアントへ
受信内容を送出する。

ソケットサーバー側では

$wrapper = array();
/*
Array
(
    [::1:49605] => Resource id #6
    [::1:49606] => Resource id #7
    [::1:49607] => Resource id #8
)
*/

という配列型で管理する。

以上のソースをコンソール上で

php server.php

でソケットサーバーを起動。
追加でコマンドプロンプトを複数起動。

php client.php

すると

>>> あなたのお名前は?AAA
AAAさん>>>
[::1:49475]:サーバーへの接続を許可しました:2018-06-26 09:45:56


AAAさん>>>

AAAさん>>> hello

AAAさん>>>
[::1:49479]:BBB:Hello2018-06-26 09:46:39
[::1:49479]:BBB:I am  BBB p.2018-06-26 09:47:29


AAAさん>>>
>>> あなたのお名前は?BBB
BBBさん>>> Hello
[::1:49479]:サーバーへの接続を許可しました:2018-06-26 09:46:34


BBBさん>>> I am  BBB p.

BBBさん>>>

BBBさん>>>
[::1:49475]:AAA:hahaha!2018-06-26 09:51:24


BBBさん>>>

各クライアントには上記のように表示される。
AAAの発言がBBBへのみ送信、逆にBBBの発言はAAAのみ送出される。

対して server.phpを実行したコンソールは

::1:49475がサーバーに接続しました
[::1:49475]:サーバー側が[AAA:hello]というメッセージを受信2018-06-26 09:46:07
::1:49479がサーバーに接続しました
[::1:49479]:サーバー側が[BBB:Hello]というメッセージを受信2018-06-26 09:46:39
[::1:49479]:サーバー側が[BBB:I am  BBB p.]というメッセージを受信2018-06-26 09:47:29
[::1:49475]:サーバー側が[AAA:hahaha!]というメッセージを受信2018-06-26 09:51:24

上記のように全クライアントからの受信内容をログとして保持。

stream_select($read, $write, $error);

で処理したストリームに各種ストリームの識別子を元に任意のデータを送出できる。

不思議なのが、サーバー側クライアント側問わずストリームの

feof($stream)

この概念が通用しないということ。
私のソースではfeofではなく単純に freadでクライアント側からの送信内容が途切れた段階・・・
つまりサーバー側で読み取り内容が0バイトの文字列を返却する時点で他の処理に移る点。

このストリームがサーバー側クライアント側ともにどのような扱いになっているのかが
全く把握できていません。
ただ、ソース上ではうまく双方向に必要な内容を返却できているだけです。

また、どうにも仕様なのかバグなのか判断つきませんが、

// 何らかのクライアント側からこのサーバーまで
// 接続されるために待ち受けを行う(※これは初回接続時のみ)
$stream = stream_socket_accept($socket, 1);

上記の stream_socket_accept関数でクライアントからの待受を行っている箇所で
指定したタイムアウト秒内に当該ソケットサーバーへアクセスがない場合

Warning: stream_socket_accept(): accept failed: 接続済みの呼び出し先が一定の時間を過ぎても正しく応答しなかったため、接続できませんでした。または接続済みのホストが応答しなかったため、確立された接続は失敗しました。

という警告が強制的に送出されるため[@]で計測の抑制をする必要があるかもしれません。

といあえず、今回はPHPで複数のユーザーが単一のソケットサーバーへ接続し
ほぼリアルタイムにソケットを一度も断絶せずにチャットライクなことを実現しました。
次に同様の機能をpython あるいは Goなどで行います。

13
16
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
13
16

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?