PHP 基于stream实现TCP 协议

使用php 自带的stream函数组 理解tcp协议.

所用到的工具和API:

  • nc,瑞士军刀
  • postman
  • stream 函数
  • event 类

一个简单tcp服务端

主要用到两个函数,stream_socket_server和stream_socket_accept

  1. stream_socket_server,创建服务端socket

     stream_socket_server(
       string $address,// 类型 ,如tcp协议: tcp://host:port
       int &$error_code = null, // 错误码
       string &$error_message = null,// 错误消息
       int $flags  STREAM_SERVER_BIND | STREAM_SERVER_LISTEN ,
       ?resource $context=null,
     ): resource|false
  2. stream_socket_accept,接受socket套接字连接. 一直等待有请求或者超时

    stream_socket_accept(
        resource $socket,// stream_socket_server创建的socket
      ?float $timeout = null,// 等待超时时间,单位秒,默认ini_get('default_socket_timeout')
      string &$peer_name = null,// 已存在的连接客户端地址,通过stream_socket_get_name()获取
    )

完整示例,创建一个监听8000端口的tcp服务:

// 服务端
$socket=stream_socket_server("tcp://0.0.0.0:8000");
// 等待连接
$connect=stream_socket_accept($socket);
// 打印连接信息
printf("connect:%s",stream_socket_get_name($connect,true));

使用NC 测试连接.

nc -v 127.0.0.1 8000 结果

image-20230916182603215

返回successed 说明成功.

使用curl 也能成功,只是没有返回内容.

image-20230916183037147

Connection reset by peer 是服务器关闭了.没有返回内容.

通过netstatlsof 查看进程情况

image-20230916183928587

小技巧:

利用linux 的watch 命令实时那刷新命令,类似top命令.

watch -n 1 "lsof -i -P -n | grep php"

添加延迟退出命令,sleep,就能查看请求过程.

image-20230916185704987

我们已经知道两种状态:LISTEN,ESTABLISHED

PS: TCP常见的几种状态

  1. CLOSED(已关闭):表示连接尚未建立或已经关闭。
  2. LISTEN(监听):表示服务器正在等待传入连接请求。
  3. SYN-SENT(已发送同步):表示客户端已发送连接请求,并等待服务器的确认。
  4. SYN-RECEIVED(已接收同步):表示服务器已收到客户端的连接请求,并发送确认。
  5. ESTABLISHED(已建立):表示连接已成功建立,双方可以进行数据传输。
  6. FIN-WAIT-1(等待关闭):表示连接的一方已发送关闭请求,等待另一方的确认。
  7. FIN-WAIT-2(等待关闭):表示连接的一方已收到关闭请求的确认,等待对方发送关闭请求。
  8. CLOSE-WAIT(等待关闭):表示连接的一方已收到关闭请求,等待自己的应用程序关闭连接。
  9. CLOSING(关闭中):表示双方同时发送关闭请求,正在关闭连接。
  10. LAST-ACK(最后确认):表示连接的一方已发送关闭请求的确认,等待对方的关闭请求。
  11. TIME-WAIT(时间等待):表示连接已关闭,但仍保留在系统中一段时间,以确保在网络中的延迟数据包被丢弃。
  12. CLOSE(关闭):表示连接已经关闭。

获取请求内容

实际就是读取stream,和php读文件一样的操作.

$message= fread($connect, 1024);
printf("message:%s \n",$message);

stream_socket_recvfrom

$message= stream_socket_recvfrom($connect, 1024);

image-20230916192055696

ps: nc使用管道操作传内容.

echo "hello" | nc 127.0.0.1:8000

返回内容

其实就是写入fput或者fwrite 都可以.

fwrite($connect,"hello world!");

stream_socket_sendto

stream_socket_sendto($connect,"hello world!");

image-20230916191509513

HTTP服务器

上面返回内容,但浏览器不显示.

image-20230916192711832

只需要组装一个http协议头的返回值就可以了.

// 协议头
$response = "HTTP/1.1 200 OK\r\n";
$response .= "Content-Type: text/html; charset=UTF-8\r\n";
$response .= "\r\n";
// 内容
$response .= "<html><body><h1>Hello, world!</h1></body></html>";

// 返回内容
fwrite($connect,$response);

image-20230916193631367

完整代码:

// 服务端
$socket=stream_socket_server("tcp://0.0.0.0:8000");

// 连接
$connect=stream_socket_accept($socket);

// 连接信息
printf("connect:%s \n",stream_socket_get_name($connect,true));

// 获取内容
$message= fread($connect, 1024);
printf("message:%s \n",$message);

// 返回内容
$response = "HTTP/1.1 200 OK\r\n";
$response .= "Content-Type: text/html; charset=UTF-8\r\n";
$response .= "\r\n";
$response .= "<html><body><h1>Hello, world!</h1></body></html>";
fwrite($connect,$response);

fclose($connect);
fclose($socket);

多连接

上面演示只是一次性请求,请求一次服务器就断开了. 支持多个请求.

把客户端处理封装成一个函数,反复调用就可以了.

// 服务端
$socket=stream_socket_server("tcp://0.0.0.0:8000");

function receive():void {
    global $socket;
    // 连接
    $connect=stream_socket_accept($socket,3);

    // 连接信息
    printf("connect:%s \n",stream_socket_get_name($connect,true));
    
    // 获取内容
    $message= fread($connect, 1024);
    printf("message:%s \n",$message);
    
    // 返回内容
    $response = "HTTP/1.1 200 OK\r\n";
    $response .= "Content-Type: text/html; charset=UTF-8\r\n";
    $response .= "\r\n";
    $response .= "<html><body><h1>Hello, world!</h1></body></html>";
    fwrite($connect,$response);

    fclose($connect);
      // 重复监听
    receive();
}

receive();
ps: 递归调用会无限增加调用栈,内存会被占满,导致系统崩溃,上面只是做为演示连接,真正使用时不会这么用.

比如,下面代码,运行到一定程度就会崩溃

function foo(){
    global  $num;
    $num++;
    count(debug_backtrace());// 堆栈数会无限多,导致系统变慢或者内存消耗光而崩溃.
    foo();
}
foo();

改成循环方式:

$socket=stream_socket_server("tcp://0.0.0.0:8000");

while(true){

    $connect = @stream_socket_accept($socket,1);
    if(!$connect){
        continue;
    }
    // onConnect
    echo "onConnect:".stream_socket_get_name($connect,true).PHP_EOL;

    // onRequest
    $request=fread($connect, 1024);

    // onResponse
    $response = "HTTP/1.1 200 OK\r\n";
    $response .= "Content-Type: text/html; charset=UTF-8\r\n";
    $response .= "\r\n";
    $response .= "<html><body><h1>Hello, world!</h1></body></html>";
    fwrite($connect, $response);

    fclose($connect);
}

fclose($socket);

超时处理

stream_socket_accept,会hold住程序,一直等待请求.直至超时.

$socket=stream_socket_server("tcp://0.0.0.0:8000");

$connect = stream_socket_accept($socket,3);
if(!$connect){
   echo "超时";
}

echo "应用结束".PHP_EOL;

在3秒后,没有应用请求,就会自动超时,报Warning错误.

image-20230916230609234

  1. 错误捕捉
...
set_error_handler(function ($errCode,$errMsg){
    echo $errMsg.PHP_EOL;
    // 重新监听
});
$connect = stream_socket_accept($socket,3);
restore_error_handler();
...

PS:

ps: restore_error_handler 重置错误捕捉,相当于注销上面的set_error_handler函数.
  1. $connect 返回false,有可能是由于超时引起的.

多客户端请求

注意: 这里的并发,指并发多个连接. 而不是并发处理.

上面虽然通解决了多个链接,但一次只能处理一次请求.需要等待一个请求结束才能发起下一个请求,不支持并发请求.

ab -n 100 -c 10 http://127.0.0.1:8000/ 并发10个请求.

如果处理太慢就有大量请求就卡住了.

image-20230916210627059

服务端只保持一个客户端,如何支持多个客户端连接?

通过 stream_set_blockingtimeout=-1 来一直监听新连接.

stream_set_blocking($socket,false) 不阻塞读取或写入,但不知道状态. 不能等待socket状态,变成异步状态.

stream_socket_accept的timeout=-1 立马超时,不等待. 只有等下次循环才能获取和判断状态.

$socket=stream_socket_server("tcp://0.0.0.0:8000");
// 不阻塞
stream_set_blocking($socket,false);
// 客户端列表
$clients=[];
while(true){
        // 超时设为-1意为不等待,如果有连接加入客户端列表
    $connect = @stream_socket_accept($socket,-1,$remoteAddress);
    if($connect){
        // onConnect
        echo "onConnect:".$remoteAddress.PHP_EOL;
        $clients[$remoteAddress]=$connect;
        stream_set_blocking($connect,false);
    }
      
    // TODO::批量处理clients,发送,接收或关闭

}

上面只加入,不处理,不关闭. 都处理等待状态.可以看到服务端可以同时处理3个连接.而不是等待处理其他连接处理完成,才能建立链接.

image-20230917013820449

I/O多路复用

上面通过循环不阻塞方式,再循环处理,无论数据有没有变动,clients都需要遍历一遍.

只监听连接数据变动可以使用系统多路复用技术,有 select、poll、epoll.

select

缺点是,最大只支持1024个请求.

$server=stream_socket_server("tcp://0.0.0.0:8000",$errno, $errstr);
if (!$server) {
    die("$errstr ($errno)");
}
stream_set_blocking($server , false);
$clients["main"] = $server;
while(1){
    // 拷贝一份,以防select修改原socket
    $read = $clients;
    $ret = @stream_select($read, $write, $except,0);
    if($ret === false){
        break;
    }

    foreach ($read as $k=>$client) {
        // 主socket
        if($client === $server){
            // 创建新客户端
            $conn = @stream_socket_accept($server,-1,$remoteAddress);
            if(!$conn){
                break;
            }
            $clients[$remoteAddress] = $conn;
            echo "total: ".(count($clients)-1)." client\n";
            continue;
        }
       // TODO::处理请求
    }
}

poll

poll和select 区别不大,只是支持更多的连接. php没有支持.

epoll

epoll 需要php安装event扩展.参考:https://www.php.net/manual/zh/book.event.php

先了解event的操作,event事件收集和触发器.

event类核心方法.

  1. EventBase类 ,事件容器.

    new EventBase(
      ?EventConfig $conf,// 事件配置对象
    )
  2. Event类, 事件

    new Event(
      // EventBase对象
        EventBase $base ,
      //文件描述符,可以是socket,stream,时钟,信号. 当为时钟时为"-1",信号为数字常量,如SIGHUP
      mixed $fd,
      /** 事件类型,可选值
      * Event::READ:表示当文件描述符准备好"读取"时触发事件。
      * Event::WRITE:表示当文件描述符准备好"写入"时触发事件。
      * Event::SIGNAL:表示用于信号的事件。
      * Event::TIMEOUT:表示在超时后触发事件。
      * Event::PERSIST: 表示事件持久化. 如Event::TIMEOUT|Event::PERSIST,又被挂起,相当于重置一样.
      */
      int $what,
      // 事件回调函数,callback( mixed $fd = null , int $what = ?, mixed $arg = null ):void
      callable $cb ,
      // 自定义变量
      mixed $arg = NULL,
    )
  3. 操作方法

    Event::add(float $timeout=-1);// 添加事件,时间为秒,支持小数. 如果将一直挂着.
    Event::del();// 删除事件
    EventBase::loop();// 分发信号

小例:

a. 定时器,每隔一段时间,执行callback

$eventBase=new EventBase();
// 定义一个类型为TIMEOUT的事件
$timeEvent=new Event($eventBase,-1,Event::TIMEOUT|Event::PERSIST,function(){
  echo microtime(true).PHP_EOL;
});
// 添加事件,1秒钟后执行
$timeEvent->add(1);
// 等待执行
$eventBase->loop();

b. 信号量触发

$eventBase=new EventBase();
// 定义一个类型为信号量的事件
$timeEvent=new Event($eventBase,SIGTERM,Event::SIGNAL,function(){
  echo "关闭事件".PHP_EOL;
});
// 添加事件,不指定超时
$timeEvent->add();
// 等待执行
$eventBase->loop();

image-20230917061648032

c. socket 变化事件

$eventBase=new EventBase();
$socket=stream_socket_server("tcp://0.0.0.0:8000");
stream_set_blocking($socket, false);
$pid=stream_socket_get_name($socket,true);
$events[$pid]=$socket;

// 循环监听socket变化
$serEvent=new Event($eventBase,$socket,Event::READ|Event::PERSIST,function ()use($socket){
    global  $eventBase;
    global  $events;// 必须全局,不能引用.不然连接后丢失断开.
    $connect=@stream_socket_accept($socket,-1,$id);
    echo "连接:".$id.PHP_EOL;
    if(!$connect){
        return;
    }
    stream_set_blocking($connect, false);
      // 循环监听客户端连接状态
    $event=new Event($eventBase,$connect,Event::READ|Event::PERSIST
        ,function ($fd,$that,$id)use($connect){
            global  $events;
            echo "msg:".stream_socket_recvfrom($connect,1024).PHP_EOL;
            stream_socket_sendto($connect,"ok.".$id);
            fclose($connect);
            unset($events[$id]);
    },$id);
    $event->add();
    $events[$id]=$event;// 添加到全局
});
$serEvent->add();

// 等待执行
$eventBase->loop();

查看.使用的是什么I/O复用?

// 支持的方法
printf("methods:%s \n",implode(',',Event::getSupportedMethods()));
$eventConfig = new EventConfig;
// 禁用select
$eventConfig->avoidMethod('select');
$eventConfig->requireFeatures(EventConfig::FEATURE_ET);// 边缘触发
$eventBase=new EventBase($eventConfig);

// 当前使用到的
echo "i/o method:".$eventBase->getMethod().PHP_EOL;
echo "feature:",$eventBase->getFeatures().PHP_EOL;

原作者:阿金
本文地址:https://hi-arkin.com/archives/php-stream-tcp.html

标签: php tcp stream_scoket select epoll kqueue

(本篇完)

评论