PHP Socket 編程進階指南
socket函數只是PHP擴展的一部分,編譯PHP時必須在配置中添加
--enable-sockets
配置項來啟用。
如果自帶的PHP沒有編譯scokets擴展,可以下載相同版本的源碼,進入ext/sockets
使用phpize
編譯安裝。
socket系列函數
socket服務端/客戶端流程:
圖中所示流程在任何編程語言裏都是通用的。
server端
接下來我們寫一個簡單的單進程TCP服務器:
socket_tcp_server.php
<?php
/**
* Created by PhpStorm.
* User: 公眾號: 飛鴻影的博客(fhyblog)
* Date: 2018/6/23
*/
//參數domain: AF_INET,AF_INET6,AF_UNIX
//參數type: SOCK_STREAM,SOCK_DGRAM
//參數protocol: SOL_TCP,SOL_UDP
$socket = socket_create(AF_INET, SOCK_STREAM, SOL_TCP);
if(!$socket) die("create server fail:".socket_strerror(socket_last_error())."\n");
//綁定
$ret = socket_bind($socket, "0.0.0.0", 9201);
if(!$ret) die("bind server fail:".socket_strerror(socket_last_error())."\n");
//監聽
$ret = socket_listen($socket, 2);
if(!$ret) die("listen server fail:".socket_strerror(socket_last_error())."\n");
echo "waiting client...\n";
while(1){
//阻塞等待客戶端連接
$conn = socket_accept($socket);
if(!$conn){
echo "accept server fail:".socket_strerror(socket_last_error())."\n";
break;
}
echo "client connect succ.\n";
parseRecv($conn);
}
/**
* 解析客戶端消息
* 協議:換行符(\n)
*/
function parseRecv($conn)
{
//循環讀取消息
$recv = ‘‘; //實際接收到的消息
while(1){
$buffer = socket_read($conn, 100); //每次讀取100byte
if($buffer === false || $buffer === ‘‘){
echo "client closed\n";
socket_close($conn); //關閉本次連接
break;
}
//解析單次消息,協議:換行符
$pos = strpos($buffer, "\n");
if($pos === false){ //消息未讀取完畢,繼續讀取
$recv .= $buffer;
}else{ //消息讀取完畢
$recv .= trim(substr($buffer, 0, $pos+1)); //去除換行符及空格
//客戶端主動端口連接
if($recv == ‘quit‘){
echo "client closed\n";
socket_close($conn); //關閉本次連接
break;
}
echo "recv: $recv \n";
socket_write($conn, "$recv \n"); //發送消息
$recv = ‘‘; //清空消息,準備下一次接收
}
}
}
socket_close($socket);
說明:例子裏我們先創建了一個TCP server,然後循環等待客戶端連接。收到客戶端連接後,循環解析來自客戶端的消息。
例子裏使用\n
作為消息結束符,如果一次沒有接收到完整消息,就循環讀取,直到遇到結束符;讀取完一條完整消息後,向客戶端發送收到的消息,然後清空消息,準備下一次接收。
我們在命令行裏運行服務端:
$ php socket_tcp_server.php
waiting client...
新開終端使用telnet連接:
$ telnet 127.0.0.1 9201
Trying 127.0.0.1...
Connected to 127.0.0.1.
Escape character is ‘^]‘.
hello Server!
我們發送了一條消息,服務端這邊會收到:
client connect succ.
recv: hello Server!
接下來,我們使用socket寫一個自己的tcp客戶端。
client端
下面的例子很簡單,創建客戶端,連接服務端,發送消息,讀取完後就結束了。
socket_tcp_client.php
<?php
/**
* Created by PhpStorm.
* User: 公眾號: 飛鴻影的博客(fhyblog)
* Date: 2018/6/23
*/
//創建連接
$socket = socket_create(AF_INET, SOCK_STREAM, SOL_TCP);
if(!$socket) die("create server fail:".socket_strerror(socket_last_error())."\n");
//連接server
$ret = socket_connect($socket, "127.0.0.1", 9201);
if(!$ret) die("client connect fail:".socket_strerror(socket_last_error())."\n");
//發送消息
socket_write($socket, "hello, I‘m client!\n");
//讀取消息
$buffer = socket_read($socket, 1024);
echo "from server: $buffer\n";
//關閉連接
socket_close($socket);
我們先在原來的telnet終端頁面輸入quit
退出連接,因為此時我們的服務端還只能接受一個客戶端連接。然後運行自己寫的客戶端:
$ php socket_tcp_client.php
from server: hello, I‘m client!
socket_select
上面的例子裏,我們的tcp服務端僅能接受一個客戶端連接。怎麽能做到支持多個客戶端連接呢?常用的有:
- 多進程
- 多線程
- I/O復用,使用select、poll、epoll等技術
- 多進程+I/O復用
本節裏我們使用第三種方法,即I/O復用。技術實現層面則是使用PHP提供的socket_select系統調用來實現。
I/O復用使得程序能同時監聽多個文件描述符。實現I/O復用的系統調用主要的有select、poll、epoll。
接下來看實例:
socket_select.php
<?php
/**
* Created by PhpStorm.
* User: 公眾號: 飛鴻影的博客(fhyblog)
* Date: 2018/6/23
*/
$socket = socket_create(AF_INET, SOCK_STREAM, SOL_TCP);
if(!$socket) die("create server fail:".socket_strerror(socket_last_error())."\n");
//綁定
$ret = socket_bind($socket, "0.0.0.0", 9201);
if(!$ret) die("bind server fail:".socket_strerror(socket_last_error())."\n");
//監聽
$ret = socket_listen($socket, 2);
if(!$ret) die("listen server fail:".socket_strerror(socket_last_error())."\n");
echo "waiting client...\n";
$clients = [$socket];
$recvs = [];
while(1){
$read = $clients; //拷貝一份,socket_select會修改$read
$ret = @socket_select($read, $write = NULL, $except = NULL,0);
if($ret === false){
break;
}
foreach ($read as $k=>$client) {
//新連接
if($client === $socket){
//阻塞等待客戶端連接
$conn = socket_accept($socket);
if(!$conn){
echo "accept server fail:".socket_strerror(socket_last_error())."\n";
break;
}
$clients[] = $conn;
echo "client connect succ. fd: ".$conn."\n";
//獲取客戶端IP地址
socket_getpeername($conn, $addr, $port);
echo "client addr: $addr:$port\n";
//獲取服務端IP地址
socket_getsockname($conn, $addr, $port);
echo "server addr: $addr:$port\n";
// print_r($clients);
echo "total: ".(count($clients)-1)." client\n";
}else{
//註意:後續使用$client而不是$conn
if (!isset($recvs[$k]) ) $recvs[$k] = ‘‘; //兼容可能沒有值的情況
$buffer = socket_read($client, 100); //每次讀取100byte
if($buffer === false || $buffer === ‘‘){
echo "client closed\n";
unset($clients[array_search($client, $clients)]); //unset
socket_close($client); //關閉本次連接
break;
}
//解析單次消息,協議:換行符
$pos = strpos($buffer, "\n");
if($pos === false){ //消息未讀取完畢,繼續讀取
$recvs[$k] .= $buffer;
}else{ //消息讀取完畢
$recvs[$k] .= trim(substr($buffer, 0, $pos+1)); //去除換行符及空格
//客戶端主動端口連接
if($recvs[$k] == ‘quit‘){
echo "client closed\n";
unset($clients[array_search($client, $clients)]); //unset
socket_close($client); //關閉本次連接
break;
}
echo "recv:".$recvs[$k]."\n";
socket_write($client, $recvs[$k]."\n"); //發送消息
$recvs[$k] = ‘‘;
}
}
}
}
socket_close($socket);
我們先使用Crtl+C
關閉上一次運行的TCP server,然後運行新寫的server:
php socket_select.php
waiting client...
新開終端telnet客戶端:
telnet 127.0.0.1 9201
Trying 127.0.0.1...
Connected to localhost.
Escape character is ‘^]‘.
hello world
hello world
再打開終端新開一個telnet客戶端,我們來看服務端的輸出:
client connect succ. fd: Resource id #5
client addr: 127.0.0.1:60065
server addr: 127.0.0.1:9201
total: 1 client
recv:hello server!
client connect succ. fd: Resource id #6
client addr: 127.0.0.1:60069
server addr: 127.0.0.1:9201
total: 2 client
recv:hello world
此時我們的服務端就不受客戶端連接數限制了。
註意點:
1、使用了socket_select後,解析消息的地方不能再是死循環,否則造成阻塞。
select 函數監視的文件描述符分為3類,分別是 writefds, readfds, exceptfds,調用之後select函數就會阻塞,直到有文件描述符就緒(有數據可讀,可寫或者except),或者超時(timeout指定等待時間,如果立即返回設為null即可),函數返回;當select函數返回之後,可以通過遍歷 fdset來找到就緒的描述符。
2、socket系統調用最大支持1024個客戶端連接,如果需要更大的客戶端連連,則需要使用poll、epoll等技術。本文不做講解。
socket_set_option
該函數用來設置socket選項,比如設置端口復用。函數原型:
bool socket_set_option ( resource $socket , int $level , int $optname , mixed $optval )
示例:
socket_set_option($socket, SOL_SOCKET, SO_REUSEADDR, 1); //復用端口
該小節不是本文重點,該函數大家了解即可,需要設置的時候能知道怎麽調用。順便提一下,端口復用技術是用來解決"驚群"問題的,大家感興趣可以看看博文:Linux網絡編程“驚群”問題總結 -
https://www.cnblogs.com/Anker/p/7071849.html 。
函數參考
這些PHP官方手冊裏都有,貼出來供大家快速查閱。
socket_accept() 接受一個Socket連接
socket_bind() 把socket綁定在一個IP地址和端口上
socket_clear_error() 清除socket的錯誤或者最後的錯誤代碼
socket_close() 關閉一個socket資源
socket_connect() 開始一個socket連接
socket_create_listen() 在指定端口打開一個socket監聽
socket_create_pair() 產生一對沒有區別的socket到一個數組裏
socket_create() 產生一個socket,相當於產生一個socket的數據結構
socket_get_option() 獲取socket選項
socket_getpeername() 獲取遠程類似主機的ip地址
socket_getsockname() 獲取本地socket的ip地址
socket_iovec_add() 添加一個新的向量到一個分散/聚合的數組
socket_iovec_alloc() 這個函數創建一個能夠發送接收讀寫的iovec數據結構
socket_iovec_delete() 刪除一個已經分配的iovec
socket_iovec_fetch() 返回指定的iovec資源的數據
socket_iovec_free() 釋放一個iovec資源
socket_iovec_set() 設置iovec的數據新值
socket_last_error() 獲取當前socket的最後錯誤代碼
socket_listen() 監聽由指定socket的所有連接
socket_read() 讀取指定長度的數據
socket_readv() 讀取從分散/聚合數組過來的數據
socket_recv() 從socket裏結束數據到緩存
socket_recvfrom() 接受數據從指定的socket,如果沒有指定則默認當前socket
socket_recvmsg() 從iovec裏接受消息
socket_select() 多路選擇
socket_send() 這個函數發送數據到已連接的socket
socket_sendmsg() 發送消息到socket
socket_sendto() 發送消息到指定地址的socket
socket_set_block() 在socket裏設置為塊模式
socket_set_nonblock() socket裏設置為非塊模式
socket_set_option() 設置socket選項
socket_shutdown() 這個函數允許關閉讀、寫、或者指定的socket
socket_strerror() 返回指定錯誤號的詳細錯誤
socket_write() 寫數據到socket緩存
socket_writev() 寫數據到分散/聚合數組
其中socket裏的write
read
、writev
readv
、recv
`send
、recvfrom
sendto
、recvmsg
sendmsg
五組 I/O 函數可以參考:https://blog.csdn.net/yangbingzhou/article/details/45221649
stream_socket系列函數
stream_socket系列函數相當於是socket函數的進一步封裝。使用該系類函數能簡化我們的編碼。
stream_socket_server
和stream_socket_accept
返回的句柄可以由fgets()
, fgetss()
, fwrite()
, fclose()
以及feof()
函數調用。
server端
我們先看一下函數原型。
stream_socket_server:
resource stream_socket_server ( string $local_socket [, int &$errno [, string &$errstr [, int $flags = STREAM_SERVER_BIND | STREAM_SERVER_LISTEN [, resource $context ]]]] )
如果是udp服務,flags指定為STREAM_SERVER_BIND
。 另外,$context
由stream_context_create
創建,例如:
$context_option[‘socket‘][‘so_reuseport‘] = 1;//端口復用
$context = stream_context_create($context_option);
stream_socket_accept:
resource stream_socket_accept ( resource $server_socket [, float $timeout = ini_get("default_socket_timeout") [, string &$peername ]] )
接下來我們使用stream_socket_
系列函數寫一個tcp server。
tcp server
示例:
stream_socket_server.php
<?php
/**
* Created by PhpStorm.
* User: 公眾號: 飛鴻影的博客(fhyblog)
* Date: 2018/6/23
*/
$socket = stream_socket_server ("tcp://0.0.0.0:9201", $errno, $errstr, STREAM_SERVER_BIND | STREAM_SERVER_LISTEN);
if (false === $socket ) {
echo "$errstr($errno)\n";
exit();
}
while(1){
echo "waiting client...\n";
$conn = stream_socket_accept($socket, -1);
if (false === $socket ) {
exit("accept error\n");
}
echo "new Client! fd:".intval($conn)."\n";
while(1){
$buffer = fread($conn, 1024);
//非正常關閉
if(false === $buffer){
echo "fread fail\n";
break;
}
$msg = trim($buffer, "\n\r");
//強制關閉
if($msg == "quit"){
echo "client close\n";
fclose($conn);
break;
}
echo "recv: $msg\n";
fwrite($conn, "recv: $msg\n");
}
}
fclose($socket);
代碼相比使用純socket
函數少了很多。
運行:
$ php stream_socket_server.php
waiting client...
new Client! fd:6
recv: hello
客戶端使用telnet:
$ telnet 127.0.0.1 9201
Trying 127.0.0.1...
Connected to 127.0.0.1.
Escape character is ‘^]‘.
hello
recv: hello
udp server
udp服務端不需要listen操作。
<?php
/**
* Created by PhpStorm.
* User: 公眾號: 飛鴻影的博客(fhyblog)
* Date: 2018/6/23
*/
$socket = stream_socket_server ("udp://0.0.0.0:9201", $errno, $errstr, STREAM_SERVER_BIND);
if (false === $socket ) {
echo "$errstr($errno)\n";
exit();
}
while(1){
// $buffer = fread($socket, 1024);
$buffer = stream_socket_recvfrom($socket, 1024, 0, $addr);
echo $addr;
//非正常關閉
if(false === $buffer){
echo "fread fail\n";
break;
}
$msg = trim($buffer, "\n\r");
//強制關閉
if($msg == "quit"){
echo "client close\n";
fclose($socket);
break;
}
echo "recv: $msg\n";
// fwrite($socket, "recv: $msg\n");
stream_socket_sendto($socket, "recv: $msg\n", 0, $addr);
}
運行:
$ php stream_socket_server_udp.php
127.0.0.1:43172recv: hello
客戶端使用 netcat:
netcat -u 127.0.0.1 9201
hello
recv: hello
quit
如果沒有netcat需要安裝:
sudo apt-get install netcat
客戶端
上面我們都是用的telnet
和netcat
來連接服務端,接下來我們使用stream_socket_
系列函數編寫tcp/udp客戶端。
簡單示例
stream_socket系列函數寫client非常簡單:
<?php
$client = stream_socket_client("tcp://127.0.0.1:9201", $errno, $erstr);
if(!$client) die("err");
fwrite($client, "a");
while(1){
$rec = fread($client, 1024);
echo $rec."\n";
}
udp客戶端僅需要修改tcp為udp。
stream_select
stream
系列函數使用stream_select
實現I/O復用,本質都是select系統調用。
接下來我們寫兩個示例,第一個示例和上面使用socket_select
實現的類似,第二個則是監聽了客戶端讀寫事件,從而實現了類似telnet的功能,相信大家會感興趣的。
同時監聽socket和連接socket
使用stream_select可以實現IO復用,使得單進程程序也能支持同時處理多個客戶端連接。示例:
<?php
/**
* Created by PhpStorm.
* User: 公眾號: 飛鴻影的博客(fhyblog)
* Date: 2018/6/23
*/
$socket = stream_socket_server ("tcp://0.0.0.0:9201", $errno, $errstr, STREAM_SERVER_BIND | STREAM_SERVER_LISTEN);
if (false === $socket ) {
echo "$errstr($errno)\n";
exit();
}
$clients = [$socket];
echo "waiting client...\n";
while(1){
$read = $clients;
$ret = stream_select($read, $w, $e, 0);
if(false === $ret){
break;
}
foreach($read as $client){
if($client == $socket){ //新客戶端
$conn = stream_socket_accept($socket, -1);
if (false === $socket ) {
exit("accept error\n");
}
echo "new Client! fd:".intval($conn)."\n";
$clients[] = $conn;
}else{
$buffer = fread($client, 1024);//註意,使用$client而不是$conn
//非正常關閉
if(false === $buffer){
echo "fread fail\n";
$key = array_search($client, $clients);
unset($clients[$key]);
break;
}
$msg = trim($buffer, "\n\r");
//強制關閉
if($msg == "quit"){
echo "client close\n";
$key = array_search($client, $clients);
unset($clients[$key]);
fclose($client);
break;
}
echo "recv: $msg\n";
fwrite($conn, "recv: $msg\n");
}
}
}
fclose($socket);
運行服務端並隨後運行telnet客戶端:
$ php stream_select.php
waiting client...
new Client! fd:6
recv: ww
new Client! fd:7
recv: kkk
可以同時支持多個客戶端。從例子可以看出來,stream_select
和socket_select
用法相同。
同時處理網絡連接和用戶輸入
下面的例子使用stream_select實現了客戶端程序運行後,支持命令行界面手動實時輸入與服務端進程交互:
<?php
/**
* Created by PhpStorm.
* User: 公眾號: 飛鴻影的博客(fhyblog)
* Date: 2018/6/23
*/
$socket = stream_socket_client("tcp://127.0.0.1:9201", $errno, $erstr);
if(!$socket) die("err");
$clients = [$socket, STDIN];
fwrite(STDOUT, "ENTER MSG:");
while(1){
$read = $clients;
$ret = stream_select($read, $w, $e, 0);
if(false === $ret){
exit("stream_select err\n");
}
foreach($read as $client){
if($client == $socket){
$msg = stream_socket_recvfrom($socket, 1024);
echo "\nRecv: {$msg}\n";
fwrite(STDOUT, "ENTER MSG:");
}elseif($client == STDIN){
$msg = trim(fgets(STDIN));
if($msg == ‘quit‘){ //必須trim此處才會相等
exit("quit\n");
}
fwrite($socket, $msg);
fwrite(STDOUT, "ENTER MSG:");
}
}
}
例子裏,我們把$socket
和STDIN
使用stream_select監聽文件描述符的變化情況,當有文件描述符就緒,函數會返回,從而執行我們邏輯代碼。
先運行tcp服務端程序stream_select.php,然後運行該客戶端程序:
$ php tcp_client_select.php
ENTER MSG:hello!
ENTER MSG:
Recv: recv: hello!
ENTER MSG:
程序一直會等待我們的輸入,除非輸入quit退出。
函數參考
stream_socket_server() - 創建server
stream_socket_accept() - 接受由 stream_socket_server創建的socket連接
stream_socket_get_name() - 獲取本地或者遠程的套接字名稱
stream_set_blocking() - 為資源流設置阻塞或者阻塞模式
stream_set_timeout() - 為資源流設置超時
stream_socket_client() - 創建client
stream_select() - select系統調用,實現IO多路選擇
stream_socket_shutdown() - 這個函數允許關閉讀、寫、或者指定的socket
stream_socket_recvfrom() -
stream_socket_sendto() -
總結
本文主要和大家講解了 PHP Socket 編程相關知識。通過學習本文,大家學到了如下內容:
- 熟悉 socket 系列函數使用
- 熟悉 stream_socket 系列函數使用
- 熟悉 I/O 復用
- 如何使用 socket 系列函數實現 TCP 服務端和客戶端
- 如何使用 socket_select 實現 I/O 多路復用
- 如何使用 stream_socket 系列函數實現TCP服務端和客戶端
- 如何使用 stream_select 實現 I/O 多路復用
也給大家留一個問題:
如何基於PHP多進程Master-Worker模型實現支持I/O復用的TCP server?
PHP Socket 編程進階指南