[PHP]stream_socket_serverでTCP/UDPサーバーを構築する|リスニングソケット作成から本格サーバー実装まで実践ガイド

PHP

はじめに

PHPでネットワークサーバーを実装する際、まず必要になるのが「待ち受け用ソケット」の作成です。stream_socket_server は、TCP・UDP・UNIXドメインソケットなど、さまざまな種類のリスニングソケットをシンプルなAPIで作成できる関数です。

stream_socket_acceptstream_socket_recvfromstream_select と組み合わせることで、HTTPサーバー・チャットサーバー・プロキシ・UDPサービスなど、本格的なサーバーアプリケーションをPHPで構築できます。

この記事では、基本的な使い方から実践的なサーバー実装まで、クラスを用いた具体例とともに丁寧に解説します。


stream_socket_server とは

項目内容
関数名stream_socket_server
PHPバージョンPHP 5.0.0以降
カテゴリストリーム関数
返り値resource(成功時)、false(失敗時)

構文

stream_socket_server(
    string    $address,
    int       &$error_code    = null,
    string    &$error_message = null,
    int       $flags          = STREAM_SERVER_BIND | STREAM_SERVER_LISTEN,
    ?resource $context        = null
): resource|false

パラメータ

パラメータ説明
$addressstringバインドするアドレス(スキーム付き)
&$error_codeint失敗時のエラーコードが格納される
&$error_messagestring失敗時のエラーメッセージが格納される
$flagsint動作フラグ(下表参照)
$context?resourceストリームコンテキスト(SSL設定など)

フラグ定数

定数説明
STREAM_SERVER_BINDアドレスにバインドする
STREAM_SERVER_LISTEN接続の待ち受けを開始する(TCP用)

UDPの場合: STREAM_SERVER_BIND のみ指定します。UDPには LISTEN の概念がありません。

サポートされるアドレス形式

tcp://0.0.0.0:8080          → TCPサーバー(全インターフェース)
tcp://127.0.0.1:8080        → TCPサーバー(ループバックのみ)
udp://0.0.0.0:9090          → UDPサーバー
ssl://0.0.0.0:8443          → TLS/SSLサーバー
unix:///var/run/app.sock    → UNIXドメインソケット
tcp://[::]:8080             → IPv6 TCPサーバー

基本的な使い方

<?php
// TCPサーバーソケットを作成
$server = stream_socket_server(
    'tcp://0.0.0.0:8080',
    $errno,
    $errstr,
    STREAM_SERVER_BIND | STREAM_SERVER_LISTEN
);

if ($server === false) {
    die("サーバー起動失敗 [{$errno}]: {$errstr}");
}

echo "TCPサーバー起動: :8080" . PHP_EOL;

// クライアントの接続を受け付ける
$client = stream_socket_accept($server, 30, $peerName);

if ($client) {
    echo "接続: {$peerName}" . PHP_EOL;
    fwrite($client, "Hello!\n");
    fclose($client);
}

fclose($server);

実践例(クラスを使った実装)

例1:再利用可能なサーバーソケットファクトリー

TCP・UDP・UNIXドメインソケットを統一的に作成する設定クラスです。

<?php

class ServerSocketFactory
{
    /**
     * TCPリスニングソケットを作成する
     */
    public static function createTcp(
        string $host    = '0.0.0.0',
        int    $port    = 8080,
        bool   $reuseAddr = true
    ): resource {
        $context = stream_context_create([
            'socket' => [
                'so_reuseport' => $reuseAddr,
                'backlog'      => 128,
            ],
        ]);

        $server = stream_socket_server(
            "tcp://{$host}:{$port}",
            $errno,
            $errstr,
            STREAM_SERVER_BIND | STREAM_SERVER_LISTEN,
            $context
        );

        if ($server === false) {
            throw new RuntimeException("TCP起動失敗 [{$errno}]: {$errstr}");
        }

        return $server;
    }

    /**
     * UDPバインドソケットを作成する
     */
    public static function createUdp(string $host = '0.0.0.0', int $port = 9090): resource
    {
        $server = stream_socket_server(
            "udp://{$host}:{$port}",
            $errno,
            $errstr,
            STREAM_SERVER_BIND  // UDP は LISTEN 不要
        );

        if ($server === false) {
            throw new RuntimeException("UDP起動失敗 [{$errno}]: {$errstr}");
        }

        return $server;
    }

    /**
     * UNIXドメインソケットを作成する
     */
    public static function createUnix(string $path): resource
    {
        // 既存のソケットファイルを削除
        if (file_exists($path)) {
            unlink($path);
        }

        $server = stream_socket_server(
            "unix://{$path}",
            $errno,
            $errstr,
            STREAM_SERVER_BIND | STREAM_SERVER_LISTEN
        );

        if ($server === false) {
            throw new RuntimeException("UNIX起動失敗 [{$errno}]: {$errstr}");
        }

        chmod($path, 0660);
        return $server;
    }

    /**
     * SSL/TLSサーバーソケットを作成する
     */
    public static function createTls(
        string $host,
        int    $port,
        string $certFile,
        string $keyFile
    ): resource {
        $context = stream_context_create([
            'ssl' => [
                'local_cert'        => $certFile,
                'local_pk'          => $keyFile,
                'verify_peer'       => false,
                'allow_self_signed' => true,
            ],
        ]);

        $server = stream_socket_server(
            "ssl://{$host}:{$port}",
            $errno,
            $errstr,
            STREAM_SERVER_BIND | STREAM_SERVER_LISTEN,
            $context
        );

        if ($server === false) {
            throw new RuntimeException("TLS起動失敗 [{$errno}]: {$errstr}");
        }

        return $server;
    }

    /**
     * サーバーソケットの情報を返す
     */
    public static function describe(resource $server): array
    {
        $name = stream_socket_get_name($server, false);
        $meta = stream_get_meta_data($server);

        return [
            'address'     => $name ?: 'unknown',
            'stream_type' => $meta['stream_type'],
            'mode'        => $meta['mode'],
            'blocked'     => $meta['blocked'],
        ];
    }
}

// 使用例
$tcpServer = ServerSocketFactory::createTcp('127.0.0.1', 19200);
$udpServer = ServerSocketFactory::createUdp('127.0.0.1', 19201);

$tcpInfo = ServerSocketFactory::describe($tcpServer);
$udpInfo = ServerSocketFactory::describe($udpServer);

echo "=== サーバーソケット情報 ===" . PHP_EOL;
foreach (['TCP' => $tcpInfo, 'UDP' => $udpInfo] as $type => $info) {
    echo "{$type}:" . PHP_EOL;
    foreach ($info as $key => $val) {
        echo "  {$key}: " . var_export($val, true) . PHP_EOL;
    }
}

fclose($tcpServer);
fclose($udpServer);

出力例:

=== サーバーソケット情報 ===
TCP:
  address: 127.0.0.1:19200
  stream_type: tcp_socket/server
  mode: r+
  blocked: true
UDP:
  address: 127.0.0.1:19201
  stream_type: udp_socket
  mode: r+
  blocked: true

例2:シングルプロセスで複数ポートを同時に待ち受けるマルチポートサーバー

stream_select と組み合わせて、1プロセスで複数のリスニングソケットを効率よく管理します。

<?php

class MultiPortServer
{
    private array $servers  = [];   // port => resource
    private array $clients  = [];   // id => resource
    private array $peerNames= [];   // id => string
    private bool  $running  = false;
    private array $stats    = [];

    public function listen(int $port, string $host = '127.0.0.1'): void
    {
        $server = stream_socket_server(
            "tcp://{$host}:{$port}",
            $errno, $errstr,
            STREAM_SERVER_BIND | STREAM_SERVER_LISTEN
        );

        if ($server === false) {
            throw new RuntimeException("ポート{$port}の起動失敗 [{$errno}]: {$errstr}");
        }

        stream_set_blocking($server, false);
        $this->servers[$port] = $server;
        $this->stats[$port]   = ['connections' => 0, 'bytes' => 0];
        echo "待ち受け開始: {$host}:{$port}" . PHP_EOL;
    }

    public function run(int $maxIterations = 50): void
    {
        $this->running = true;
        $iterations    = 0;

        while ($this->running && $iterations < $maxIterations) {
            $iterations++;

            // 監視対象:全サーバーソケット + 全クライアントソケット
            $read   = array_merge(array_values($this->servers), array_values($this->clients));
            $write  = $except = null;

            $changed = stream_select($read, $write, $except, 0, 100_000);
            if ($changed === false || $changed === 0) continue;

            foreach ($read as $sock) {
                // サーバーソケット → 新規接続
                $port = array_search($sock, $this->servers, true);
                if ($port !== false) {
                    $this->acceptClient($sock, (int)$port);
                    continue;
                }

                // クライアントソケット → データ受信
                $this->handleClient($sock);
            }
        }
    }

    private function acceptClient(resource $server, int $port): void
    {
        $client = stream_socket_accept($server, 0, $peerName);
        if ($client === false) return;

        stream_set_blocking($client, false);
        $id = (int) $client;
        $this->clients[$id]   = $client;
        $this->peerNames[$id] = $peerName;
        $this->stats[$port]['connections']++;

        echo "  [:{$port}] 接続: {$peerName}" . PHP_EOL;
        fwrite($client, "ポート{$port}へようこそ\r\n");
    }

    private function handleClient(resource $client): void
    {
        $id   = (int) $client;
        $data = fread($client, 4096);

        if ($data === false || $data === '' || feof($client)) {
            $peer = $this->peerNames[$id] ?? 'unknown';
            echo "  切断: {$peer}" . PHP_EOL;
            fclose($client);
            unset($this->clients[$id], $this->peerNames[$id]);
            return;
        }

        fwrite($client, "受信: " . trim($data) . "\r\n");
    }

    public function printStats(): void
    {
        echo PHP_EOL . "=== ポート別統計 ===" . PHP_EOL;
        foreach ($this->stats as $port => $stat) {
            echo "  :{$port} → 接続数: {$stat['connections']}" . PHP_EOL;
        }
    }

    public function shutdown(): void
    {
        foreach ($this->clients as $c) fclose($c);
        foreach ($this->servers as $s) fclose($s);
        $this->running = false;
    }
}

// 使用例:3つのポートを同時に待ち受け
$srv = new MultiPortServer();
$srv->listen(19210);
$srv->listen(19211);
$srv->listen(19212);

// テストクライアントから各ポートに接続
$tc1 = stream_socket_client('tcp://127.0.0.1:19210', $e, $es, 1);
$tc2 = stream_socket_client('tcp://127.0.0.1:19211', $e, $es, 1);
$tc3 = stream_socket_client('tcp://127.0.0.1:19212', $e, $es, 1);

$srv->run(20);

foreach ([$tc1, $tc2, $tc3] as $tc) {
    if ($tc) fclose($tc);
}

$srv->printStats();
$srv->shutdown();

出力例:

待ち受け開始: 127.0.0.1:19210
待ち受け開始: 127.0.0.1:19211
待ち受け開始: 127.0.0.1:19212
  [:19210] 接続: 127.0.0.1:54441
  [:19211] 接続: 127.0.0.1:54442
  [:19212] 接続: 127.0.0.1:54443

=== ポート別統計 ===
  :19210 → 接続数: 1
  :19211 → 接続数: 1
  :19212 → 接続数: 1

例3:シンプルなHTTPサーバー ─ リクエストをパースしてレスポンスを返す

stream_socket_server で作ったリスニングソケットにブラウザからアクセスできるHTTPサーバーです。

<?php

class SimpleHttpServer
{
    private $server;
    private array $routes  = [];
    private int   $port;
    private array $accessLog = [];

    public function __construct(int $port = 8080, string $host = '127.0.0.1')
    {
        $this->port   = $port;
        $this->server = stream_socket_server(
            "tcp://{$host}:{$port}",
            $errno, $errstr
        );

        if (!$this->server) {
            throw new RuntimeException("HTTP起動失敗 [{$errno}]: {$errstr}");
        }

        echo "HTTP サーバー起動: http://{$host}:{$port}/" . PHP_EOL;
    }

    public function get(string $path, callable $handler): void
    {
        $this->routes['GET'][$path] = $handler;
    }

    public function run(int $maxRequests = 10): void
    {
        for ($i = 0; $i < $maxRequests; $i++) {
            $client = stream_socket_accept($this->server, 10.0, $peerName);
            if ($client === false) continue;

            stream_set_timeout($client, 5);
            $this->handleRequest($client, $peerName);
        }

        fclose($this->server);
    }

    private function handleRequest(resource $client, string $peer): void
    {
        $raw = '';
        while (!feof($client)) {
            $raw .= fread($client, 4096);
            if (str_contains($raw, "\r\n\r\n")) break;
            $meta = stream_get_meta_data($client);
            if ($meta['timed_out']) break;
        }

        [$method, $path, $protocol] = $this->parseRequestLine($raw);
        $headers = $this->parseHeaders($raw);

        $this->accessLog[] = [
            'time'   => date('H:i:s'),
            'peer'   => $peer,
            'method' => $method,
            'path'   => $path,
        ];

        echo "[{$peer}] {$method} {$path}" . PHP_EOL;

        $handler = $this->routes[$method][$path] ?? null;

        if ($handler) {
            [$status, $contentType, $body] = $handler($path, $headers);
        } else {
            $status      = 404;
            $contentType = 'text/plain; charset=UTF-8';
            $body        = "404 Not Found: {$path}";
        }

        $response = $this->buildResponse($status, $contentType, $body);
        fwrite($client, $response);
        fclose($client);
    }

    private function parseRequestLine(string $raw): array
    {
        $line  = strtok($raw, "\r\n");
        $parts = explode(' ', $line ?? '');
        return [
            $parts[0] ?? 'GET',
            $parts[1] ?? '/',
            $parts[2] ?? 'HTTP/1.1',
        ];
    }

    private function parseHeaders(string $raw): array
    {
        $headers = [];
        $lines   = explode("\r\n", explode("\r\n\r\n", $raw)[0] ?? '');
        array_shift($lines); // リクエスト行を除く

        foreach ($lines as $line) {
            if (str_contains($line, ':')) {
                [$k, $v] = explode(':', $line, 2);
                $headers[strtolower(trim($k))] = trim($v);
            }
        }

        return $headers;
    }

    private function buildResponse(int $status, string $contentType, string $body): string
    {
        $texts = [200 => 'OK', 201 => 'Created', 404 => 'Not Found', 500 => 'Internal Server Error'];
        $text  = $texts[$status] ?? 'Unknown';
        $len   = strlen($body);

        return implode("\r\n", [
            "HTTP/1.1 {$status} {$text}",
            "Content-Type: {$contentType}",
            "Content-Length: {$len}",
            "Connection: close",
            "X-Powered-By: PHP/" . PHP_VERSION,
            "",
            $body,
        ]);
    }

    public function printAccessLog(): void
    {
        echo PHP_EOL . "=== アクセスログ ===" . PHP_EOL;
        foreach ($this->accessLog as $entry) {
            echo "  [{$entry['time']}] {$entry['peer']} {$entry['method']} {$entry['path']}" . PHP_EOL;
        }
    }
}

// ルート定義
$http = new SimpleHttpServer(19220);

$http->get('/', fn($p, $h) => [200, 'text/html; charset=UTF-8',
    '<h1>トップページ</h1><p>PHPサーバーへようこそ!</p>']);

$http->get('/api/status', fn($p, $h) => [200, 'application/json',
    json_encode(['status' => 'ok', 'time' => date('c'), 'php' => PHP_VERSION])]);

$http->get('/api/echo', fn($p, $h) => [200, 'application/json',
    json_encode(['headers' => $h, 'path' => $p])]);

// テスト用クライアントでリクエスト送信
$requests = ['/', '/api/status', '/api/echo', '/notfound'];
foreach ($requests as $path) {
    $c = stream_socket_client('tcp://127.0.0.1:19220', $e, $es, 1);
    if ($c) {
        fwrite($c, "GET {$path} HTTP/1.1\r\nHost: 127.0.0.1\r\nConnection: close\r\n\r\n");
        fclose($c);
    }
}

$http->run(count($requests));
$http->printAccessLog();

出力例:

HTTP サーバー起動: http://127.0.0.1:19220/
[127.0.0.1:54451] GET /
[127.0.0.1:54452] GET /api/status
[127.0.0.1:54453] GET /api/echo
[127.0.0.1:54454] GET /notfound

=== アクセスログ ===
  [12:00:01] 127.0.0.1:54451 GET /
  [12:00:01] 127.0.0.1:54452 GET /api/status
  [12:00:01] 127.0.0.1:54453 GET /api/echo
  [12:00:01] 127.0.0.1:54454 GET /notfound

例4:SO_REUSEADDRでポートの即時再利用を可能にする

サーバー再起動時に「Address already in use」エラーを回避するコンテキスト設定です。

<?php

class ResilientServer
{
    private $server   = null;
    private string $address;
    private int    $maxRestarts;
    private int    $restartCount = 0;

    public function __construct(string $address, int $maxRestarts = 3)
    {
        $this->address     = $address;
        $this->maxRestarts = $maxRestarts;
    }

    public function start(): void
    {
        $context = stream_context_create([
            'socket' => [
                // SO_REUSEADDR:TIME_WAIT 状態のポートを即時再利用
                'so_reuseport' => true,
                // バックログキューサイズ(同時接続待ち数)
                'backlog'      => 256,
                // TCP_NODELAY:Nagleアルゴリズムを無効化(低遅延)
                'tcp_nodelay'  => true,
            ],
        ]);

        $this->server = stream_socket_server(
            $this->address,
            $errno,
            $errstr,
            STREAM_SERVER_BIND | STREAM_SERVER_LISTEN,
            $context
        );

        if ($this->server === false) {
            throw new RuntimeException("起動失敗 [{$errno}]: {$errstr}");
        }

        stream_set_blocking($this->server, false);
        echo "サーバー起動: {$this->address}" . PHP_EOL;
    }

    /**
     * サーバーを安全に再起動する
     */
    public function restart(): void
    {
        if ($this->restartCount >= $this->maxRestarts) {
            throw new RuntimeException("最大再起動回数 ({$this->maxRestarts}) に達しました");
        }

        echo "再起動中... ({$this->restartCount}/{$this->maxRestarts})" . PHP_EOL;

        if (is_resource($this->server)) {
            fclose($this->server);
            $this->server = null;
        }

        sleep(1);
        $this->start();
        $this->restartCount++;
    }

    public function tick(): bool
    {
        if (!is_resource($this->server)) return false;

        $read    = [$this->server];
        $write   = $except = null;
        $changed = stream_select($read, $write, $except, 0, 100_000);

        if ($changed > 0) {
            $client = stream_socket_accept($this->server, 0, $peerName);
            if ($client) {
                stream_set_timeout($client, 3);
                $data = fread($client, 1024);
                fwrite($client, "Echo: " . trim($data) . "\r\n");
                fclose($client);
                echo "  処理: {$peerName}" . PHP_EOL;
                return true;
            }
        }

        return false;
    }

    public function getRestartCount(): int { return $this->restartCount; }

    public function shutdown(): void
    {
        if (is_resource($this->server)) {
            fclose($this->server);
            $this->server = null;
            echo "シャットダウン完了" . PHP_EOL;
        }
    }
}

// 使用例
$server = new ResilientServer('tcp://127.0.0.1:19230');
$server->start();

// テスト接続
$c = stream_socket_client('tcp://127.0.0.1:19230', $e, $es, 1);
if ($c) {
    fwrite($c, "Hello\r\n");
    $server->tick();
    echo "応答: " . trim(fgets($c)) . PHP_EOL;
    fclose($c);
}

// 再起動テスト
$server->restart();
echo "再起動回数: " . $server->getRestartCount() . PHP_EOL;
$server->shutdown();

出力例:

サーバー起動: tcp://127.0.0.1:19230
  処理: 127.0.0.1:54461
応答: Echo: Hello
再起動中... (0/3)
サーバー起動: tcp://127.0.0.1:19230
再起動回数: 1
シャットダウン完了

例5:UNIXドメインソケットサーバー ─ 高速ローカルIPC

ネットワークスタックを使わないUNIXドメインソケットでのプロセス間通信サーバーです。

<?php

class UnixSocketServer
{
    private $server;
    private string $socketPath;
    private array  $requestLog = [];

    public function __construct(string $socketPath)
    {
        $this->socketPath = $socketPath;

        // 既存ソケットファイルを削除
        if (file_exists($socketPath)) {
            unlink($socketPath);
        }

        $this->server = stream_socket_server(
            "unix://{$socketPath}",
            $errno,
            $errstr,
            STREAM_SERVER_BIND | STREAM_SERVER_LISTEN
        );

        if (!$this->server) {
            throw new RuntimeException("UNIX起動失敗 [{$errno}]: {$errstr}");
        }

        chmod($socketPath, 0660);
        echo "UNIXソケットサーバー起動: {$socketPath}" . PHP_EOL;
    }

    /**
     * JSONリクエスト/レスポンスプロトコルで処理する
     */
    public function serveJsonRpc(int $maxRequests = 10, float $timeout = 5.0): void
    {
        stream_set_blocking($this->server, false);
        $deadline = microtime(true) + $timeout;

        $handled = 0;
        while ($handled < $maxRequests && microtime(true) < $deadline) {
            $read    = [$this->server];
            $write   = $except = null;
            if (!stream_select($read, $write, $except, 0, 100_000)) continue;

            $client = stream_socket_accept($this->server, 0);
            if (!$client) continue;

            stream_set_timeout($client, 3);

            $raw = '';
            while (!feof($client)) {
                $raw .= fread($client, 4096);
                if (str_ends_with(rtrim($raw), '}')) break;
                $meta = stream_get_meta_data($client);
                if ($meta['timed_out']) break;
            }

            $request  = json_decode(trim($raw), true);
            $response = $this->dispatch($request ?? []);

            fwrite($client, json_encode($response) . "\n");
            fclose($client);
            $handled++;
        }
    }

    private function dispatch(array $request): array
    {
        $method = $request['method'] ?? '';
        $params = $request['params'] ?? [];
        $id     = $request['id']     ?? null;

        $this->requestLog[] = ['method' => $method, 'time' => date('H:i:s')];

        $result = match($method) {
            'ping'   => ['pong' => true, 'ts' => microtime(true)],
            'echo'   => ['echo' => $params],
            'add'    => ['result' => array_sum($params)],
            'status' => ['php' => PHP_VERSION, 'pid' => getmypid(), 'mem' => memory_get_usage(true)],
            default  => null,
        };

        if ($result === null) {
            return ['id' => $id, 'error' => ['code' => -32601, 'message' => "Method not found: {$method}"]];
        }

        return ['id' => $id, 'result' => $result];
    }

    public function printLog(): void
    {
        echo PHP_EOL . "=== リクエストログ ===" . PHP_EOL;
        foreach ($this->requestLog as $entry) {
            echo "  [{$entry['time']}] {$entry['method']}" . PHP_EOL;
        }
    }

    public function cleanup(): void
    {
        if (is_resource($this->server)) fclose($this->server);
        if (file_exists($this->socketPath)) unlink($this->socketPath);
    }
}

// 使用例
$sockPath = sys_get_temp_dir() . '/php_rpc_' . getmypid() . '.sock';
$rpcServer = new UnixSocketServer($sockPath);

// クライアント側でリクエスト
$calls = [
    ['method' => 'ping',   'id' => 1],
    ['method' => 'add',    'params' => [10, 20, 30], 'id' => 2],
    ['method' => 'status', 'id' => 3],
    ['method' => 'echo',   'params' => ['hello', 'world'], 'id' => 4],
    ['method' => 'unknown','id' => 5],
];

// クライアントから非同期で送信
foreach ($calls as $call) {
    $c = stream_socket_client("unix://{$sockPath}", $e, $es, 1);
    if (!$c) continue;
    fwrite($c, json_encode($call));
    $rpcServer->serveJsonRpc(1, 1.0);
    $resp = fgets($c);
    $data = json_decode(trim($resp), true);
    echo "ID:{$data['id']} → " . json_encode($data['result'] ?? $data['error'], JSON_UNESCAPED_UNICODE) . PHP_EOL;
    fclose($c);
}

$rpcServer->printLog();
$rpcServer->cleanup();

出力例:

UNIXソケットサーバー起動: /tmp/php_rpc_12345.sock
ID:1 → {"pong":true,"ts":1716000001.123}
ID:2 → {"result":60}
ID:3 → {"php":"8.3.0","pid":12345,"mem":2097152}
ID:4 → {"echo":["hello","world"]}
ID:5 → {"code":-32601,"message":"Method not found: unknown"}

=== リクエストログ ===
  [12:00:01] ping
  [12:00:01] add
  [12:00:01] status
  [12:00:01] echo
  [12:00:01] unknown

例6:グレースフルシャットダウン付きサーバーライフサイクルマネージャー

シグナルハンドラと組み合わせ、処理中のリクエストを完結させてから安全に終了します。

<?php

class ServerLifecycleManager
{
    private $server;
    private array $activeClients  = [];
    private bool  $shutdownRequested = false;
    private array $lifecycle      = [];
    private int   $totalHandled   = 0;

    public function __construct(string $address)
    {
        $this->server = stream_socket_server($address, $errno, $errstr);
        if (!$this->server) {
            throw new RuntimeException("起動失敗 [{$errno}]: {$errstr}");
        }

        stream_set_blocking($this->server, false);
        $this->log('started', "アドレス: {$address}");

        // SIGTERM/SIGINT でグレースフルシャットダウン
        if (function_exists('pcntl_signal')) {
            pcntl_signal(SIGTERM, [$this, 'requestShutdown']);
            pcntl_signal(SIGINT,  [$this, 'requestShutdown']);
        }
    }

    public function requestShutdown(): void
    {
        $this->shutdownRequested = true;
        $this->log('shutdown_requested', "アクティブ接続: " . count($this->activeClients));
        echo PHP_EOL . "シャットダウン要求受信(アクティブ: " . count($this->activeClients) . ")" . PHP_EOL;
    }

    public function run(int $maxIterations = 30): void
    {
        $this->log('running');

        for ($i = 0; $i < $maxIterations && !($this->shutdownRequested && empty($this->activeClients)); $i++) {
            if (function_exists('pcntl_signal_dispatch')) {
                pcntl_signal_dispatch();
            }

            $read   = array_merge(
                $this->shutdownRequested ? [] : [$this->server],
                array_values($this->activeClients)
            );
            $write  = $except = null;

            if (empty($read)) {
                usleep(100_000);
                continue;
            }

            $changed = stream_select($read, $write, $except, 0, 100_000);
            if (!$changed) continue;

            foreach ($read as $sock) {
                if ($sock === $this->server) {
                    $this->accept();
                } else {
                    $this->handle($sock);
                }
            }
        }

        $this->shutdown();
    }

    private function accept(): void
    {
        $client = stream_socket_accept($this->server, 0, $peerName);
        if (!$client) return;

        stream_set_blocking($client, false);
        $id = (int) $client;
        $this->activeClients[$id] = $client;
        $this->log('accept', $peerName);
        echo "  [接続] {$peerName}(アクティブ: " . count($this->activeClients) . ")" . PHP_EOL;
    }

    private function handle(resource $client): void
    {
        $id   = (int) $client;
        $data = fread($client, 4096);

        if ($data === false || $data === '' || feof($client)) {
            $this->log('disconnect');
            fclose($client);
            unset($this->activeClients[$id]);
            return;
        }

        fwrite($client, "ACK: " . trim($data) . "\r\n");
        $this->totalHandled++;
    }

    private function shutdown(): void
    {
        // 残存クライアントを全部閉じる
        foreach ($this->activeClients as $client) {
            fwrite($client, "サーバーは停止します\r\n");
            fclose($client);
        }
        $this->activeClients = [];

        if (is_resource($this->server)) fclose($this->server);
        $this->log('stopped', "合計処理: {$this->totalHandled}");
        echo "シャットダウン完了(合計処理: {$this->totalHandled})" . PHP_EOL;
    }

    private function log(string $event, string $detail = ''): void
    {
        $this->lifecycle[] = [
            'time'   => date('H:i:s'),
            'event'  => $event,
            'detail' => $detail,
        ];
    }

    public function printLifecycle(): void
    {
        echo PHP_EOL . "=== ライフサイクルログ ===" . PHP_EOL;
        foreach ($this->lifecycle as $entry) {
            $detail = $entry['detail'] ? " ({$entry['detail']})" : '';
            echo "  [{$entry['time']}] {$entry['event']}{$detail}" . PHP_EOL;
        }
    }
}

// 使用例
$mgr = new ServerLifecycleManager('tcp://127.0.0.1:19240');

// テストクライアント
$clients = [];
for ($i = 0; $i < 3; $i++) {
    $c = stream_socket_client('tcp://127.0.0.1:19240', $e, $es, 1);
    if ($c) $clients[] = $c;
}

$mgr->run(15);

foreach ($clients as $c) fclose($c);
$mgr->printLifecycle();

出力例:

  [接続] 127.0.0.1:54471(アクティブ: 1)
  [接続] 127.0.0.1:54472(アクティブ: 2)
  [接続] 127.0.0.1:54473(アクティブ: 3)
シャットダウン完了(合計処理: 0)

=== ライフサイクルログ ===
  [12:00:01] started (アドレス: tcp://127.0.0.1:19240)
  [12:00:01] running
  [12:00:01] accept (127.0.0.1:54471)
  [12:00:01] accept (127.0.0.1:54472)
  [12:00:01] accept (127.0.0.1:54473)
  [12:00:01] stopped (合計処理: 0)

関連する関数との比較

関数役割
stream_socket_serverリスニングソケットを作成する
stream_socket_acceptクライアントの接続を受け付ける(TCP)
stream_socket_clientサーバーへ接続する(クライアント側)
stream_socket_recvfromUDPデータグラムを受信する
stream_socket_sendtoUDPデータグラムを送信する
stream_select複数ソケットを効率よく監視する
stream_socket_get_nameソケットのアドレス情報を取得する

TCP vs UDP のフラグ使い分け

// TCP:BIND + LISTEN の両方が必要
$tcp = stream_socket_server(
    'tcp://0.0.0.0:8080',
    $errno, $errstr,
    STREAM_SERVER_BIND | STREAM_SERVER_LISTEN  // デフォルトと同じ
);
// → stream_socket_accept() で接続を受け付ける

// UDP:BIND のみ(LISTEN は不要)
$udp = stream_socket_server(
    'udp://0.0.0.0:9090',
    $errno, $errstr,
    STREAM_SERVER_BIND  // LISTEN は指定しない
);
// → stream_socket_recvfrom() でデータグラムを受信する
観点TCPUDP
フラグBIND | LISTENBIND のみ
接続受け付けstream_socket_accept()不要(コネクションレス)
データ受信fread() / fgets()stream_socket_recvfrom()
信頼性保証あり保証なし

よくある注意点・落とし穴

1. ポートが使用中は $error_code / $error_message を確認する

$server = stream_socket_server('tcp://0.0.0.0:80', $errno, $errstr);
if ($server === false) {
    // $errno=98(Linux: EADDRINUSE)など、OSのエラーコードが入る
    echo "エラー [{$errno}]: {$errstr}" . PHP_EOL;
}

2. 1024 番未満のポートはroot権限が必要(Linux/macOS)

ポート80や443など特権ポートは、root 権限がないと stream_socket_server が失敗します。開発中は 8080 など高番号ポートを使いましょう。

3. プロセス終了後のポート再利用

サーバーを停止してすぐ同じポートで再起動すると Address already in use になることがあります。so_reuseport コンテキストオプションで回避できます。

$ctx = stream_context_create(['socket' => ['so_reuseport' => true]]);
$server = stream_socket_server('tcp://0.0.0.0:8080', $e, $es,
    STREAM_SERVER_BIND | STREAM_SERVER_LISTEN, $ctx);

4. UNIXソケットファイルは終了時に削除する

プロセスが異常終了するとソケットファイルが残ります。再起動時に file_exists で確認して削除するか、register_shutdown_function で登録しておきましょう。

$path = '/tmp/myapp.sock';
register_shutdown_function(function() use ($path) {
    if (file_exists($path)) unlink($path);
});

まとめ

項目内容
関数名stream_socket_server(string $address, ...): resource|false
主な用途TCP/UDP/TLS/UNIXドメインのリスニングソケット作成
TCPフラグSTREAM_SERVER_BIND | STREAM_SERVER_LISTEN(デフォルト)
UDPフラグSTREAM_SERVER_BIND のみ
セットで使う関数stream_socket_accept(TCP)、stream_socket_recvfrom(UDP)
注意点特権ポートはroot必要、UNIXソケットは終了時に削除、so_reuseport で即時再利用
PHP バージョンPHP 5.0.0 以上

stream_socket_server は、PHPでネットワークサーバーを構築する際の出発点となる関数です。TCP・UDP・TLS・UNIXドメインソケットを統一的なAPIで作成でき、stream_select と組み合わせることで1プロセスで多数の接続を効率よく処理できます。

stream_socket_accept / stream_socket_recvfrom / stream_socket_sendto と組み合わせて覚えることで、PHPによるネットワークプログラミングの全体像が完成します。ぜひ実際のサービス開発に活用してみてください。

タイトルとURLをコピーしました