轉載請註明文章出處: 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也可以用於網路程式設計。兩者的主要區別是:
- 流是PHP中的核心概念,所以stream開頭的函式總是可用;sockets是PHP的一個擴充,雖然大部分情況下都預設啟用;
socket
系列函式相對底層,而stream
系列函式是高層的抽象。
如果你想體驗原味Socket程式設計,用socket
開頭的API比較適合;否則建議使用流函式。有關流的知識,請參考本人之前的博文:PHP回顧之流。
接下來我們用流函式實現一個簡單的TCP客戶端和服務端。
客戶端
客戶端網路程式設計可以歸結為簡單的三步:
- 連線服務端(connect);
- 收發訊息(receive/send);
- 關閉連線(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);
複製程式碼
客戶端已經搞定,接下來看服務端。
服務端
服務端程式設計也很簡單,四步搞定:
- 監聽埠(listen);
- 接受新連線(accept);
- 收發網路訊息(receive/send);
- 迴圈第二步和第三步(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_blocking
和stream_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;
}
}
// 父程式先退出,不會出現殭屍程式,忽略孤兒程式的處理
複製程式碼
啟動客戶端後,可以看到服務端正確的同時處理多個客戶端,這正是我們期待的。
缺憾
上述程式碼實現了客戶端和可併發的服務端,作為演示基本夠用。如果要投入到實踐中使用,至少有以下方面的不足:
- 多程式/多執行緒/協程缺失,除處理網路訊息外,不能(難)做其他邏輯業務;
- 沒有協議解析,會導致多條資訊合併成一條讀取(或者一條資訊被拆成多條);
- select低效且有併發連線數目限制,客戶端量大時需要poll/epoll等技術;
每個方面展開來說至少都是一篇長文。本文目的是簡要介紹PHP中的Socket程式設計,行文到此已經達到目的。由於網路協議十分繁雜,想深入網路程式設計請參閱更多權威文件。
總結
本文基於PHP5引入的流簡要介紹了PHP中的Socket程式設計,並給出了一個簡單併發伺服器的實現。文中程式碼僅做演示用,在生產環境中,請使用成熟的網路框架/庫。