網(wǎng)頁實(shí)時(shí)聊天之PHP實(shí)現(xiàn)websocket
Nov 30, 2016 pm 11:59 PM前言
websocket 作為 HTML5 里一個(gè)新的特性一直很受人關(guān)注,因?yàn)樗娴姆浅??,打破?http “請(qǐng)求-響應(yīng)”的常規(guī)思維,實(shí)現(xiàn)了服務(wù)器向客戶端主動(dòng)推送消息,本文介紹如何使用 PHP 和 JS 應(yīng)用 websocket 實(shí)現(xiàn)一個(gè)網(wǎng)頁實(shí)時(shí)聊天室;
以前寫過一篇文章講述如何使用ajax長輪詢實(shí)現(xiàn)網(wǎng)頁實(shí)時(shí)聊天,見鏈接: 網(wǎng)頁實(shí)時(shí)聊天之js和jQuery實(shí)現(xiàn)ajax長輪詢 ,但是輪詢和服務(wù)器的 pending 都是無謂的消耗,websocket 才是新的趨勢。
最近艱難地“擠”出了一點(diǎn)時(shí)間,完善了很早之前做的 websocket “請(qǐng)求-原樣返回”服務(wù)器,用js完善了下客戶端功能,把過程和思路分享給大家,順便也普及一下 websocket 相關(guān)的知識(shí),當(dāng)然現(xiàn)在討論 websocket 的文章也特別多,有些理論性的東西我也就略過了,給出參考文章供大家選擇閱讀。
正文開始前,先貼一張聊天室的效果圖(請(qǐng)不要在意CSS渣的頁面):
然后當(dāng)然是源碼: 我是源碼鏈接 - github - 枕邊書
websocket
簡介
WebSocket 不是一門技術(shù),而是一種全新的協(xié)議。它應(yīng)用 TCP 的 Socket(套接字),為網(wǎng)絡(luò)應(yīng)用定義了一個(gè)新的重要的能力:客戶端和服務(wù)器端的雙全工傳輸和雙向通信。是繼 Java applets、 XMLHttpRequest、 Adobe Flash,、ActiveXObject、 各類 Comet 技術(shù)之后,服務(wù)器推送客戶端消息的新趨勢。
與http的關(guān)系
在網(wǎng)絡(luò)分層上,websocket 與 http 協(xié)議都是應(yīng)用層的協(xié)議,它們都是基于 tcp 傳輸層的,但是 websocket 在建立連接時(shí),是借用 http 的 101 switch protocol 來達(dá)到協(xié)議轉(zhuǎn)換(Upgrade)的,從 HTTP 協(xié)議切換成 WebSocket 通信協(xié)議,這個(gè)動(dòng)作協(xié)議中稱“握手”;
握手成功后,websocket 就使用自己的協(xié)議規(guī)定的方式進(jìn)行通訊,跟 http 就沒有關(guān)系了。
握手
以下是一個(gè)我自己的瀏覽器發(fā)送的典型的握手 http 頭:?
服務(wù)器收到握手請(qǐng)求后,提取出請(qǐng)求頭中的 “Sec-WebSocket-Key” 字段,追回一個(gè)固定的字符串 ‘258EAFA5-E914-47DA-95CA-C5AB0DC85B11’, 然后進(jìn)行 sha1 加密,最后轉(zhuǎn)換為 base64 編碼,作為 key 以 “Sec-WebSocket-Accept” 字段返回給客戶端,客戶端匹配此 key 后,便建立了連接,完成了握手;
數(shù)據(jù)傳輸
websocket 有自己規(guī)定的數(shù)據(jù)傳輸格式,稱為 幀(Frame),下圖是一個(gè)數(shù)據(jù)幀的結(jié)構(gòu),其中單位為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 ... | +---------------------------------------------------------------+
具體每個(gè)字段是什么意思,有興趣的可以看一下這篇文章 The WebSocket Protocol 5.數(shù)據(jù)幀 感覺自己對(duì)二進(jìn)制的操作還不是很靈活,也就沒有挑戰(zhàn)自己寫算法解析數(shù)據(jù)了,下面的數(shù)據(jù)幀解析和封裝都是使用的網(wǎng)上的算法。
不過,我工作中寫支付網(wǎng)關(guān)中還是會(huì)經(jīng)常用到數(shù)據(jù)的進(jìn)制操作的,這個(gè)一定是要仔細(xì)研究總結(jié)一下的,嗯,先記下。
PHP 實(shí)現(xiàn) websocket 服務(wù)器
PHP 實(shí)現(xiàn) websocket 的話,主要是應(yīng)用 PHP 的 socket 函數(shù)庫:
PHP 的 socket 函數(shù)庫跟 C 語言的 socket 函數(shù)非常類似,以前翻過一遍 APUE, 所以覺得還挺好理解。在 PHP 手冊(cè)中看一遍 socket 函數(shù),我想大家也能對(duì) php 的 socket 編程有一定的認(rèn)識(shí)。
下面會(huì)在代碼中對(duì)所用函數(shù)進(jìn)行簡單的注釋。
文件描述符
忽然提及'文件描述符',大家可能會(huì)有些奇怪。
但作為服務(wù)器,是必須要對(duì)已經(jīng)連接的 socket 進(jìn)行存儲(chǔ)和識(shí)別的。每一個(gè) socket 代表一個(gè)用戶,如何關(guān)聯(lián)和查詢用戶信息與 socket 的對(duì)應(yīng)就是一個(gè)問題了,這里便應(yīng)用了關(guān)于文件描述符的一點(diǎn)小技巧。
我們知道 linux 是'萬物皆文件'的,C 語言的 socket 的實(shí)現(xiàn)便是一個(gè)個(gè)的’文件描述符‘ ,這個(gè)文件描述符一般是打開文件的順序遞增的 int 數(shù)值,從 0 一直遞增(當(dāng)然系統(tǒng)是有限制的)。每一個(gè) socket 都對(duì)應(yīng)一個(gè)文件,讀寫 socket 都是操作對(duì)應(yīng)的文件,所以也能像文件系統(tǒng)一樣應(yīng)用 read 和 write 函數(shù)。
tips: linux 中, 標(biāo)準(zhǔn)輸入對(duì)應(yīng)的是文件描述符 0;標(biāo)準(zhǔn)輸出對(duì)應(yīng)的文件描述符是 1; 標(biāo)準(zhǔn)錯(cuò)誤對(duì)應(yīng)的文件描述符是 2;所以我們可以使用 0 1 2對(duì)輸入輸出重定向。
那么類似于 C socket 的 PHP socket 自然也繼承了這一點(diǎn),它創(chuàng)建的 socket 也是類型于 int 值為 4 5 之類的資源類型。 我們可以使用 (int) 或 intval() 函數(shù)把 socket 轉(zhuǎn)換為一個(gè)唯一的ID,從而可以實(shí)現(xiàn)用一個(gè) ’類索引數(shù)組‘ 來存儲(chǔ) socket 資源和對(duì)應(yīng)的用戶信息;
結(jié)果類似:
$connected_sockets = array( (int)$socket => array( 'resource' => $socket, 'name' => $name, 'ip' => $ip, 'port' => $port, ... ) )
創(chuàng)建服務(wù)器socket
下面是一段創(chuàng)建服務(wù)器 socket 的代碼:
// 創(chuàng)建一個(gè) TCP socket, 此函數(shù)的可選值在官方文檔中寫得十分詳細(xì),這里不再提了 $this->master = socket_create(AF_INET, SOCK_STREAM, SOL_TCP); // 設(shè)置IP和端口重用,在重啟服務(wù)器后能重新使用此端口; socket_set_option($this->master, SOL_SOCKET, SO_REUSEADDR, 1); // 將IP和端口綁定在服務(wù)器socket上; socket_bind($this->master, $host, $port); // listen函數(shù)使主動(dòng)連接套接口變?yōu)楸贿B接套接口,使得此 socket 能被其他 socket 訪問,從而實(shí)現(xiàn)服務(wù)器功能。后面的參數(shù)則是自定義的待處理socket的最大數(shù)目,并發(fā)高的情況下,這個(gè)值可以設(shè)置大一點(diǎn),雖然它也受系統(tǒng)環(huán)境的約束。 socket_listen($this->master, self::LISTEN_SOCKET_NUM);
這樣,我們就得到一個(gè)服務(wù)器 socket,當(dāng)有客戶端連接到此 socket 上時(shí),它將改變狀態(tài)為可讀,那就看接下來服務(wù)器的處理邏輯了。
服務(wù)器邏輯
這里著重講一下 socket_select($read, $write, $except, $tv_sec [, $tv_usec])
:
select 函數(shù)使用傳統(tǒng)的 select 模型,可讀、寫、異常的 socket 會(huì)被分別放入 $socket, $write, $except 數(shù)組中,然后返回 狀態(tài)改變的 socket 的數(shù)目,如果發(fā)生了錯(cuò)誤,函數(shù)將會(huì)返回 false.
需要注意的是最后兩個(gè)時(shí)間參數(shù),它們只有單位不同,可以搭配使用,用來表示 socket_select 阻塞的時(shí)長,為0時(shí)此函數(shù)立即返回,可以用于輪詢機(jī)制。 為 NULL 時(shí),函數(shù)會(huì)一直阻塞下去, 這里我們置 $tv_sec 參數(shù)為null,讓它一直阻塞,直到有可操作的 socket 返回。
下面是服務(wù)器的主要邏輯:
$write = $except = NULL; $sockets = array_column($this->sockets, 'resource'); // 獲取到全部的 socket 資源 $read_num = socket_select($sockets, $write, $except, NULL); foreach ($sockets as $socket) { // 如果可讀的是服務(wù)器 socket, 則處理連接邏輯; if ($socket == $this->master) { socket_accept($this->master); // socket_accept() 接受 請(qǐng)求 “正在 listen 的 socket(像我們的服務(wù)器 socket )” 的連接, 并一個(gè)客戶端 socket, 錯(cuò)誤時(shí)返回 false; self::connect($client); continue; } // 如果可讀的是其他已連接 socket ,則讀取其數(shù)據(jù),并處理應(yīng)答邏輯 } else { // 函數(shù) socket_recv() 從 socket 中接受長度為 len 字節(jié)的數(shù)據(jù),并保存在 $buffer 中。 $bytes = @socket_recv($socket, $buffer, 2048, 0); if ($bytes < 9) { // 當(dāng)客戶端忽然中斷時(shí),服務(wù)器會(huì)接收到一個(gè) 8 字節(jié)長度的消息(由于其數(shù)據(jù)幀機(jī)制,8字節(jié)的消息我們認(rèn)為它是客戶端異常中斷消息),服務(wù)器處理下線邏輯,并將其封裝為消息廣播出去 $recv_msg = $this->disconnect($socket); } else { // 如果此客戶端還未握手,執(zhí)行握手邏輯 if (!$this->sockets[(int)$socket]['handshake']) { self::handShake($socket, $buffer); continue; } else { $recv_msg = self::parse($buffer); } } // 廣播消息 $this->broadcast($msg); } } }
這里只是服務(wù)器處理消息的基礎(chǔ)代碼,日志記錄和異常處理都略過了,而且還有些數(shù)據(jù)幀解析和封裝的方法,各位也不一定看愛,有興趣的可以去 github 上支持一下我的源碼~~
此外,為了便于服務(wù)器與客戶端的交互,我自己定義了 json 類型的消息格式,形似:
$msg = [ 'type' => $msg_type, // 有普通消息,上下線消息,服務(wù)器消息 'from' => $msg_resource, // 消息來源 'content' => $msg_content, // 消息內(nèi)容 'user_list' => $uname_list, // 便于同步當(dāng)前在線人數(shù)與姓名 ];
客戶端
創(chuàng)建客戶端
前端我們使用 js 調(diào)用 Websocket 方法很簡單就能創(chuàng)建一個(gè) websocket 連接,服務(wù)器會(huì)為幫我們完成連接、握手的操作,js 使用事件機(jī)制來處理瀏覽器與服務(wù)器的交互:
// 創(chuàng)建一個(gè) websocket 連接 var ws = new WebSocket("ws://127.0.0.1:8080"); // websocket 創(chuàng)建成功事件 ws.onopen = function () { }; // websocket 接收到消息事件 ws.onmessage = function (e) { var msg = JSON.parse(e.data); } // websocket 錯(cuò)誤事件 ws.onerror = function () { };
發(fā)送消息也很簡單,直接調(diào)用 ws.send(msg)
方法就行了。
頁面功能
頁面部分主要是讓用戶使用起來方便,這里給消息框 textarea 添加了一個(gè)鍵盤監(jiān)控事件,當(dāng)用戶按下回車鍵時(shí)直接發(fā)送消息;
function confirm(event) { var key_num = event.keyCode; if (13 == key_num) { send(); } else { return false; } }
還有用戶打開客戶端時(shí)生成一個(gè)默認(rèn)唯一用戶名;
然后是一些對(duì)數(shù)據(jù)的解析構(gòu)造,對(duì)客戶端頁面的更新,這里就不再啰嗦了,感興趣的可以看源碼。
用戶名異步處理
這里不得不提一下用戶登陸時(shí)確定用戶名時(shí)的一個(gè)小問題,我原來是想在客戶端創(chuàng)建一個(gè)連接后直接發(fā)送用戶名到服務(wù)器,可是控制臺(tái)里報(bào)出了 “websocket 仍在連接中或已關(guān)閉” 的錯(cuò)誤信息。
Uncaught DOMException: Failed to execute 'send' on 'WebSocket': Still in CONNECTING state.
考慮到連接可能還沒處理好,我就實(shí)現(xiàn)了 sleep 方法等了一秒再發(fā)送用戶名,可是錯(cuò)誤仍然存在。
后來忽然想到 js 的單線程阻塞機(jī)制,才明白使用 sleep 一直阻塞也是沒有用的,利用好 js 的事件機(jī)制才是正道:于是在服務(wù)器端添加邏輯,在握手成功后,向客戶端發(fā)送握手已成功的消息;客戶端先將用戶名存入一個(gè)全局變量,接收到服務(wù)器的握手成功的提醒消息后再發(fā)送用戶名,于是成功在第一時(shí)間更新用戶名。
小結(jié)
聊天室擴(kuò)展方向
簡易聊天室已經(jīng)完成,當(dāng)然還要給它帶有希望的美好未來,希望有人去實(shí)現(xiàn):
- 頁面美化(信息添加顏色等)
- 服務(wù)器識(shí)別 '@' 字符而只向某一個(gè) socket 寫數(shù)據(jù)實(shí)現(xiàn)聊天室的私聊;
- 多進(jìn)程(使用 redis 等緩存數(shù)據(jù)庫來實(shí)現(xiàn)資源的共享),可參考我以前的一篇文章: 初探PHP多進(jìn)程
- 消息記錄數(shù)據(jù)庫持久化(log 日志還是不方便分析)
- ...
總結(jié)
多讀些經(jīng)典書籍還是很有用的,有些東西真的是觸類旁通,APUE/UNP 還是要再多翻幾遍。此外互聯(lián)網(wǎng)技術(shù)日新月異,挑一些自己喜歡的學(xué)習(xí)一下,跟大家分享一下也是挺舒服的(雖然程序和博客加一塊用了至少10個(gè)小時(shí)...)。
參考:
websocket協(xié)議翻譯
刨根問底 HTTP 和 WebSocket 協(xié)議(下)
學(xué)習(xí)WebSocket協(xié)議—從頂層到底層的實(shí)現(xiàn)原理(修訂版)
嗯,持續(xù)更新。喜歡的可以點(diǎn)個(gè)推薦或關(guān)注,有錯(cuò)漏之處,請(qǐng)指正,謝謝。

Alat AI Hot

Undress AI Tool
Gambar buka pakaian secara percuma

Undresser.AI Undress
Apl berkuasa AI untuk mencipta foto bogel yang realistik

AI Clothes Remover
Alat AI dalam talian untuk mengeluarkan pakaian daripada foto.

Clothoff.io
Penyingkiran pakaian AI

Video Face Swap
Tukar muka dalam mana-mana video dengan mudah menggunakan alat tukar muka AI percuma kami!

Artikel Panas

Alat panas

Notepad++7.3.1
Editor kod yang mudah digunakan dan percuma

SublimeText3 versi Cina
Versi Cina, sangat mudah digunakan

Hantar Studio 13.0.1
Persekitaran pembangunan bersepadu PHP yang berkuasa

Dreamweaver CS6
Alat pembangunan web visual

SublimeText3 versi Mac
Perisian penyuntingan kod peringkat Tuhan (SublimeText3)