PHP回顧之socket程式設計

tlanyan發表於2019-03-01

轉載請註明文章出處: tlanyan.me/php-review-…

PHP回顧系列目錄

web開發一直是PHP的主戰場,也是PHP最為被世人所熟知的一面。其實只要你願意去發掘,PHP除了做網頁在許多其他方面也是小能手。

本文簡要介紹PHP的Socket程式設計。

準備知識

在開始之前,希望你已經知道網路程式設計中的一些基本概念。比如OSI七層模型、TCP/IP四層模型;TCP中的三次握手、四次揮手等。這些概念是網路程式設計的理論基礎,實踐中不一定用得到,但能讓你把握整體脈絡,更快的定位程式設計中出現的問題。

再說一下Socket。我們常說的網路程式設計就是指Socket程式設計,它既指代實現了TCP/IP協議簇的一套網路程式設計API,也指代一個客戶端與伺服器的連線。socket是插座/介面的意思,計算機中常翻譯成“套接字”。實際中可以簡單的認為網路程式設計與Socket程式設計等價,一個tcp連線的說法等價於一個socket。

PHP中的API

PHP中有以socket開頭的一套函式API用於Socket程式設計,PHP5引入“流”的抽象概念後,以stream開頭的一套API也可以用於網路程式設計。兩者的主要區別是:

  1. 流是PHP中的核心概念,所以stream開頭的函式總是可用;sockets是PHP的一個擴充,雖然大部分情況下都預設啟用;
  2. socket系列函式相對底層,而stream系列函式是高層的抽象。

如果你想體驗原味Socket程式設計,用socket開頭的API比較適合;否則建議使用流函式。有關流的知識,請參考本人之前的博文:PHP回顧之流

接下來我們用流函式實現一個簡單的TCP客戶端和服務端。

客戶端

客戶端網路程式設計可以歸結為簡單的三步:

  1. 連線服務端(connect);
  2. 收發訊息(receive/send);
  3. 關閉連線(close)。

下面是客戶端的程式碼,傳送10條訊息到服務端:

// client.php
$host = "127.0.0.1";
$port = 8000;

$socket = @stream_socket_client("tcp://{$host}:{$port}", $errno, $errMsg);
if ($socket === false) {
    throw new RuntimeException("unable to create socket: " . $errMsg);
}
fwrite(STDOUT, "success connect to server: [{$host}:{$port}]...
");

foreach (range(1, 10) as $i) {
    if ($i % 5 === 0) {
        $method = "broadcast";
    } else {
        $method = "echo";
    }
    $args = [sprintf("The %dth greeting", $i)];
    $message = json_encode([
        "method" => $method,
        "args" => $args,
    ]);
    fwrite(STDOUT, "
send to server: $message
");
    $len = @fwrite($socket, $message);
    if ($len === 0) {
        fwrite(STDOUT, "socket closed
");
        break;
    }

    $msg = @fread($socket, 4096);
    if ($msg) {
        fwrite(STDOUT, "receive server: $msg
");
    }
    elseif (feof($socket)) {
       fwrite(STDOUT, "socket closed
");
       break;
    }

    sleep(2);
}

fwrite(STDOUT, "close connnection...
");
fclose($socket);
複製程式碼

客戶端已經搞定,接下來看服務端。

服務端

服務端程式設計也很簡單,四步搞定:

  1. 監聽埠(listen);
  2. 接受新連線(accept);
  3. 收發網路訊息(receive/send);
  4. 迴圈第二步和第三步(loop)。

由於服務端一般是長時間執行,除非重啟或程式被殺死,極少會主動關閉服務。另外服務端一般需要長時間執行,所以應當執行在CLI模式下(短連的客戶端程式碼可以在web中使用,例如代替CURL獲取網頁內容,連線redis/MQ等)。

我們簡單的將收到的訊息返回客戶端(Echo伺服器):

// server.php
$port = 8000;
$socket = @stream_socket_server("tcp://0.0.0.0:$port", $errno, $errMsg);
if ($socket === false) {
    throw new RuntimeException("fail to listen on port: {$port}!");
}
fwrite(STDOUT, "socket server listen on port: {$port}" . PHP_EOL);

while (true) {
    $client = @stream_socket_accept($socket);
    if ($client == false) {
        continue;
    }

    fwrite(STDOUT, "client:" . (int)$client . " connnected.
");
    @fwrite($client, "Welcome aboard!
");

    while (true) {
        $msg = @fread($client, 4096);
        if ($msg) {
            fwrite(STDOUT, "
receive client: $msg
");
            // echo
            @fwrite($client, $msg);
        } elseif (feof($client)) {
            fwrite(STDOUT, "client:" . (int)$client . " disconnnect!
");
            fclose($client);
            break;
        }
    }
}
複製程式碼

先啟動服務端指令碼:php server.php, 然後開啟新的視窗啟動客戶端:php client.php。可以看到訊息被正確的傳送和接收。客戶端退出後,可多次重新執行客戶端指令碼檢視效果。

併發

同時執行兩個或以上客戶端,會發現第二個起卡住,前面的客戶端退出後才繼續執行。回顧服務端程式碼,可以看到accept一個客戶端後,服務端就專心為其服務,直到斷開才服務下一個。

同時服務多個客戶端,這才是我們期望的。預設情況下socket處於阻塞模式,無資料時fread函式會一直等待,導致程式不能抽身服務其他客戶端。要同時服務多個客戶端,第一步是設定非阻塞模式,第二步是更改輪詢方式。流函式中的stream_set_blockingstream_select兩個函式是我們想要的。

將服務端的程式碼更改如下:

// server.php
<?php
$port = 8000;
$socket = @stream_socket_server("tcp://0.0.0.0:$port", $errno, $errMsg);
if ($socket === false) {
    throw new RuntimeException("fail to listen on port: {$port}!");
}
fwrite(STDOUT, "socket server listen on port: {$port}" . PHP_EOL);
stream_set_blocking($socket, false);

$clients = [];
$changed = [];
while (true) {
    checkMessage();
    fwrite(STDOUT, "
new read message
");
    accept();
    handleMessage();
}

function checkMessage() {
    global $socket, $changed, $clients;
    $changed = array_merge([$socket], $clients);
    $write = null;
    $except = null;
    stream_select($changed, $write, $except, null);
}

function accept() {
    global $socket, $changed, $clients;
    if (!in_array($socket, $changed)) {
        return;
    }

    while ($client = @stream_socket_accept($socket, 0)) {
        $clients[] = $client;

        fwrite(STDOUT, "client:" . (int)$client . " connnected.
");
        fwrite($client, "welcome aboard!");
        stream_set_blocking($client, false);

        $key = array_search($client, $changed);
        unset($changed[$key]);
    }
}

function handleMessage() {
    global $changed, $clients;
    foreach ($changed as $key => $client) {
        while (true) {
            $msg = @fread($client, 4096);
            if ($msg) {
                fwrite(STDOUT, "receive client " . (int)$client . " message: $msg
");
                $json = json_decode($msg, true);
                if ($json) {
                    $method = $json["method"];
                    if ($method === `echo`) {
                        @fwrite($client, $msg);
                    } else {
                        foreach ($clients as $cl) {
                            @fwrite($cl, "message from " . (int)$client . ": $msg");
                        }
                    }
                }
            } else {
                if (feof($client)) {
                    fwrite(STDOUT, "
client " . (int)$client . " closed.
");
                    fclose($client);
                    $key = array_search($client, $clients);
                    unset($clients[$key]);
                }
                break;
            }
        }
    }
}
複製程式碼

然後啟動服務端:php server.php,再同時啟動多個客戶端,或者用多個程式同時傳送訊息(需安裝pcntl擴充):

// client.php
for ($index = 0; $index < 10; ++ $index) {
    $pid = pcntl_fork();
    if ($pid < 0) {
        fwrite(STDERR, "fail to fork!
");
        exit;
    }

    if ($pid === 0) {
        connectServer();  // connectServer就是上文中client.php中的程式碼
        exit;
    }
}
// 父程式先退出,不會出現殭屍程式,忽略孤兒程式的處理
複製程式碼

啟動客戶端後,可以看到服務端正確的同時處理多個客戶端,這正是我們期待的。

缺憾

上述程式碼實現了客戶端和可併發的服務端,作為演示基本夠用。如果要投入到實踐中使用,至少有以下方面的不足:

  1. 多程式/多執行緒/協程缺失,除處理網路訊息外,不能(難)做其他邏輯業務;
  2. 沒有協議解析,會導致多條資訊合併成一條讀取(或者一條資訊被拆成多條);
  3. select低效且有併發連線數目限制,客戶端量大時需要poll/epoll等技術;

每個方面展開來說至少都是一篇長文。本文目的是簡要介紹PHP中的Socket程式設計,行文到此已經達到目的。由於網路協議十分繁雜,想深入網路程式設計請參閱更多權威文件。

總結

本文基於PHP5引入的流簡要介紹了PHP中的Socket程式設計,並給出了一個簡單併發伺服器的實現。文中程式碼僅做演示用,在生產環境中,請使用成熟的網路框架/庫。

參考

  1. php.net/manual/en/b…
  2. www.unixguide.net/network/soc…
  3. php.net/manual/en/b…

相關文章