长期以来,有很多观点认为 PHP 并不适合网络编程。但是,当前我也看不到很多开发者在这一领域有新的尝试。比如国内的 Swoole,国外的 ReactPHP。在我现在的公司海马体,他们自己研发了基于 PHP 的 RPC FrameWork。所以,有了这一篇文章,也记录了自己在这一方面的尝试过程。

这篇文章主要描述网络编程在 PHP 中的应用,一些基础的网络概念并不会涉及,比如说什么是网络、什么是 IP、什么是端口。

Socket 和 Stream

你知道什么事 TIMTOWTDI 吗?这个是我在《The PHP Socket Programming Handbook》这本书上看到的。一开始我也不知道,后来作者解释了,这是“There is more tahn one way to do it” 这句话的首字母缩写。翻译过来就是“完成一件事情并不仅仅只有一种做法”,这是 PHP 的非官方口号。

在网络编程这件事情上,PHP 就提供了两套接口,分别是 Socket 和 Stream。前者几乎是对 C 中的系列 Socket 函数的原始封装,而且没有继承在 PHP 版本中的,需要在编译 PHP 的时候加入启用 Socket 的标志。后者相对于 Socket 更进一步,将 Socket 等资源都抽象为文件,提供同一的接口像读写文件一样操作 Socket 资源。更重要的是,我们并不需要而外的编译标志,默认启用的。

在我们这篇文章中,我们会使用 Stream 这套接口编写所有的示例程序,除非是专门演示 Socket 接口。

Server 和 Client

现在的网络编程,都是基于 Client/Server 模型。比如你要架设一个网站,和这个世界分享你的博文,描述你的心路历程。这个时候,你不可能把你的博文发送给每一个想要看你博文、或者可能想要看你博文的人,你也不知道这些人在什么地方。于是你就需要把你的这个网站以及网站上的博文存放在一台服务器上,然后对外概公开访问(基于 HTTP 协议)。

构建简单的服务端

首先,我们需要来构建基于 TCP 协议的服务端,对外提供服务,接受请求返回响应。示例代码也非常简单,如下:

<?php
$server = @stream_socket_server('tcp://0.0.0.0:1337', $errCode, $errMsg);	// 创建 TCP 服务端
if ($server === false) {		// 错误处理
    fwrite(STDERR, "[$errCode] $errMsg");
    exit(1);
}

创建一个服务端真的是非常容易,只需要调用 stream_socket_server 就可以了。这个函数的作用是向操作系统说,我要面向整个网络所有的 IP 监听 1337 端口。当发送给这个接口的消息的时候,你可要转发给我。

可是当你执行这个脚本文件 php server.php 的时候,就会发现问题,终端没有任何输出,程序就已经结束了。是的,代码本身没有任何问题,问题在于我们创建了服务,但是服务会随着脚本的结束而结束。调用 stream_socket_server 函数,正常情况下会返回一个整型数值,我们称之为 fd ,在 Linux 中是文件描述符,在 Windows 中称之为句柄。当脚本之执行完成之后,这个资源 fd 也会被回收。

另外一个问题是,我们虽然创建了服务,告诉操作系统,我们监听 1337 端口,却没有告诉服务器当消息到达的时候,如何处理消息。我们继续写这部分的代码:

<?php
// ...承接上面的示例代码...
$connect = @stream_socket_accept($server, 60, $peerName);		// 建立 TCP 连接,告诉操作系统,我准备好了,60 秒内有消息发给我
$buffer = fread($connect, 4096);		// 从 $connect 中读取消息,最大长度为 4KB
echo $buffer . PHP_EOL;	// 输出客户端发送的消息内容

我们详细来说一下这两行代码。通过 stream_socket_accept 函数可以告诉操作系统准备接受消息,第二个参数是超时时间为 60 秒,第三个参数暂时不用理会。这个函数默认情况下是阻塞的,也就是说它会傻傻等待消息的到来,然后才会继续执行第 4 行代码。如果超过 60 秒还是没有消息,仍然直接结束了。第四行代码将会读不到任何的数据。这个函数的返回值也是一个 fd, 这个文件描述符代表的是客户端和服务器端建立的连接。所以,我们也是从这个文件中读取内容。

接下来,第四行代码,就是从 steam 中读取客户端传递的内容。问题来了,我们目前还没有写客户端,怎么来验证这段代码呢?在类 Unix 操作系统中可以使用 NetCat 这个软件来模拟客户端的 TCP 请求。这个工具在大多数的发行版中都已经预装了:

php server.php		## 执行这个服务端脚本,会阻塞 60 秒来等待客户端建立连接
nc 127.0.01 1337   ## 从新打开一个终端,回车执行这命令,然后在输入要发送的消息后回车发送

当服务端收到客户端发送的消息之后,就会执行第 4 行和第 5 行,读写消息。问题来了,如何回复消息呢?如下代码:

<?php
fwrite($connect, 'Hi!');			// 读取消息使用 fread, 那么发送消息自然是使用 fwrite 啦

然后我们继续提出问题,提出问题后解决问题。新的问题是客户端只能发送一条消息吗?显然不是吧。按照目前的实现,服务端接受完一次消息并返回响应之后就结束了。所以,我们需要用循环不断地检测是否有新的消息:

<?php
for (; ;) {			// 使用死循环不断检测消息
    $connect = @stream_socket_accept($server, 1, $peerName);		// 每次等待一秒钟
    if ($connect) {			// 如果一秒钟内没有新的连接建立并发送消息,则返回 false
        $buffer = fread($connect, 4096);
        fwrite($connect, strrev($buffer)); // 反转字符串并返回响应
        fclose($connect);       // 关闭连接
    }
}

因为死循环的存在,所以可以不断的接受客户端传来的消息并响应。但是你想到了没有,我们使用 stream_socket_accept 函数和客户端建立了一个连接,但是这个客户端并不急于发送消息,也没有断开连接。此时,又来了一个客户端建立 TCP 连接,并希望发送消息,这又会如何?实验过程如下:

  1. 我们启动服务端: php server.php
  2. 打开第一个终端,输入命令 nc 127.0.0.1 1337 ,不用发送消息
  3. 打开第二个终端,输入命令 nc 127.0.0.1 1337 , 发送一条消息
  4. 观察第二个终端,是否返回了服务端的响应呢?没有的
  5. 然后使用第一个终端,发送一条消息,第一个终端接收到了服务端的消息了吗?是的,并输出服务端的响应退出了
  6. 然后再去看第二个终端,是否在第一个终端退出后,也收到了消息并退出了?是的

通过这个实验,我们目前为止,我们的程序同一时间,只能和一个客户端建立连接通信。如果有多个请求进入,则会等待当前的请求处理完毕关闭之后继续处理。这说明,我们这个服务端的实现是阻塞的,没有办法应用在实际的生产环境下,一次只能处理一个连接,这太让人失望了。如果要处理这个问题,就要引入多进程或者多线程编程,这会让网络编程的复杂度提升不止一个等级。怎么办呢?不需要焦虑。

非阻塞的服务端

然后我们来徒手写一个非阻塞的服务端,使用 Linux 的 Select 模型。在 Stream 系列的接口中,提供了 stream_set_blocking 函数来设置资源读写的模式,默认是阻塞模式,通过第二个参数可以设置为非阻塞模式,这个模式的设置影响 accept 、 fread 、 fwrite 等函数。

<?php
$server = stream_socket_server('tcp://0.0.0.0:1024', $errCode, $errMsg);
stream_set_blocking($server, false);		// 设置为非阻塞模式
$client = stream_socket_accept($server, 0, $clientAddress);
stream_set_blocking($client, false);		// 客户端也是一样的,设置为非阻塞模式

使用 Select 模型,Stream 提供了 stream_select 函数,函数原型如下:

<?php
stream_select($readable, $writable, $except, $tvSec, $tvUsec);

调用 stream_select 函数需要提供 5 个参数,每个参数的含义如下:

• readable 参数: 这是一个数组,当中存放可读的被激活的资源 fd。
• writable 参数: 这是一个数组,当中存放可写的被激活的资源 fd。
• except 参数:一般不用,可以传入空数组 ,因为是引用传递,所以需要传入一个值为空数组的变量。
• tvSec 参数: 超时控制,单位为秒。
• tvUsec 参数:超时控制,单位为微妙。

Linux Select 网络编程模型的核心就是这个集合,在 C 语言中,其维护了一个名为 fd_set 的文件描述符集合。在 PHP 中,分为可读的数组,和可写的数组。系统内核会在指定的时间间隔中,轮询每一个 fd 文件,检查是否有新的数据到达。如果有的话,就通过我们 stream_select 函数通知我们的程序,这个函数会返回一个整型数值,是存在新的消息的 fd 的数量。

编写 stream_select 程序的框架代码如下:

<?php
$server = stream_socket_server('tcp://0.0.0.0:1024', $errCode, $errMsg);
if (false === $server) {  /** 错误处理 **/ }
stream_set_blocking($server, false);		// 设置为非阻塞模式
$connections = $buffers = [];			// connections 保存所有连接的客户端,buffers 保存所有未处理的客户端发送的消息
while (true) {		// 死循环,不断检测新的客户端和消息
    $readable = $writable = $connections;
    $except = [];
    array_unshift($readable, $server);		// 将 server 也加入可读的集合,以此判断是否建立了新的连接
    if (stream_select($readable, $writable, $except, 0, 500) > 0) {
        foreach ((array)$readable as $stream) {		// 遍历所有可读的 stream
            if ($stream === $server) {	
              	$client = stream_socket_accept($server, 0, $clientAddress);		// 接受新的连接
                if (is_resource($client)) {			// 判断客户端是否是有效的资源
                    stream_set_blocking($client, false);		// 设置为非阻塞模式
                    echo 'New client is ' . $clientAddress . PHP_EOL;
                    $key = (int)$client;		// 以 fd 为 key, 保存所有的连接,后面检查连接是否失效
                    $connections[$key] = $client;
                }
            } else { 		// 有新的消息到达
              	$key = (int)$stream;
                if (!isset($buffers[$key])) {			// 判断 Buffers 当中是否存在该 fd,不存在则初始化为空字符串
                    $buffers[$key] = '';
                }
                $buffers[$key] .= fread($stream, 4096);		// 读取消息内容
            }
        }
        foreach ((array)$writable as $stream) {	// 遍历可写的 stream, 回复客户端的信息 	
          	$key = (int)$stream;
            if (isset($buffers[$key]) && strlen($buffers[$key]) > 0) {	// 判断 buffers 中是否存在 stream 发送的消息,并且消息不为空
                $writtenBytes = fwrite($stream, $buffers[$key], 4096);      // 回复消息
                $buffers[$key] = substr($buffers[$key], $writtenBytes);			// 将 Buffer 当中的消息清空,防止重复回复消息		
            }
        }
    }
    foreach ($connections as $connection) {
        feof($connection) && fclose($connection);   // 关闭失效的连接
    }
}

长期以来,有很多观点认为 PHP 并不适合网络编程。但是,当前我也看不到很多开发者在这一领域有新的尝试。比如国内的 Swoole,国外的 ReactPHP。在我现在的公司海马体,他们自己研发了基于 PHP 的 RPC FrameWork。所以,有了这一篇文章,也记录了自己在这一方面的尝试过程。
这篇文章主要描述网络编程在 PHP 中的应用,一些基础的网络概念并不会涉及,比如说什么是网络、什么是 IP、什么是端口。
Socket 和 Stream
你知道什么事 TIMTOWTDI 吗?这个是我在《The PHP Socket Programming Handbook》这本书上看到的。一开始我也不知道,后来作者解释了,这是“There is more tahn one way to do it” 这句话的首字母缩写。翻译过来就是“完成一件事情并不仅仅只有一种做法”,这是 PHP 的非官方口号。
在网络编程这件事情上,PHP 就提供了两套接口,分别是 Socket 和 Stream。前者几乎是对 C 中的系列 Socket 函数的原始封装,而且没有继承在 PHP 版本中的,需要在编译 PHP 的时候加入启用 Socket 的标志。后者相对于 Socket 更进一步,将 Socket 等资源都抽象为文件,提供同一的接口像读写文件一样操作 Socket 资源。更重要的是,我们并不需要而外的编译标志,默认启用的。
在我们这篇文章中,我们会使用 Stream 这套接口编写所有的示例程序,除非是专门演示 Socket 接口。
Server 和 Client
现在的网络编程,都是基于 Client/Server 模型。比如你要架设一个网站,和这个世界分享你的博文,描述你的心路历程。这个时候,你不可能把你的博文发送给每一个想要看你博文、或者可能想要看你博文的人,你也不知道这些人在什么地方。于是你就需要把你的这个网站以及网站上的博文存放在一台服务器上,然后对外概公开访问(基于 HTTP 协议)。如下图所示:
构建简单的服务端
首先,我们需要来构建基于 TCP 协议的服务端,对外提供服务,接受请求返回响应。示例代码也非常简单,如下:

<?php
$server = @stream_socket_server('tcp://0.0.0.0:1337', $errCode, $errMsg);   // 创建 TCP 服务端
if ($server === false) {        // 错误处理
    fwrite(STDERR, "[$errCode] $errMsg");
    exit(1);
}

创建一个服务端真的是非常容易,只需要调用 stream_socket_server 就可以了。这个函数的作用是向操作系统说,我要面向整个网络所有的 IP 监听 1337 端口。当发送给这个接口的消息的时候,你可要转发给我。
可是当你执行这个脚本文件 php server.php 的时候,就会发现问题,终端没有任何输出,程序就已经结束了。是的,代码本身没有任何问题,问题在于我们创建了服务,但是服务会随着脚本的结束而结束。调用 stream_socket_server 函数,正常情况下会返回一个整型数值,我们称之为 fd ,在 Linux 中是文件描述符,在 Windows 中称之为句柄。当脚本之执行完成之后,这个资源 fd 也会被回收。
另外一个问题是,我们虽然创建了服务,告诉操作系统,我们监听 1337 端口,却没有告诉服务器当消息到达的时候,如何处理消息。我们继续写这部分的代码:

<?php
// ...承接上面的示例代码...
$connect = @stream_socket_accept($server, 60, $peerName);       // 建立 TCP 连接,告诉操作系统,我准备好了,60 秒内有消息发给我
$buffer = fread($connect, 4096);        // 从 $connect 中读取消息,最大长度为 4KB
echo $buffer . PHP_EOL; // 输出客户端发送的消息内容

我们详细来说一下这两行代码。通过 stream_socket_accept 函数可以告诉操作系统准备接受消息,第二个参数是超时时间为 60 秒,第三个参数暂时不用理会。这个函数默认情况下是阻塞的,也就是说它会傻傻等待消息的到来,然后才会继续执行第 4 行代码。如果超过 60 秒还是没有消息,仍然直接结束了。第四行代码将会读不到任何的数据。这个函数的返回值也是一个 fd, 这个文件描述符代表的是客户端和服务器端建立的连接。所以,我们也是从这个文件中读取内容。
接下来,第四行代码,就是从 steam 中读取客户端传递的内容。问题来了,我们目前还没有写客户端,怎么来验证这段代码呢?在类 Unix 操作系统中可以使用 NetCat 这个软件来模拟客户端的 TCP 请求。这个工具在大多数的发行版中都已经预装了:
php server.php ## 执行这个服务端脚本,会阻塞 60 秒来等待客户端建立连接
nc 127.0.01 1337 ## 从新打开一个终端,回车执行这命令,然后在输入要发送的消息后回车发送
当服务端收到客户端发送的消息之后,就会执行第 4 行和第 5 行,读写消息。问题来了,如何回复消息呢?如下代码:

<?php
fwrite($connect, 'Hi!');            // 读取消息使用 fread, 那么发送消息自然是使用 fwrite 啦

然后我们继续提出问题,提出问题后解决问题。新的问题是客户端只能发送一条消息吗?显然不是吧。按照目前的实现,服务端接受完一次消息并返回响应之后就结束了。所以,我们需要用循环不断地检测是否有新的消息:

<?php
for (; ;) {         // 使用死循环不断检测消息
    $connect = @stream_socket_accept($server, 1, $peerName);        // 每次等待一秒钟
    if ($connect) {         // 如果一秒钟内没有新的连接建立并发送消息,则返回 false
        $buffer = fread($connect, 4096);
        fwrite($connect, strrev($buffer)); // 反转字符串并返回响应
        fclose($connect);       // 关闭连接
    }
}

因为死循环的存在,所以可以不断的接受客户端传来的消息并响应。但是你想到了没有,我们使用 stream_socket_accept 函数和客户端建立了一个连接,但是这个客户端并不急于发送消息,也没有断开连接。此时,又来了一个客户端建立 TCP 连接,并希望发送消息,这又会如何?实验过程如下:

  1. 我们启动服务端: php server.php
  2. 打开第一个终端,输入命令 nc 127.0.0.1 1337 ,不用发送消息
  3. 打开第二个终端,输入命令 nc 127.0.0.1 1337 , 发送一条消息
  4. 观察第二个终端,是否返回了服务端的响应呢?没有的
  5. 然后使用第一个终端,发送一条消息,第一个终端接收到了服务端的消息了吗?是的,并输出服务端的响应退出了
  6. 然后再去看第二个终端,是否在第一个终端退出后,也收到了消息并退出了?是的
    通过这个实验,我们目前为止,我们的程序同一时间,只能和一个客户端建立连接通信。如果有多个请求进入,则会等待当前的请求处理完毕关闭之后继续处理。这说明,我们这个服务端的实现是阻塞的,没有办法应用在实际的生产环境下,一次只能处理一个连接,这太让人失望了。如果要处理这个问题,就要引入多进程或者多线程编程,这会让网络编程的复杂度提升不止一个等级。怎么办呢?不需要焦虑。
    非阻塞的服务端
    然后我们来徒手写一个非阻塞的服务端,使用 Linux 的 Select 模型。在 Stream 系列的接口中,提供了 stream_set_blocking 函数来设置资源读写的模式,默认是阻塞模式,通过第二个参数可以设置为非阻塞模式,这个模式的设置影响 accept 、 fread 、 fwrite 等函数。
<?php
$server = stream_socket_server('tcp://0.0.0.0:1024', $errCode, $errMsg);
stream_set_blocking($server, false);        // 设置为非阻塞模式
$client = stream_socket_accept($server, 0, $clientAddress);
stream_set_blocking($client, false);        // 客户端也是一样的,设置为非阻塞模式

使用 Select 模型,Stream 提供了 stream_select 函数,函数原型如下:

<?php
stream_select($readable, $writable, $except, $tvSec, $tvUsec);

调用 stream_select 函数需要提供 5 个参数,每个参数的含义如下:
• readable 参数: 这是一个数组,当中存放可读的被激活的资源 fd。
• writable 参数: 这是一个数组,当中存放可写的被激活的资源 fd。
• except 参数:一般不用,可以传入空数组 ,因为是引用传递,所以需要传入一个值为空数组的变量。
• tvSec 参数: 超时控制,单位为秒。
• tvUsec 参数:超时控制,单位为微妙。
Linux Select 网络编程模型的核心就是这个集合,在 C 语言中,其维护了一个名为 fd_set 的文件描述符集合。在 PHP 中,分为可读的数组,和可写的数组。系统内核会在指定的时间间隔中,轮询每一个 fd 文件,检查是否有新的数据到达。如果有的话,就通过我们 stream_select 函数通知我们的程序,这个函数会返回一个整型数值,是存在新的消息的 fd 的数量。
编写 stream_select 程序的框架代码如下:

<?php
$server = stream_socket_server('tcp://0.0.0.0:1024', $errCode, $errMsg);
if (false === $server) {  /** 错误处理 **/ }
stream_set_blocking($server, false);        // 设置为非阻塞模式
$connections = $buffers = [];           // connections 保存所有连接的客户端,buffers 保存所有未处理的客户端发送的消息
while (true) {      // 死循环,不断检测新的客户端和消息
    $readable = $writable = $connections;
    $except = [];
    array_unshift($readable, $server);      // 将 server 也加入可读的集合,以此判断是否建立了新的连接
    if (stream_select($readable, $writable, $except, 0, 500) > 0) {
        foreach ((array)$readable as $stream) {     // 遍历所有可读的 stream
            if ($stream === $server) {  
                $client = stream_socket_accept($server, 0, $clientAddress);     // 接受新的连接
                if (is_resource($client)) {         // 判断客户端是否是有效的资源
                    stream_set_blocking($client, false);        // 设置为非阻塞模式
                    echo 'New client is ' . $clientAddress . PHP_EOL;
                    $key = (int)$client;        // 以 fd 为 key, 保存所有的连接,后面检查连接是否失效
                    $connections[$key] = $client;
                }
            } else {        // 有新的消息到达
                $key = (int)$stream;
                if (!isset($buffers[$key])) {           // 判断 Buffers 当中是否存在该 fd,不存在则初始化为空字符串
                    $buffers[$key] = '';
                }
                $buffers[$key] .= fread($stream, 4096);     // 读取消息内容
            }
        }
        foreach ((array)$writable as $stream) { // 遍历可写的 stream, 回复客户端的信息   
            $key = (int)$stream;
            if (isset($buffers[$key]) && strlen($buffers[$key]) > 0) {  // 判断 buffers 中是否存在 stream 发送的消息,并且消息不为空
                $writtenBytes = fwrite($stream, $buffers[$key], 4096);      // 回复消息
                $buffers[$key] = substr($buffers[$key], $writtenBytes);         // 将 Buffer 当中的消息清空,防止重复回复消息        
            }
        }
    }
    foreach ($connections as $connection) {
        feof($connection) && fclose($connection);   // 关闭失效的连接
    }
}

在 ReactPHP 框架中,默认情况下,就使用了 Linux Select 网络编程模型。有兴趣的可以去阅读它的源码,对照着上面的代码你会觉得很熟悉。运行上面的代码,可以开三个终端,第一个用来启动服务端脚本,第二和第三个可以使用 NetCat 来模拟两个客户端,你会看到现在已经支持多客户端发送并回复消息了。

最后更新于:
2021.04.23