基於 OpenResty 實現一個 WS 聊天室

基於 OpenResty 實現一個 WS 聊天室

WebSocket

WebSocket 協議分析

WebSocket 協議解決了瀏覽器和服務器之間的全雙工通訊問題。在WebSocket出現以前,瀏覽器若是須要從服務器及時得到更新,則須要不停的對服務器主動發起請求,也就是 Web 中經常使用的 poll 技術。這樣的操做很是低效,這是由於每發起一次新的 HTTP 請求,就須要單獨開啓一個新的 TCP 連接,同時 HTTP 協議自己也是一種開銷很是大的協議。爲了解決這些問題,因此出現了 WebSocket 協議。WebSocket 使得瀏覽器和服務器之間能經過一個持久的 TCP 連接就能完成數據的雙向通訊。關於 WebSocket 的 RFC 提案,能夠參看 RFC6455。javascript

WebSocket 和 HTTP 協議通常狀況下都工做在瀏覽器中,但 WebSocket 是一種徹底不一樣於 HTTP 的協議。儘管,瀏覽器須要經過 HTTP 協議的 GET 請求,將 HTTP 協議升級爲 WebSocket 協議。升級的過程被稱爲 握手(handshake)。當瀏覽器和服務器成功握手後,則能夠開始根據 WebSocket 定義的通訊幀格式開始通訊了。像其餘各類協議同樣,WebSocket 協議的通訊幀也分爲控制數據幀和普通數據幀,前者用於控制 WebSocket 連接狀態,後者用於承載數據。下面咱們將一一分析 WebSocket 協議的握手過程以及通訊幀格式。css

WebSocket 協議的握手過程

握手的過程也就是將 HTTP 協議升級爲 WebSocket 協議的過程。前面咱們說過,握手開始首先由瀏覽器端發送一個 GET 請求開發,該請求的 HTTP 頭部信息以下:html

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 請求中,增長了一些新的字段,其做用以下所示:java

  • Upgrade: 規定必需的字段,其值必需爲 websocket, 若是不是則握手失敗;
  • Connection: 規定必需的字段,值必需爲 Upgrade, 若是不是則握手失敗;
  • Sec-WebSocket-Key: 必需字段,一個隨機的字符串;
  • Sec-WebSocket-Version: 必需字段,表明了 WebSocket 協議版本,值必需是 13, 不然握手失敗;

返回的響應中,若是握手成功會返回狀態碼爲 101 的 HTTP 響應。同時其餘字段說明以下:jquery

  • Upgrade: 規定必需的字段,其值必需爲 websocket, 若是不是則握手失敗;
  • Connection: 規定必需的字段,值必需爲 Upgrade, 若是不是則握手失敗;
  • Sec-WebSocket-Accept: 規定必需的字段,該字段的值是經過固定字符串258EAFA5-E914-47DA-95CA-C5AB0DC85B11加上請求中Sec-WebSocket-Key字段的值,而後再對其結果經過 SHA1 哈希算法求出的結果。

當瀏覽器和服務器端成功握手後,就能夠傳送數據了,傳送數據是按照 WebSocket 協議的數據格式生成的。git

WebSocket 協議數據幀

數據幀的定義相似於 TCP/IP 協議的格式定義,具體看下圖:github

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 連接中接收到的數據幀,都要按照以上格式進行解析,這樣才能知道該數據幀是用於控制的仍是用於傳送數據的。web

OpenResty

2.1 resty.websocket 庫

模塊文檔:redis

https://github.com/openresty/lua-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

  1. new
  2. set_timeout
  3. send_text
  4. send_binary
  5. send_ping
  6. send_pong
  7. send_close
  8. send_frame
  9. recv_frame

2.2 resty.redis 模塊

模塊文檔:

https://github.com/openresty/lua-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

  1. subscribe 訂閱頻道
  2. publish 發佈信息
  3. read_reply 接收信息

實現代碼

  1. 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)
  1. 前端頁面
<!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> &nbsp;
    <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>
相關文章
相關標籤/搜索