首頁 >php教程 >php手册 >網頁即時聊天之PHP實現websocket

網頁即時聊天之PHP實現websocket

WBOY
WBOY原創
2016-11-30 23:59:331598瀏覽

前言

websocket作為HTML5裡的一個新的一直很受人們關注,因為它真的非常酷,打破了http“請求響應”的慣常思維,實現了服務器向客戶端主動積極的消息,本文介紹瞭如何使用PHP和JS應用websocket實現一個網頁即時聊天室;

之前寫過一篇文章講述如何使用ajax長輪詢實現網頁實時聊天,見鏈接:網頁實時聊天之js和jQuery實現ajax長輪詢,但是輪詢和服務器的pending都是無所謂的消耗,websocket才是新趨勢。

最近艱難地「擠」了一點時間,完善了很早就做的websocket「請求原樣返回」伺服器,用js完善了下客戶端功能,把過程和思路分享給大家,順便也普及一下websocket相關的知識,當然現在討論websocket的文章也特別多,有些理論性的東西我也略過了,給出參考文章供大家選擇閱讀。

正文開始前,先貼一張聊天室的效果圖(請不要在意CSS渣的頁面):

然後當然是源碼: 我是連結 - github - 枕邊書


websocket

簡介

WebSocket 不是一門技術,而是一種全新的協定。它應用 TCP 的 Socket(套接字),為網路應用定義了一個新的重要的能力:客戶端和伺服器端的雙全工傳輸和雙向通訊。是繼 Java applets、 XMLHttpRequest、 Adob​​​​e Flash,、ActiveXObject、 各類 Comet 技術之後,伺服器推送客戶端訊息的新趨勢。

與http的關係

在網路分層上,websocket 與http 協議都是應用層的協議,它們都是基於tcp 傳輸層的,但是websocket 在建立連接時,是藉用http 的101 switch protocol 來達到協議轉換(Upgrade)的,從HTTP 協定切換成WebSocket 通訊協定,這個動作協定中稱為「握手」;

握手成功後,websocket 就使用自己的協議規定的方式進行通訊,跟 http 就沒有關係了。

握手

以下是一個我自己的瀏覽器發送的典型的握手 http 頭: 

伺服器收到握手請求後,提取出請求頭中的“Sec-WebSocket-Key” 字段,追回一個固定的字符串'258EAFA5-E914-47DA-95CA-C5AB0DC85B11', 然後進行sha1 加密,最後轉換為base64 編碼,作為key 以「Sec-WebSocket-Accept」 欄位返回給客戶端,客戶端匹配此key 後,便建立了連接,完成了握手;

資料傳輸

websocket 有自己規定的資料傳輸格式,稱為 幀(Frame),下圖是一個資料幀的結構,其中單位為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>

具體每個字段是什麼意思,有興趣的可以看一下這篇文章The WebSocket Protocol 5.數據幀感覺自己對二進制的操作還不是很靈活,也就沒有挑戰自己寫算法解析數據了,下面的數據幀解析和封裝都是使用的網路上的演算法。

不過,我工作中寫支付網關中還是會經常用到數據的進制操作的,這個一定是要仔細研究總結一下的,嗯,先記下。


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 資源和對應的用戶資訊;

結果類似:

<code class="language-none">$connected_sockets = array(
    (int)$socket => array(
        'resource' => $socket,
        'name' => $name,
        'ip' => $ip,
        'port' => $port,
        ...
    )
)</code>

創建伺服器socket

下面是一段創建伺服器 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 的事件机制才是正道:于是在服务器端添加逻辑,在握手成功后,向客户端发送握手已成功的消息;客户端先将用户名存入一个全局变量,接收到服务器的握手成功的提醒消息后再发送用户名,于是成功在第一时间更新用户名。


小结

聊天室扩展方向

简易聊天室已经完成,当然还要给它带有希望的美好未来,希望有人去实现:

  • 页面美化(信息添加颜色等)
  • 服务器识别 '@' 字符而只向某一个 socket 写数据实现聊天室的私聊;
  • 多进程(使用 redis 等缓存数据库来实现资源的共享),可参考我以前的一篇文章: 初探PHP多进程
  • 消息记录数据库持久化(log 日志还是不方便分析)
  • ...

总结

多读些经典书籍还是很有用的,有些东西真的是触类旁通,APUE/UNP 还是要再多翻几遍。此外互联网技术日新月异,挑一些自己喜欢的学习一下,跟大家分享一下也是挺舒服的(虽然程序和博客加一块用了至少10个小时...)。

参考:

websocket协议翻译

刨根问底 HTTP 和 WebSocket 协议(下)

学习WebSocket协议—从顶层到底层的实现原理(修订版)

嗯,持续更新。喜欢的可以点个推荐或关注,有错漏之处,请指正,谢谢。

陳述:
本文內容由網友自願投稿,版權歸原作者所有。本站不承擔相應的法律責任。如發現涉嫌抄襲或侵權的內容,請聯絡admin@php.cn