⽹页实时聊天之PHP实现websocket
前⾔
websocket 作为 HTML5 ⾥⼀个新的特性⼀直很受⼈关注,因为它真的⾮常酷,打破了 http “请求-响应”的常规思维,实现了服务器向客户端主动推送消息,本⽂介绍如何使⽤ PHP 和 JS 应⽤ websocket 实现⼀个⽹页实时聊天室;
以前写过⼀篇⽂章讲述如何使⽤ajax长轮询实现⽹页实时聊天,见链接:,但是轮询和服务器的 pending 都是⽆谓的消耗,websocket 才是新的趋势。
最近艰难地“挤”出了⼀点时间,完善了很早之前做的 websocket “请求-原样返回”服务器,⽤js完善了下客户端功能,把过程和思路分享给⼤家,顺便也普及⼀下websocket 相关的知识,当然现在讨论 websocket 的⽂章也特别多,有些理论性的东西我也就略过了,给出参考⽂章供⼤家选择阅读。
正⽂开始前,先贴⼀张聊天室的效果图(请不要在意CSS渣的页⾯):
然后当然是源码:
websocket
简介
WebSocket 不是⼀门技术,⽽是⼀种全新的协议。它应⽤ TCP 的 Socket(套接字),为⽹络应⽤定义了⼀个新的重要的能⼒:客户端和服务器端的双全⼯传输和双向通信。是继 Java applets、 XMLHttpRequest、 Adobe Flash,、ActiveXObject、各类 Comet 技术之后,服务器推送客户端消息的新趋势。
与http的关系
在⽹络分层上,websocket 与 http 协议都是应⽤层的协议,它们都是基于 tcp 传输层的,但是 websocket 在建⽴连接时,是借⽤ http 的 101 switch protocol 来达到协议转换(Upgrade)的,从 HTTP 协议切换成 WebSocket 通信协议,这个动作协议中称“握⼿”;
握⼿成功后,websocket 就使⽤⾃⼰的协议规定的⽅式进⾏通讯,跟 http 就没有关系了。
握⼿
以下是⼀个我⾃⼰的浏览器发送的典型的握⼿ http 头:
服务器收到握⼿请求后,提取出请求头中的 “Sec-WebSocket-Key” 字段,追回⼀个固定的字符串 ‘258
EAFA5-E914-47DA-95CA-C5AB0DC85B11’,然后进⾏sha1 加密,最后转换为 base64 编码,作为 key 以 “Sec-WebSocket-Accept” 字段返回给客户端,客户端匹配此 key 后,便建⽴了连接,完成了握⼿;
数据传输
websocket 有⾃⼰规定的数据传输格式,称为帧(Frame),下图是⼀个数据帧的结构,其中单位为bit:
0 1 2 3
0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1
+-+-+-+-+-------+-+-------------+-------------------------------+
|F|R|R|R| opcode|M| Payload len | Extended payload length |
|I|S|S|S| (4) |A| (7) | (16/64) |
|N|V|V|V| |S| | (if payload len==126/127) |
| |1|2|3| |K| | |
+-+-+-+-+-------+-+-------------+ - - - - - - - - - - - - - - - +
| Extended payload length continued, if payload len == 127 |
+ - - - - - - - - - - - - - - - +-------------------------------+
| |Masking-key, if MASK set to 1 |
+-------------------------------+-------------------------------+
| Masking-key (continued) | Payload Data |
+-------------------------------- - - - - - - - - - - - - - - - +
: Payload Data continued ... :
+ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - +
| Payload Data continued ... |
+---------------------------------------------------------------+
具体每个字段是什么意思,有兴趣的可以看⼀下这篇⽂章感觉⾃⼰对⼆进制的操作还不是很灵活,也就没有挑战⾃⼰写算法解析数据了,下⾯的数据帧解析和封装都是使⽤的⽹上的算法。
不过,我⼯作中写⽀付⽹关中还是会经常⽤到数据的进制操作的,这个⼀定是要仔细研究总结⼀下的,嗯,先记下。
PHP 实现 websocket 服务器
PHP 实现 websocket 的话,主要是应⽤ PHP 的 socket 函数库:
PHP 的 socket 函数库跟 C 语⾔的 socket 函数⾮常类似,以前翻过⼀遍 APUE, 所以觉得还挺好理解。在 PHP ⼿册中看⼀遍 socket 函数,我想⼤家也能对
php 的 socket 编程有⼀定的认识。
下⾯会在代码中对所⽤函数进⾏简单的注释。
⽂件描述符
忽然提及'⽂件描述符',⼤家可能会有些奇怪。
但作为服务器,是必须要对已经连接的 socket 进⾏存储和识别的。每⼀个 socket 代表⼀个⽤户,如何关联和查询⽤户信息与 socket 的对应就是⼀个问题了,
这⾥便应⽤了关于⽂件描述符的⼀点⼩技巧。
我们知道 linux 是'万物皆⽂件'的,C 语⾔的 socket 的实现便是⼀个个的’⽂件描述符‘ ,这个⽂件描述符⼀般是打开⽂件的顺序递增的 int 数值,从 0 ⼀直递增
(当然系统是有限制的)。每⼀个 socket 都对应⼀个⽂件,读写 socket 都是操作对应的⽂件,所以也能像⽂件系统⼀样应⽤ read 和 write 函数。
tips: linux 中,标准输⼊对应的是⽂件描述符 0;标准输出对应的⽂件描述符是 1;标准错误对应的⽂件描述符是 2;所以我们可以使⽤ 0 1 2对输⼊输出重定
向。
那么类似于 C socket 的 PHP socket ⾃然也继承了这⼀点,它创建的 socket 也是类型于 int 值为 4 5 之类的资源类型。我们可以使⽤ (int) 或 intval() 函数把
socket 转换为⼀个唯⼀的ID,从⽽可以实现⽤⼀个 ’类索引数组‘ 来存储 socket 资源和对应的⽤户信息;
结果类似:
$connected_sockets = array(
(int)$socket => array(
'resource' => $socket,
'name' => $name,
'ip' => $ip,
'port' => $port,
...
)
)
创建服务器socket
下⾯是⼀段创建服务器 socket 的代码:
// 创建⼀个 TCP socket, 此函数的可选值在官⽅⽂档中写得⼗分详细,这⾥不再提了
$this->master = socket_create(AF_INET, SOCK_STREAM, SOL_TCP);
// 设置IP和端⼝重⽤,在重启服务器后能重新使⽤此端⼝;
socket_set_option($this->master, SOL_SOCKET, SO_REUSEADDR, 1);
// 将IP和端⼝绑定在服务器socket上;
socket_bind($this->master, $host, $port);
// listen函数使主动连接套接⼝变为被连接套接⼝,使得此 socket 能被其他 socket 访问,从⽽实现服务器功能。后⾯的参数则是⾃定义的待处理socket的最⼤数⽬,并发⾼的情况下,这个值可以设置⼤socket_listen($this->master, self::LISTEN_SOCKET_NUM);
这样,我们就得到⼀个服务器 socket,当有客户端连接到此 socket 上时,它将改变状态为可读,那就
看接下来服务器的处理逻辑了。
服务器逻辑
这⾥着重讲⼀下socket_select($read, $write, $except, $tv_sec [, $tv_usec]):
select 函数使⽤传统的 select 模型,可读、写、异常的 socket 会被分别放⼊ $socket, $write, $except 数组中,然后返回状态改变的 socket 的数⽬,如果发⽣
了错误,函数将会返回 false.
需要注意的是最后两个时间参数,它们只有单位不同,可以搭配使⽤,⽤来表⽰ socket_select 阻塞的时长,为0时此函数⽴即返回,可以⽤于轮询机制。为socket编程聊天室基本流程
NULL 时,函数会⼀直阻塞下去, 这⾥我们置 $tv_sec 参数为null,让它⼀直阻塞,直到有可操作的 socket 返回。
下⾯是服务器的主要逻辑:
$write = $except = NULL;
$sockets = array_column($this->sockets, 'resource'); // 获取到全部的 socket 资源
$read_num = socket_select($sockets, $write, $except, NULL);
foreach ($sockets as $socket) {
// 如果可读的是服务器 socket, 则处理连接逻辑;
if ($socket == $this->master) {
socket_accept($this->master);
// socket_accept() 接受请求 “正在 listen 的 socket(像我们的服务器 socket )” 的连接, 并⼀个客户端 socket, 错误时返回 false;
self::connect($client);
continue;
}
// 如果可读的是其他已连接 socket ,则读取其数据,并处理应答逻辑
} else {
// 函数 socket_recv() 从 socket 中接受长度为 len 字节的数据,并保存在 $buffer 中。
$bytes = @socket_recv($socket, $buffer, 2048, 0);
if ($bytes < 9) {
// 当客户端忽然中断时,服务器会接收到⼀个 8 字节长度的消息(由于其数据帧机制,8字节的消息我们认为它是客户端异常中断消息),服务器处理下线逻辑,并将其封装为消息⼴播出去 $recv_msg = $this->disconnect($socket);
} else {
// 如果此客户端还未握⼿,执⾏握⼿逻辑
if (!$this->sockets[(int)$socket]['handshake']) {
self::handShake($socket, $buffer);
continue;
} else {
$recv_msg = self::parse($buffer);
}
}
// ⼴播消息
$this->broadcast($msg);
}
}
}
这⾥只是服务器处理消息的基础代码,⽇志记录和异常处理都略过了,⽽且还有些数据帧解析和封装的⽅法,各位也不⼀定看爱,有兴趣的可以去 github 上⽀
持⼀下我的源码~~
此外,为了便于服务器与客户端的交互,我⾃⼰定义了 json 类型的消息格式,形似:
$msg = [
'type' => $msg_type, // 有普通消息,上下线消息,服务器消息
'from' => $msg_resource, // 消息来源
'content' => $msg_content, // 消息内容
'user_list' => $uname_list, // 便于同步当前在线⼈数与姓名
];
客户端
创建客户端
前端我们使⽤ js 调⽤ Websocket ⽅法很简单就能创建⼀个 websocket 连接,服务器会为帮我们完成连接、握⼿的操作,js 使⽤事件机制来处理浏览器与服务
器的交互:
// 创建⼀个 websocket 连接
var ws = new WebSocket("ws://127.0.0.1:8080");
// websocket 创建成功事件
};
// websocket 接收到消息事件
var msg = JSON.parse(e.data);
}
// websocket 错误事件
};
发送消息也很简单,直接调⽤ws.send(msg)⽅法就⾏了。
页⾯功能
页⾯部分主要是让⽤户使⽤起来⽅便,这⾥给消息框 textarea 添加了⼀个键盘监控事件,当⽤户按下回车键时直接发送消息;
function confirm(event) {
var key_num = event.keyCode;
if (13 == key_num) {
send();
} else {
return false;
}
}
还有⽤户打开客户端时⽣成⼀个默认唯⼀⽤户名;
然后是⼀些对数据的解析构造,对客户端页⾯的更新,这⾥就不再啰嗦了,感兴趣的可以看源码。
⽤户名异步处理
这⾥不得不提⼀下⽤户登陆时确定⽤户名时的⼀个⼩问题,我原来是想在客户端创建⼀个连接后直接发送⽤户名到服务器,可是控制台⾥报出了 “websocket 仍在连接中或已关闭” 的错误信息。
Uncaught DOMException: Failed to execute 'send' on 'WebSocket': Still in CONNECTING state.
考虑到连接可能还没处理好,我就实现了 sleep ⽅法等了⼀秒再发送⽤户名,可是错误仍然存在。
后来忽然想到 js 的单线程阻塞机制,才明⽩使⽤ sleep ⼀直阻塞也是没有⽤的,利⽤好 js 的事件机制才是正道:于是在服务器端添加逻辑,在握⼿成功后,向客户端发送握⼿已成功的消息;客户端先将⽤户名存⼊⼀个全局变量,接收到服务器的握⼿成功的提醒消息后再发送⽤户名,于是成功在第⼀时间更新⽤户名。
⼩结
聊天室扩展⽅向
简易聊天室已经完成,当然还要给它带有希望的美好未来,希望有⼈去实现:
页⾯美化(信息添加颜⾊等)
服务器识别 '@' 字符⽽只向某⼀个 socket 写数据实现聊天室的私聊;
多进程(使⽤ redis 等缓存数据库来实现资源的共享),可参考我以前的⼀篇⽂章:
消息记录数据库持久化(log ⽇志还是不⽅便分析)
...
总结
多读些经典书籍还是很有⽤的,有些东西真的是触类旁通,APUE/UNP 还是要再多翻⼏遍。此外互联⽹技术⽇新⽉异,挑⼀些⾃⼰喜欢的学习⼀下,跟⼤家分享⼀下也是挺舒服的(虽然程序和博客加⼀块⽤了⾄少10个⼩时...)。
参考:
嗯,持续更新。喜欢的可以点个推荐或关注,有错漏之处,请指正,谢谢。
版权声明:本站内容均来自互联网,仅供演示用,请勿用于商业和其他非法用途。如果侵犯了您的权益请与我们联系QQ:729038198,我们将在24小时内删除。
发表评论