本篇文章給大家?guī)砹岁P(guān)于php+socket的相關(guān)知識,其中主要介紹了怎么使用php原生socket實現(xiàn)一個簡易的web聊天室?感興趣的朋友下面一起來看一下,希望對大家有幫助。
php原生socket實現(xiàn)websocket聊天室
前言
這篇文章實現(xiàn)了使用php原生socket實現(xiàn)了一個簡易的web聊天室,最終代碼在文章最底部。
不出意外的話這應(yīng)該是這個系列文章的最后一篇了,寫這個系列文章時本以為是很簡單的東西,但實際幾篇寫下來使我?guī)缀跬ㄗx了 workerman 的代碼,所以永遠不要眼高手低,一定還是要自己嘗試,最好是寫出來,才能證明自己真正的弄懂了一件事情
websocket介紹
webSocket 協(xié)議是一種網(wǎng)絡(luò)通信協(xié)議,在 2008 年誕生,2011 年成為國際標準,RFC6455 定義了它的通信標準,如今所有瀏覽器都已支持了該協(xié)議。webSocket 是 HTML5 開始提供的一種在單個 TCP 連接上進行全雙工[^1]通訊的協(xié)議,服務(wù)器可以主動向客戶端推送消息,客戶端也可以主動向服務(wù)端發(fā)送消息。
webSocket 約定了一個通信協(xié)議的規(guī)范,通過握手機制,客戶端(瀏覽器)和服務(wù)器(webserver)之間能建立一個類似 tcp 的連接,從而方便 cs 通信。
為什么需要websocket
HTTP 協(xié)議是一種無狀態(tài)的、無連接的、單向的應(yīng)用層協(xié)議。它采用了 請求 => 響應(yīng)
模型,通信請求僅能由客戶端發(fā)起,服務(wù)端對請求做出應(yīng)答處理,這種通信模型有一個弊端:無法實現(xiàn)服務(wù)端主動向客戶端發(fā)起消息。傳統(tǒng)的 HTTP 請求,其并發(fā)能力都是依賴同時發(fā)起多個 TCP 連接訪問服務(wù)器實現(xiàn)的而 websocket 則允許我們在一條 ws 連接上同時并發(fā)多個請求,即在 A 請求發(fā)出后 A 響應(yīng)還未到達,就可以繼續(xù)發(fā)出 B 請求。由于 TCP 的慢啟動特性,以及連接本身的握手損耗,都使得 websocket 協(xié)議的這一特性有很大的效率提升。
特點
建立在 TCP 協(xié)議之上,服務(wù)端的實現(xiàn)相對比較容易
與 HTTP 協(xié)議有良好的兼容性,默認端口也是 80 和 443,并且握手階段采用 HTTP 協(xié)議,因此握手時不容易被屏蔽,能通過各種 HTTP 代理服務(wù)器。
數(shù)據(jù)格式比較輕量,性能開銷小,通信高效。
可以發(fā)送文本,也可以發(fā)送二進制數(shù)據(jù)。
沒有同源限制,客戶端可以與任意服務(wù)器進行通信。
協(xié)議標識符是 ws(如果加密則為 wss),服務(wù)地址就是 URL。
PHP實現(xiàn)websocket
客戶端與服務(wù)端握手
websocket 協(xié)議在連接前需要握手[^2],通常握手方式有以下幾種方式
基于 flash 的握手協(xié)議(不建議)
基于 md5 加密方式的握手協(xié)議:較早的握手方法,有兩個 key,使用 md5 加密
基于 sha1 加密方式的握手協(xié)議
當前主要的握手協(xié)議,本文將以此協(xié)議為主
獲取客戶端上報的
Sec-WebSocket-key
拼接
key
+258EAFA5-E914-47DA-95CA-C5AB0DC85B11
對字符串做
SHA1
計算,再把得到的結(jié)果通過base64
加密,最后再返回給客戶端
客戶端請求信息如下:
GET /chat HTTP/1.1Host: server.example.com Upgrade: websocket Connection: Upgrade Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==Origin: http://example.com Sec-WebSocket-Protocol: chat, superchat Sec-WebSocket-Version: 13
客戶端需返回如下數(shù)據(jù):
HTTP/1.1 101 Switching Protocols Upgrade: websocket Sec-WebSocket-Version: 13Connection: Upgrade Sec-WebSocket-Accept: s3pPLMBiTxaQ9kYGzzhZRbK+xOo=
我們根據(jù)此協(xié)議通過 PHP 方式實現(xiàn):
<?php $socket = socket_create(AF_INET, SOCK_STREAM, SOL_TCP); socket_set_option($socket, SOL_SOCKET, SO_REUSEADDR, true); socket_bind($socket, 0, 8888); socket_listen($socket); while (true) { $conn_sock = socket_accept($socket); $request = socket_read($conn_sock, 102400); $new_key = getShaKey($request); $response = "HTTP/1.1 101 Switching Protocols\r\n"; $response .= "Upgrade: websocket\r\n"; $response .= "Sec-WebSocket-Version: 13\r\n"; $response .= "Connection: Upgrade\r\n"; $response .= "Sec-WebSocket-Accept: {$new_key}\r\n\r\n"; socket_write($conn_sock, $response); } function getShaKey($request) { // 獲取 Sec-WebSocket-key preg_match("/Sec-WebSocket-Key: (.*)\r\n/", $request, $match); // 拼接 key + 258EAFA5-E914-47DA-95CA-C5AB0DC85B11 $new_key = trim($match[1]) . '258EAFA5-E914-47DA-95CA-C5AB0DC85B11'; // 對字符串做 `SHA1` 計算,再把得到的結(jié)果通過 `base64` 加密 return base64_encode(sha1($new_key, true)); }
相關(guān)語法解釋可參考 之前的文章,本文章不做詳細介紹。
使用前端測試,打開我們的任意瀏覽器控制臺(console)輸入以下內(nèi)容,返回的 websocket 對象的 readyState 為 1 即為握手成功,此為前端內(nèi)容,本文不多做介紹,詳情可參考 菜鳥教程:
console.log(new WebSocket('ws://192.162.2.166:8888')); // 運行后返回: WebSocket { binaryType: "blob" bufferedAmount: 0 extensions: "" onclose: null onerror: null onmessage: null onopen: null protocol: "" readyState: 1 url: "ws://192.162.2.166:8888/" }
發(fā)送數(shù)據(jù)與接收數(shù)據(jù)
使用 websocket 協(xié)議傳輸協(xié)議需要遵循特定的格式規(guī)范,詳情請參考 datatracker.ietf.org/doc/html/rfc6...
為了方便,這里直接貼出加解密代碼,以下代碼借鑒與 workerman 的 src/Protocols/Websocket.php
文件:
// 解碼客戶端發(fā)送的消息 function decode($buffer) { $len = \ord($buffer[1]) & 127; if ($len === 126) { $masks = \substr($buffer, 4, 4); $data = \substr($buffer, 8); } else { if ($len === 127) { $masks = \substr($buffer, 10, 4); $data = \substr($buffer, 14); } else { $masks = \substr($buffer, 2, 4); $data = \substr($buffer, 6); } } $dataLength = \strlen($data); $masks = \str_repeat($masks, \floor($dataLength / 4)) . \substr($masks, 0, $dataLength % 4); return $data ^ $masks; } // 編碼發(fā)送給客戶端的消息 function encode($buffer) { if (!is_scalar($buffer)) { throw new \Exception("You can't send(" . \gettype($buffer) . ") to client, you need to convert it to a string. "); } $len = \strlen($buffer); $first_byte = "\x81"; if ($len <= 125) { $encode_buffer = $first_byte . \chr($len) . $buffer; } else { if ($len <= 65535) { $encode_buffer = $first_byte . \chr(126) . \pack("n", $len) . $buffer; } else { $encode_buffer = $first_byte . \chr(127) . \pack("xxxxN", $len) . $buffer; } } return $encode_buffer; }
我們修改剛才 客戶端與服務(wù)端握手 階段的代碼,修改后全代碼全文如下,該段代碼實現(xiàn)了將客戶端發(fā)送的消息轉(zhuǎn)為大寫后返回給客戶端(當然只是為了演示):
<?php $socket = socket_create(AF_INET, SOCK_STREAM, SOL_TCP); socket_set_option($socket, SOL_SOCKET, SO_REUSEADDR, true); socket_bind($socket, 0, 8888); socket_listen($socket); while (true) { $conn_sock = socket_accept($socket); $request = socket_read($conn_sock, 102400); $new_key = getShaKey($request); $response = "HTTP/1.1 101 Switching Protocols\r\n"; $response .= "Upgrade: websocket\r\n"; $response .= "Sec-WebSocket-Version: 13\r\n"; $response .= "Connection: Upgrade\r\n"; $response .= "Sec-WebSocket-Accept: {$new_key}\r\n\r\n"; // 發(fā)送握手數(shù)據(jù) socket_write($conn_sock, $response); // 新增內(nèi)容,獲取客戶端發(fā)送的消息并轉(zhuǎn)為大寫還給客戶端 $msg = socket_read($conn_sock, 102400); socket_write($conn_sock, encode(strtoupper(decode($msg)))); } function getShaKey($request) { // 獲取 Sec-WebSocket-key preg_match("/Sec-WebSocket-Key: (.*)\r\n/", $request, $match); // 拼接 key + 258EAFA5-E914-47DA-95CA-C5AB0DC85B11 $new_key = trim($match[1]) . '258EAFA5-E914-47DA-95CA-C5AB0DC85B11'; // 對字符串做 `SHA1` 計算,再把得到的結(jié)果通過 `base64` 加密 return base64_encode(sha1($new_key, true)); } function decode($buffer) { $len = \ord($buffer[1]) & 127; if ($len === 126) { $masks = \substr($buffer, 4, 4); $data = \substr($buffer, 8); } else { if ($len === 127) { $masks = \substr($buffer, 10, 4); $data = \substr($buffer, 14); } else { $masks = \substr($buffer, 2, 4); $data = \substr($buffer, 6); } } $dataLength = \strlen($data); $masks = \str_repeat($masks, \floor($dataLength / 4)) . \substr($masks, 0, $dataLength % 4); return $data ^ $masks; } function encode($buffer) { if (!is_scalar($buffer)) { throw new \Exception("You can't send(" . \gettype($buffer) . ") to client, you need to convert it to a string. "); } $len = \strlen($buffer); $first_byte = "\x81"; if ($len <= 125) { $encode_buffer = $first_byte . \chr($len) . $buffer; } else { if ($len <= 65535) { $encode_buffer = $first_byte . \chr(126) . \pack("n", $len) . $buffer; } else { $encode_buffer = $first_byte . \chr(127) . \pack("xxxxN", $len) . $buffer; } } return $encode_buffer; }
使用 在線測試工具 進行測試,可以看到消息已經(jīng)可以正常發(fā)送接收,接下來的文章將繼續(xù)優(yōu)化代碼,實現(xiàn)簡易聊天室,敬請關(guān)注:
實現(xiàn)web聊天室
我們緊接著上文的代碼繼續(xù)優(yōu)化,以實現(xiàn)簡易的web聊天室
多路復用
其實就是加一下 socket_select()
函數(shù),本文就不寫原理與語法了,詳情可參考 之前的文章,以下代碼修改自前文 發(fā)送數(shù)據(jù)與接收數(shù)據(jù)
... socket_listen($socket); +$sockets[] = $socket; +$user = []; while (true) { + $tmp_sockets = $sockets; + socket_select($tmp_sockets, $write, $except, null); + foreach ($tmp_sockets as $sock) { + if ($sock == $socket) { + $sockets[] = socket_accept($socket); + $user[] = ['socket' => $socket, 'handshake' => false]; + } else { + $curr_user = $user[array_search($sock, $user)]; + if ($curr_user['handshake']) { // 已握手 + $msg = socket_read($sock, 102400); + echo '客戶端發(fā)來消息' . decode($msg); + socket_write($sock, encode('這是來自服務(wù)端的消息')); + } else { + // 握手 + } + } + } - $conn_sock = socket_accept($socket); - $request = socket_read($conn_sock, 102400); ...
實現(xiàn)聊天室
最終成果演示
我們將上述代碼改造成類,并在類變量儲存用戶信息,添加消息處理等邏輯,最后貼出代碼,建議保存下來自己嘗試一下,也許會有全新的認知,后端代碼:
<?php new WebSocket(); class Websocket { /** * @var resource */ protected $socket; /** * @var array 用戶列表 */ protected $user = []; /** * @var array 存放所有 socket 資源 */ protected $socket_list = []; public function __construct() { $this->socket = socket_create(AF_INET, SOCK_STREAM, SOL_TCP); socket_set_option($this->socket, SOL_SOCKET, SO_REUSEADDR, true); socket_bind($this->socket, 0, 8888); socket_listen($this->socket); // 將 socket 資源放入 socket_list $this->socket_list[] = $this->socket; while (true) { $tmp_sockets = $this->socket_list; socket_select($tmp_sockets, $write, $except, null); foreach ($tmp_sockets as $sock) { if ($sock == $this->socket) { $conn_sock = socket_accept($sock); $this->socket_list[] = $conn_sock; $this->user[] = ['socket' => $conn_sock, 'handshake' => false, 'name' => '無名氏']; } else { $request = socket_read($sock, 102400); $k = $this->getUserIndex($sock); if (!$request) { continue; } // 用戶端斷開連接 if ((\ord($request[0]) & 0xf) == 0x8) { $this->close($k); continue; } if (!$this->user[$k]['handshake']) { // 握手 $this->handshake($k, $request); } else { // 已握手 $this->send($k, $request); } } } } } /** * 關(guān)閉連接 * * @param $k */ protected function close($k) { $u_name = $this->user[$k]['name'] ?? '無名氏'; socket_close($this->user[$k]['socket']); $socket_key = array_search($this->user[$k]['socket'], $this->socket_list); unset($this->socket_list[$socket_key]); unset($this->user[$k]); $user = []; foreach ($this->user as $v) { $user[] = $v['name']; } $res = [ 'type' => 'close', 'users' => $user, 'msg' => $u_name . '已退出', 'time' => date('Y-m-d H:i:s') ]; $this->sendAllUser($res); } /** * 獲取用戶索引 * * @param $socket * @return int|string */ protected function getUserIndex($socket) { foreach ($this->user as $k => $v) { if ($v['socket'] == $socket) { return $k; } } } /** * 握手 * @param $k * @param $request */ protected function handshake($k, $request) { preg_match("/Sec-WebSocket-Key: (.*)\r\n/", $request, $match); $key = base64_encode(sha1($match[1] . '258EAFA5-E914-47DA-95CA-C5AB0DC85B11', true)); $response = "HTTP/1.1 101 Switching Protocols\r\n"; $response .= "Upgrade: websocket\r\n"; $response .= "Connection: Upgrade\r\n"; $response .= "Sec-WebSocket-Accept: {$key}\r\n\r\n"; socket_write($this->user[$k]['socket'], $response); $this->user[$k]['handshake'] = true; } /** * 接收并處理消息 * * @param $k * @param $msg */ public function send($k, $msg) { $msg = $this->decode($msg); $msg = json_decode($msg, true); if (!isset($msg['type'])) { return; } switch ($msg['type']) { case 'login': // 登錄 $this->user[$k]['name'] = $msg['name'] ?? '無名氏'; $users = []; foreach ($this->user as $v) { $users[] = $v['name']; } $res = [ 'type' => 'login', 'name' => $this->user[$k]['name'], 'msg' => $this->user[$k]['name'] . ': login success', 'users' => $users, ]; $this->sendAllUser($res); break; case 'message': // 接收并發(fā)送消息 $res = [ 'type' => 'message', 'name' => $this->user[$k]['name'] ?? '無名氏', 'msg' => $msg['msg'], 'time' => date('H:i:s'), ]; $this->sendAllUser($res); break; } } /** * 發(fā)送給所有人 * */ protected function sendAllUser($msg) { if (is_array($msg)) { $msg = json_encode($msg); } $msg = $this->encode($msg); foreach ($this->user as $k => $v) { socket_write($v['socket'], $msg, strlen($msg)); } } /** * 解碼 * * @param $buffer * @return string */ protected function decode($buffer) { $len = \ord($buffer[1]) & 127; if ($len === 126) { $masks = \substr($buffer, 4, 4); $data = \substr($buffer, 8); } else { if ($len === 127) { $masks = \substr($buffer, 10, 4); $data = \substr($buffer, 14); } else { $masks = \substr($buffer, 2, 4); $data = \substr($buffer, 6); } } $dataLength = \strlen($data); $masks = \str_repeat($masks, \floor($dataLength / 4)) . \substr($masks, 0, $dataLength % 4); return $data ^ $masks; } protected function encode($buffer) { if (!is_scalar($buffer)) { throw new \Exception("You can't send(" . \gettype($buffer) . ") to client, you need to convert it to a string. "); } $len = \strlen($buffer); $first_byte = "\x81"; if ($len <= 125) { $encode_buffer = $first_byte . \chr($len) . $buffer; } else { if ($len <= 65535) { $encode_buffer = $first_byte . \chr(126) . \pack("n", $len) . $buffer; } else { $encode_buffer = $first_byte . \chr(127) . \pack("xxxxN", $len) . $buffer; } } return $encode_buffer; } }
前端代碼如下(前端內(nèi)容不在本文討論范圍之內(nèi),具體可參考 菜鳥教程):
<!doctype html> <html> <head> <meta charset="UTF-8"> <meta name="viewport" content="width=device-width, user-scalable=no, initial-scale=1.0, maximum-scale=1.0, minimum-scale=1.0"> <meta http-equiv="X-UA-Compatible" content="ie=edge"> <title>Document</title> </head> <style> * { margin: 0; padding: 0; } h3 { display: flex; justify-content: center; margin: 30px auto; } .but-box { border-radius: 5px; display: flex; justify-content: center; align-items: center; margin-top: 10px; } #box { display: flex; margin: 5px auto; border-radius: 5px; border: 1px #ccc solid; height: 400px; width: 700px; overflow-y: auto; overflow-x: hidden; position: relative; } #msg-box { width: 480px; margin-right: 111px; height: 100%; overflow-y: auto; overflow-x: hidden; } #user-box { width: 110px; overflow-y: auto; overflow-x: hidden; float: left; border-left: 1px #ccc solid; height: 100%; background-color: #F1F1F1; } button { float: right; width: 80px; height: 35px; font-size: 18px; } input { width: 100%; height: 30px; padding: 2px; line-height: 20px; outline: none; border: solid 1px #CCC; } .but-box p { margin-right: 160px; } </style> <body> <h3>這是一個php socket實現(xiàn)的web聊天室</h3> <div id="box"> <div id="msg-box"></div> <div id="user-box"></div> </div> <div> <p><textarea cols="60" rows="3" style="resize:none;pedding: 10px" id="content"> </textarea></p> <button id="send">發(fā)送</button> </div> <script src="https://cdn.bootcss.com/jquery/2.2.1/jquery.min.js"></script> <script> let ws = new WebSocket('ws://124.222.85.67:8888'); ws.onopen = function (event) { console.log('連接成功'); var name = prompt('請輸入用戶名:'); ws.send(JSON.stringify({ type: 'login', name: name })); if (!name) { alert('好你個壞蛋,竟然沒有輸入用戶名'); } }; ws.onmessage = function (event) { let data = JSON.parse(event.data); console.log(data); switch (data.type) { case 'close': case 'login': $("#user-box").html(''); data.users.forEach(function (item) { $("#user-box").append(`<p style="color: grey;">${item}</p>`); }); if (data.msg) { $("#msg-box").append(`<p style="color: grey;">${data.msg}</p>`); } break; case 'message': $("#msg-box").append(`<p><span style="color: #0A89FF">${data.time}</span><span style="color: red">${data.name}</span>${data.msg}</p>`); break; } }; ws.onclose = function (event) { alert('連接關(guān)閉'); }; document.onkeydown = function (event) { if (event.keyCode == 13) { send(); } } $("#send").click(function () { send(); }); function send() { let content = $("#content").val(); $("#content").val(''); if (!content) { return; } ws.send(JSON.stringify({ type: 'message', msg: content })); } </script> </body> </html>
1、是通訊傳輸?shù)囊粋€術(shù)語。 通信允許數(shù)據(jù)在兩個方向上同時傳輸,它在能力上相當于兩個單工通信方式的結(jié)合
2、為了建立 websocket 連接,需要通過瀏覽器發(fā)出請求,之后服務(wù)器進行回應(yīng),這個過程通常稱為“握手”(Handshaking)