Websocket

一、基础知识

1 通讯方式

轮询:让客户端每隔一定时间向服务端发送一次请求。缺点:延迟、请求太多服务端压力大。

长轮询:客户端向服务端发送请求,服务器保持这个请求一定时间,一旦有数据到来就立即返回数据。否则保持一段间后返回没有新数据,此时客户端重新发送请求,保持循环。优点:数据的响应没有延迟。例如:WebQQ,Web微信等。

websocket:客户端和服务端创建连接以后,这个连接不会断开。那么就可以实现双向通信。


二、 轮询方式

1 简单案例

前端定时向后端发送请求,后端发现数据更新后将新数据发送回去。

前端设定了一个index,来确保每次发送的数据都是新数据,不用重复发送数据。

<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Title</title>
<style>
.message{
height: 500px;
border: 1px solid #dddddd;
width: 100%;
}
</style>
</head>
<body>

<!-- 使用jquery库 -->
<script src="https://cdn.bootcdn.net/ajax/libs/jquery/3.7.1/jquery.min.js"></script>
<script>
// 发送数据
function sendMessage(){
var text = $("#txt").val();

$.ajax({
url:'/send/msg/',
type: 'GET',
data: {
text: text
}, success: function(res){
console.log("请求发送成功", res)
}
})
}

max_index = 0;

// 定时任务
setInterval(function(){
$.ajax({
url:'/get/msg/',
type: 'GET',
data: {
index: max_index
},
dataType: "JSON",
success: function(dataDict){
max_index = dataDict.max_index;
var dataArray = dataDict.data;

$.each(dataArray, function (index, item) {
console.log(index, item);
var tag = $("<div>");
tag.text(item);
$("#message").append(tag);
})
}
})
}, 2000)
</script>

<div class="message" id="message"></div>
<div>
<input type="text" id="txt" placeholder="请输入">
<input type="button" value="发送" onclick="sendMessage();">
</div>

</body>
</html>
from django.http import HttpResponse, JsonResponse
from django.shortcuts import render

# Create your views here.

# 有数据过来就存到此处,相当于数据库
DB = []

# 首页,返回前端
def home(request):
return render(request, 'home.html')

# 接收主动发送过来的数据存到数据库中
def send_msg(request):
text = request.GET.get('text')
DB.append(text)

return HttpResponse("OK")

# 前端轮询,此处接收index,确保发送的是新数据
def get_msg(request):
try:
index = int(request.GET.get("index"))
except TypeError:
index = 0

context = {
"data": DB[index:],
"max_index": len(DB),
}

return JsonResponse(context)

三、 长轮询方式

1 简单案例

访问/home/ 显示的聊天室界面。同时每个用户创建一个队列。

点击发送内容,数据也可以发送到后台。同时扔到每个人的队列中。

递归获取消息,去自己的队列中获取数据,然后再界面上展示。

<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Title</title>
<style>
.message{
height: 500px;
border: 1px solid #dddddd;
width: 100%;
}
</style>
</head>
<body>

<!-- 使用jquery库 -->
<script src="https://cdn.bootcdn.net/ajax/libs/jquery/3.7.1/jquery.min.js"></script>
<script>

// 设置全局UID,第一次发送后就一直存在此处
USER_UID = "{{ uid }}";

// 发送数据
function sendMessage(){
var text = $("#txt").val();

$.ajax({
url:'/send/msg/',
type: 'GET',
data: {
text: text
}, success: function(res){
console.log("请求发送成功", res)
}
})
}

function getMessage(){
$.ajax({
url:'/get/msg/',
type: 'GET',
data: {
uid: USER_UID
},
dataType: "JSON",
success: function(res){
// 两种情况:超时 / 有新数据
console.log(res);
if(res.status){
var tag = $("<div>");
tag.text(res.data);
$("#message").append(tag);
}
// 无论哪种结果,最后都再访问一次后端,然后等后端发送消息回来
getMessage();
}
})
}

// 先执行一遍getMessage
$(function(){
getMessage();
})
</script>

<div class="message" id="message"></div>
<div>
<input type="text" id="txt" placeholder="请输入">
<input type="button" value="发送" onclick="sendMessage();">
</div>

</body>
</html>
import queue

from django.http import HttpResponse, JsonResponse
from django.shortcuts import render


# 有数据过来就存到此处,相当于数据库
USER_QUEUE = {}

# 首页,返回前端
def home(request):

uid = request.GET.get('uid')
USER_QUEUE[uid] = queue.Queue()

return render(request, 'home.html', {'uid': uid})

# 接收主动发送过来的数据存到数据库中
def send_msg(request):
text = request.GET.get('text')
for uid, q in USER_QUEUE.items():
q.put(text)

return HttpResponse("OK")

# 去自己的队列获得消息
def get_msg(request):
uid = request.GET.get('uid')
q = USER_QUEUE[uid]

# 保证队列存在可以添加此代码
# if uid not in USER_QUEUE:
# USER_QUEUE[uid] = queue.Queue()


res = {'status': True, 'data': None}

# 阻塞,当队列为空时会阻塞timeout秒等待数据。
try:
data = q.get(timeout=10)
res['data'] = data
except queue.Empty as e:
res['status'] = False

return JsonResponse(res)

2 优化方案

Q:服务端持有这个连接,压力是否会很大?

A:使用IO多复用+异步可以解决。


四、websocket方式

1 定义

简单理解:web版的socket。

使用场景:想要服务端向客户端主动推送消息:

  • web聊天室
  • 实时图标,柱状图、饼图。

2 原理

author::你真的了解WebSocket吗? - 武沛齐

http协议:

  • 连接
  • 数据传输
  • 断开连接

websocket协议,是建立在http协议之上的。

  • 连接,客户端发起
  • 握手(验证),客户端发送一个消息,后端接收到消息再做一些特殊处理并返回
  • 收发数据(加密)
  • 断开连接

请求和响应的【握手】信息需要遵循规则:

  • 从请求【握手】信息中提取 Sec-WebSocket-Key
  • 利用magic_string 和 Sec-WebSocket-Key 进行hmac1加密,再进行base64加密
  • 将加密结果响应给客户端

验证时服务器的具体操作:

  1. 获得Sec-WebSocket-Key = mnwFxiOlctXFN/DeMt1Amg==
  2. v1 = "mnwFxiOlctXFN/DeMt1Amg==" + "258EAFA5-E914-47DA-95CA-C5AB0DC85B11"
  3. res = base64(hmac1(v1))
  4. 返回res

解密时的数据包:

  1. 取第2个字节的后7位(payload length):

    • 值=127时,数据头2字节、8字节,后面字节(4字节 masking key + 数据)。
    • 值=128,数据头2字节、2字节,后面字节(4字节 masking key + 数据)。
    • 值<=125,数据头2字节,后面字节(4字节 masking key + 数据)。
  2. 获得masking key,然后对数据解密

    var DECODED = "";
    for(var i = 0;i < ENCODE.length;i++){
    DECODED[i] = ENCODED[i] ^ MASK[i % 4];
    }

验证时客服端发送的数据

GET /chatsocket HTTP/1.1
Host: 127.0.0.1:8002
Connection: Upgrade
Pragma: no-cache
Cache-Control: no-cache
Upgrade: websocket
Origin: http://localhost:63342
Sec-WebSocket-Version: 13
Sec-WebSocket-Key: mnwFxiOlctXFN/DeMt1Amg==
Sec-WebSocket-Extensions: permessage-deflate; client_max_window_bits
...
...

验证时服务端返回的数据

HTTP/1.1 101 Switching Protocols
Upgrade:websocket
Connection:Upgrade
Sec-WebSocket-Accept: res

五、websocket使用

1 Django中配置

安装:pip install channels。注意版本问题,使用不了就降低版本(实测Django=4.2.6可以用Channels=3.0.5)。

案例:新建一个项目是ws_demo,新建一个appapp001

需要新增两个文件:一个routing.py(相当于websocketurls.py),一个consumers.py(相当于websocketviews.py

在django中需要了解:

  • wsgi:是一套Python Web的接口标准协议/规范。django一般情况下都是wsgi。启动时是Starting development server at http://127.0.0.1:8080/
  • asgi:wsgi + 异步 + websocket。启动时是Starting ASGI/Channels version 3.0.3 development server at http://127.0.0.1:8080/

制作流程:

  • 访问地址看到聊天室的页面,服务端发送http请求。
  • 让客户端主动向服务端发起websocket连接,服务端接收到连接后通过(握手)。

注意:

  • 无论是客户端还是服务端主动断开连接,还是用户关闭了页面,都会触发websocket_disconnect()函数。

客户端代码是返回的首页模板,服务端代码是修改customers.py

# setting.py
# 配置
INSTALLED_APPS = [
'channels'
]

# 新增(老版本没有asgi文件就自己创建一个)
ASGI_APPLICATION = "<project_name>.asgi.application"
# asgi.py
# 修改asgi,让它既支持http又支持websocket

import os
from django.core.asgi import get_asgi_application
from channels.routing import ProtocolTypeRouter, URLRouter
from ws_demo import routing

os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'ws_demo.settings')

# 原来asgi.py文件中的东西
# application = get_asgi_application()

application = ProtocolTypeRouter({
"http": get_asgi_application(),
"websocket": URLRouter(routing.websocket_urlpatterns),
})


# ws_demo路径下新建一个文件routing
from django.urls import re_path
from app001 import consumers

websocket_urlpatterns = [
# 当url:xxx/room/x1 匹配成功
re_path(r'room/(?P<group>\w+)/$', consumers.ChatConsumer.as_asgi()),
]
# app001下新建一个customers.py
from channels.generic.websocket import WebsocketConsumer
from channels.exceptions import StopConsumer


class ChatConsumer(WebsocketConsumer):
def websocket_connect(self, message):
""" 有客户端来向后端发送websocket连接请求时,自动触发 """
print("哼哼啊啊啊啊啊啊啊啊啊啊啊啊啊啊啊啊啊啊啊啊啊啊啊啊啊啊啊啊")
# 服务端允许和客户端创建连接
self.accept()

def websocket_receive(self, message):
""" 浏览器基于websocket向后端发送数据,自动触发接收消息 """
print(message)

self.send("不要回复不要回复")
# 手动断开连接
self.close()

def websocket_disconnect(self, message):
""" 客户端与服务端断开连接时,自动触发 """

# 执行后服务端内部会断开
raise StopConsumer()

2 客户端发消息

<!-- 客户端发消息 -->
<div class="message" id="message"></div>
<div>
<input type="text" id="txt" placeholder="请输入">
<input type="button" value="发送" onclick="sendMessage();">
</div>

<script>
// 不能在浏览器中直接ws://localhost:8000/ws//,因为websocket需要创建WebSocket对象
socket = new WebSocket("ws://127.0.0.1:8000/room/123/");

function sendMessage(){
let message = document.getElementById("txt").value;
// 发消息给后端
socket.send(message);
}
</script>
# 服务端收消息
from channels.generic.websocket import WebsocketConsumer
from channels.exceptions import StopConsumer

class ChatConsumer(WebsocketConsumer):
def websocket_connect(self, message):
self.accept()

def websocket_receive(self, message):
# 接收消息并输出
text = message.get("text", " ")
print("get: " + text)

def websocket_disconnect(self, message):
raise StopConsumer()

3 服务端发消息

<!-- 客户端收消息 -->
<div class="message" id="message"></div>
<div>
<input type="text" id="txt" placeholder="请输入">
<input type="button" value="发送" onclick="sendMessage();">
</div>

<script>
// 不能在浏览器中直接ws://localhost:8000/ws//,因为websocket需要创建WebSocket对象
socket = new WebSocket("ws://127.0.0.1:8000/room/123/");

// 回调函数,创建好连接以后自动触发(服务端执行self.accept()后)
socket.onopen = function (event) {
let tag = document.createElement("div");
tag.innerHTML = "[i]连接成功";
document.getElementById("message").appendChild(tag);
};


// 回调函数,当websocket接收到服务端发来的消息时,自动触发这个函数
socket.onmessage = function (event) {
console.log(event.data);
};
</script>
# 服务端发消息
class ChatConsumer(WebsocketConsumer):
def websocket_connect(self, message):
""" 有客户端来向后端发送websocket连接请求时,自动触发 """
# 服务端允许和客户端创建连接
self.accept()

self.send("[!]World Hello!")

def websocket_receive(self, message):
""" 浏览器基于websocket向后端发送数据,自动触发接收消息 """
text = message.get("text", " ")
print("get: " + text)

def websocket_disconnect(self, message):
""" 客户端与服务端断开连接时,自动触发 """
raise StopConsumer()

4 综合案例

注:该案例只是客户端和服务端一对一通信,还不能群聊。

<!-- 客户端 -->
<div class="message" id="message"></div>
<div>
<input type="text" id="txt" placeholder="请输入">
<input type="button" value="发送" onclick="sendMessage();">
<input type="button" value="关闭" onclick="closeConn();">
</div>

<script>
// 不能在浏览器中直接ws://localhost:8000/ws//,因为websocket需要创建WebSocket对象
socket = new WebSocket("ws://127.0.0.1:8000/room/123/");

// 创建好连接以后自动触发(服务端执行self.accept()后)
socket.onopen = function (event) {
let tag = document.createElement("div");
tag.innerHTML = "[i]连接成功";
document.getElementById("message").appendChild(tag);
};

socket.onmessage = function (event) {
let tag = document.createElement("div");
tag.innerHTML = event.data;
document.getElementById("message").appendChild(tag);
};

// 服务端主动断开连接时被触发(服务端执行self.close()后)
socket.onclose = function(event){
let tag = document.createElement("div");
tag.innerHTML = "[x]连接关闭";
document.getElementById("message").appendChild(tag);
}

function sendMessage(){
let message = document.getElementById("txt").value;
socket.send(message);
}

function closeConn(){
socket.close();
}
</script>
# 服务端
from channels.generic.websocket import WebsocketConsumer
from channels.exceptions import StopConsumer

class ChatConsumer(WebsocketConsumer):
def websocket_connect(self, message):
""" 有客户端来向后端发送websocket连接请求时,自动触发 """
# 服务端允许和客户端创建连接
self.accept()

self.send("[!]World Hello!")

def websocket_receive(self, message):
""" 浏览器基于websocket向后端发送数据,自动触发接收消息 """
text = message.get("text", " ")
print("get: " + text)

# 客户端手动断开
if text == "关闭":
self.close()
# 调用self.close()后也会触发websocket_disconnect()函数,如果不想触发就抛出StopConsumer()的异常
# raise StopConsumer()
return

self.send("[!]收到了" + text + "喵")

def websocket_disconnect(self, message):
""" 客户端与服务端断开连接时,自动触发 """
print("客户端主动断开了喵")
# 执行后服务端内部会断开
raise StopConsumer()

5 连接列表实现群聊

在后端维护有个连接列表,有连接来就加入列表中,然后发送消息时遍历用户发送消息,用户离开时就移除列表。

优点:实现快。

缺点:效率低,功能不够强大。

<!-- 客户端 -->
<div class="message" id="message"></div>
<div>
<input type="text" id="txt" placeholder="请输入">
<input type="button" value="发送" onclick="sendMessage();">
<input type="button" value="关闭" onclick="closeConn();">
</div>

<script>
// 不能在浏览器中直接ws://localhost:8000/ws//,因为websocket需要创建WebSocket对象
socket = new WebSocket("ws://127.0.0.1:8000/room/123/");

// 创建好连接以后自动触发(服务端执行self.accept()后)
socket.onopen = function (event) {
let tag = document.createElement("div");
tag.innerHTML = "[i]连接成功";
document.getElementById("message").appendChild(tag);
};

socket.onmessage = function (event) {
let tag = document.createElement("div");
tag.innerHTML = event.data;
document.getElementById("message").appendChild(tag);
};

// 服务端主动断开连接时被触发(服务端执行self.close()后)
socket.onclose = function(event){
let tag = document.createElement("div");
tag.innerHTML = "[x]连接关闭";
document.getElementById("message").appendChild(tag);
}

function sendMessage(){
let message = document.getElementById("txt").value;
socket.send(message);
}

function closeConn(){
socket.close();
}
</script>
# 服务端
from channels.generic.websocket import WebsocketConsumer
from channels.exceptions import StopConsumer

CONN_LIST = []


class ChatConsumer(WebsocketConsumer):
def websocket_connect(self, message):
""" 有客户端来向后端发送websocket连接请求时,自动触发 """
# 服务端允许和客户端创建连接
self.accept()
CONN_LIST.append(self)

self.send("[!]World Hello!")

def websocket_receive(self, message):
""" 浏览器基于websocket向后端发送数据,自动触发接收消息 """
text = message.get("text", " ")
print("get: " + text)

for conn in CONN_LIST:
conn.send("[!]收到了" + text + "喵")

def websocket_disconnect(self, message):
""" 客户端与服务端断开连接时,自动触发 """
print("客户端主动断开了喵")

CONN_LIST.remove(self)
# 执行后服务端内部会断开
raise StopConsumer()

6 Channel layers实现

6.1 配置

author::django channels - 武沛齐 - 博客园 (cnblogs.com)

如果使用redis作为内存需要安装:pip3 install channels-redis

# setting.py(使用系统内存)
# 配置存储位置,此处是配置在内存中
CHANNEL_LAYERS = {
"default": {
"BACKEND": "channels.layer.InMemeoryCahnnelLayer",
}
}
# setting.py(使用redis)
# 配置存储位置,此处是配置在redis中
CHANNEL_LAYERS = {
'default': {
'BACKEND': 'channels_redis.core.RedisChannelLayer',
'CONFIG': {"hosts": ["redis://10.211.55.25:6379/1"],},
},
}

6.2使用

客户端发送时是发送http://127.0.0.1:8000/index/?qq=123,来进入不同的群。

服务器依照群号来发送消息。

<!-- 客户端 -->
<div class="message" id="message"></div>
<div>
<input type="text" id="txt" placeholder="请输入">
<input type="button" value="发送" onclick="sendMessage();">
<input type="button" value="关闭" onclick="closeConn();">
</div>

<script>
// 此处换成qq群号,便于区分进入了哪个群
socket = new WebSocket("ws://127.0.0.1:8000/room/{{ qq_group_num }}/");

// 创建好连接以后自动触发(服务端执行self.accept()后)
socket.onopen = function(event) {
let tag = document.createElement("div");
tag.innerHTML = "[i]连接成功";
document.getElementById("message").appendChild(tag);
};

socket.onmessage = function(event) {
let tag = document.createElement("div");
tag.innerHTML = event.data;
document.getElementById("message").appendChild(tag);
};

// 服务端主动断开连接时被触发(服务端执行self.close()后)
socket.onclose = function(event) {
let tag = document.createElement("div");
tag.innerHTML = "[x]连接关闭";
document.getElementById("message").appendChild(tag);
}

function sendMessage() {
let message = document.getElementById("txt").value;
socket.send(message);
}

function closeConn(){
socket.close();
}
</script>
# urls.py
from django.urls import path
from app001 import views

urlpatterns = [
# http://127.0.0.1:8000/index/?qq=123456,模拟依照群号进群
path('index/', views.index)
]
# views.py
from django.shortcuts import render

def index(request):
qq_group_num = request.GET.get("qq", "")
return render(request, 'index.html',{"qq_group_num": qq_group_num})
# consumers.py
from channels.generic.websocket import WebsocketConsumer
from channels.exceptions import StopConsumer
from asgiref.sync import async_to_sync

class ChatConsumer(WebsocketConsumer):
def websocket_connect(self, message):
""" 有客户端来向后端发送websocket连接请求时,自动触发 """
# 服务端允许和客户端创建连接
self.accept()

# 获取群号,获取路由匹配
group = self.scope['url_route']['kwargs'].get("group")

# 将客户端的连接对象加入到某个地方(内存 or redis)
# 注意async_to_sync。该功能是异步的,所以代码中如果是同步的,需要先将异步转成同步
async_to_sync(self.channel_layer.group_add)(group, self.channel_name)

def websocket_receive(self, message):
""" 浏览器基于websocket向后端发送数据,自动触发接收消息 """
group = self.scope['url_route']['kwargs'].get("group")
# 定义执行方法xx.oo,消息是message
dic = {"type": "xx.oo", "message": message}

# 通知组内所有客户端,执行xx_oo(存的时候是点,写函数的时候就是_,在此方法中定义功能)
async_to_sync(self.channel_layer.group_send)(group, dic)

def xx_oo(self, event):
text = event['message']['text']
self.send("[!]收到了" + text + "喵")

def websocket_disconnect(self, message):
""" 客户端与服务端断开连接时,自动触发 """
group = self.scope['url_route']['kwargs'].get("group")
async_to_sync(self.channel_layer.group_discard)(group, self.channel_name)

# 执行后服务端内部会断开
raise StopConsumer()