前言
websocket 作为 HTML5 里一个新的特性一直很受人关注,因为它真的非常酷,打破了 http “请求-响应”的常规思维,实现了服务器向客户端主动推送消息,本文介绍如何使用 PHP 和 JS 应用 websocket 实现一个网页实时聊天室;
以前写过一篇文章讲述如何使用ajax长轮询实现网页实时聊天,见链接: 网页实时聊天之js和jQuery实现ajax长轮询 ,但是轮询和服务器的 pending 都是无谓的消耗,websocket 才是新的趋势。
最近艰难地“挤”出了一点时间,完善了很早之前做的 websocket “请求-原样返回”服务器,用js完善了下客户端功能,把过程和思路分享给大家,顺便也普及一下 websocket 相关的知识,当然现在讨论 websocket 的文章也特别多,有些理论性的东西我也就略过了,给出参考文章供大家选择阅读。
正文开始前,先贴一张聊天室的效果图(请不要在意CSS渣的页面):
然后当然是源码: 我是源码链接 - github - 枕边书
WebSocket is not a technology, but a brand new protocol. It uses TCP's Socket (socket) and defines a new important capability for network applications: full-duplex transmission and two-way communication between the client and the server. It is a new trend for servers to push client messages after Java applets, XMLHttpRequest, Adobe Flash, ActiveXObject, and various Comet technologies.
In terms of network layering, websocket and http protocols are both application layer protocols. They are both based on the tcp transport layer. However, when websocket establishes a connection, it borrows the 101 switch protocol of http to achieve protocol conversion (Upgrade). Switch from HTTP protocol to WebSocket communication protocol. This action protocol is called "handshake";
After the handshake is successful, websocket uses the method specified by its own protocol to communicate, and has nothing to do with http.
Here is a typical handshake http header sent by my own browser:
After the server receives the handshake request, it extracts the "Sec-WebSocket-Key" field in the request header, recovers a fixed string '258EAFA5-E914-47DA-95CA-C5AB0DC85B11', then performs sha1 encryption, and finally converts it to Base64 encoding, used as the key and returned to the client in the "Sec-WebSocket-Accept" field. After the client matches this key, the connection is established and the handshake is completed;
Websocket has its own specified data transmission format, called Frame. The following figure is the structure of a data frame, where the unit is bit:
<code class="language-none"> 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 ... | +---------------------------------------------------------------+</code>
For the specific meaning of each field, if you are interested, you can read this article The WebSocket Protocol 5. The data frame feels that it is not very flexible in binary operations, so there is no challenge to write an algorithm to parse the data. The following data Frame parsing and encapsulation are both online algorithms used.
However, in my work when writing payment gateways, data hexadecimal operations are often used. This must be carefully studied and summarized. Well, write it down first.
If PHP implements websocket, it mainly uses PHP’s socket function library:
PHP’s socket function library is very similar to the socket function in C language. I have read APUE once before, so I think it is quite easy to understand. After reading the socket function in the PHP manual, I think everyone can also have a certain understanding of socket programming in PHP.
The functions used will be briefly commented in the code below.
You may be a little surprised by the sudden mention of 'file descriptor'.
But as a server, it is necessary to store and identify the connected socket. Each socket represents a user. How to associate and query the correspondence between user information and socket is a problem. Here a little trick about file descriptors is applied.
We know that Linux is 'everything is a file', and the socket implementation in C language is a 'file descriptor'. This file descriptor is generally an int value that increases in the order in which the file is opened, increasing from 0 (of course The system has limitations). Each socket corresponds to a file, and reading and writing sockets operate on the corresponding file, so the read and write functions can also be applied like a file system.
tips: In Linux, standard input corresponds to file descriptor 0; standard output corresponds to file descriptor 1; standard error corresponds to file descriptor 2; so we can use 0 1 2 to redirect input and output.
Then PHP sockets similar to C sockets naturally inherit this, and the sockets they create are also of type resource types such as int with a value of 4 5. We can use the (int) or intval() function to convert the socket into a unique ID, so that a 'class index array' can be used to store socket resources and corresponding user information;
The result is similar:
<code class="language-none">$connected_sockets = array( (int)$socket => array( 'resource' => $socket, 'name' => $name, 'ip' => $ip, 'port' => $port, ... ) )</code>
The following is a piece of code to create a server socket:
<code class="language-none">// 创建一个 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);</code>
这样,我们就得到一个服务器 socket,当有客户端连接到此 socket 上时,它将改变状态为可读,那就看接下来服务器的处理逻辑了。
这里着重讲一下 socket_select($read, $write, $except, $tv_sec [, $tv_usec])
:
select 函数使用传统的 select 模型,可读、写、异常的 socket 会被分别放入 $socket, $write, $except 数组中,然后返回 状态改变的 socket 的数目,如果发生了错误,函数将会返回 false.
需要注意的是最后两个时间参数,它们只有单位不同,可以搭配使用,用来表示 socket_select 阻塞的时长,为0时此函数立即返回,可以用于轮询机制。 为 NULL 时,函数会一直阻塞下去, 这里我们置 $tv_sec 参数为null,让它一直阻塞,直到有可操作的 socket 返回。
下面是服务器的主要逻辑:
<code class="language-none">$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); } } }</code>
这里只是服务器处理消息的基础代码,日志记录和异常处理都略过了,而且还有些数据帧解析和封装的方法,各位也不一定看爱,有兴趣的可以去 github 上支持一下我的源码~~
此外,为了便于服务器与客户端的交互,我自己定义了 json 类型的消息格式,形似:
<code class="language-none">$msg = [ 'type' => $msg_type, // 有普通消息,上下线消息,服务器消息 'from' => $msg_resource, // 消息来源 'content' => $msg_content, // 消息内容 'user_list' => $uname_list, // 便于同步当前在线人数与姓名 ];</code>
前端我们使用 js 调用 Websocket 方法很简单就能创建一个 websocket 连接,服务器会为帮我们完成连接、握手的操作,js 使用事件机制来处理浏览器与服务器的交互:
<code class="language-none">// 创建一个 websocket 连接 var ws = new WebSocket("ws://127.0.0.1:8080"); // websocket 创建成功事件 ws.onopen = function () { }; // websocket 接收到消息事件 ws.onmessage = function (e) { var msg = JSON.parse(e.data); } // websocket 错误事件 ws.onerror = function () { };</code>
发送消息也很简单,直接调用 ws.send(msg)
方法就行了。
页面部分主要是让用户使用起来方便,这里给消息框 textarea 添加了一个键盘监控事件,当用户按下回车键时直接发送消息;
<code class="language-none">function confirm(event) { var key_num = event.keyCode; if (13 == key_num) { send(); } else { return false; } }</code>
还有用户打开客户端时生成一个默认唯一用户名;
然后是一些对数据的解析构造,对客户端页面的更新,这里就不再啰嗦了,感兴趣的可以看源码。
这里不得不提一下用户登陆时确定用户名时的一个小问题,我原来是想在客户端创建一个连接后直接发送用户名到服务器,可是控制台里报出了 “websocket 仍在连接中或已关闭” 的错误信息。
Uncaught DOMException: Failed to execute 'send' on 'WebSocket': Still in CONNECTING state.
考虑到连接可能还没处理好,我就实现了 sleep 方法等了一秒再发送用户名,可是错误仍然存在。
后来忽然想到 js 的单线程阻塞机制,才明白使用 sleep 一直阻塞也是没有用的,利用好 js 的事件机制才是正道:于是在服务器端添加逻辑,在握手成功后,向客户端发送握手已成功的消息;客户端先将用户名存入一个全局变量,接收到服务器的握手成功的提醒消息后再发送用户名,于是成功在第一时间更新用户名。
简易聊天室已经完成,当然还要给它带有希望的美好未来,希望有人去实现:
多读些经典书籍还是很有用的,有些东西真的是触类旁通,APUE/UNP 还是要再多翻几遍。此外互联网技术日新月异,挑一些自己喜欢的学习一下,跟大家分享一下也是挺舒服的(虽然程序和博客加一块用了至少10个小时...)。
参考:
websocket协议翻译
刨根问底 HTTP 和 WebSocket 协议(下)
学习WebSocket协议—从顶层到底层的实现原理(修订版)
嗯,持续更新。喜欢的可以点个推荐或关注,有错漏之处,请指正,谢谢。