WebSocket详解

发布时间 2023-12-12 11:34:00作者: Meeeoww


实时跟服务端通信的方案

1.轮询
原理简单易懂,就是客户端通过一定的时间间隔以频繁请求的方式向服务器发送请求,来保持客户端和服务器端的数据同步。问题很明显,当客户端以固定频率向服务器端发送请求时,服务器端的数据可能并没有更新,带来很多无谓请求,浪费带宽,效率低下。
2.长轮询
客户端和服务端保持一个长链接(http),等服务端有消息返回就断开,如果没有消息就会hold住等待一段时间,然后再重新链接,这也是一个循环的过程。
3.websocket协议
客户端发起HTTP握手,告诉服务端进行WebSocket协议通讯,并告知WebSocket协议版本。
服务端确认协议版本,升级为WebSocket协议。之后如果有数据需要推送,会主动推送给客户端。

WebSocket介绍

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

握手阶段采用 HTTP 协议。
数据格式轻量,性能开销小。客户端与服务端进行数据交换时,服务端到客户端的数据包头只有2到10字节,客户端到服务端需要加上另外4字节的掩码。HTTP每次都需要携带完整头部。
更好的二进制支持,可以发送文本,和二进制数据
没有同源限制,客户端可以与任意服务器通信

非 WebSocket 模式传统 HTTP 客户端与服务器的交互如下图所示:

使用 WebSocket 模式客户端与服务器的交互如下图:

上图对比可以看出,相对于传统 HTTP 每次请求-应答都需要客户端与服务端建立连接的模式,WebSocket 是类似 Socket 的 TCP 长连接的通讯模式,一旦 WebSocket 连接建立后,后续数据都以帧序列的形式传输。在客户端断开 WebSocket 连接或 Server 端断掉连接前,不需要客户端和服务端重新发起连接请求。在海量并发及客户端与服务器交互负载流量大的情况下,极大的节省了网络带宽资源的消耗,有明显的性能优势,且客户端发送和接受消息是在同一个持久连接上发起,实时性优势明显。

我们再通过客户端和服务端交互的报文看一下 WebSocket 通讯与传统 HTTP 的不同。

在客户端,new WebSocket 实例化一个新的 WebSocket 客户端对象,连接类似 ws://yourdomain:port/path 的服务端 WebSocket URL,WebSocket 客户端对象会自动解析并识别为 WebSocket 请求,从而连接服务端端口,执行双方握手过程。

客户端发送请求协议

GET / HTTP/1.1\r\n  # 请求首行,握手阶段还是使用http协议
Host: 127.0.0.1:8080\r\n # 请求头
Connection: Upgrade\r\n  # 表示要升级协议
Pragma: no-cache\r\n
Cache-Control: no-cache\r\n
User-Agent: Mozilla/5.0 (Macintosh; Intel Mac OS X 10_14_6) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/90.0.4430.72 Safari/537.36\r\n
Upgrade: websocket\r\n  # 要升级协议到websocket协议
Origin: http://localhost:63342\r\n
Sec-WebSocket-Version: 13\r\n  # 表示websocket的版本。如果服务端不支持该版本,需要返回一个Sec-WebSocket-Versionheader,里面包含服务端支持的版本号
Accept-Encoding: gzip, deflate, br\r\n
Accept-Language: zh-CN,zh;q=0.9,en;q=0.8\r\n
Sec-WebSocket-Key: 07EWNDBSpegw1vfsIBJtkg==\r\n # 对应服务端响应头的Sec-WebSocket-Accept,由于没有同源限制,websocket客户端可任意连接支持websocket的服务。这个就相当于一个钥匙一把锁,避免多余的,无意义的连接
Sec-WebSocket-Extensions: permessage-deflate; client_max_window_bits\r\n\r\n

可以看到,客户端发起的 WebSocket 连接报文类似传统 HTTP 报文,”Upgrade:websocket”参数值表明这是 WebSocket 类型请求,“Sec-WebSocket-Key”是 WebSocket 客户端发送的一个 base64 编码的密文,要求服务端必须返回一个对应加密的“Sec-WebSocket-Accept”应答,否则客户端会抛出“Error during WebSocket handshake”错误,并关闭连接。

服务端收到报文后响应协议(响应成功,便握手成功,就可以双工通信了):

HTTP/1.1 101 Switching Protocols\r\n  # 响应首行,还是使用http协议
Upgrade:websocket\r\n                 # 表示要升级到websocket协议
Connection: Upgrade\r\n               # 表示要升级协议
Sec-WebSocket-Accept: 07EWNDBSpegw1vfsIBJtkg==\r\n # 根据客户端请求首部的Sec-WebSocket-Key计算出来。
WebSocket-Location: ws://127.0.0.1:8000\r\n\r\n

代码实操

socket模块实现

服务端

import base64
import hashlib
import socket

sock = socket.socket()
# 绑定host,默认端口8000
sock.bind(("127.0.0.1", 8000))
# 半连接池大小
sock.listen(5)


def get_headers(data):
    """
    将请求头转换为字典
    """
    header_dict = {}
    data = str(data, encoding="utf-8")
    """
    请求头格式:
    GET / HTTP/1.1\r\n  # 请求首行,握手阶段还是使用http协议
    Host: 127.0.0.1:8080\r\n # 请求头
    Connection: Upgrade\r\n  # 表示要升级协议
    Pragma: no-cache\r\n
    Cache-Control: no-cache\r\n
    User-Agent: Mozilla/5.0 (Macintosh; Intel Mac OS X 10_14_6) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/90.0.4430.72 Safari/537.36\r\n
    Upgrade: websocket\r\n  # 要升级协议到websocket协议
    Origin: http://localhost:63342\r\n
    Sec-WebSocket-Version: 13\r\n  # 表示websocket的版本。如果服务端不支持该版本,需要返回一个Sec-WebSocket-Versionheader,里面包含服务端支持的版本号
    Accept-Encoding: gzip, deflate, br\r\n
    Accept-Language: zh-CN,zh;q=0.9,en;q=0.8\r\n
    Sec-WebSocket-Key: 07EWNDBSpegw1vfsIBJtkg==\r\n # 对应服务端响应头的Sec-WebSocket-Accept,由于没有同源限制,websocket客户端可任意连接支持websocket的服务。这个就相当于一个钥匙一把锁,避免多余的,无意义的连接
    Sec-WebSocket-Extensions: permessage-deflate; client_max_window_bits\r\n\r\n
    """
    header, body = data.split("\r\n\r\n", 1)  # 因为请求头信息结尾都是\r\n,并且最后末尾部分是\r\n\r\n;
    # 所以以此分割
    header_list = header.split("\r\n")
    for i in range(0, len(header_list)):
        if i == 0:
            if len(header_list[0].split(" ")) == 3:
                header_dict['method'], header_dict['url'], header_dict['protocol'] = header_list[0].split(" ")
        else:
            k, v = header_list[i].split(":", 1)
            header_dict[k] = v.strip()
    return header_dict


def get_data(info):
    """
    对返回消息进行解码
    比较复杂,详见数据帧格式解析:
    https://www.cnblogs.com/chyingp/p/websocket-deep-in.html
    """
    payload_len = info[1] & 127
    if payload_len == 126:
        mask = info[4:8]
        decoded = info[8:]
    elif payload_len == 127:
        mask = info[10:14]
        decoded = info[14:]
    else:
        mask = info[2:6]
        decoded = info[6:]
    bytes_list = bytearray()  # 使用字节将数据全部收集,再去字符串编码,这样不会导致中文乱码
    for i in range(len(decoded)):
        chunk = decoded[i] ^ mask[i % 4]  # 解码方式
        bytes_list.append(chunk)
    body = str(bytes_list, encoding='utf-8')
    return body


# 等待用户连接
conn, addr = sock.accept()
print("客户端连接了:", conn, addr)
# 获取握手消息,magic string ,sha1加密
data = conn.recv(1024)
headers = get_headers(data)

# 对请求头中的sec-websocket-key进行加密
res_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"
# 固定的RFC规定的
magic_string = '258EAFA5-E914-47DA-95CA-C5AB0DC85B11'
# 确认握手Sec-WebSocket-Key固定格式:headers头部的Sec-WebSocket-Key+'258EAFA5-E914-47DA-95CA-C5AB0DC85B11'
# 确认握手的秘钥值为 传入的秘钥+magic_string,使用sha1算法加密,然后base64转码
value = headers['Sec-WebSocket-Key'] + magic_string
ac = base64.b64encode(hashlib.sha1(value.encode('utf-8')).digest())
response_str = res_tpl % (ac.decode('utf-8'), headers['Host'], headers['url'])

# 响应握手信息,这样就建立连接成功,双方就可以互通信息了
conn.send(bytes(response_str, encoding='utf-8'))
# 可以进行通信--接收客户端发送的消息
while True:
    data = conn.recv(1024)
    data = get_data(data)
    print(data)

客户端

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <title>websocket通信</title>
    <script src="https://cdn.bootcdn.net/ajax/libs/jquery/3.6.0/jquery.js"></script>
</head>
<body>
请输入:<input type="text" id="chat-message-input">
<button id="chat-message-submit">发送</button>
<hr>
通信内容:
<br>
<textarea id="chat-log" cols="30" rows="10"></textarea>
</body>
<script>
    var chatSocket = new WebSocket('ws://127.0.0.1:8000');

    chatSocket.onmessage = function (e) {
        var data = JSON.parse(e.data);
        var message = data['message'];
        console.log(message)
        var datamsg = $('#chat-log').val() + message + '\n'
        $('#chat-log').val(datamsg)
    };

    chatSocket.onclose = function (e) {
        console.error(e);
    };
    $('#chat-message-submit').click(function () {
        console.log(chatSocket.readyState)
        if (chatSocket.readyState === 1) {
            var message = $('#chat-message-input').val()
            chatSocket.send(JSON.stringify({
                'message': message
            }));
            $('#chat-message-input').val("")
        } else {
            console.log("还没有连接")
        }


    })
</script>
</html>

django+channels集成实现

环境

本案例使用的环境是(channels2.0最低django版本要求是1.11+,python3.5+)
python 3.6
Django 2.2.2
channels 2.1.7
channels-redis 2.3.3

第一步:安装模块

Django==2.1.4 #channels2.0最低django版本要求是1.11+,python3.5+
channels==2.1.7
channels-redis==2.3.3

第二步:配置settings.py

INSTALLED_APPS = [
    ...
    'app01.apps.App01Config',
    'channels',
]
#2 配置channels layer
ASGI_APPLICATION = 'django_websocket.routing.application' #自己routing的路径
CHANNEL_LAYERS = {
    # 真实上线使用redis
    # 'default': {
    #     'BACKEND': 'channels_redis.core.RedisChannelLayer',
    #     'CONFIG': {
    #         "hosts": [('127.0.0.1', 6379)],  # 需修改redis的地址
    #     },
    # },
  # 测试阶段,放到内存中即可
    'default': {
        'BACKEND': 'channels.layers.InMemoryChannelLayer',
    },
}

第三步:配置路由
在项目settings文件同级目录中新增routing.py

from channels.auth import AuthMiddlewareStack
from channels.routing import ProtocolTypeRouter, URLRouter
from app01 import routing

application = ProtocolTypeRouter({
    # 'websocket': AuthMiddlewareStack( # 使用AuthMiddlewareStack,后续在视图类中可以取出self.scope,相当于request对象
    #     URLRouter(
    #         routing.websocket_urlpatterns# 指明路由文件是django_websocket/routing.py,类似于路由分发
    #     )
    # ),
    'websocket': URLRouter(
            routing.websocket_urlpatterns  # 指明路由文件是django_websocket/routing.py,类似于路由分发
),
})

在app里配置路由和对应的消费者,(我是app01下的routing.py):

from django.urls import path
from . import consumers

websocket_urlpatterns = [
    path('ws/chat', consumers.Chatting), #consumers.Chatting 是该路由的消费者
]

第四步:在app目录下编写consumers.py

from channels.generic.websocket import AsyncWebsocketConsumer
import json
class Chatting(AsyncWebsocketConsumer):
    async def connect(self):
        print('connect')
        # AuthMiddlewareStack:封装了django的auth模块,使用这个配置我们就可以在consumer中通过下边的代码获取到用户的信息
        print(self.scope)
        self.room_group_name = 'xiaoyuanqujing'
        # 加入聊天室
        await self.channel_layer.group_add(
            self.room_group_name,
            self.channel_name
        )

        await self.accept()

    async def disconnect(self, close_code):
        # 离开聊天室
        print('disconnect')
        print(self.scope)
        await self.channel_layer.group_discard(
            self.room_group_name,
            self.channel_name
        )

    # 通过WebSocket,接收数据
    async def receive(self, text_data):
        print('receive')
        text_data_json = json.loads(text_data)
        message = text_data_json['message']
        print(message)
        # Send message to room group
        await self.channel_layer.group_send(
            self.room_group_name,
            {
                'type': 'chat_message', # 写一个当前类中的方法名,会触发该方法执行,组里有多少人会触发多少次
                'message': message,
                'username':str(self.scope['query_string']) # 把当前客户端的名字传入(也可以去ip地址)
            }
        )

    # Receive message from room group
    async def chat_message(self, event):
        print('chat_message')
        # 取出发言人的名字和说的话
        message = event['username'] + event['message']
        # 通过WebSocket发送
        await self.send(text_data=json.dumps({
            'message': message
        }))

第五步:前端发起websocket请求

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <title>websocket通信</title>
    <script src="https://cdn.bootcdn.net/ajax/libs/jquery/3.6.0/jquery.js"></script>
</head>
<body>
<p>姓名:<input type="text" id="username">
    <button id="btn">加入聊天室</button>
</p>
请输入:<input type="text" id="chat-message-input">
<button id="chat-message-submit">发送</button>
<hr>
通信内容:
<br>
<textarea id="chat-log" cols="30" rows="10"></textarea>
</body>
<script>
    var chatSocket
    $('#btn').click(function () {
        var username = $('#username').val()
        chatSocket = new WebSocket('ws://' + window.location.host + '/ws/chat?name=' + username);
        chatSocket.onopen = function (e) {
            console.log(e)

        }
        chatSocket.onmessage = function (e) {
            var data = JSON.parse(e.data);
            var message = data['message'];
            console.log(message)
            var datamsg = $('#chat-log').val() + message + '\n'
            $('#chat-log').val(datamsg)
        };

        chatSocket.onclose = function (e) {
            console.error(e);
        };
    })


    $('#chat-message-submit').click(function () {
        console.log(chatSocket.readyState)
        if (chatSocket.readyState === 1) {
            var message = $('#chat-message-input').val()
            chatSocket.send(JSON.stringify({
                'message': message
            }));
            $('#chat-message-input').val("")
        } else {
            console.log("还没有连接")
        }


    })
</script>
</html>

第六步:启动redis,启动项目