基於 OpenResty 實現一個 WS 聊天室
WebSocket
WebSocket 協議分析
WebSocket 協議解決了瀏覽器和服務器之間的全雙工通信問題。在WebSocket出現之前,瀏覽器如果需要從服務器及時獲得更新,則需要不停的對服務器主動發起請求,也就是 Web 中常用的 poll 技術。這樣的操作非常低效,這是因為每發起一次新的 HTTP 請求,就需要單獨開啟一個新的 TCP 鏈接,同時 HTTP 協議本身也是一種開銷非常大的協議。為了解決這些問題,所以出現了 WebSocket 協議。WebSocket 使得瀏覽器和服務器之間能通過一個持久的 TCP 鏈接就能完成數據的雙向通信。關於 WebSocket 的 RFC 提案,可以參看 RFC6455。
WebSocket 和 HTTP 協議一般情況下都工作在瀏覽器中,但 WebSocket 是一種完全不同於 HTTP 的協議。盡管,瀏覽器需要通過 HTTP 協議的 GET 請求,將 HTTP 協議升級為 WebSocket 協議。升級的過程被稱為 握手(handshake)。當瀏覽器和服務器成功握手后,則可以開始根據 WebSocket 定義的通信幀格式開始通信了。像其他各種協議一樣,WebSocket 協議的通信幀也分為控制數據幀和普通數據幀,前者用於控制 WebSocket 鏈接狀態,后者用於承載數據。下面我們將一一分析 WebSocket 協議的握手過程以及通信幀格式。
WebSocket 協議的握手過程
握手的過程也就是將 HTTP 協議升級為 WebSocket 協議的過程。前面我們說過,握手開始首先由瀏覽器端發送一個 GET 請求開發,該請求的 HTTP 頭部信息如下:
Connection: Upgrade
Sec-WebSocket-Extensions: permessage-deflate; client_max_window_bits
Sec-WebSocket-Key: lGrvj+i7B76RB3YYbScQ9g==
Sec-WebSocket-Version: 13
Upgrade: websocket
當服務器端,成功驗證了以上信息后,則會返回一個形如以下信息的響應:
Connection: upgrade
Sec-WebSocket-Accept: nImJE2gpj1XLtrOb+5cBMJn7bNQ=
Upgrade: websocket
可以看到,瀏覽器發送的 HTTP 請求中,增加了一些新的字段,其作用如下所示:
- Upgrade: 規定必需的字段,其值必需為 websocket, 如果不是則握手失敗;
- Connection: 規定必需的字段,值必需為 Upgrade, 如果不是則握手失敗;
- Sec-WebSocket-Key: 必需字段,一個隨機的字符串;
- Sec-WebSocket-Version: 必需字段,代表了 WebSocket 協議版本,值必需是 13, 否則握手失敗;
返回的響應中,如果握手成功會返回狀態碼為 101 的 HTTP 響應。同時其他字段說明如下:
- Upgrade: 規定必需的字段,其值必需為 websocket, 如果不是則握手失敗;
- Connection: 規定必需的字段,值必需為 Upgrade, 如果不是則握手失敗;
- Sec-WebSocket-Accept: 規定必需的字段,該字段的值是通過固定字符串258EAFA5-E914-47DA-95CA-C5AB0DC85B11加上請求中Sec-WebSocket-Key字段的值,然后再對其結果通過 SHA1 哈希算法求出的結果。
當瀏覽器和服務器端成功握手后,就可以傳送數據了,傳送數據是按照 WebSocket 協議的數據格式生成的。
WebSocket 協議數據幀
數據幀的定義類似於 TCP/IP 協議的格式定義,具體看下圖:
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 ... |
+---------------------------------------------------------------+
以上這張圖,一行代表 32 bit (位) ,也就是 4 bytes。總體上包含兩份,幀頭部和數據內容。每個從 WebSocket 鏈接中接收到的數據幀,都要按照以上格式進行解析,這樣才能知道該數據幀是用於控制的還是用於傳送數據的。
OpenResty
2.1 resty.websocket 庫
模塊文檔:
OR 的 websocket 庫已經默認安裝了, 我們在此用到的是 resty.websocket.server
(ws服務端)模塊, server模塊提供了各種函數來處理 WebSocket 定義的幀。
local server = require "resty.websocket.server"
local wb, err = server:new{
timeout = 5000, -- in milliseconds
max_payload_len = 65535,
}
if not wb then
ngx.log(ngx.ERR, "failed to new websocket: ", err)
return ngx.exit(444)
end
Methods
- new
- set_timeout
- send_text
- send_binary
- send_ping
- send_pong
- send_close
- send_frame
- recv_frame
2.2 resty.redis 模塊
模塊文檔:
resty.redis 模塊實現了 Redis 官方所有的命令的同名方法, 這里主要用到的是redis的發布訂閱相關功能。
local redis = require "resty.redis"
local red = redis:new()
red:set_timeout(1000) -- 1 sec
-- or connect to a unix domain socket file listened
-- by a redis server:
-- local ok, err = red:connect("unix:/path/to/redis.sock")
local ok, err = red:connect("127.0.0.1", 6379)
if not ok then
ngx.say("failed to connect: ", err)
return
end
Methods
- subscribe 訂閱頻道
- publish 發布信息
- read_reply 接收信息
實現代碼
- websocket.lua
-- 簡易聊天室
local server = require "resty.websocket.server"
local redis = require "resty.redis"
local channel_name = "chat"
local uname = "網友" .. tostring(math.random(10,99)) .. ": "
-- 創建 websocket 連接
local wb, err = server:new{
timeout = 10000,
max_payload_len = 65535
}
if not wb then
ngx.log(ngx.ERR, "failed to create new websocket: ", err)
return ngx.exit(444)
end
local push = function()
-- 創建redis連接
local red = redis:new()
red:set_timeout(5000) -- 1 sec
local ok, err = red:connect("172.17.0.3", 6379)
if not ok then
ngx.log(ngx.ERR, "failed to connect redis: ", err)
wb:send_close()
return
end
--訂閱聊天頻道
local res, err = red:subscribe(channel_name)
if not res then
ngx.log(ngx.ERR, "failed to sub redis: ", err)
wb:send_close()
return
end
-- 死循環獲取消息
while true do
local res, err = red:read_reply()
if res then
local item = res[3]
local bytes, err = wb:send_text(item)
if not bytes then
-- 錯誤直接退出
ngx.log(ngx.ERR, "failed to send text: ", err)
return ngx.exit(444)
end
end
end
end
-- 啟用一個線程用來發送信息
local co = ngx.thread.spawn(push)
-- 主線程
while true do
-- 如果連接損壞 退出
if wb.fatal then
ngx.log(ngx.ERR, "failed to receive frame: ", err)
return ngx.exit(444)
end
local data, typ, err = wb:recv_frame()
if not data then
-- 空消息, 發送心跳
local bytes, err = wb:send_ping()
if not bytes then
ngx.log(ngx.ERR, "failed to send ping: ", err)
return ngx.exit(444)
end
ngx.log(ngx.ERR, "send ping: ", data)
elseif typ == "close" then
-- 關閉連接
break
elseif typ == "ping" then
-- 回復心跳
local bytes, err = wb:send_pong()
if not bytes then
ngx.log(ngx.ERR, "failed to send pong: ", err)
return ngx.exit(444)
end
elseif typ == "pong" then
-- 心跳回包
ngx.log(ngx.ERR, "client ponged")
elseif typ == "text" then
-- 將消息發送到 redis 頻道
local red2 = redis:new()
red2:set_timeout(1000) -- 1 sec
local ok, err = red2:connect("172.17.0.3", 6379)
if not ok then
ngx.log(ngx.ERR, "failed to connect redis: ", err)
break
end
local res, err = red2:publish(channel_name, uname .. data)
if not res then
ngx.log(ngx.ERR, "failed to publish redis: ", err)
end
end
end
wb:send_close()
ngx.thread.wait(co)
- 前端頁面
<!DOCTYPE HTML>
<html>
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, user-scalable=no">
<style>
p{margin:0;}
</style>
<script src="https://cdn.bootcss.com/jquery/3.3.1/jquery.min.js"></script>
<script type="text/javascript">
var ws = null;
function WebSocketConn() {
if (ws != null && ws.readyState == 1) {
log("已經在線");
return
}
if ("WebSocket" in window) {
// Let us open a web socket
ws = new WebSocket("ws://123.207.144.90/ws");
ws.onopen = function () {
log('成功進入聊天室');
};
ws.onmessage = function (event) {
log(event.data)
};
ws.onclose = function () {
// websocket is closed.
log("已經和服務器斷開");
};
ws.onerror = function (event) {
console.log("error " + event.data);
};
} else {
// The browser doesn't support WebSocket
alert("WebSocket NOT supported by your Browser!");
}
}
function SendMsg() {
if (ws != null && ws.readyState == 1) {
var msg = document.getElementById('msgtext').value;
ws.send(msg);
} else {
log('請先進入聊天室');
}
}
function WebSocketClose() {
if (ws != null && ws.readyState == 1) {
ws.close();
log("發送斷開服務器請求");
} else {
log("當前沒有連接服務器")
}
}
function log(text) {
var li = document.createElement('p');
li.appendChild(document.createTextNode(text));
//document.getElementById('log').appendChild(li);
$("#log").prepend(li);
return false;
}
WebSocketConn();
</script>
</head>
<body>
<div id="sse">
<a href="javascript:WebSocketConn()">進入聊天室</a>
<a href="javascript:WebSocketClose()">離開聊天室</a>
<br>
<br>
<input id="msgtext" type="text">
<br>
<a href="javascript:SendMsg()">發送信息</a>
<br>
<br>
<div id="log"></div>
</div>
</body>
</html>