WebSocket剖析

WebSocket剖析

http协议

在了解WebSocket之前,有必要简单复习一下http协议。

请求和响应

Http协议用于客户端与服务端的通信,客户端发出请求(request),服务端返回响应(response)。下面我们以访问https://www.sogou.com/搜狗首页为例,来看看请求报文和响应报文:
下面是从客户端访问服务器的请求报文的截取内容:

GET / HTTP/1.1
Host: www.sogou.com
Connection: keep-alive
Cache-Control: max-age=0

第一行的GET表示请求方法;随后的 / 表示请求访问的资源对象(request-URI),这里是根页面;最后的HTTP/1.1是协议和版本号。
第二行开始是首部字段:Host字段表示服务器域名。(这里只截取了部分首部字段,实际的字段更多)
http请求报文由请求方法、URI、HTTP版本、HTTP首部字段构成。

下面是服务器返回的响应报文的截取内容:

HTTP/1.1 200 OK
Server: nginx
Date: Tue, 19 Sep 2017 08:37:38 GMT
Content-Type: text/html; charset=UTF-8
Transfer-Encoding: chunked
Connection: keep-alive

<html>
......

第一行的HTTP/1.1表示服务器对应的HTTP版本; 200 OK表示请求处理结果的状态码和原因短语。
第二行开始是首部字段,包括服务器安装的软件版本,响应日期等。
响应报文包括响应头和响应体。
响应头由HTTP版本、状态码、原因短语、首部字段组成。
最下面的<html>开始是响应体,也就是用户在浏览器上看到的具体网页,由两组\r\n换行符与上面的响应头分隔。

http是被动的协议

使用http协议,通信只能由客户端发起,服务端返回响应永远是被动的,不能由服务端主动发起。

http是非持久的协议

使用http协议通信时,需要不断地建立,关闭http连接。每当有新请求到达时,就会有对应的新的响应产生。一次请求,一次响应,结束,这就是http的生命周期。http1.1中多了一个keep-alive,在一次http连接中,可以发送多个请求,接收多个响应。但是一个请求只能有一个响应。

轮询和长轮询

如果希望实现持久连接的效果,比如在聊天室应用中,就要借助轮询(poll)或者长轮询(long poll)。简单来说,轮询就是客户端每隔几秒,就向服务端发送一次请求,询问是否有新消息。而长轮询则是阻塞模式,客户端发起请求后,一段时间内(web微信是25秒的样子,可以打开浏览器的开发者工具,查看Network,有一个pending状态,定期会刷新一次),服务端只要没有新消息,就不返回响应。一旦新消息到达或者超时,就返回响应给客户端,一次连接结束,客户端重新发起请求,周而复始。web QQ 和 web 微信,都是用长轮询做的。

轮询和长轮询效率低,消耗资源:

  • 轮询要求不停地连接,即浏览器隔几秒就要向服务器发出请求,然而HTTP请求可能包含较长的头部,其中真正有效的数据可能只是很小的一部分,显然这样会浪费很多的带宽资源。同理,服务器隔几秒就要返回响应,消息可能存在延时,不仅浪费带宽,还要求服务器有很快的处理速度。
  • 长轮询要求http连接始终打开,也会对服务器造成很大压力,要求服务器处理大并发的能力。

http是无状态协议

http协议本身并不保留之前的一切请求或响应报文的信息。这是为了更快地处理大量事物,确保协议的可伸缩性,也能减轻服务器的压力。而且由于不需要保存状态,http协议本身比较简单,能被应用在各种场景里。如果需要管理状态,需要借助Cookie和Session,在每次连接中,告诉服务端你是谁。

WebSocket

是什么

WebSocket协议是在HTML5中定义的,目前主流浏览器都支持这一标准。它能更好的节省服务器资源和带宽,并且能够更实时地进行通讯。

WebSocket是一种在单个TCP连接上进行全双工通讯的协议。使得客户端和服务器之间的数据交换变得更加简单,允许服务端主动向客户端推送数据。在WebSocket API中,浏览器和服务器只需要完成一次握手,两者之间就直接可以创建持久性的连接,并进行双向数据传输。

主要特点

  • 较少的控制开销。在连接创建后,服务器和客户端之间交换数据时,用于协议控制的数据包头部相对较小。在不包含扩展的情况下,对于服务器到客户端的内容,此头部大小只有2至10字节(和数据包长度有关);对于客户端到服务器的内容,此头部还需要加上额外的4字节的掩码。相对于HTTP请求每次都要携带完整的头部,此项开销显著减少了。

  • 更强的实时性。由于协议是全双工的,所以服务器可以随时主动给客户端下发数据。相对于HTTP请求需要等待客户端发起请求服务端才能响应,延迟明显更少;即使是和Comet等类似的长轮询比较,其也能在短时间内更多次地传递数据。

  • 保持连接状态。与HTTP不同的是,Websocket需要先创建连接,这就使得其成为一种有状态的协议,之后通信时可以省略部分状态信息。而HTTP请求可能需要在每个请求都携带状态信息(如身份认证等)。

  • 更好的二进制支持。Websocket定义了二进制帧,相对HTTP,可以更轻松地处理二进制内容。

  • 没有同源限制,客户端可以与任意服务器通信。

  • Websocket使用ws或wss的统一资源标志符,比如:ws://example.com/path。类似于HTTPS,其中wss表示在TLS之上的Websocket。Websocket使用和 HTTP 相同的 TCP 端口,可以绕过大多数防火墙的限制。默认情况下,Websocket协议使用80端口;运行在TLS之上时,默认使用443端口。

    bg2017051503

握手过程

为了创建Websocket连接,需要通过浏览器发出请求,之后服务器进行回应,这个过程通常称为“握手”(handshaking)。Websocket 通过 HTTP/1.1 协议的101状态码进行握手。过程如下:

客户端请求

GET / HTTP/1.1
Upgrade: websocket
Connection: Upgrade
Host: example.com
Origin: http://example.com
Sec-WebSocket-Key: sN9cRrP/n9NdMgdcy2VJFQ==
Sec-WebSocket-Version: 13

服务端回应

HTTP/1.1 101 Switching Protocols
Upgrade: websocket
Connection: Upgrade
Sec-WebSocket-Accept: fFBooB7FAkLlXgRSz0BT3v4hq5s=
Sec-WebSocket-Location: ws://example.com/

说明

  • Connection必须设置Upgrade,表示客户端希望连接升级
  • Upgrade字段必须设置Websocket,表示希望升级到Websocket协议
  • Sec-WebSocket-Key是随机的字符串,作验证用的,为了避免和HTTP请求混淆:
    • 服务端提取Sec-WebSocket-Key
    • 将一个特殊字符串(magic_string)和Sec-WebSocket-Key先进行SHA-1摘要计算,之后进行BASE-64编码
    • 编码结果作为响应头Sec-WebSocket-Accept字段的值,返回给客户端
    • 客户端将这个值和本地计算的值对比,如果一致,则进行Websocket通信
  • Sec-WebSocket-Version 表示支持的Websocket版本。RFC6455要求使用的版本是13,之前草案的版本均应当弃用
  • 其他一些定义在HTTP协议中的字段,比如cookie,也可以在Websocket中使用。

客户端和服务端通过Websocket通信示例

客户端和服务端传输数据时,需要对数据进行【封包】和【解包】。客户端的JavaScript类库已经封装【封包】和【解包】过程,直接调用API即可。这里用python的Socket来手动实现服务端。

客户端示例代码
<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <title>Title</title>
</head>
<body>
<div>
    <input type="text" id="txt">
    <input type="button" id="submit" value="提交" onclick="sendMsg()">
    <input type="button" id="close" value="关闭连接" onclick="closeConn()">
</div>
<div id="info"></div>

<script>
    var ws = new WebSocket("ws://127.0.0.1:8000");

    /* WebSocket 对象的回调函数:
     * onopen 连接成功后自动执行
     * onmessage 服务端向客户端发送数据时,自动执行
     * onclese 服务端断开连接时,自动执行
     * */
    ws.onopen = function () {
        var ele = document.createElement('div');
        ele.innerText = '【服务端 连接成功】';
        document.getElementById('info').appendChild(ele);
    };

    ws.onmessage = function (event) {
        var response = event.data;
        var ele = document.createElement('div');
        ele.innerText = response;
        document.getElementById('info').appendChild(ele);
    };

    ws.onclose = function (event) {
        var ele = document.createElement('div');
        ele.innerText = '【websocket 连接关闭】';
        document.getElementById('info').appendChild(ele);
    };

    function sendMsg() {
        var txt = document.getElementById('txt');
        ws.send(txt.value); //发送数据
        txt.value = '';
    }

    function closeConn() {
        ws.close(); //关闭websocket
        var ele = document.createElement('div');
        ele.innerText = '【客户端 连接关闭】';
        document.getElementById('info').appendChild(ele);
    }
</script>

</body>
</html>
API说明
实例化WebSocket对象
var ws = new WebSocket('ws://127.0.0.1:8080');
readyState

readyState属性返回实例对象的当前状态,共有四种:

  • CONNECTING:值为0,表示正在连接。
  • OPEN:值为1,表示连接成功,可以通信了。
  • CLOSING:值为2,表示连接正在关闭。
  • CLOSED:值为3,表示连接已经关闭,或者打开连接失败。
switch (ws.readyState) {
  case WebSocket.CONNECTING:
    // do something
    break;
  case WebSocket.OPEN:
    // do something
    break;
  case WebSocket.CLOSING:
    // do something
    break;
  case WebSocket.CLOSED:
    // do something
    break;
  default:
    // this never happens
    break;
}
onopen

用于指定连接成功后的回调函数

ws.onopen = function() {
  consoel.log('连接成功');
  // do something
}
onclose

服务端断开连接时,要执行的回调函数

ws.onclose = function(event) {
  var code = event.code;
  var reason = event.reason;
  var wasClean = event.wasClean;
  // handle close event
};
onmessage

该属性用于指定收到服务器消息后的回调函数

ws.onmessage = function(event) {
  var data = event.data;
  // 处理数据
};
send()

用于webSocket对象向服务器发送数据

ws.send('hello world');
onerror

指定出错时的回到函数

ws.onerror = function(event) {
  console.log('something wrong');
};
其它

如果要为webSocket对象的某个事件指定多个回调函数,可以使用addEventListener方法来扩展:

function foo() {
    console.log('do something');
}

ws.addEventListener('open', foo);

扩展阅读:
https://developer.mozilla.org/zh-CN/docs/Web/API/EventTarget/addEventListener

服务端

由于服务端是用python的Socket手动实现的,因此,握手,消息解包,消息封包,都需要手动完成。

客户端发送过来的是二进制数据,握手阶段必然要提取请求头信息,并进行websocket通信的验证。

提取请求头
def get_headers(data):
    """将请求头转化为字典"""
    header_dict = {}
    data = str(data, encoding='utf-8')
    header, body = data.split('\r\n\r\n', 1)
    header_list = header.split('\r\n')

    for i in range(0, len(header_list)):
        if i == 0:
            if len(header_list[i].split(' ')) == 3:  # 分离请求头首行信息
                header_dict['method'], header_dict['uri'], header_dict['protocol'] = header_list[i].split(' ')

        else:  # 首部字段
            k, v = header_list[i].split(':', 1)
            header_dict[k] = v.strip()

    return header_dict
websocket通信的验证
def handshaking_response(data):
    """
    响应客户端websocket握手:1.提取请求头 2.计算Sec-WebSocket-Key 3.返回携带Sec-WebSocket-Accept的响应
    :param data: 客户端握手请求数据
    :return: 
    """
    headers = get_headers(data)  # 提取请求头
    # 从请求头提取Sec-WebSocket-Key
    # 将magic_string和Sec-WebSocket-Key先进行SHA-1摘要计算,
    # 之后进行BASE - 64编码,编码结果作为响应头Sec-WebSocket-Accept字段的值,返回给客户端
    magic_string = '258EAFA5-E914-47DA-95CA-C5AB0DC85B11'  # 协议规定的魔法字符串
    value = headers['Sec-WebSocket-Key'] + magic_string
    res = base64.b64encode(hashlib.sha1(value.encode('utf-8')).digest())

    response_tpl = "HTTP/1.1 101 Switching Protocols\r\n" \
                   "Upgrade:websocket\r\n" \
                   "Connection: Upgrade\r\n" \
                   "Sec-WebSocket-Accept: %s\r\n" \
                   "WebSocket-Location: ws://%s%s\r\n\r\n"
    # 响应
    response = response_tpl % (res.decode('utf-8'), headers['Host'], headers['uri'])
    return response
消息解包
def get_msg(data):
    """
    服务端手动解包客户端发来的数据
    :param data: 客户端发来的原始bytes数据
    :return: msg 解包后的请求体数据
    """
    payload_len = data[1] & 127
    if payload_len == 126:
        extend_payload_len = msg[2:4]
        mask = data[4:8]
        decoded = data[8:]  # decoded 是请求体数据
    elif payload_len == 127:
        extend_payload_len = data[2:10]
        mask = data[10:14]
        decoded = data[14:]
    else:
        extend_payload_len = None
        mask = data[2:6]
        decoded = data[6:]

    bytes_list = bytearray()
    for i in range(len(decoded)):
        chunk = decoded[i] ^ mask[i % 4]
        bytes_list.append(chunk)

    msg = str(bytes_list, encoding='utf-8')
    return msg
消息封包
def send_msg(conn, msg_bytes):
    """
    服务端向客户端发送消息
    :param conn: 客户端连接到服务器的socket对象
    :param msg_bytes: 向客户端发送的字节
    :return: 
    """
    token = b'\x81'
    length = len(msg_bytes)
    if length < 126:
        token += struct.pack('B', length)
    elif length <= 0xFFFF:
        token += struct.pack('!BH', 126, length)
    else:
        token += struct.pack("!BQ", 127, length)

    msg = token + msg_bytes
    conn.send(msg)

    return True
主程序

主程序中将调用以上函数,进行websocket通信

def main():
    # 创建TCP套接字
    tcpsock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
    tcpsock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
    tcpsock.bind(('127.0.0.1', 8000))
    tcpsock.listen(5)

    while True: # 连接循环
        print('waitting for connection...')
        # 收到握手请求
        conn, addr = tcpsock.accept()
        data = conn.recv(1024)
        print('connected from', addr)

        # 返回握手响应
        response = handshaking_response(data)
        conn.send(bytes(response, encoding='utf-8'))

        # 通讯循环
        while True:
            try:
                data = conn.recv(8096)
                if not data:
                    break
                msg = get_msg(data) # 解包收到的数据
                print('收到信息:',msg)
                send_msg(conn, ('服务端响应:'+ msg).encode('utf-8')) # 封包,发送数据
            except Exception as e:
                print('客户端异常断开')

        conn.close()

    tcpsock.close()

if __name__ == '__main__':
    main()

在tornado中使用WebSocket

WebSocket作为一种较新的标准,并不被所有的web框架所支持,比如大名鼎鼎的Django,是不支持的。不过tornado框架原生支持tornado,并且简单易用,基本使用流程如下:

  • 视图继承tornado.websocket.WebSocketHandler
  • 定义回调函数open, 客户端连接成功时,自动执行
  • 定义回调函数on_message, 收到客户端消息时,自动执行
  • 定义回调函数 on_close, 客户端断开连接时,自动执行
实现基于WebSocket的实时聊天室

下面我们来实现一个简单的web聊天室,基本逻辑如下:

  • 客户端通过http协议访问:http://127.0.0.1:8000/
  • 服务端返回index.html页面
  • index.html页面加载完成后,会通过JS创建WebSocket对象,访问ws://127.0.0.1:8000/chat
  • 服务端执行继承了WebSocketHandler的视图中的回调函数,开始进行websocket通信。

服务端代码:

#! user/bin/env python
# -*- coding: utf-8 -*-
import uuid
import json
import tornado.web
import tornado.ioloop
import tornado.websocket


class IndexHandler(tornado.web.RequestHandler):
    """处理客户端的http请求"""
    def get(self):
        self.render('index.html')


class ChatHandler(tornado.websocket.WebSocketHandler):
    """处理websocket请求"""
    waiters = set()  # 存储当前聊天室用户
    messages = []  # 存储历史消息

    def open(self):
        print('连接建立')
        ChatHandler.waiters.add(self)
        uid = str(uuid.uuid4()) # 生成用户标识
        self.write_message(uid)

        # 将历史信息传入模板渲染,并将结果返回给客户端
        for msg in ChatHandler.messages:
            content = self.render_string('message.html', **msg)
            self.write_message(content)

    def on_message(self, message):
        msg = json.loads(message)
        ChatHandler.messages.append(msg)

        # 给聊天室的所有用户返回刚收到的信息
        for client in ChatHandler.waiters:
            content = client.render_string('message.html', **msg)
            client.write_message(content)

    def on_close(self):
        # 客户端断开连接后,移除该对象
        ChatHandler.waiters.remove(self)


def main():
    settings = {
         'template_path': 'templates',
    }
    application = tornado.web.Application([
        (r'/', IndexHandler),
        (r'/chat', ChatHandler),
    ], **settings)

    application.listen(8000)
    tornado.ioloop.IOLoop.instance().start()


if __name__ == '__main__':
    main()

message.html模板:

<div style="border: 1px solid #dddddd;margin: 10px;">
    <div>游客{{uid}}</div>
    <div style="margin-left: 20px;">{{message}}</div>
</div>

index.html模板:

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <title>Python聊天室</title>
</head>
<body>
    <div>
        <input type="text" id="txt"/>
        <input type="button" id="btn" value="提交" onclick="sendMsg();"/>
        <input type="button" id="close" value="关闭连接" onclick="closeConn();"/>
    </div>
    <div id="container" style="border: 1px solid #dddddd;margin: 20px;min-height: 500px;">

    </div>

    <script>

        window.onload = function() {
            wsUpdater.start();
        };

        var wsUpdater = {
            socket: null,
            uid: null,
            start: function() {
                var url = "ws://127.0.0.1:8000/chat";
                wsUpdater.socket = new WebSocket(url);
                wsUpdater.socket.onmessage = function(event) {
                    console.log(event);
                    if(wsUpdater.uid){
                        wsUpdater.showMessage(event.data);
                    }else{
                        wsUpdater.uid = event.data;
                    }
                }
            },
            // 显示消息
            showMessage: function(content) {
                var container = document.getElementById('container');
                var ele = document.createElement('div');
                ele.innerHTML = content;
                container.appendChild(ele);
            }
        };
        //发送消息
        function sendMsg() {
            var msg = {
                uid: wsUpdater.uid,
                message: document.getElementById('txt').value
            };
            wsUpdater.socket.send(JSON.stringify(msg));
        }
        //关闭连接
        function closeConn() {
          wsUpdater.socket.close();
        }

</script>

</body>
</html>

可以开多个浏览器窗口测试,真正做到了消息的即时推送。基本效果如下,比较简陋:

3


本文所有源码,可以在这里查看:https://github.com/Ayhan-Huang/WebSocket-Test

本文参考了以下文章,在此表示感谢:

http://www.ruanyifeng.com/blog/2017/05/websocket.html

http://www.cnblogs.com/wupeiqi/p/6558766.html

  • 3
    点赞
  • 3
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值