swoole
一、前言
1.1、什么是 swoole
swoole是基于C开发的一个php扩展,类似你熟悉的Mysqli、cURL等等。
swoole的作用,其实更多的是解决php在某些方面的缺陷(当然,php是最好的语言),比如即时通讯、异步任务、消息队列等等。
Swoole是PHP语言的高性能网络通信框架,提供了PHP语言的异步多线程服务器,异步TCP/UDP网络客户端,异步MySQL,数据库连接池,AsyncTask,消息队列,毫秒定时器,异步文件读写,异步DNS查询。 Swoole虽然是标准的PHP扩展,实际上与普通的扩展不同。普通的扩展只是提供一个库函数。而swoole扩展在运行后会接管PHP的控制权,进入事件循环。当IO事件发生后,swoole会自动回调指定的PHP函数。
1.2、php的cli模式
PHP 除了可以被 Apache IIS 等服务器调用,还可以通过 cli 模式运行,因为 php 本质上还是 C 语言写的程序。
①、将 php.exe 加入环境变量
②、新建 cli.php
<?phpecho "hello php cli\n";
③、打开 cmd ,切换到 cli.php 所在目录,输入
php cli.php
④、修改 cli.php
<?phpecho "hello php cli\n";var_dump($_SERVER["argc"]); //$_SERVER["argc"] 为传递的参数的个数var_dump($_SERVER["argv"]); //S_SERVER["argv"] 为传递的参数的值,以数组表示
⑤、打开 cmd ,切换到 cli.php 所在目录,输入
php cli.php one two
返回:
hello php cliint(3)array(3) {[0] =>string(8) "test.php"[1] =>string(3) "one"[2] =>string(3) "two"
}
1.3、进程和线程
①、进程
对于操作系统而言,进程就是一个任务,比方说你打开了一个记事本,那就启动了一个进程,打开了两个浏览器,就是另外开启了两个进程,再或者说我现在在word内写文章,打开word也会占用一个进程。也就是说,一个进程至少要干一件事情。
对于linux系统而言,如果你想要查看当前系统中运行着哪些进程,可以通过ps命令进行查看。
比如我现在打开一个终端,用vim打开一个文件
vim test.php
打开后这个终端不动,再新打开一个终端,执行ps命令后
ps aux | grep vim
root 8381 0.0 0.4 143844 4724 pts/0 S+ 09:30 0:00 vim test.php
root 8876 0.0 0.0 103324 884 pts/2 S+ 09:40 0:00 grep vim
可以看到,有两个vim相关的进程在我执行ps的那一霎那还在执行。
②、线程
有些情况下,一个进程会同时做一些事情,比如说word。它可以同时进行打字、拼写检查等操作。注意这里我们说的同时进行。像这样,在一个进程内部,同时运行着多个“子任务”,我们就可以把这些子任务称之为“线程”。即进程是由多个线程组成的,一个进程至少要有一个线程。实际上,线程是操作系统最小的执行单元。
③、多任务的实现
A、试想一下,如果我们要同时执行多个任务怎么办?
根据上文的理解,我们可以:启动多个进程
B、试想一下,如果我们要同时执行多个任务怎么办?根据上文的理解,我们可以
-
启动多个进程
-
启动一个进程,并在该进程内启动多个线程
-
启动多个进程,每个进程内启动多个线程
④、多进程实现
我们举一个实际点的例子:各位熟悉的apache,其实就是一种多进程实现的案例。当父进程监听到有新的请求时,就会fork出新的子进程来对之进行处理。
Linux的fork()函数通过系统调用即可实现创建一个与原进程几乎相同的进程。对于多任务,通常我们会设计Master-Worker模式,即一个Master进程负责分配任务,多个Worker进程负责执行任务。同理,如果是多线程,Master就是主线程,Worker就是子线程。
⑤、多进程与多线程的区别
多进程的优点就是稳定性很高,如果一个进程挂了,不会影响其他子进程,当然,如果主进程挂了那就都玩完(主进程挂点的可能性微乎其微,后面讲进程模型会说到)。而对于多线程,这个恐怕就是致命的缺点了,因为所有线程共享内存,如果某一个线程挂了,那这个进程几乎就崩溃了。
性能方面,不论是进程还是线程,如果启动太多,无疑都会带来CPU的调度问题,因为进程或者线程的切换,本身就非常耗费资源。数量达到一定程度的时候,CPU和内存就消耗殆尽,电脑就死机了。
举一个例子:使用过windows的用户都知道,如果我们打开的软件越多(开启的进程也就越多),电脑就会越卡,甚至装死机没反应。
线程与进程相比,自然是要比进程更轻量一些,而且线程之间是共享内存的,所以不同线程之间的交互就显得容易实现。而对于多进程之间的通信,需要借助消息队列,共享内存等复杂的方式才可以实现。
1.4、IO模型
①、什么是 IO
IO即Input/Output,输入和输出的意思。在计算机的世界里,涉及到数据交换的地方,比如磁盘、网络等,就需要IO接口。
通常,IO是相对的。比如说你打开浏览器,通过网络IO获取我们网站的网页,浏览器首先会往服务器发送请求,这是一个Output操作,随后服务器给浏览器返回信息,这就是一个Input操作。以上都是基于浏览器而言。但是,有些操作就比较特殊。比如程序在运行时,数据被加载在内存中,通过程序往磁盘写数据,对内存而言,这就是单方面的的Output。
②、IO模型
IO模型通常有很多种,我们简单介绍下同步IO和异步IO。
③、同步IO
实际上我们刚刚介绍的浏览器请求服务器的过程正是同步IO的例子。
那我们再比如,假设我们要通过程序往磁盘写大量的数据,如果没有磁盘IO操作,php程序在内存中执行的速度是非常快的,但是磁盘写数据的过程相对而言就是漫长的,CPU就需要等待磁盘IO操作之后才能继续执行其他代码,像上面这两种情况,我们都称之为同步IO。
php本身是单线程的,当php进程被挂起的时候,像上面的读取磁盘数据,往磁盘写数据,在IO操作之前php代码就没办法继续执行了。
因为IO操作阻塞了当前线程,如果某用户也想从磁盘上读取或者写数据,就需要等待。
有些人要反驳了,这不对呀,我经历不是这样的,很多人可以同时访问我的网站,这没问题的。
这个没必要纠结,php本身是单进程单线程的,用户可以同时访问你的网站实际上是web服务器的功劳。这就是我们之前讨论过的,如何解决多任务的问题。
web服务器的进程模型暂时不多讨论,免得懵。
如果不考虑web服务器,是不是当前进程一旦阻塞,其他人访问php都会被阻塞啦?答案是肯定的。要解决这个问题,有回到我们一直强调的多进程或者多线程。
但是,如果为了解决并发问题,系统开启了大量的进程,就像我们之前说的,操作系统在进程或者线程间切换同样会造成CPU大量的开销。有没有更好的解决方案呢?
④、异步IO
答案就就是异步IO。我们再来强调一遍异步IO是要解决什么问题的:同一线程内,执行一些耗时的任务时,其他代码是不能继续执行的,要等待该任务操作完之后才可以。
异步IO是什么样的呢?当程序需要执行一个非常耗时的IO操作的时候,它只发出IO指令,不需要等待IO的结果,然后可以继续执行其他的代码了。当IO返回结果时,再通知CPU去处理,这就是异步IO。
总结:同步IO模型下,主线程只能被挂起等待,但是在异步IO模型中,主线程发起IO指令后,可以继续执行其他指令,没有被挂起,也没有切换线程的操作。由此看来,使用异步IO明显可以提高了系统性能。
1.5、TCP/IP和UDP
①、浏览器访问网站的过程
平时我们打开一个浏览器,然后输入网址后回车,即展现了一个网页的内容。这是一个非常简单的操作。我们来简单的概括下背后的逻辑。
-
浏览器通过TCP/IP协议建立到服务器的TCP连接
-
客户端向服务器发送HTTP协议请求包,请求服务器里的资源文档
-
服务器向客户端发送HTTP协议应答包,如果请求的资源包含有动态语言的内容,那么服务器会调用动态语言的解释引擎负责处理“动态内容”,并将处理得到的数据返回给客户端
-
客户端与服务器断开,由客户端解释HTML文档,在客户端屏幕上渲染图形结果
表面上看这就是两台电脑之间进行的一种通信。
更确切的说,是两台计算机上两个进程之间的通信。你打开浏览器相当于启动了一个浏览器进程,而服务端事先也启动了某个进程,在某个端口监听,时刻等待客户端的连接。
那么问题来了,为什么客户端可以请求到服务器呢?服务器上跑那么多服务,又是怎么知道客户端想要什么呢?
其实答案很简单,因为有网络。计算机为了联网,就必须遵循通信协议。早期的互联网有很多协议,但是最重要的就非TCP协议和IP协议莫属了。所以,我们把互联网的协议简称为TCP/IP协议。
②、IP协议
想必大家都有过这样的经历,客户端通过telnet连接服务器的时候,往往都需要一个ip地址和一个端口。如果客户端跟服务器之间有数据的交互,其过程大致是这样的:
IP协议负责把你本机的数据发送到服务端,数据被分割成一块一块的。然后通过IP包发送出去。IP包的特点是按块发送,但不保证能到达,也不保证数据块依次到达。
如果是这样进行数据传输,服务器根本不能保证接收到的数据的完整性和顺序性,这样是不是就会有很大的问题呢?
③、TCP协议
于是乎,TCP协议应运而生,它是建立在IP协议之上,专门负责建立可靠连接,并保证数据包顺序到达。TCP协议会通过握手建立连接,然后,对每个IP包编号,确保对方顺序收到,如果出现丢包,则重新发送。
这个时候再说TCP协议是一种面向连接、可靠的、基于IP之上的传出层协议就不难理解了吧。
TCP协议还有一个更重要的特点,它是基于数据流的。
什么意思呢?这就好比客户端和服务端要进行数据交互,中间有一个管子连接着,这个时候交互数据就好比管子中的水,当数据在传输(水在流动)的过程中,服务端是无法知道哪段数据是我们想要的完整数据。怎么解决这一问题呢?这个时候就需要双方约定一个协议来解决了。再往后说就说到应用层协议了,比如http协议,我们姑且不谈。
④、UDP协议
TCP懂了,UDP自然就不难理解了。
相对于TCP,使用UDP协议进行通信的最大区别就是,UDP不需要建立连接,给他一个ip和端口,就可以直接发送数据包了。但是,能不能成功到达就不知道了。虽然UDP传输不可靠,但是速度快。对于一些对数据要求不高的场景,使用UDP通信无疑是不错的选择。
二、swoole
2.1、swoole的安装与升级
windows用户可以使用cygwin环境来学习swoole,但是会有很多问题,下面我们主要介绍下linux环境下swoole的安装。
php版本推荐php5.4+
此外,你应该保证系统中安装了如下软件
gcc-4.4+
make
autoconf
gcc版本不够或者上述软件有一项未安装,下面的操作就没必要往下看了。
注:还需安装以下插件
A、pcre——主要用于swoole_server::connections-
yum install -y pcre pcre-devel
两种安装方式
①、方法一:编译安装
A、下载最新的稳定版,以pecl官网为准,后面针对swoole的学习,我们以1.9.6为准,如果本地已经安装过swoole了,但是版本不一致,可以直接看下面关于swoole升级的讲解。此外,由于swoole还很年轻,各个版本中可能会与我们所讲述的配置有所冲突,建议尽可能的保证你的版本跟我们一致,除非你知道版本本身的差异。
我们切换到 /usr/local/src 目录,你也可以下载到你期望的路径。利用wget下载,提示wget不是命令的请先下载一下这个命令
wget .9.6.tgz
B、随后利用tar命令解压,同样tar命令不存在的自行下载
tar zxvf swoole-1.9.6.tgz
C、切换到 swoole-1.9.6 目录
cd swoole-1.9.6
D、找到phpize所在路径,注意要找你要给具体php版本安装的那个phpize,比如我的php源码在 /usr/local/php56/ 目录,phpize路径就是 /usr/local/php56/bin/phpize,在不确保终端下的phpize是哪个版本的php时候,建议指定绝对路径
/usr/local/php/bin/phpize
终端下输入上面的命令后回车即可
E、检查&&编译&&安装
./configure --with-php-config=/usr/local/php/bin/php-config
make
sudo make install
依次输入上述命令进行操作。
注:如果要支持ssl,需要(当然,要先确保你的系统安装了openssl,php也安装了openssl扩展)
./configure --with-php-config=/usr/local/php/bin/php-config --enable-openssl
②、方法二:PECL安装
方法一的编译安装的过程稍微有一些麻烦,swoole也是pecl的项目,所以,我们还可以通过pecl进行一键安装
pecl install swoole
如果以上步骤一切正常的话,即表示swoole已经成功的安装了。
③、修改 php.ini
成功之后,我们打开php.ini,把swoole.so加入到文件最后
extension=swoole.so
随后通过命令php -m查看swoole是否被正确的安装
php -m | grep swoole
能看到结果即表示安装成功了,当然这是在我们安装过程中一切顺利的情况下进行的。
④、swoole升级
swoole现在还处于发展中,可能我们还没学完,新的版本又要出来了。有同学要说了,过段时间我估计就忘记现在安装的swoole的版本是多少了,这个怎么办?
我们可以通过 php --ri 命令查看swoole版本
php --ri swoole | grep Version#结果
Version => 1.9.6
如果后期发现有新的版本发布了,怎么升级swoole呢?
-
编译升级:编译升级,只需要从pecl官网下载最新的稳定版,按照我们一开始的编译安装步骤再走一遍就完事了。之前安装的版本不需要过问,这就相当于重新安装一次新版本就好了。友情提醒,尽可能的下载稳定版,非稳定版可能会发生很多意外的事。
-
pecl升级:这个更简单,一条命令搞定
pecl upgrade swoole#结果
swoole
2.2、swoole初识之异步多线程服务器
①、同步和异步
我们在 IO模型 中解释过同步和异步的概念,并非是web开发模式下ajax这种异步的请求。在常见的web开发模式下,我们所碰到的几乎都是同步模式。
为什么这么说?无论是fpm还是httpd,同一时间内一个进程只能处理一个请求,如果当前进程处于繁忙,后面的请求也只能继续等待有新的空闲进程。如果负载稍微上去了些,我们还可以调整fpm和httpd的进程数,即增加worker进程的数量。但是,在服务器资源有限的情况下,随着worker进程数量的递增,系统消耗的资源也会逐步增加,直至over。
swoole是既支持全异步,也支持同步,同步模式我们后面结合fpm再说。
从 IO模型 中,我们也可以感受到异步很强大。为什么喃?
我们举一个一名老师指导多名学生解题的场景。
同步模式下,当该老师在给某学生A指导题目的时候,嘴里可能一边嘟囔着“这个要这么写...”,话没说完,另一个学生B喊道“老师快来,我这碰到难题了,快过来指导指导”。
“等会,没看见在忙吗?”
然后学生B只能乖乖的等老师给A解答完之后才可以。
异步模式就不同啦,老师在给A指导的同时,B又屁颠屁颠的喊着“老师老师...”,这个时候老师态度上就360大转弯,“来了来了”,顺便跟A说了“你先理解下我刚才说的,等会好了叫我”,然后呢,后面的剧情可能就是这样的
-
A解答完毕跟老师说“谢谢”,B喊老师
-
B先喊老师,A进入B一开始的状态,B解答完毕跟老师说“谢谢”
-
剧情很多,自己没事想吧
又重温了下什么是同步和异步的概念,禁止混淆。
②、socket编程
socket是什么?
在大部分的书本或者网络文章中,你都能找到一个解释:套接字,是属于应用层和传输层之间的抽象层。真想把发明这词的人拉出来暴打一顿,这也太抽象了。
socket即套接字,是用来与另一个进程进行跨网络通信的文件,说是“文件”,也很好理解哈,因为在linux中一切都可以理解为“文件”。比如客户端可以借助socket与服务器之间建立连接。你也可以把socket理解为一组函数库,它确实也就是一堆函数。
我们知道,常见的网络应用都是基于Client-Server模型的。即一个服务器进程和多个客户端进程组合而成,如果你还理解为是一台电脑对另一台电脑,可以回去把 进程/线程 再看看了。在Client-Server模型中,服务器管理某种资源,并且通过对它管理的资源进行操作来为客户端提供服务。
那Client和Server又如何实现通信呢?这就要利用socket一系列的函数实现了。
基于套接字接口的网络应用的描述,用下面这张图来理解就好。
再往大了说,我们这个是不是可以扩展为二者的聊天了呢?
有兴趣的可以先捣鼓捣鼓,没兴趣的也可以捣鼓捣鼓了,因为后面我们基于websocket的实例可能不是聊天,而是web通知,敬请期待。
3.3、常见的websocket问题
①、引言
上一节我们讲述了websocket在swoole中的使用,并且我们也给出了一个简单的聊天模型,不同的客户端可以相互发消息。有些同学不以为然,server有swoole提供强大的API,客户端由h5提供websocket API,操作很方便,没感觉到什么问题呀,这一章节是否有存在的必要性呢?
有,非常有。今天我们就针对websocket中常见的几个问题做一个详细的总结说明,具体要说的重点大概有下面3个
-
心跳检测的必要性
-
校验客户端连接的有效性
-
客户端的重连机制
②、心跳检测
还记得我们在进程模型一文中介绍的Master进程吗?当时我们说过,Master进程,包括主线程,多个Reactor线程等。其实主进程内还包括其他线程,比如我们现在讲的心跳检测,在Master进程内就有专门用于心跳检测的线程。
那到底什么是心跳检测呢?说着websocket,怎么谈到要医治病人了?这个心跳检测呢,是server定时检测客户端是否还连接的意思,即server定时检测client是否还活着,所以我们说的专业点就是所谓的心跳检测。
等等,老师你说“定时检测”?是不是说之前学的定时器可以派上用场了?
怎么感觉之前讲的不教你在实际场景中运用一次你就不会似的。当然,你要是用定时器也没问题,不过呢,我们都说有专门的心跳检测线程的存在了,所以,我们只需要简单的配置,开启这个心跳检测线程就可以了。
有同学还有疑问,server我们有onClose回调,客户端断开连接我们可以主动关闭连接或者删除客户端的映射关系,再者说,即使连接无效,断了就断了呗,反正我的server面向的client也没有多少,心跳检测就真的有存在的必要性么?
正常情况下,不需要。客户端断开连接能够通知到server,server自然也就可以主动关闭连接。但是,有很多非正常情况的存在,比如断电断网尤其是移动网络盛行的当下,二者之间建立的友好关系(连接)非常不稳定,这就必然会导致大量的fd(fd的数量是有限的,还记得最大是多少吗?)被浪费!所以为了解决这些问题,swoole内置了心跳检测机制。
我们只需要做如下简单的配置即可
$serv->set(["heartbeat_check_interval" => N,"heartbeat_idle_time" => M,
]);
如上,分别配置heartbeat_check_interval和heartbeat_idle_time参数,二者配合使用,其含义就是N秒检查一次,看看哪些连接M秒内没有活动的,就认为这个连接是无效的,server就会主动关闭这个无效的连接。
是不是说N秒server会主动向客户端发一个心跳包,没有收到客户端响应的才认为这个连接是死连接呢?那还要heartbeat_idle_time做什么,对吧?
swoole的实现原理是这样的:server每次收到客户端的数据包都会记录一个时间戳,N秒内循环检测下所有的连接,如果M秒内该连接还没有活动,才断开这个连接。
心跳检测的问题,记得自己动手实践实践哦,有不懂的可以下面给我留言。
③、校验客户端连接的有效性
按照我们上文创建的websocket server,当然只有本地的ip才能连接上,因为server监听的ip是127.0.0.1。实际项目上线后,如果你的websocket server是对外开放的,就需要把ip修改为服务器外网的ip地址或者修改为0.0.0.0。
如此,也便带来了新的问题:
任意客户端都可以连接到我们的server了,这个“任意”可不止我们自己认为有效的客户端,还包括你的我的所有的非有效或者恶意的连接,这可不是我们想要的。
如何避免这一问题呢?方法有很多种,比如我们可以在连接的时候认为只有get传递的参数valid=1才允许连接;或者我们只允许登录用户才可以连接server;再或者我们可以校验客户端每次send所携带的token,server对该值校验通过后才认为当前是有效连接等等。与此同时,server开启心跳检测,对于恶意无效的连接,直接干掉!
上面简单的介绍了一些解决方案,下面我们以client 连接server时携带token为例做一个实际说明。
首先我们只允许登录用户才可以连接server,假设某用户的唯一标识uid=100,token的生成规则我们约定如下:token=md5(md5(uid)+key),其中key=客户端和服务端双方约定的某个字符串,我们这里假设key="^www.lulublog&swoole$",不包括双引号。
server的代码实现如下
<?php
class WebSocketServerValid
{private $_serv;public $key = "^www.lulublog&swoole$";public function __construct(){$this->_serv = new swoole_websocket_server("0.0.0.0", 9501);$this->_serv->set(["worker_num" => 1,"heartbeat_check_interval" => 30,"heartbeat_idle_time" => 62,]);$this->_serv->on("open", [$this, "onOpen"]);$this->_serv->on("message", [$this, "onMessage"]);$this->_serv->on("close", [$this, "onClose"]);}/*** @param $serv* @param $request*/public function onOpen($serv, $request){$this->checkAccess($serv, $request);}/*** @param $serv* @param $frame*/public function onMessage($serv, $frame){$this->_serv->push($frame->fd, "Server: " . $frame->data);}public function onClose($serv, $fd){echo "client {$fd} closed.\n";}/*** 校验客户端连接的合法性,无效的连接不允许连接* @param $serv* @param $request* @return mixed*/public function checkAccess($serv, $request){// get不存在或者uid和token有一项不存在,关闭当前连接if (!isset($request->get) || !isset($request->get["uid"]) || !isset($request->get["token"])) {$this->_serv->close($request->fd);return false;}$uid = $request->get["uid"];$token = $request->get["token"];// 校验token是否正确,无效关闭连接if (md5(md5($uid) . $this->key) != $token) {$this->_serv->close($request->fd);return false;}}public function start(){$this->_serv->start();}
}
$server = new WebSocketServerValid;
$server->start();
可以看到,checkAccess是授权方法,我们在onOpen回调内对uid以及token进行了校验,无效则关闭连接。
为了模拟效果,我们分别贴上两种客户端代码,连接失败和连接成功
连接失败的主要jsdiamante如下
var ws = new WebSocket("ws://139.199.201.210:9501");
ws.onopen = function(event) {ws.send("This is websocket client.");
};
ws.onmessage = function(event) {console.log(event.data);
};
ws.onclose = function(event) {console.log("Client has closed.\n");
};
无论是console控制台还是server终端我们都可以看到客户端连接被关闭的提醒。下面我们再看模拟一种成功的结果
php代码和js代码如下
<?php
$key = "^www.lulublog&swoole$";
$uid = 100;
$token = md5(md5($uid) . $key);
?>
<script>
var ws = new WebSocket("ws://139.199.201.210:9501?uid=<?php echo $uid; ?>&token=<?php echo $token; ?>");
ws.onopen = function(event) {ws.send("This is websocket client.");
};
ws.onmessage = function(event) {console.log(event.data);
};
ws.onclose = function(event) {console.log("Client has closed.\n");
};
</script>
可以看到,这次连接没有被关闭且console控制台会正常输出一些信息
Server: This is websocket client.
即我们完成了校验连接有效性的案例,下面我们接着看最后一个问题
④、客户端重连机制
有同学注意到,我们刚刚设置的心跳检测时间是30秒,如果客户端62秒内没有与server通信,server会关闭该连接,即部分人在上述success案例中的console控制台上会看到Client has closed.的提醒。这是我们设置的机制,属于正常现象。
那我们要说的重连机制又是什么呢?
客户端重连机制又可以理解为一种保活机制,你也可以跟服务端的心跳检测在一起理解为双向心跳。即我们有一种需求是,如何能保证客户端和服务端的连接一直是有效的,不断开的。
其实很简单,对客户端而言,只要触发error或者close再或者连接失败,就主动重连server,这便是我们的目的。
下面贴一段代码,来解决这个问题
<script>var ws; //websocket实例var lockReconnect = false; //避免重复连接var wsUrl = "ws://127.0.0.1:9501";function createWebSocket(url) {try {ws = new WebSocket(url);initEventHandle();} catch (e) {reconnect(url);}}function initEventHandle() {ws.onclose = function() {reconnect(wsUrl);};ws.onerror = function() {reconnect(wsUrl);};ws.onopen = function() {//心跳检测重置heartCheck.reset().start();};ws.onmessage = function(event) {//如果获取到消息,心跳检测重置//拿到任何消息都说明当前连接是正常的heartCheck.reset().start();}}function reconnect(url) {if (lockReconnect) return;lockReconnect = true;//没连接上会一直重连,设置延迟避免请求过多setTimeout(function() {createWebSocket(url);lockReconnect = false;}, 2000);}//心跳检测var heartCheck = {timeout: 60000, //60秒timeoutObj: null,serverTimeoutObj: null,reset: function() {clearTimeout(this.timeoutObj);clearTimeout(this.serverTimeoutObj);return this;},start: function() {var self = this;this.timeoutObj = setTimeout(function() {//这里发送一个心跳,后端收到后,返回一个心跳消息,//onmessage拿到返回的心跳就说明连接正常ws.send("");self.serverTimeoutObj = setTimeout(function() { //如果超过一定时间还没重置,说明后端主动断开了ws.close(); //如果onclose会执行reconnect,我们执行ws.close()就行了.如果直接执行reconnect 会触发onclose导致重连两次}, self.timeout);}, this.timeout);}}createWebSocket(wsUrl);
</script>
在这种情况下,你可以尝试把server中断或者断网试试,结果是client会不停的每隔一定时间尝试连接server,直至连接成功。
完整代码
3.4、websocket通知案例以及多端口复合协议的使用
①、引言
最初是打算写个聊天室分享给大家,后来仔细斟酌了一下,还是讲个web通知吧,两个案例都差不多。
当然,在前面两篇介绍websocket的基础之上,相信你一定会觉得web通知这个功能就是一个小case。所以本文我们把重点放在后面多端口复合协议的使用。
websocket通知的实现方式,基本上跟websocket初识一文中最后介绍的案例差不多,只不过我们当时是循环所有的客户端推送消息,此时我们是一对一推送提醒。
②、需求分析
我们以评论被回复为例,当一条评论被其他某个用户(假设是用户B)回复,即发一条通知给被回复的评论所属人(假设是用户A),告诉A,他的评论被回复了。
③、功能分析
-
我们不能保证用户B和用户A都处于连接状态,但是通常情况下,用户B至少是连接状态,用户A不一定跟server保持连接;
-
任一用户都不止对应一个客户端。换言之,用户A和用户B都可能打开了多个tab页,对于一个tab页,就会有一个独立的fd标识,我们这里认为任一用户只有最新的fd有效,或者你可以认为用户所有的tab页的连接都有效;
-
因为没有用户系统,我们以get传递的参数uid为标识,uid=100视为用户A,uid=101视为用户B;
-
我们不准备做一个评论系统,我们模拟的tab页包将会包含一个输入内容的文本框、一个输入目标uid的input和一个发送的按钮以满足需求。
④、流程分析
-
用户A($_GET['uid'] = 100)在某个tab页的输入框输入"回复xxx的内容"字样后,点击发送
-
用户B($_GET['uid'] = 101)如果处于连接状态,则alert提醒用户B,他的评论被回复了
⑤、server 端代码
分析了半天,我们看server端代码的实现
<?php
class CommentServer
{private $_serv;public $key = "^www.lulublog&swoole$";// 用户id和fd对应的映射,key => value,key是用户的uid,value是用户的fdpublic $user2fd = [];public function __construct(){$this->_serv = new swoole_websocket_server("0.0.0.0", 9501);$this->_serv->set(["worker_num" => 1,"heartbeat_check_interval" => 60,"heartbeat_idle_time" => 125,]);$this->_serv->on("open", [$this, "onOpen"]);$this->_serv->on("message", [$this, "onMessage"]);$this->_serv->on("close", [$this, "onClose"]);}/*** @param $serv* @param $request* @return mixed*/public function onOpen($serv, $request){// 连接授权$accessResult = $this->checkAccess($serv, $request);if (!$accessResult) {return false;}// 始终把用户最新的fd跟uid映射在一起if (array_key_exists($request->get["uid"], $this->user2fd)) {$existFd = $this->user2fd[$request->get["uid"]];$this->close($existFd, "uid exists.");$this->user2fd[$request->get["uid"]] = $request->fd;return false;} else {$this->user2fd[$request->get["uid"]] = $request->fd;}}/*** @param $serv* @param $frame* @return mixed*/public function onMessage($serv, $frame){// 校验数据的有效性,我们认为数据被`json_decode`处理之后是数组并且数组的`event`项非空才是有效数据// 非有效数据,关闭该连接$data = $frame->data;$data = json_decode($data, true);if (!$data || !is_array($data) || empty($data["event"])) {$this->close($frame->fd, "data format invalidate.");return false;}// 根据数据的`event`项,判断要做什么,`event`映射到当前类具体的某一个方法,方法不存在则关闭连接$method = $data["event"];if (!method_exists($this, $method)) {$this->close($frame->fd, "event is not exists.");return false;}$this->$method($frame->fd, $data);}public function onClose($serv, $fd){echo "client {$fd} closed.\n";}/*** 校验客户端连接的合法性,无效的连接不允许连接* @param $serv* @param $request* @return mixed*/public function checkAccess($serv, $request){// get不存在或者uid和token有一项不存在,关闭当前连接if (!isset($request->get) || !isset($request->get["uid"]) || !isset($request->get["token"])) {$this->close($request->fd, "access faild.");return false;}$uid = $request->get["uid"];$token = $request->get["token"];// 校验token是否正确,无效关闭连接if (md5(md5($uid) . $this->key) != $token) {$this->close($request->fd, "token invalidate.");return false;}return true;}/*** @param $fd* @param $message* 关闭$fd的连接,并删除该用户的映射*/public function close($fd, $message = ""){// 关闭连接$this->_serv->close($fd);// 删除映射关系if ($uid = array_search($fd, $this->user2fd)) {unset($this->user2fd[$uid]);}}public function alertTip($fd, $data){// 推送目标用户的uid非真或者该uid尚无保存的映射fd,关闭连接if (empty($data["toUid"]) || !array_key_exists($data["toUid"], $this->user2fd)) {$this->close($fd);return false;}$this->push($this->user2fd[$data["toUid"]], ["event" => $data["event"], "msg" => "收到一条新的回复."]);}/*** @param $fd* @param $message*/public function push($fd, $message){if (!is_array($message)) {$message = [$message];}$message = json_encode($message);// push失败,closeif ($this->_serv->push($fd, $message) == false) {$this->close($fd);}}public function start(){$this->_serv->start();}
}
$server = new CommentServer;
$server->start();
满眼看下来,代码挺长的,没关系,我们整理了一下代码的逻辑
-
我们给CommentServer类增加了一个属性 $user2fd,这个是key => value结构,用于保存uid和fd的映射关系
-
onOpen回调做两件事,验证授权和添加新的映射关系
-
onMessage回调只接收含有event项的数组,event等同于CommentServer类的方法名,我们这里只有一个用于web通知的alertTip方法
-
此外,我们封装了该类的close方法和push方法,close方法用于server主动关闭连接,删除uid和fd的映射,push方法用于向指定的fd推送消息
⑥、客户端代码
<!--?php--><p><?php
$key = "^www.lulublog&swoole$";
$uid = isset($_GET["uid"]) ? intval($_GET["uid"]) : 0;
$token = md5(md5($uid) . $key);
?><div>发送内容:<textarea name="content" id="content" cols="30" rows="10"></textarea><br>发送给谁:<input type="text" name="toUid" value="" id="toUid"><br><button onclick="send();">发送</button>
</div><script>var ws = new WebSocket("ws://139.199.201.210:9501?uid=<?php echo $uid ?>&token=<?php echo $token; ?>");ws.onopen = function(event) {};ws.onmessage = function(event) {var data = event.data;data = eval("("+data+")");if (data.event == "alertTip") {alert(data.msg);}};ws.onclose = function(event) {console.log("Client has closed.\n");};function send() {var obj = document.getElementById("content");var content = obj.value;var toUid = document.getElementById("toUid").value;ws.send("{"event":"alertTip", "toUid": "+toUid+"}");}
</script>
</p><!--?php-->
server开启之后,演示的效果我们看下动图
结果中,注意看地址栏,alert弹窗是在哪个tab页弹出的。
⑦、多端口复合协议:server与server之间的交互
上例中,我们模拟的是评论被回复的简单例子。
回顾过去讲的内容,无论是tcp server,http server还是websocket server,server都是独立的,server与server之间并没有太多的交互。
实际上有没有交互的必要呢?
假设现在有这么一个需求,在刚刚评论的案例中,前文用户的回复不是直接发送给被回复的用户,而是评论在后台被人审核成功的一瞬间,再通知被回复的用户呢?
审核操作改为ajax操作,success回调内再new一个websocket客户端,然后send?可以,但是这显然不是一个很好的操作。
在websocket初识的时候我们说过,要想与websocket server通信,客户端只能是websocket客户端!既然我们刚刚否决了new一个websocket客户端,那是要怎么做呢?
从程序的角度出发,如果我们在php的层面上直接就能通知到websocket服务器,换言之,如果我们能够从php的层面上,直接实现alertTip方法的功能是不是就对了?
前文我们介绍tcp server的时候了解到,首先我们要想让web应用同server进行“互撩”,swoole_client少不了,既然有swoole_client,swoole_server肯定也少不了。但是目前server正在跑websocket,难不成我们在单独跑一个tcp server?对,我们就是要在websocket server的基础之上,想办法再跑一个tcp server。
为了使用多端口复合协议,swoole为server提供了listen方法,可以让当前server监听新的端口。
比如我们可以让刚刚创建的websocket server额外监听9502端口,这个端口主要负责tcp的工作。
$this->_tcp = $this->_serv->listen("127.0.0.1", 9502, SWOOLE_SOCK_TCP);
$this->_tcp->set(["open_eof_check" => true, //打开EOF检测"package_eof" => "\r\n", //设置EOF"open_eof_split" => true, // 自动分包
]);
$this->_tcp->on("Receive", [$this, "onReceive"]);
listen函数返回的是swoole_server_port对象,需要注意的是swoole_server_port的set函数只能设置一些特定的参数,比如socket参数、协议相关等,像worker_num、log_file、max_request等等这些都是不支持的。就tcp服务器而言,swoole_server_port对象也仅仅对onConnect\onReceive\onClose这三个回调支持,其他的一律不可用,详细可翻阅swoole手册查看。
下面我们就以评论审核通知来看看多端口复合协议的玩法。
再来看下我们现在的流程
-
用户回复某评论 => 评论进入审核状态 ;很明显这个过程我们不需要做什么
-
管理员审核该评论 => 通知被回复的人;这个时候我们要做的就等同于alertTip函数要做的
server端除了刚刚设置的$this->_tcp一段代码之外,我们单独绑定了onReceive回调,下面看onReceive回调的实现
public function onReceive($serv, $fd, $fromId, $data)
{try {$data = json_decode($data, true);if (!isset($data["event"])) {throw new \Exception("params error, needs event param.", 1);}$method = $data["event"];// 调起对应的方法if(!method_exists($this, $method)) {throw new \Exception("params error, not support method.", 1);}$this->$method($fd, $data);return true;} catch (\Exception $e) {$msg = $e->getMessage();throw new \Exception("{$msg}", 1);}
}
可以看到,除了进行简单的判断之外,如果tcp客户单携带一个event=alertTip即可
在这之前,websocket客户端的代码我们依然以前面的为例,假设要回复的用户uid=100,我们运行server之后,先让uid=100的客户端连接到server,运行的客户端地址栏添加uid参数等于100即可
下面我们再写一个tcp client,连接9502端口,我们的tcp server在这个端口监听
<?phpclass Client
{private $client;public function __construct (){$this->client = new Swoole\Client(SWOOLE_SOCK_TCP);if (!$this->client->connect("127.0.0.1", 9502)) {$msg = "swoole client connect failed.";throw new \Exception("Error: {$msg}.");}}/*** @param $data Array* send data*/public function sendData ($data){$data = $this->togetherDataByEof($data);$this->client->send($data);}/*** 数据末尾拼接EOF标记* @param Array $data 要处理的数据* @return String json_encode($data) . EOF*/public function togetherDataByEof($data){if (!is_array($data)) {return false;}return json_encode($data) . "\r\n";}
}$client = new Client;
$client->sendData(["event" => "alertTip","toUid" => 100,
]);
现在无论是websocket服务器、tcp 服务器还是websocket客户端 tcp客户端都已经准备就绪了,下面我们浏览器直接访问下tcp client,如果正常的话,websocket客户端所在页面会弹出有新回复的通知。
看动图运行结果