TCP是一个流式的协议,客户端向服务器发送一段数据后,可能并不会被服务器一次就完整的接收到。客户端向服务器发送多段数据,可能服务器一次就接收到了全部。在实际应用中,希望在服务器上能够一次接收一段完整的数据,不多也不少。
传统的TCP服务器中,往往需要由程序员维护一个缓存区,先将读取到数据写入缓存区,然后再通过预先设定好的协议内容来区分一段完整数据的开头、结尾和长度,并将一段完整的数据交给逻辑部分处理,这就是自定义协议的功能。
在Swoole中已经在底层实现了一个数据缓存区,并内置了几种常用的协议类型,并直接在底层做好了数据的拆分,以保证在onReceive
回调函数中,一定能够收到一个或数个完整的数据段。
数据缓存区的大小可以通过配置pakcage_max_length
来控制。
$configs = [];
$configs["package_max_length"] = 8192;
$server->set($configs);
Swoole目前支持两种通讯协议:EOF结束符协议、固定包头加包体协议
package_max_length
package_max_length
用于设置最大数据包尺寸,当开启open_length_check
或open_eof_check
或open_http_protocol
等协议解析后,Swoole底层会进程数据包拼接,此时在数据包未收取完整时,所有数据都将保存在内存中。所以需要设置package_max_length
一个数据包最大允许占用的内存尺寸。
如果同时有1万个TCP连接在发送数据,每个数据包2MB,在最极端的情况下会占用20GB的内存空间。所以此参数不宜设置过大,否则会占用很大的内存。
相关配置选项
open_length_check
当发现数据包长度超过package_max_length
时会直接丢弃此数据并关闭连接,因此不会占用任何内存,适用于websocket
、mqtt
、http2
协议。
open_eof_check
由于无法事先得知数据包的长度,所以接收到的数据还是会保存在内存中持续增长。当发现内存占用已经超过package_max_length
时,将直接丢地此数据包并关闭连接。
open_http_protocol
HTTP的GET请求最大允许8KB数据且无法修改此配置,POST请求会检测Content-Type
,如果发现超过package_max_length
则直接丢地此数据,并发送HTTP 400错误并关闭连接。
EOF协议
- 使用一组固定的、不会在正常数据内出现的字符串
/r/n
作为分割协议的标记,称之为EOF协议。
什么是EOF协议呢?
EOF全称 End of File,使用\r\n
作为结束标记。
在逐个读取数据流中的数据时,如果发现读到EOF标记,就表示已经读到数据末尾。
在TCP的数据流中,使用EOF协议的数据流的特征是|数据|EOF|数据|EOF|
。
EOF协议处理的原理是在每串正常数据的末尾会添加一个预先规定的且绝对不会再数据中出现的字符串作为结束标记,这样接收到的数据就可以根据EOF标记来切分数据。
典型的memcached、ftp、stmp都是使用/r/n
作为结束符。当发送数据时只要在数据包的末尾添加/r/n
即可。
使用EOF协议处理一定要确保数据包中间不会出现EOF,否则将会造成分包错误。
如何开启EOF协议支持呢?
Swoole中可使用配置选项来开启EOF功能
$configs = [];
// 开启EOF检测
$configs["open_eof_split"] = true;
// 设置EOF标记
$configs["package_eof"] = "/r/n";
// 设置服务器运行时参数
$server->set($config);
open_eof_split
open_eof_split
会启用EOF自动分包,当设置open_eof_check
选项后Swoole底层会检测数据是否以特定的字符串结尾来进行数据缓冲,默认只会截取接收到的数据末尾部分做对比,此时可能会产生多条数据合并在一个数据中。
EOF切割需要遍历整个数据包的内容查询\r\n
结束标记,因此会消耗大量CPU资源。假设每个数据包为2MB,每秒10000个请求,则可能产生20GB条CPU字符匹配指令。
open_eof_split
选项会开启Swoole底层对接收到的数据从头开始依次扫描检查,当找到第一个EOF标记时,将已经扫描过的数据作为一个完整的数据包通过onReceive
回调函数发送给PHP层处理,这里需要注意的是package_eof
只允许设置长度不超过8的字符串。
open_eof_split
选项是依次扫描数据中的EOF标记的,虽然每次回调都只会收到一个完整的数据包,但性能较差。因此Swoole提供了另一种不同的选项open_eof_check
。
$configs = [];
// 开启EOF检测
$configs["open_eof_check"] = true;
$server->set($configs);
open_eof_check
选项的结果为布尔值,必须为true
或false
,传入其他类型的数值会被强制转换为布尔值。当启用open_eof_split
参数后,Swoole底层会从数据包中间查找EOF结束标记并拆分数据包。服务器onReceive
每次仅仅会收到一个以EOF字符串结尾的数据包。当启用open_eof_split
参数后,无论open_eof_check
是否启用都会生效。
package_eof
package_eof
需要与open_eof_check
或open_eof_split
配合使用,用来设置EOF字符串,常见如\r\n
。需要注意的是package_eof
最大只允许传入8个字节的字符串。
open_eof_check
$configs = [];
$configs["open_eof_check"] = true;
$server->set($configs);
open_eof_check
用于打开EOF检测,开启后将检测客户端连接发送过来的数据,当数据包结尾是package_eof
指定的字符串时才会投递给Worker工作进程,否则会一直拼接数据包,直到超过packge_max_length
缓冲区或超时时才会中止。另外当出错时Swoole底层会认为是恶意连接丢弃数据并强制关闭连接。
open_eof_check
同样会开启EOF检测,不同的是open_eof_check
只会检查接收数据的末尾是否为EOF标记。相比较open_eof_split
而言此种方式性能最好,几乎没有损耗。
但是如果同时收到多条带有EOF标记的数据,这种方式会同时将多条数据包合并为一个回调给PHP层处理,因此需要PHP层通过EOF标记对数据做二次拆分。
open_eof_check
选项值是布尔型的true
或false
,当传入其他类型的数值时会被强制转换为布尔值。此配置仅仅对STREAM
类型的Socket有效,如TCP、UnixSocketStream。例如常见的Memcache\SMTP\POP等协议都是以\r\n
作为结束标记,因此可以使用此配置。当配置开启后可以保证Worker工作进程一次性收到一个或多个完整的数据包。
open_eof_check
EOF检测不会从数据中查找EOF结束标记字符串,所以Worker工作进程可能会同时收到多个数据包,因此需要在应用层代码中自行拆包。
$recv = $client->recv();
if(!empty($recv)){
$arr = explode("\r\n", $recv);
}
Swoole1.7.15+版本中新增open_eof_split
配置项,支持从数据中查找EOF结束标记字符串并切分数据。
open_eof_split
与open_eof_check
之间的差异是什么呢?
-
open_eof_check
只会检查接收数据的末尾是否为EOF,因此性能最好几乎没有小号。 -
open_eof_check
无法解决多个数据包合并的问题,比如同时发送两条带有EOF的数据,Swoole底层可能会一次性全部返回。 -
open_eof_split
会从左到右对数据进行逐个字节比对,查找数据中的EOF并进行分包,因此性能较差,而且每次只会返回一个数据包。
实例:面向过程方式
服务器
$ vim server.php
set($configs);
$server->on("Start", function(swoole_server $server){
echo "[start] master {$server->master_pid} manager {$server->manager_pid}".PHP_EOL;
});
$server->on("Shutdown", function(swoole_server $server){
echo "[shutdown]".PHP_EOL;
});
//注册监听客户端连接进入事件
$server->on("Connect", function(swoole_server $server, $fd){
echo "[connect] client {$fd}".PHP_EOL;
});
//注册监听接收客户端消息事件
$server->on("Receive", function(swoole_server $server, $fd, $reactor_id, $data){
echo "[receive] {$data}".PHP_EOL;
$message = "success\r\n";
$server->send($fd, $message);
});
//注册监听客户端连接关闭事件
$server->on("Close", function(swoole_server $server, $fd){
echo "[close] client {$fd}".PHP_EOL;
});
// 启动服务器
$server->start();
客户端
$ vim client.php
set($configs);
//连接服务器
$host = "127.0.0.1";
$port = 9501;
$timeout = 1;
$result = $client->connect($host, $port, $timeout);
if(!$result){
die("connect failed".PHP_EOL);
}
//向服务器发送数据,注意发送数据必须具有EOF标记,否则不会响应。
$message = "hello";
//$message = "world\r\n";
$length = $client->send($message);//发送成功则返回消息长度
echo $length.PHP_EOL;
if(!$length){
die("send failed".PHP_EOL);
}
//接收从服务器发送的数据
$result = $client->recv();
if(!$result){
die("recv failed".PHP_EOL);
}
echo $result;
//关闭客户端连接
$client->close();
运行测试
$ php server.php
[start] master 4109 manager 4110
[connect] client 1
[receive] world
$ php client.php
5
PHP Warning: Swoole\Client::recv(): recv() failed. Error: Resource temporarily unavailable [11] in /home/jc/projects/swoole/eof/client.php on line 27
recv failed
注意:在客户端send
消息时并没有添加EOF结束标记\r\n
导致客户端报错
PHP Warning: Swoole\Client::recv(): recv() failed. Error: Resource temporarily unavailable [11] in /home/jc/projects/swoole/eof/client.php on line 27
recv failed
实例:面向对象方式
服务器
$ vim server.php
server = new swoole_server($host, $port, $mode, $type);
}catch(Exception $ex){
$message = $ex->getMessage();
//监听端口失败会抛出异常
$this->debug("[constructor] listen port {$port} failed, {$message}");
}
$this->server->set($configs);
$this->server->on("Start", [$this, "onStart"]);
$this->server->on("Connect", [$this, "onConnect"]);
$this->server->on("Receive", [$this, "onReceive"]);
$this->server->on("Close", [$this, "onClose"]);
$this->server->on("Task", [$this, "onTask"]);
$this->server->on("Finish", [$this, "onFinish"]);
$this->server->start();
}
public function onStart(swoole_server $server)
{
$this->debug("[start] master {$server->master_pid} manager {$server->manager_pid}");
}
public function onConnect(swoole_server $server, $fd, $reactor_id)
{
$this->debug("[connect] reactor {$reactor_id} client {$fd}");
}
public function onReceive(swoole_server $server, $fd, $reactor_id, $data)
{
$this->debug(PHP_EOL."[receive] client {$fd} message : {$data}");
$params = [];
$params["fd"] = $fd;
$params["data"] = $data;
$message = json_encode($params);
$this->server->task($message);
$this->debug("[receive] contine handle worker");
}
public function onTask(swoole_server $server, $task_id, $worker_id, $message)
{
$this->debug("[task] worker {$worker_id} task {$task_id} message: {$message}");
$params = json_decode($message, true);
$fd = $params["fd"];
$data = $params["data"];
if(strpos($this->eof, $data) > 0){
$list = explode($this->eof, $data);
foreach($list as $item){
if(empty($item)){
continue;
}
$this->send($fd, $item.$this->eof);
}
}
return "success";
}
public function onFinish(swoole_server $server, $task_id, $data)
{
$this->debug("[finish] task {$task_id} data:{$data}");
}
public function send($fd, $data, $server_socket = -1)
{
return $this->server->send($fd, $data, $server_socket);
}
public function onClose(swoole_server $server, $fd, $reactor_id)
{
$this->debug("[close] reactor {$reactor_id} client {$fd}");
}
public function debug($message)
{
echo $message.PHP_EOL;
}
}
$host = "0.0.0.0";
$port = 9501;
$configs = [];
$configs["worker_num"] = 1;
$configs["task_worker_num"] = 2;
$configs["daemonize"] = false;
$configs["max_request"] = 10000;
$configs["dispatch_mode"] = 2;
$configs["open_eof_check"] = true;
$configs["open_eof_split"] = true;
$configs["package_max_length"] = 8192;
$configs["package_eof"] = "\r\n";
$server = new Server($host, $port, $configs);
客户端
$ vim client.php
client = new swoole_client($socket_type, $is_sync);
//设置客户端参数,必须在connect前执行。
$this->client->set($configs);
//注册异步事件回调函数
$this->client->on("Connect", [$this, "onConnect"]);
$this->client->on("Receive", [$this, "onReceive"]);
$this->client->on("Close", [$this, "onClose"]);
$this->client->on("Error", [$this, "onError"]);
//连接远程服务器并监听指定主机的端口
$this->connect($host, $port);
}
public function debug($message)
{
echo $message.PHP_EOL;
}
public function connect($host, $port)
{
//异步模式下connect会立即返回true但实际连接并未建立,因此不能在connect之后使用send方法。
//当连接创建成功后系统会自动调用onConnect方法,此时才可以使用send向服务器发送消息。
$fp = $this->client->connect($host, $port);
if(!$fp){
$this->debug("Error {$fp->errCode}: {$fp->errMsg}");
return;
}
}
public function onConnect(swoole_client $client)
{
if($this->isConnected()){
$this->debug("[connect]");
$message = "hello".$this->eof."world".$this->eof;
$count = 0;
$max = 10;
while(true){
if($count >= $max){
break;
}
$this->debug("[send] {$count}:$message");
$this->send($message);
$count++;
}
}
}
public function onReceive(swoole_client $client, $data)
{
$data = $this->client->recv();
echo "[receive1] {$data}".PHP_EOL;
$this->debug("[receive] {$data}");
}
public function onClose(swoole_client $client)
{
$this->debug("[close]");
}
public function onError(swoole_client $client)
{
$this->debug("[error]");
}
public function send($message)
{
$this->client->send($message);
}
public function isConnected()
{
return $this->client->isConnected();
}
}
$host = "127.0.0.1";
$port = 9501;
$configs = [];
$configs["open_eof_check"] = true;
$configs["open_eof_split"] = true;
$configs["package_eof"] = "\r\n";
$configs["package_max_length"] = 8192;
$client = new Client($host, $port);
运行测试
$ php server.php
[start] master 5702 manager 5703
[connect] reactor 0 client 1
[receive] client 1 message : hello
[receive] contine handle worker
[task] worker 0 task 0 message: {"fd":1,"data":"hello\r\n"}
[receive] client 1 message : world
[receive] contine handle worker
[task] worker 0 task 1 message: {"fd":1,"data":"world\r\n"}
[finish] task 0 data:success
[finish] task 1 data:success
[receive] client 1 message : hello
[receive] contine handle worker
[task] worker 0 task 2 message: {"fd":1,"data":"hello\r\n"}
[receive] client 1 message : world
[receive] contine handle worker
[task] worker 0 task 3 message: {"fd":1,"data":"world\r\n"}
[finish] task 2 data:success
[finish] task 3 data:success
[receive] client 1 message : hello
[receive] contine handle worker
[task] worker 0 task 4 message: {"fd":1,"data":"hello\r\n"}
[receive] client 1 message : world
[receive] contine handle worker
[task] worker 0 task 5 message: {"fd":1,"data":"world\r\n"}
[finish] task 4 data:success
[finish] task 5 data:success
[receive] client 1 message : hello
[receive] contine handle worker
[task] worker 0 task 6 message: {"fd":1,"data":"hello\r\n"}
[receive] client 1 message : world
[receive] contine handle worker
[task] worker 0 task 7 message: {"fd":1,"data":"world\r\n"}
[finish] task 6 data:success
[finish] task 7 data:success
[receive] client 1 message : hello
[receive] contine handle worker
[task] worker 0 task 8 message: {"fd":1,"data":"hello\r\n"}
[receive] client 1 message : world
[receive] contine handle worker
[task] worker 0 task 9 message: {"fd":1,"data":"world\r\n"}
[finish] task 8 data:success
[finish] task 9 data:success
[receive] client 1 message : hello
[receive] contine handle worker
[task] worker 0 task 10 message: {"fd":1,"data":"hello\r\n"}
[receive] client 1 message : world
[receive] contine handle worker
[task] worker 0 task 11 message: {"fd":1,"data":"world\r\n"}
[finish] task 10 data:success
[finish] task 11 data:success
[receive] client 1 message : hello
[receive] contine handle worker
[task] worker 0 task 12 message: {"fd":1,"data":"hello\r\n"}
[receive] client 1 message : world
[receive] contine handle worker
[task] worker 0 task 13 message: {"fd":1,"data":"world\r\n"}
[finish] task 12 data:success
[finish] task 13 data:success
[receive] client 1 message : hello
[receive] contine handle worker
[task] worker 0 task 14 message: {"fd":1,"data":"hello\r\n"}
[receive] client 1 message : world
[receive] contine handle worker
[task] worker 0 task 15 message: {"fd":1,"data":"world\r\n"}
[finish] task 14 data:success
[finish] task 15 data:success
[receive] client 1 message : hello
[receive] contine handle worker
[task] worker 0 task 16 message: {"fd":1,"data":"hello\r\n"}
[receive] client 1 message : world
[receive] contine handle worker
[task] worker 0 task 17 message: {"fd":1,"data":"world\r\n"}
[finish] task 16 data:success
[finish] task 17 data:success
[receive] client 1 message : hello
[receive] contine handle worker
[task] worker 0 task 18 message: {"fd":1,"data":"hello\r\n"}
[receive] client 1 message : world
[receive] contine handle worker
[task] worker 0 task 19 message: {"fd":1,"data":"world\r\n"}
[finish] task 18 data:success
[finish] task 19 data:success
$ php client.php
[connect]
[send] 0:hello
world
[send] 1:hello
world
[send] 2:hello
world
[send] 3:hello
world
[send] 4:hello
world
[send] 5:hello
world
[send] 6:hello
world
[send] 7:hello
world
[send] 8:hello
world
[send] 9:hello
world
固定包头协议
- 在收据首部添加一组固定格式的数据作为协议头,称之为固定包头协议。
- 协议头的格式必须固定,并且其中需要标明后续数据的长度
Length
。 - 长度字段
Length
的格式只支持S,L,N,V
和s,l,n,v
。
网络通信过程中可能会出现分包与合包的情况,这时就需要使用到固定包头协议。固定包头协议是在实际应用中最为常见的协议,协议规定一个固定长度的包头,在包头的固定位置有一个指定好的字段用于存放后续数据的实际长度。这样服务器可以先读取固定长度的数据,并从中提取出长度,然后再读取指定长度的数据,即可获得一段完整的数据。在Swoole中提供了固定包头的协议格式,不过需要注意的是,Swoole之允许二进制形式的包头,因此需要使用PHP的pack
和unpack
用来打包和解包。
//解包封包
function packdata($data, $package_length_type)
{
return pack($package_length_type, strlen($data)).$data;
}
function unpackdata($data, $package_length_type)
{
$length = $package_length_type=="N" ? 4 : 2;
return substr($data, $length);
}
固定包头协议非常通用,在BAT的服务器程序中经常能够看到,这种协议的特点是一个数据包总是由包头和包体两部分组成,包头由一个字段指定包体或整个包的长度,长度一般是使用2字节或4字节来表示。服务器收到包头后,可以根据长度值来精确控制需要再接收多少数据后得到完整数据包。Swoole的配置可以很好的支持这种协议,也可以灵活地配置参数来应对所有情况。
$package_length_type = "N";
$package_body_offset = $package_length_type=="N" ? 4 : 2;
Swoole的服务器和异步客户端都是在onReceive
回调函数中处理数据包,当设置了协议处理之后,只有当收到一个完整的数据包时才会触发onReceive
事件。
$server->on("receive", function(swoole_server $server, $fd, $reactor_id, $data) use($package_length_type){
$worker_id = $server->worker_id;
$length = strlen($data);
debug("[receive] worker:{$worker_id} length:{$length} data:{$data}");
$recv = unpackdata($data, $package_length_type);
debug("[receive] recv:{$recv}");
$send = packdata($recv, $package_length_type);
debug("[receive] send:{$send}");
$server->send($fd, $send);
});
当同步客户端在设置了协议处理后,调用$client->recv()
不再需要传入长度,recv
函数在收到完整的数据包或发生错误后返回。
$result = $client->recv();
if(!$result)
{
exit("recv failed");
}
$data = unpackdata($result, $package_length_type);
debug("[receive] {$data}");
固定包头协议是在需要发送的数据前添加一段预先约定好的长度和格式的数据体,在该数据体中存放着有序数据的相应信息,一般情况下后续数据的长度字段是必填项,这样一段数据体称之为协议包头。在TCP的数据流中使用固定包头协议的数据流特征是|length长度|数据|length长度|数据|
。
开启协议检测
在Swoole中使用配置选项来开启固定包头的协议功能,通过设置open_length_check
选项可打开固定包头协议解析功能,可使用package_length_offset
、package_body_offset
、package_length_type
三个配置控制解析功能。package_length_offset
规定了包头中第几个字节开始是长度字段,package_body_offset
规定了包头的长度,package_length_type
规定了长度字段的类型。
$configs = [];
//设置是否开启协议解析
$configs["open_length_check"] = true;
// 设置数据包缓存区大小,若缓存数据超过该值则引发错误,具体错误处理由开启的协议解析类型所决定。
$configs["package_max_length"] = 81920;
// 指定包长字段的类型
$configs["package_length_type"] = 'N';
// 设置从包头中第几个字节开始存放长度字段
$configs["package_length_offset"] = 0;
// 设置从第几个字节开始计算长度
$configs["package_body_offset"] = 4;
$server->set($configs)
在package_length_type
中规定了length
长度字段的类型,这个类型等价于使用PHP的pack
函数打包数据时所使用的类型,具体类型如下:
-
c
1字节范围从-128到127,表示有符号的Char
字符。 -
C
1字节范围从0到255,表示无符号的Char
字符。 -
s
2字节范围从-32768到32767,表示有符号的机器字节序。 -
S
2字节范围从0到65535,表示无符号的机器字节序。 -
n
2字节范围从0到65535,表示无符号的网络字节序。 -
N
4字节范围从0到4294967295,表示无符号的网络字节序。 -
l
4字节范围从-2147483648到2147483648,表示有符号的机器字节序。 -
L
4字节范围从0到4294967295,表示无符号的机器字节序。 -
v
2字节范围从0到65535,表示无符号的小端字节序。 -
V
4字节范围从0到4294967295,表示无符号的小端字节序。
字节序
在内存中一个整数由四个连续的字节序列表示,例如一个int
类型的变量i
其内存地址为0x100
,此变量的四个字节会被存储在0x100
、0x101
、0x102
、0x103
。
字节序决定了变量在内存中四个地址的存放顺序,通过会根据字节排序顺序的不同,将字节序区分为大端字节序和小端字节序。
假设变量i
的16进制表示为0x12345678
,在大端字节序中变量将会以下形式存放在内存。
... 0x100 0x101 0x102 0x103 ...
... 12 34 56 78 ...
在小端字节序中,则会以以下形式存放在内存中。
... 0x100 0x101 0x102 0x103 ...
... 78 56 34 12 ...
可以发现,以内存地址由小到大代表从低到高,将变量从左到右设定为从低到高,大端序列是高位字节存放在高位,低位字节存放在低位,小端则反之。
在机器中使用大端还是小端完全取决于硬件本身,因此不同机器之间通信时,需要使用一种统一地字节序来进行传递,由此诞生了网络字节序的概念。网络字节序一般采用大端序由IP协议所指定,而机器字节序则由机器本身所决定。两者之间需要借助于系统函数调用进行转换。
实例:面向过程
服务器
$ vim server.php
set($configs);
$server->on("connect", function(swoole_server $server, $fd){
debug("[connect] client {$fd}");
});
$server->on("close", function(swoole_server $server, $fd){
debug("[close] client {$fd}");
});
$server->on("receive", function(swoole_server $server, $fd, $reactor_id, $data) use($package_length_type){
$worker_id = $server->worker_id;
$length = strlen($data);
debug("[receive] worker:{$worker_id} length:{$length} data:{$data}");
$recv = unpackdata($data, $package_length_type);
debug("[receive] recv:{$recv}");
$send = packdata($recv, $package_length_type);
debug("[receive] send:{$send}");
$server->send($fd, $send);
});
$server->start();
客户端
$ vim client.php
set($configs);
$host = "127.0.0.1";
$port = 9501;
$bool = $client->connect($host, $port);
if(!$bool)
{
exit("connect failed");
}
$data = "hello world";
$data = packdata($data, $package_length_type);
$length = $client->send($data);
if(!$length)
{
$errcode = $client->errCode;
exit("send failed");
}
$result = $client->recv();
if(!$result)
{
exit("recv failed");
}
$data = unpackdata($result, $package_length_type);
debug("[receive] {$data}");
$client->close();
运行测试
$ php server.php
[connect] client 1
[receive] worker:0 length:15 data:
hello world
[receive] recv:hello world
[receive] send:
hello world
[close] client 1
$ php client.php
[receive] hello world