基于 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>
相关文章
相关标签/搜索