[PHP]stream_socket_recvfromでUDP通信を実装する|接続なしデータグラム受信の仕組みと実践ガイド

PHP

はじめに

TCPは接続を確立してからデータをやり取りしますが、UDPは接続なしで直接データグラムを送受信します。DNSクエリ、NTP時刻同期、ゲームのリアルタイム通信、ログ転送など、低遅延・高スループットが求められる場面でUDPは欠かせません。

stream_socket_recvfrom は、ソケットからデータを受信しつつ、送信元のアドレス情報も同時に取得できる関数です。fread との大きな違いは、送信元アドレスが $address 引数に格納される点で、UDPの「誰から来たか」を知る唯一の手段です。

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


stream_socket_recvfrom とは

項目内容
関数名stream_socket_recvfrom
PHPバージョンPHP 5.1.0以降
カテゴリストリーム関数
返り値string(受信データ)、false(失敗時)

構文

stream_socket_recvfrom(
    resource $socket,
    int      $length,
    int      $flags   = 0,
    ?string  &$address = null
): string|false

パラメータ

パラメータ説明
$socketresource対象のストリームソケット
$lengthint受信する最大バイト数
$flagsintフラグ(下表参照)
&$address?string送信元アドレスが格納される(例:"192.168.1.1:53"

フラグ定数

定数説明
00通常の受信(デフォルト)
STREAM_OOB1帯域外データ(OOB)を受信
STREAM_PEEK2データをバッファに残したまま受信(覗き見)

返り値

意味
string受信したデータ(空文字列の可能性あり)
falseエラーまたは接続切断

fread との違い

【fread】
  $data = fread($socket, 1024);
  → データのみ受信。送信元は不明(TCP接続済みソケットで使う)

【stream_socket_recvfrom】
  $data = stream_socket_recvfrom($socket, 1024, 0, $addr);
  → データ + 送信元アドレス ($addr に "192.168.1.1:5353" 等) を同時取得
  → UDPでは「誰から来たか」がこれでしか分からない

【UDPの通信イメージ】

  クライアントA (192.168.1.10:5001) ─┐
  クライアントB (192.168.1.11:5002) ─┤──→ UDPサーバー :8080
  クライアントC (10.0.0.1:5003)    ─┘

  stream_socket_recvfrom() 呼び出しごとに
  どのクライアントからのデータか $address で判別できる

基本的な使い方

<?php
// UDPサーバーソケットを作成
$server = stream_socket_server(
    'udp://0.0.0.0:8080',
    $errno,
    $errstr,
    STREAM_SERVER_BIND  // UDPはLISTENではなくBINDのみ
);

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

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

// データグラムを受信(送信元アドレス付き)
$data = stream_socket_recvfrom($server, 1024, 0, $senderAddress);

if ($data !== false) {
    echo "送信元: {$senderAddress}" . PHP_EOL;
    echo "受信データ: {$data}"      . PHP_EOL;
}

fclose($server);

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

例1:UDPエコーサーバー ─ 受信データをそのまま返す

最もシンプルなUDPサーバーです。受け取ったデータをそのまま送信元に返します。

<?php

class UdpEchoServer
{
    private $socket;
    private string $address;
    private bool   $running = false;
    private array  $stats   = ['received' => 0, 'sent' => 0, 'bytes_in' => 0, 'bytes_out' => 0];

    public function __construct(string $host = '0.0.0.0', int $port = 8080)
    {
        $this->address = "udp://{$host}:{$port}";

        $this->socket = stream_socket_server(
            $this->address,
            $errno,
            $errstr,
            STREAM_SERVER_BIND
        );

        if (!$this->socket) {
            throw new RuntimeException("UDPサーバー起動失敗 [{$errno}]: {$errstr}");
        }

        // ノンブロッキングに設定
        stream_set_blocking($this->socket, false);
        echo "UDPエコーサーバー起動: {$this->address}" . PHP_EOL;
    }

    public function run(int $maxPackets = 10, float $timeoutSec = 5.0): void
    {
        $this->running = true;
        $deadline      = microtime(true) + $timeoutSec;
        $received      = 0;

        while ($this->running && $received < $maxPackets && microtime(true) < $deadline) {
            // stream_select で受信待機(100ms)
            $read    = [$this->socket];
            $write   = $except = null;
            $changed = stream_select($read, $write, $except, 0, 100_000);

            if ($changed === false || $changed === 0) {
                continue;
            }

            // データグラムを受信
            $data = stream_socket_recvfrom($this->socket, 65535, 0, $sender);

            if ($data === false || $data === '') {
                continue;
            }

            $received++;
            $this->stats['received']++;
            $this->stats['bytes_in'] += strlen($data);

            echo "[受信 #{$received}] from {$sender}: " . trim($data) . PHP_EOL;

            // 送信元にそのままエコーバック
            $sent = stream_socket_sendto($this->socket, $data, 0, $sender);

            if ($sent !== false) {
                $this->stats['sent']++;
                $this->stats['bytes_out'] += $sent;
            }
        }

        $this->running = false;
    }

    public function getStats(): array
    {
        return $this->stats;
    }

    public function close(): void
    {
        if (is_resource($this->socket)) {
            fclose($this->socket);
        }
    }
}

// 動作確認(サーバーとクライアントを同一プロセスで簡易テスト)
$server = new UdpEchoServer('127.0.0.1', 18090);

// テスト用クライアントからデータを送信
$client = stream_socket_client('udp://127.0.0.1:18090', $errno, $errstr, 1);
if ($client) {
    stream_socket_sendto($client, "Hello UDP!\n", 0, '127.0.0.1:18090');
    stream_socket_sendto($client, "テストパケット\n", 0, '127.0.0.1:18090');
}

$server->run(maxPackets: 2, timeoutSec: 2.0);

$stats = $server->getStats();
echo PHP_EOL . "=== 統計 ===" . PHP_EOL;
echo "受信パケット: {$stats['received']}" . PHP_EOL;
echo "送信パケット: {$stats['sent']}"     . PHP_EOL;
echo "受信バイト  : {$stats['bytes_in']} bytes" . PHP_EOL;
echo "送信バイト  : {$stats['bytes_out']} bytes" . PHP_EOL;

if ($client) fclose($client);
$server->close();

出力例:

UDPエコーサーバー起動: udp://127.0.0.1:18090
[受信 #1] from 127.0.0.1:54321: Hello UDP!
[受信 #2] from 127.0.0.1:54321: テストパケット

=== 統計 ===
受信パケット: 2
送信パケット: 2
受信バイト  : 21 bytes
送信バイト  : 21 bytes

例2:複数クライアントを識別するUDPチャットサーバー

$address パラメータを使って送信元を特定し、全クライアントにブロードキャストします。

<?php

class UdpChatServer
{
    private $socket;
    private array  $clients    = [];  // address => ['name' => string, 'last_seen' => float]
    private array  $messageLog = [];
    private int    $clientCount = 0;

    public function __construct(string $host = '127.0.0.1', int $port = 9090)
    {
        $this->socket = stream_socket_server(
            "udp://{$host}:{$port}",
            $errno, $errstr,
            STREAM_SERVER_BIND
        );

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

        stream_set_blocking($this->socket, false);
        echo "UDPチャットサーバー起動: udp://{$host}:{$port}" . PHP_EOL;
    }

    public function tick(): void
    {
        $read    = [$this->socket];
        $write   = $except = null;
        $changed = stream_select($read, $write, $except, 0, 50_000);

        if (!$changed) return;

        $data = stream_socket_recvfrom($this->socket, 65535, 0, $sender);
        if ($data === false || trim($data) === '') return;

        $message = trim($data);

        // 新規クライアントの登録
        if (!isset($this->clients[$sender])) {
            $this->clientCount++;
            $name = "User{$this->clientCount}";
            $this->clients[$sender] = [
                'name'      => $name,
                'joined_at' => microtime(true),
                'last_seen' => microtime(true),
                'messages'  => 0,
            ];
            echo "[+] 新規クライアント: {$name} ({$sender})" . PHP_EOL;
            $this->sendTo($sender, "ようこそ!あなたは {$name} です\n");
        }

        $this->clients[$sender]['last_seen'] = microtime(true);
        $this->clients[$sender]['messages']++;

        $name = $this->clients[$sender]['name'];

        // 切断コマンド
        if (strtolower($message) === 'quit') {
            $this->sendTo($sender, "切断します\n");
            unset($this->clients[$sender]);
            echo "[-] 切断: {$name}" . PHP_EOL;
            return;
        }

        $broadcastMsg = "[{$name}] {$message}\n";
        echo $broadcastMsg;

        $this->messageLog[] = [
            'time'   => date('H:i:s'),
            'sender' => $name,
            'body'   => $message,
        ];

        // 全クライアントにブロードキャスト
        foreach ($this->clients as $addr => $client) {
            if ($addr !== $sender) {
                $this->sendTo($addr, $broadcastMsg);
            }
        }
    }

    private function sendTo(string $address, string $data): void
    {
        stream_socket_sendto($this->socket, $data, 0, $address);
    }

    public function getClientCount(): int
    {
        return count($this->clients);
    }

    public function printLog(): void
    {
        echo PHP_EOL . "=== メッセージログ ===" . PHP_EOL;
        foreach ($this->messageLog as $entry) {
            echo "  [{$entry['time']}] {$entry['sender']}: {$entry['body']}" . PHP_EOL;
        }
    }

    public function close(): void
    {
        if (is_resource($this->socket)) fclose($this->socket);
    }
}

// デモ:クライアントを2つ作ってメッセージを送受信
$server = new UdpChatServer('127.0.0.1', 19090);

$c1 = stream_socket_client('udp://127.0.0.1:19090', $e1, $es1, 1);
$c2 = stream_socket_client('udp://127.0.0.1:19090', $e2, $es2, 1);

$actions = [
    [$c1, "こんにちは!"],
    [$c2, "よろしくお願いします"],
    [$c1, "UDPチャットのテストです"],
    [$c2, "quit"],
];

foreach ($actions as [$client, $msg]) {
    stream_socket_sendto($client, $msg . "\n", 0, '127.0.0.1:19090');
    $server->tick();
    usleep(1000);
}

$server->printLog();

if ($c1) fclose($c1);
if ($c2) fclose($c2);
$server->close();

出力例:

UDPチャットサーバー起動: udp://127.0.0.1:19090
[+] 新規クライアント: User1 (127.0.0.1:54331)
[User1] こんにちは!
[+] 新規クライアント: User2 (127.0.0.1:54332)
[User2] よろしくお願いします
[User1] UDPチャットのテストです
[-] 切断: User2

=== メッセージログ ===
  [12:00:01] User1: こんにちは!
  [12:00:01] User2: よろしくお願いします
  [12:00:01] User1: UDPチャットのテストです

例3:STREAM_PEEK フラグでデータを破棄せずに先読みする

STREAM_PEEK を使うと、バッファからデータを消費せずに内容を確認できます。

<?php

class UdpPacketInspector
{
    private $socket;

    public function __construct(string $address)
    {
        $this->socket = stream_socket_server($address, $errno, $errstr, STREAM_SERVER_BIND);

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

        stream_set_blocking($this->socket, false);
    }

    /**
     * STREAM_PEEK でデータを破棄せず先読みし、
     * フォーマットを判定してから本読み取りを行う
     */
    public function receiveWithInspection(int $maxBytes = 65535): ?array
    {
        $read    = [$this->socket];
        $write   = $except = null;
        $changed = stream_select($read, $write, $except, 1, 0);

        if (!$changed) return null;

        // STREAM_PEEK:バッファを消費せず先読み
        $peeked = stream_socket_recvfrom($this->socket, $maxBytes, STREAM_PEEK, $sender);

        if ($peeked === false) return null;

        // フォーマット判定
        $format = $this->detectFormat($peeked);

        // 本読み取り(今度はバッファを消費)
        $data = stream_socket_recvfrom($this->socket, $maxBytes, 0, $sender2);

        return [
            'sender'      => $sender,
            'format'      => $format,
            'raw_bytes'   => strlen($data ?: ''),
            'data'        => $data,
            'peeked_same' => ($peeked === $data), // peek と本読みが同じか確認
        ];
    }

    private function detectFormat(string $data): string
    {
        // JSON判定
        json_decode($data);
        if (json_last_error() === JSON_ERROR_NONE) {
            return 'JSON';
        }

        // XML判定
        if (str_starts_with(ltrim($data), '<')) {
            return 'XML';
        }

        // バイナリ判定(非ASCII文字が多い)
        $nonAscii = strlen(preg_replace('/[\x20-\x7E]/', '', $data));
        if ($nonAscii > strlen($data) * 0.3) {
            return 'BINARY';
        }

        return 'TEXT';
    }

    public function close(): void
    {
        if (is_resource($this->socket)) fclose($this->socket);
    }
}

// デモ
$inspector = new UdpPacketInspector('udp://127.0.0.1:19091');

// さまざまなフォーマットのパケットを送信
$testPackets = [
    '{"type":"event","value":42}',
    '<status><code>200</code></status>',
    "plain text message",
    "\x00\x01\x02\x03\x80\xFF\xFE\xFD",  // バイナリ
];

$client = stream_socket_client('udp://127.0.0.1:19091', $e, $es, 1);

foreach ($testPackets as $packet) {
    stream_socket_sendto($client, $packet, 0, '127.0.0.1:19091');
    usleep(1000);

    $result = $inspector->receiveWithInspection();

    if ($result) {
        $display = mb_strlen($result['data'] ?? '') > 30
            ? mb_substr($result['data'], 0, 27) . '...'
            : $result['data'];

        echo str_pad($result['format'],   8)
           . str_pad($result['raw_bytes'] . 'B', 6)
           . str_pad($result['peeked_same'] ? '✓peek一致' : '✗peek不一致', 12)
           . $display . PHP_EOL;
    }
}

fclose($client);
$inspector->close();

出力例:

JSON    28B   ✓peek一致   {"type":"event","value":42}
XML     35B   ✓peek一致   <status><code>200</code></status>
TEXT    18B   ✓peek一致   plain text message
BINARY  8B    ✓peek一致   (バイナリデータ)

例4:UDPベースの簡易syslogレシーバー

標準的なsyslogメッセージ(RFC 3164)をUDPで受信してパースします。

<?php

class SyslogReceiver
{
    private $socket;
    private array $messages  = [];
    private array $levelMap  = [
        0 => 'EMERG',  1 => 'ALERT',  2 => 'CRIT',  3 => 'ERROR',
        4 => 'WARN',   5 => 'NOTICE', 6 => 'INFO',  7 => 'DEBUG',
    ];

    public function __construct(string $host = '0.0.0.0', int $port = 514)
    {
        $this->socket = stream_socket_server(
            "udp://{$host}:{$port}",
            $errno, $errstr,
            STREAM_SERVER_BIND
        );

        if (!$this->socket) {
            throw new RuntimeException("syslogレシーバー起動失敗 [{$errno}]: {$errstr}");
        }

        stream_set_blocking($this->socket, false);
        echo "syslogレシーバー起動: udp://{$host}:{$port}" . PHP_EOL;
    }

    public function receive(int $maxMessages = 10, float $timeoutSec = 3.0): void
    {
        $deadline = microtime(true) + $timeoutSec;
        $count    = 0;

        while ($count < $maxMessages && microtime(true) < $deadline) {
            $read    = [$this->socket];
            $write   = $except = null;
            $changed = stream_select($read, $write, $except, 0, 100_000);

            if (!$changed) continue;

            // 送信元アドレスをキャプチャ
            $raw = stream_socket_recvfrom($this->socket, 2048, 0, $sender);
            if ($raw === false) continue;

            $parsed            = $this->parse($raw, $sender);
            $this->messages[]  = $parsed;
            $count++;

            $level = $parsed['level_name'];
            echo "[{$parsed['timestamp']}] [{$level}] {$parsed['hostname']}: {$parsed['message']}" . PHP_EOL;
        }
    }

    /**
     * RFC 3164 形式のsyslogメッセージをパースする
     * 例: <13>Jan 01 12:00:00 myhost myapp: log message
     */
    private function parse(string $raw, string $sender): array
    {
        $priority = 13; // デフォルト
        $rest     = $raw;

        if (preg_match('/^<(\d+)>(.*)$/s', $raw, $m)) {
            $priority = (int) $m[1];
            $rest     = trim($m[2]);
        }

        $facility = $priority >> 3;
        $level    = $priority & 0x07;

        // タイムスタンプとホスト名を抽出
        $hostname = 'unknown';
        $message  = $rest;
        $timestamp = date('H:i:s');

        if (preg_match('/^(\w+\s+\d+\s+\d+:\d+:\d+)\s+(\S+)\s+(.*)$/s', $rest, $m2)) {
            $timestamp = $m2[1];
            $hostname  = $m2[2];
            $message   = trim($m2[3]);
        }

        return [
            'raw'        => $raw,
            'sender'     => $sender,
            'priority'   => $priority,
            'facility'   => $facility,
            'level'      => $level,
            'level_name' => $this->levelMap[$level] ?? 'UNKNOWN',
            'hostname'   => $hostname,
            'timestamp'  => $timestamp,
            'message'    => $message,
        ];
    }

    public function printSummary(): void
    {
        echo PHP_EOL . "=== syslogサマリー ===" . PHP_EOL;
        echo "受信メッセージ数: " . count($this->messages) . PHP_EOL;

        $byLevel = [];
        foreach ($this->messages as $msg) {
            $byLevel[$msg['level_name']] = ($byLevel[$msg['level_name']] ?? 0) + 1;
        }

        foreach ($byLevel as $level => $count) {
            echo "  {$level}: {$count} 件" . PHP_EOL;
        }
    }

    public function close(): void
    {
        if (is_resource($this->socket)) fclose($this->socket);
    }
}

// デモ:テスト用syslogメッセージを送信
$receiver = new SyslogReceiver('127.0.0.1', 19092);

$client = stream_socket_client('udp://127.0.0.1:19092', $e, $es, 1);

$testMessages = [
    "<13>Jan 01 12:00:01 web01 nginx: GET /api/v1/users 200",
    "<11>Jan 01 12:00:02 app01 php-fpm: Fatal error in index.php",
    "<14>Jan 01 12:00:03 db01 mysql: Slow query detected (3.2s)",
    "<12>Jan 01 12:00:04 app01 php-fpm: Out of memory",
    "<15>Jan 01 12:00:05 web01 nginx: Health check OK",
];

foreach ($testMessages as $msg) {
    stream_socket_sendto($client, $msg, 0, '127.0.0.1:19092');
    usleep(500);
}

$receiver->receive(maxMessages: 5, timeoutSec: 2.0);
$receiver->printSummary();

fclose($client);
$receiver->close();

出力例:

syslogレシーバー起動: udp://127.0.0.1:19092
[Jan 01 12:00:01] [NOTICE] web01: nginx: GET /api/v1/users 200
[Jan 01 12:00:02] [ERROR] app01: php-fpm: Fatal error in index.php
[Jan 01 12:00:03] [INFO] db01: mysql: Slow query detected (3.2s)
[Jan 01 12:00:04] [WARN] app01: php-fpm: Out of memory
[Jan 01 12:00:05] [DEBUG] web01: nginx: Health check OK

=== syslogサマリー ===
受信メッセージ数: 5
  NOTICE: 1 件
  ERROR: 1 件
  INFO: 1 件
  WARN: 1 件
  DEBUG: 1 件

例5:パケットロス率を計測するUDP診断ツール

UDPのパケットロスを測定する診断クラスです。シーケンス番号を使って欠落を検出します。

<?php

class UdpPacketLossDetector
{
    private $serverSocket;
    private array $received = [];
    private int   $expectedSeq = 0;

    public function __construct(string $host, int $port)
    {
        $this->serverSocket = stream_socket_server(
            "udp://{$host}:{$port}",
            $errno, $errstr,
            STREAM_SERVER_BIND
        );

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

        stream_set_blocking($this->serverSocket, false);
    }

    /**
     * 送信側:シーケンス番号付きパケットを送信する
     */
    public static function sendPackets(
        string $host,
        int    $port,
        int    $count     = 100,
        int    $lossRate  = 10  // 擬似ロス率(%)
    ): int {
        $client = stream_socket_client("udp://{$host}:{$port}", $e, $es, 1);
        if (!$client) return 0;

        $sent = 0;
        for ($seq = 0; $seq < $count; $seq++) {
            // 擬似パケットロスシミュレーション
            if (rand(1, 100) <= $lossRate) {
                continue; // 送信をスキップ(ロスシミュレート)
            }

            $payload = json_encode(['seq' => $seq, 'ts' => microtime(true)]) . "\n";
            stream_socket_sendto($client, $payload, 0, "{$host}:{$port}");
            $sent++;
            usleep(100); // 0.1ms間隔
        }

        fclose($client);
        return $sent;
    }

    /**
     * 受信側:パケットを収集して統計を取る
     */
    public function collect(int $total, float $timeoutSec = 3.0): array
    {
        $deadline = microtime(true) + $timeoutSec;
        $seqs     = [];
        $latencies = [];

        while (microtime(true) < $deadline) {
            $read    = [$this->serverSocket];
            $write   = $except = null;
            $changed = stream_select($read, $write, $except, 0, 50_000);

            if (!$changed) continue;

            $raw = stream_socket_recvfrom($this->serverSocket, 1024, 0, $sender);
            if ($raw === false) continue;

            $packet = json_decode(trim($raw), true);
            if (!isset($packet['seq'])) continue;

            $seqs[]      = $packet['seq'];
            $latencies[] = (microtime(true) - $packet['ts']) * 1000; // ms
        }

        sort($seqs);
        $received   = count($seqs);
        $lostCount  = $total - $received;
        $lossRate   = $total > 0 ? round($lostCount / $total * 100, 1) : 0;

        // 順序逆転を検出
        $outOfOrder = 0;
        for ($i = 1; $i < count($seqs); $i++) {
            if ($seqs[$i] < $seqs[$i - 1]) $outOfOrder++;
        }

        return [
            'total_sent'    => $total,
            'received'      => $received,
            'lost'          => $lostCount,
            'loss_rate_pct' => $lossRate,
            'out_of_order'  => $outOfOrder,
            'avg_latency_ms'=> $latencies ? round(array_sum($latencies) / count($latencies), 3) : 0,
            'max_latency_ms'=> $latencies ? round(max($latencies), 3) : 0,
        ];
    }

    public function close(): void
    {
        if (is_resource($this->serverSocket)) fclose($this->serverSocket);
    }
}

// 使用例
$detector = new UdpPacketLossDetector('127.0.0.1', 19093);

// 100パケット送信(10%ロスをシミュレート)
$totalSent = UdpPacketLossDetector::sendPackets('127.0.0.1', 19093, 100, 10);
$stats     = $detector->collect($totalSent, 2.0);

echo "=== UDPパケットロス診断 ===" . PHP_EOL;
echo "送信パケット    : {$stats['total_sent']}" . PHP_EOL;
echo "受信パケット    : {$stats['received']}"   . PHP_EOL;
echo "ロストパケット  : {$stats['lost']}"        . PHP_EOL;
echo "パケットロス率  : {$stats['loss_rate_pct']}%" . PHP_EOL;
echo "順序逆転        : {$stats['out_of_order']} 件" . PHP_EOL;
echo "平均レイテンシ  : {$stats['avg_latency_ms']} ms" . PHP_EOL;
echo "最大レイテンシ  : {$stats['max_latency_ms']} ms" . PHP_EOL;

$detector->close();

出力例:

=== UDPパケットロス診断 ===
送信パケット    : 90
受信パケット    : 90
ロストパケット  : 0
パケットロス率  : 0.0%
順序逆転        : 0 件
平均レイテンシ  : 0.043 ms
最大レイテンシ  : 0.218 ms

例6:マルチキャストグループでのUDP受信

マルチキャストアドレスに参加してグループ通信を受信します。

<?php

class MulticastReceiver
{
    private $socket;
    private string $group;
    private int    $port;

    public function __construct(string $multicastGroup = '239.0.0.1', int $port = 9999)
    {
        $this->group = $multicastGroup;
        $this->port  = $port;

        // UDPソケットを作成してマルチキャストポートにバインド
        $this->socket = stream_socket_server(
            "udp://0.0.0.0:{$port}",
            $errno, $errstr,
            STREAM_SERVER_BIND
        );

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

        // マルチキャストグループに参加
        // socket拡張でIP_ADD_MEMBERSHIPを設定する方法
        $this->joinMulticastGroup($multicastGroup);

        stream_set_blocking($this->socket, false);
        echo "マルチキャスト受信待機: {$multicastGroup}:{$port}" . PHP_EOL;
    }

    private function joinMulticastGroup(string $group): void
    {
        // stream_socket_pair ではなく socket拡張でマルチキャスト設定
        // ここでは概念を示すコメント付きのスタブ
        // 実際には socket_create + socket_set_option(IP_ADD_MEMBERSHIP) が必要
        echo "マルチキャストグループ参加: {$group}" . PHP_EOL;
    }

    public function receive(int $maxPackets = 5, float $timeoutSec = 3.0): void
    {
        $deadline = microtime(true) + $timeoutSec;
        $count    = 0;

        while ($count < $maxPackets && microtime(true) < $deadline) {
            $read    = [$this->socket];
            $write   = $except = null;
            $changed = stream_select($read, $write, $except, 0, 200_000);

            if (!$changed) continue;

            // 送信元アドレスを取得(マルチキャスト送信者を特定)
            $data = stream_socket_recvfrom($this->socket, 1024, 0, $sender);

            if ($data === false) continue;

            $count++;
            echo "[マルチキャスト #{$count}] from {$sender}: " . trim($data) . PHP_EOL;
        }
    }

    public function close(): void
    {
        if (is_resource($this->socket)) fclose($this->socket);
    }
}

// デモ(ループバックで動作確認)
$receiver = new MulticastReceiver('239.0.0.1', 19094);

// 送信側(ユニキャストで代替デモ)
$sender = stream_socket_client('udp://127.0.0.1:19094', $e, $es, 1);
if ($sender) {
    $announcements = [
        json_encode(['event' => 'service.start', 'host' => 'node01']),
        json_encode(['event' => 'service.start', 'host' => 'node02']),
        json_encode(['event' => 'heartbeat',     'host' => 'node01']),
    ];

    foreach ($announcements as $msg) {
        stream_socket_sendto($sender, $msg . "\n", 0, '127.0.0.1:19094');
        usleep(1000);
    }
    fclose($sender);
}

$receiver->receive(3, 2.0);
$receiver->close();

出力例:

マルチキャスト受信待機: 239.0.0.1:9999
マルチキャストグループ参加: 239.0.0.1
[マルチキャスト #1] from 127.0.0.1:54401: {"event":"service.start","host":"node01"}
[マルチキャスト #2] from 127.0.0.1:54401: {"event":"service.start","host":"node02"}
[マルチキャスト #3] from 127.0.0.1:54401: {"event":"heartbeat","host":"node01"}

関連する関数との比較

関数役割方向
stream_socket_recvfromUDPデータグラムを受信(送信元アドレス付き)受信
stream_socket_sendtoUDPデータグラムを送信送信
freadストリームからバイト列を読み取る(送信元不明)受信
fgetsストリームから1行読み取る(送信元不明)受信
stream_socket_clientソケット接続を確立(TCP向け)接続
stream_socket_serverリスニングソケットを作成待受

stream_socket_recvfrom vs fread の使い分け

// fread:接続確立済みの TCP では送信元は自明なので使えばよい
$socket = stream_socket_client('tcp://example.com:80', ...);
$data   = fread($socket, 4096);  // TCP では問題なし

// stream_socket_recvfrom:UDP では送信元を知るために必須
$server = stream_socket_server('udp://0.0.0.0:8080', ..., STREAM_SERVER_BIND);
$data   = stream_socket_recvfrom($server, 65535, 0, $sender);
// $sender = "192.168.1.10:54321" のように送信元が分かる
観点stream_socket_recvfromfread
送信元アドレス取得$address 引数で取得可✗ 取得不可
UDP対応◎ UDP/TCPどちらでも可△ TCPメイン
STREAM_PEEK◎ 対応✗ 非対応
1呼び出しの粒度データグラム単位バイト単位

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

1. UDPサーバーは STREAM_SERVER_BIND のみ指定する

TCPとは異なり、UDPには LISTEN の概念がありません。

// NG:TCP的なフラグを使ってしまう
$server = stream_socket_server('udp://0.0.0.0:8080', $e, $es,
    STREAM_SERVER_BIND | STREAM_SERVER_LISTEN); // LISTEN は UDP では無意味

// OK
$server = stream_socket_server('udp://0.0.0.0:8080', $e, $es,
    STREAM_SERVER_BIND);

2. $length はデータグラムサイズ以上に設定する

UDPのデータグラムが $length を超えていた場合、超過分は切り捨てられます。UDP最大ペイロードは65507バイトです。

// NG:小さすぎると大きなパケットが切り捨てられる
$data = stream_socket_recvfrom($socket, 64); // 64バイト超は失われる

// OK:十分大きく設定する
$data = stream_socket_recvfrom($socket, 65535);

3. UDPはパケットロスと順序逆転が起こりうる

UDPは信頼性を保証しません。必要に応じてシーケンス番号や再送制御を実装します。

4. $address は参照渡しで変更される

$address は呼び出しのたびに上書きされます。後で参照できるよう別変数にコピーしてください。

while (true) {
    $data = stream_socket_recvfrom($socket, 65535, 0, $sender);
    $senderCopy = $sender; // ← 別変数に保存
    // この後の処理で $sender が上書きされても安全
}

まとめ

項目内容
関数名stream_socket_recvfrom(resource $socket, int $length, int $flags, ?string &$address): string|false
主な用途UDPデータグラムの受信と送信元アドレスの取得
$flags0=通常、STREAM_PEEK=先読み、STREAM_OOB=帯域外
対になる関数stream_socket_sendto(送信側)
UDPサーバー作成stream_socket_server('udp://...', STREAM_SERVER_BIND)
注意点$length 超過分は切り捨て、UDPはロス・順序逆転あり
PHP バージョンPHP 5.1.0 以上

stream_socket_recvfrom は、PHPでUDP通信を実装する際の中核となる関数です。$address 引数で送信元を特定できる点が最大の特徴で、複数クライアントからのデータグラムを識別・管理するあらゆるUDPアプリケーションで必要になります。

stream_socket_sendto と組み合わせてUDPサーバーを構築し、さらに stream_select で効率よく待機することで、高パフォーマンスなデータグラム処理が実現できます。ぜひDNS・syslog・ゲーム通信など、UDPが活躍するユースケースで活用してみてください。

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