前言

上回我们简单介绍了一下TCP Server的工作方式以及如何用Swoole实现一个简单的TCP Server,这次我们来聊聊信息流动中,非常重要基石之一——协议(PROTOCOL)。

教师节献礼加更,祝愿我的老师们身体健康,合家美满,感谢他们没有放弃我,一直以来给我的支持与鼓励!

协议,通信的基石

每次讲到协议,都会想起小时候学习语文时,有段时间特别痴迷各种文字游戏。

那青葱的岁月吖~

其中有一种游戏,相信各位也应该接触过,就是断句游戏——一句话,如果加上不同的标点符号,则可能会产生截然相反的歧义。

后来高中的文言文断句练习彻底把这种愉快的游戏毁了=。=

可能最经典但其实也不怎么好笑的就是“下雨天留客天留我不留”,短短的一句话,断做“下雨天留客,天留我不留”,抑或是是断做“下雨天,留客天,留我不?留!”

其实个人呢,觉得这个断的有点牵强,可能因为我还不是古人,吾还年轻~

那么断句和我说的协议又有什么关系呢?关系大了,如果断错了句,传递的信息就会发生误解,网络通信中也一样,我们都知道计算机底层的数据本质上都可以看作0和1,也就是说再复杂的消息,承载的时候也只是0和1,如果不能正确的断句,那肯定是会出问题的。

啥?光量子计算机有十六个状态位?好吧……

通信的双方约定一种理解的规则,以便对理解对方想表达的信息,这种解析信息的规则,就是今天的主题,协议。

在语文上,我们用的是标点符号;数学上,我们有各种的加减乘除……

从HTTP到TCP,从应用层回到传输层

相信TCP协议(Transmission Control Protocol)应该是想学习SWOOLE的童鞋最容易遇到的拦路虎之一,因为一般我们使用PHP做网站开发的时候,并不需要处理涉及TCP协议的东西,只要了解一部分HTTP协议(HyperText Transfer Protocol)就可以做很多事情。

甚至只是知道Get和Post就可以了,更细致的工作,巨人们已经帮我们完成了。

在故事继续之前,请允许我先简单引入一下传说中的4层协议,TCP就是传输层的协议,而HTTP是应用层,这两个协议有什么关系呢?我们做个有趣的实验看看:

以下代码改编至拙作《当SWOOLE遇上TCP》

  1. <?php
  2. $server = new \swoole_server("127.0.0.1",8088,SWOOLE_PROCESS,SWOOLE_SOCK_TCP);
  3. $server->on('connect', function ($serv, $fd)
  4. {
  5. });
  6. $server->on('receive', function ($serv, $fd, $from_id, $data)
  7. {
  8. // 这次,我们只需要简单的把收到的数据打印出来即可
  9. // 但是,我们会在一头一尾各打印一行邪恶的分隔线
  10. // 以便清楚的划分收到的数据内容
  11. echo "====================邪恶的开头分隔线====================".PHP_EOL;
  12. echo $data;//打印收到的数据正文
  13. echo "====================邪恶的结尾分隔线====================".PHP_EOL;
  14. }
  15. $server->on('close', function ($serv, $fd)
  16. {
  17. echo "client: close.\n";
  18. });
  19. $server -> start();

远程主机\IP\端口的问题,本文就掠过啦,有需要看本系列的前作。

好,我们之前是通过telnet,实现与SWOOLE的TCP Server之间的简单通信的,这次我们玩点不一样的,首先仍然是启动SWOOLE Server,然后,打开浏览器,没错,在地址栏中输入:“http://127.0.0.1:8088” ————

喂,我运行的是TCP Server,开浏览器干什么啦?

显然,浏览器什么都没有输出,又或者爆出一个错误,但这个时候返回我们的终端看看:

  1. > php swoole_server_demo.php
  2. ====================邪恶的开头分隔线====================
  3. GET / HTTP/1.1
  4. Host: 127.0.0.1:8088
  5. Connection: keep-alive
  6. Upgrade-Insecure-Requests: 1
  7. User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/250.36 (KHTML, like Gecko) Chrome/52.0.2743.250 Safari/250.36
  8. Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,*/*;q=0.8
  9. Accept-Encoding: gzip, deflate, sdch
  10. Accept-Language: zh-CN,zh;q=0.8
  11. ====================邪恶的结尾分隔线====================

没错,虽然我们运行的是TCP Server,虽然我们是使用浏览器,而不是telnet访问的,我们的Server仍然打印出了显然非常有规律的信息,相信很多童鞋已经发现了,我们使用Chrome开发网页时,经常使用的调试工具箱里,就会在Network工具中的Header中看到类似的东西。

这就是根据HTTP协议编写的一段信息。

而编写者是谁呢?没错,就是我们一直默默无闻而几乎是互联网改变世界的基石之一,浏览器,每当我们通过浏览器访问不同的网站时,浏览器都会默默生成类似的文本作为WebRequest的正文,提交给对应的服务端。

有兴趣的童鞋,可以试试使用附带Get请求、Post请求等方式访问,看看Server端收到的文本所有什么不同

木有错,这就是超文本传输协议的本体,也是为什么叫超文本的原因,它是通过特定格式的字符串完成请求的描述的,在《当SWOOLE遇上SERVER》一文中,我曾经提到Apache收到客户端请求以后,经过一定的解析,再由Zend调用PHP脚本执行业务工作并完成输出;这里提到的请求就是这个。

当然,浏览器上经常遇到协议还有HTTPS,这里先按下不表。

完整的HTTP协议非常复杂,笔者这里就不详细叙述了,但HTTP协议有一个基本规则,各个字段之间,是通过“\r\n”进行分割的,简单说,当我们收到一个“完整的”HTTP请求的时候,可以用explode方法快速的划分区段,然后再根据区段进行解析,就能知道用户请求的是什么了。

看格式其实或多或少都能猜到写了什么

知道用户请求的是什么,我们就可以选择性的输出用户想要的东西,例如:

  1. $server->on('receive', function ($serv, $fd, $from_id, $data)
  2. {
  3. $reqAry = explode("\r\n",$data);
  4. if (stripos($reqAry[0],"Hello.php") !== FALSE )
  5. {
  6. echo "用户想调用Hello.php".PHP_EOL;
  7. $serv->send($fd,"你调用了Hello.php方法");
  8. }
  9. else if (stripos($reqAry[0],"World.php") !== FALSE )
  10. {
  11. echo "用户想调用World.php".PHP_EOL;
  12. $serv->send($fd,"你调用了World.php方法");
  13. }
  14. else
  15. {
  16. echo "用户想请求了一个不支持的方法".PHP_EOL;
  17. $serv->send($fd,"404,你调用的方法我们不支持。");
  18. }
  19. }

我们修改一下receive的回调,对收到的数据使用“\r\n”进行分割,然后对第0个元素进行简单的判断处理,然后,在浏览器中分别访问:http://127.0.0.1:8088/Hello.php、http://127.0.0.1:8088/World.php、http://127.0.0.1:8088/Index.php,看看Shell中的输出:

  1. > php swoole_server_demo.php
  2. 用户想调用Hello.php
  3. 用户想调用World.php
  4. 用户想请求了一个不支持的方法

然后我们会发现浏览器分别打印了我们在分支语句中send回来的内容,就像平时调用了echo一样。

严格来说,这样写,不一定能输出出来,因为HTTP协议对返回值的格式也有约定。

如果我们对这个方法做的更完善一些,例如根据请求名,反射出Controller实例,并执行Controller的某个Method,整个过程几乎就跟我们常见的MVC框架一样了。

事实上,在笔者看来,C中执行的业务逻辑,可以看作是”业务层”协议了

无论是根据“\r\n”分段也好,根据“ ”拆分每段内部的字段也好,这些规则,都是协议本身的一部分。

一般网络小说中,掌握了规则的强者总是开始努力打破规则,乃至定制自己的规则(所谓我的领域做主)

HTTP的规则简单介绍的这里,我们回到一开始的问题,为什么我运行了一个TCP Server,却能实现HTTP的内容?

相信盆友强忍着读到这里估计都会觉得笔者太罗嗦了,HTTP协议在传输层就是TCP协议实现的嘛

想象一下你和你的基友正在打电话,你们说的是汉语、英语、德语、法语或者基语,是不是都不会影响两个电话之间的通信,电话工作是只要保证把声音传达到位,至于里边的内容,电话是不关心的。

所谓不在其位,不谋其政

而分层协议的工作原理也是一样,TCP作为传输层协议,它仅实现了传输层的某些特性,例如长连接,例如一个高可靠性的传输到位确认机制,但它对它传输的内容,具体怎么被识别或者处理,是不关心的。

TCP也有自己的交互流程和解析机制,但要比HTTP复杂,这里就不讨论了。

而HTTP协议是应用层协议,顾名思义,它关注的是应用,也就是收到传输层TCP收到的消息以后,根据具体的应用进行处理。

除了HTTP以外,常见的诸如HTTPS、FTP、WebSocket等,也都是应用层协议,而它们的传输层都是TCP实现的。

应用层协议百花齐放,传输层的协议却要凋零的多,最常见的,无非是TCP和UDP。

就像有声语言可能有千百种,一个电话一个短信就够了。

所以,在架设自己的TCP Server的时候,要解决的第一个问题,就是,我的应用层协议是什么?

我心即天心

首先,要解决应用层协议的问题,先要选择一个传输层协议,基于这个协议的特点,我们再去设计应用层协议。

就像选择开发语言和开发环境一样,虽然说语言只是工具,但工具也有适用场景,不是说绝对不行,只是事倍功半的事儿,必要时还是可以避免的。

就像前文所言,协议的设计完全是由掌握了规则之力的人决定的(例如CTO),笔者这边就不多讨论怎么设计协议才是对的,仅介绍设计基于TCP协议时要注意的问题。

无尽的数据流

TCP协议最大的一个特点,就是其传输的数据流是连续的,就像打电话一样,打电话的时候,我们以语气的停顿、语音、语调等作为理解对方意图的辅助元素,那TCP协议传输的数据流,OnReceive的时候也分分钟会遇到类似这样的问题:

假设我们在tellnet中执行了以下的伪代码,向Server发送了7条数据

  1. > TCP协议最大的一个特点
  2. > 就是其传输的数据流是连续的
  3. > 就像打电话一样
  4. > 打电话的时候
  5. > 我们以语气的停顿
  6. > 语音
  7. > 语调等作为理解对方意图的辅助元素

此时,虽然Server仍然有90%的可能(主要是网络通畅和输入的速度),OnReceive方法会被回调7次,而且每次收到的数据都与发送时一模一样,仍然不能排除会有以下的可能出现:

  1. TCP协议最大的一个特点就是其传输的数据流是连
  2. 续的
  3. 就像打
  4. 电话一样打电话的时候我
  5. 们以语气的停
  6. 顿语音语调等作为理解对方意图的辅助元素

首先,并不一定会回调7次,可能会回调1次就收到了所有数据,也可能要回调70次才能完整的收到所有数据,但是,无论回调多少次,收到的顺序是与发送顺序保持一致的,也就是不会出现以下情况:

  1. 续的
  2. 就像打
  3. TCP协议最大的一个特点就是其传输的数据流是连
  4. 们以语气的停
  5. 顿语音语调等作为理解对方意图的辅助元素
  6. 电话一样打电话的时候我

所以,很多时候,我们都会称呼TCP的数据叫数据流,从传输层来看,TCP的数据包之间没有边界,怎么从TCP的数据流中正确的截取每个数据包,是设计TCP协议的第一步。

这就是传说中的分包和合包

最常见的数据包处理方式有两种,分别是结束符和固定包头两种,Swoole也非常贴心的替我们提供了这两种方案的常规处理,这样我们在使用的时候就不需要自己写分包合包的代码了。

结束符(EOF)

结束符处理方式很简单,双方约定各个数据包的结尾有稳定的结束符,且在数据包的正文中不要出现该结束符,那么数据的接收方,只要逐个字节地检查收到的数据,一旦发现结束符,就把上一个结束符(也可能是开头),到当前结束符之间的数据拆出来,作为一个数据包,进行进一步的处理

常见的应用层协议中,MEMCACHE\FTP\STMP都是采用这种思路,它们使用的结束符是“\r\n”

而在Swoole中,可以在配置中这样写:

  1. $server = new \swoole_server("127.0.0.1",8088,SWOOLE_PROCESS,SWOOLE_SOCK_TCP);
  2. $server->set(
  3. [
  4. 'open_eof_split' => true,
  5. 'package_eof' => "\r\n"
  6. ]
  7. );
  8. // 回调方法略
  9. $server-start();

此时,假如发来的数据是根据”\r\n”作为结束符分包的数据流,每次OnReceive的时候,就一定是Swoole已经帮我们分好的数据包,我们直接做进一步的应用协议处理就好了。

固定包头+包体

这种方案也是非常非常常见的解决方案,核心设计思路是,每个数据包由两部分组成,分别是固定长度的包头,和不确定长度的包体。包头中描述了包体的长度,接收数据的时候,先按包头的固定长度读取一定的数据,然后解析包头中的内容,获得这个数据包包体的长度,然后继续接收数据,直到收到了跟包头中描述的包体长度一样的数据,进而截断出完整的数据包。

可以说,基本上除了EOF的方式以外,都是这种处理方式

例如说,我们的数据包可以这么写:

这个数据包由十九个字组成今天天气好好啊这个数据包由二十个字组成昨天晚上又加班了

每个数据包的前12个字就是包头,读了包头,我们就知道了整个数据包的长度,减去包头12个字,就知道这个数据包剩下还要读取长了。

当然,作为计算机,使用二进制的方式直接描述数据包才是更常见解决方案。

例如说,我们约定包头的长度是4个byte,这4个byte按照大端序就组成了一个int,而这个int数据描述的就是整个数据包的长度(包括包头本身的4个byte的长度),那么此时,Swoole中的配置应该是:

  1. $server->set(
  2. [
  3. 'open_length_check' => true,
  4. 'package_length_type' => 'N', //N表示32bit的大端序
  5. 'package_length_offset' => 0,//从第几个字节开始是长度,比如包头长度为4个byte,第0个byte开始就是长度值,那这里就填入0
  6. 'package_body_offset' => 2,//从第几个字节开始计算长度,比如包头为长度为4个byte,第0个字节为长度值,包体长度为1000。如果长度包含包头,这里填入0,如果不包含包头,这里填入4
  7. 'package_max_length' => 1024//最大允许的包长度。因为在一个请求包完整接收前,需要将所有数据保存在内存中,所以需要做保护。避免内存占用过大。
  8. ]
  9. );

虽然今天是教师节,如果把大端序的问题也讲进来估计就超时了,大端序、小端序以及php的pack方法、unpack方法等,就暂时按下不表了

关于TCP的通讯协议问题,SWOOLE手册中也有相关的说明网络通信协议设计

小结

今天,笔者简单介绍了应用层协议和传输层协议的关系,并基于TCP协议,给出了基于TCP的应用层协议时,应当注意的问题,也给出了Swoole中相关的一些解决方案,希望能给刚接触网络通信的PHPer们带来一点启发。

回顾

这个系列我已经上传到了github上,欢迎围观:github.com/szyhf/swoole_study

  1. 当SWOOLE遇上PHP 【SWOOLE安装、PHP的CLI模式】
  2. 当SWOOLE遇上SERVER 【TCP/IP】
  3. 当SWOOLE遇上TCP【TCP】

番外:

  1. 守护进程二三事与Supervisor