web端实现即时通讯(聊天)的几种方式

即时通讯技术简单的说就是实现这样一种功能:服务器端可以即时地将数据的更新或变化反应到客户端,例如消息即时推送等功能都是通过这种技术实现的。但是在Web中,由于浏览器的限制,实现即时通讯需要借助一些方法。这种限制出现的主要原因是,一般的Web通信都是浏览器先发送请求到服务器,服务器再进行响应完成数据的现实更新。

实现Web端即时通讯的方法:实现即时通讯主要有四种方式,它们分别是轮询、长轮询(comet)、长连接(SSE)、WebSocket。它们大体可以分为两类,一种是在HTTP基础上实现的,包括短轮询、cometSSE;另一种不是在HTTP基础上实现是,即WebSocket。下面分别介绍一下这四种轮询方式,以及它们各自的优缺点。

轮询

这种方式的优点是比较简单,易于理解,实现起来也没有什么技术难点。缺点是显而易见的,这种方式由于需要不断的建立http连接,严重浪费了服务器端和客户端的资源。尤其是在客户端,距离来说,如果有数量级想对比较大的人同时位于基于短轮询的应用中,那么每一个用户的客户端都会疯狂的向服务器端发送http请求,而且不会间断。人数越多,服务器端压力越大,这是很不合理的。

因此短轮询不适用于那些同时在线用户数量比较大,并且很注重性能的Web应用。

轮询直白点说就是前端每隔固定时间向后台发送一次请求,询问服务器是否有新数据

缺点: 延迟,需要固定的轮询时间,不一定是实时数据;大量耗费服务器内存和宽带资源,因为不停的请求服务器,很多时候 并没有新的数据更新,因此绝大部分请求都是无效请求

长轮询(comet)

当服务器收到客户端发来的请求后,服务器端不会直接进行响应,而是先将这个请求挂起,然后判断服务器端数据是否有更新。如果有更新,则进行响应,如果一直没有数据,则到达一定的时间限制(服务器端设置)才返回。 。 客户端JavaScript响应处理函数会在处理完服务器返回的信息后,再次发出请求,重新建立连接。

长轮询和短轮询比起来,明显减少了很多不必要的http请求次数,相比之下节约了资源。长轮询的缺点在于,连接挂起也会导致资源的浪费。

轮询与长轮询都是基于HTTP的,两者本身存在着缺陷:轮询需要更快的处理速度;长轮询则更要求处理并发的能力;两者都是“被动型服务器”的体现:服务器不会主动推送信息,而是在客户端发送ajax请求后进行返回的响应。而理想的模型是”在服务器端数据有了变化后,可以主动推送给客户端”,这种”主动型”服务器是解决这类问题的很好的方案。Web Sockets就是这样的方案。

长连接(SSE)

SSE是HTML5新增的功能,全称为Server-Sent Events。它可以允许服务推送数据到客户端。SSE在本质上就与之前的长轮询、短轮询不同,虽然都是基于http协议的,但是轮询需要客户端先发送请求。而SSE最大的特点就是不需要客户端发送请求,可以实现只要服务器端数据有更新,就可以马上发送到客户端。

SSE的优势很明显,它不需要建立或保持大量的客户端发往服务器端的请求,节约了很多资源,提升应用性能。并且后面会介绍道,SSE的实现非常简单,并且不需要依赖其他插件。

WebSocket

WebSocketHtml5定义的一个新协议,与传统的http协议不同,该协议可以实现服务器与客户端之间全双工通信。简单来说,首先需要在客户端和服务器端建立起一个连接,这部分需要http。连接一旦建立,客户端和服务器端就处于平等的地位,可以相互发送数据,不存在请求和响应的区别。

WebSocket的优点是实现了双向通信,缺点是服务器端的逻辑非常复杂。现在针对不同的后台语言有不同的插件可以使用。

WebSocket的模式就是在于当前端向后端发送请求创建一个websocket链连接之后,连接默认不断开,前端和服务端就维护了一个连接,前端可以通过连接给服务端发消息,服务端也可以通过连接给前端发消息,实现了双向通信,也叫双工通道

websocket原理

websocket协议原理

1.WebSocket协议是基于TCP的一种新的协议。WebSocket最初在HTML5规范中被引用为TCP连接,作为基于TCP的套接字API的占位符。它实现了浏览器与服务器全双工(full-duplex)通信。其本质是保持TCP连接,在浏览器和服务端通过Socket进行通信

2.websocket是建立在http协议之上的

连接,客户端发起

握手(验证),客户端发送一个消息,后端接收到消息再做一些特殊处理并返回,服务端需要支持websocket协议

收发数据(加密)

断开连接

websocket握手流程

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

注:magic string为:258EAFA5-E914-47DA-95CA-C5AB0DC85B11

扩展:

  http协议:\r\n分割、请求头和请求体\r\n分割、无状态、短连接。

  websocket协议:\r\n分割,创建连接后不断开、验证+数据加密;

1.客户端向服务的发送

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15

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
...
...

\r\n\r\n

2.服务的接收返回

1
2
3
4
5
6
7
8
9
10
// 1.Sec-WebSocket-Key  与 magic string 进行拼接
// magic string默认为:258EAFA5-E914-47DA-95CA-C5AB0DC85B11
v1 = 'mnwFxiOlctXFN/DeMt1Amg==' + '258EAFA5-E914-47DA-95CA-C5AB0DC85B11'


// 2.将拼接好的结果进行hmac1加密
v2 = hmac1(v1)

// 3. 将hmac1加密过的数据进行base64加密
v3 = base64(v2)
1
2
3
4
5
// 服务端返回
HTTP/1.1 101 Switching Protocols
Upgrade:websocket
Connection: Upgrade
Sec-WebSocket-Accept:v3 //将加密好的数据通过Sec-WebSocket-Accept返回给前端

收发数据(加密)

客户端和服务端传输数据时,以字节的形式进行传输需要对数据进行【封包】和【解包】。客户端的JavaScript类库已经封装【封包】和【解包】过程,但Socket服务端需要手动实现。

流程:

1.先获取第二个字节,1个字节是8位

2.再获取第二个字节的后7位 - > payload len

payload len== 127,向后读取8个字节,然后是其他字节(取4个字节拿到masking key 再去读后面的数据进行解密)

payload len== 126,向后读取2个字节,再去读取2个字节,然后是其他字节 (取4个字节拿到masking key 再去读后面的数据进行解密)

payload len<= 125, 向后读取2个字节,然后是其他字节 (取4个字节拿到masking key 再去读后面的数据进行解密)

3.获取masking key 进行解密

1
2
3
4
5
6
// 解密算法

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

几种Web即时通信技术比较

性能、兼容性对比

从兼容性角度考虑:短轮询>长轮询>长连接SSE>WebSocket

从性能方面考虑:WebSocket>长连接SSE>长轮询>短轮询

实现对比

对比条件短轮询长轮询Websocketsse
通讯方式httphttp基于TCP长连接通讯http
触发方式轮询轮询事件事件
优点兼容性好容错性强,实现简单相比短轮询大大降低了http请求次数,节约资源全双工通讯协议,性能开销小、安全性高,有一定可扩展性实现简便,开发成本低
缺点安全性差,占较多的内存资源与请求数安全性差,占较多的内存资源与请求数传输数据需要进行二次解析,增加开发成本及难度只适用高级浏览器
适用范围b/s服务b/s服务网络游戏、银行交互和支付服务端到客户端单向推送

短轮询、长轮询、sse实现

代码实现

轮询

前端每隔固定时间向后台发送一次请求,询问服务器是否有新数据

轮询的工作流程

  1. 前端发送数据

  2. 后端接收处理数据

  3. 前端定时请求数据

  4. 后端返回新数据

发送-接收数据
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
// 前端
<div class="message" id="message">

<div>
<input type="text" id="txt" placeholder="请输入">
{#发送按钮绑定点击事件#}
<input type="button" value="发送" onclick="sendMessage();">

</div>
{#jquery cdn #}
<script src="https://cdn.bootcdn.net/ajax/libs/jquery/3.6.0/jquery.js"></script>
<script>

// 发送数据
function sendMessage() {

var text = $("#txt").val();
// 基于ajax将用户输入的文本信息发送到后台
$.ajax({
url: '/pool/send/msg/',
type: 'GET',
data: {
text: text
},
success: function (res) {
console.log('请求发送成功', res)
}

})
}
</script>
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
#后端django views

# 假设DB是我们的缓存数据库
DB = []


def views(request):
return render(request, 'home.html')


def send_msg(request):
print('接收到到前端请求', request.GET)
text = request.GET.get('text')
# 将接收到的请求添加进数据库
DB.append(text)
return HttpResponse('ok')
定时请求、更新数据
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
// 前端
// 设置用户的的数据索引
max_index = 0;


// 每隔固定向后台发送请求获取数据并展示在界面上
setInterval(function () {
// 发送请求获取数据
$.ajax(
{
url: '/pool/get/msg/',
type: 'GET',
data: {
index: max_index
},
success: function (dataDict) {

// 将后端返回到index传给全局变量
max_index = dataDict.max_index
$.each(dataDict.data, function (index, item) {
// 将内容拼接成div标签,添加到message区域
var tag = $('<div>')
tag.text(item)
$("#message").append(tag);

})
}
}
)

}, 1000)// 间隔时间,单位毫秒
1
2
3
4
5
6
7
8
9
10
11
12
13
#django views

def get_msg(request):
index = request.GET.get('index')
index = int(index)
# 将接收到数据从数据库取处理出来返回,通过索引处理,如果是之前到老数据,不重复显示
context = {
'data': DB[index:],
# 将当前用户数据最大索引返回给前端
'max_index': len(DB)
}

return JsonResponse(context)

长轮询

长轮询: 当服务器收到前端发来的请求后,服务器端不会直接进行响应,而是先将这个请求挂起,然后判断服务器端数据是否有更新。如果有更新,则进行响应,

如果一直没有数据,则到达一定的时间限制(服务器端设置)才返回,返回完之后,客户端再次建立连接,周而复始,基于事件的触发,一个事件接一个事件

长轮询处理流程:

  1. 访问接口地址的时候,后端为每个用户创建一个队列

  2. 前端发送内容,数据发送到后天,将数据存储到每个人的队列中

  3. 前端递归获取消息,去自己的队列中获取数据,然后在界面上显示

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
//前端
USER_UID = "{{ uid }}";

// 发送数据
function sendMessage() {

var text = $("#txt").val();
// 基于ajax将用户输入的文本信息发送到后台
$.ajax({
url: '/pool/send/msg/',
type: 'GET',
data: {
text: text

}
})
}


// 获取数据
function getMessage() {
$.ajax(
{
url: '/pool/get/msg',
type: 'GET',
dataType: 'JSON',
data: {
uid: USER_UID,
},
success: function (res) {
if (res.status) {
var tag = $("div");
tag.text(res.data)
$("#message").append(tag);
}
// 递归调用
getMessage();
}
}
)
}

$(function (){
getMessage();
})
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
# django views
from django.shortcuts import render, HttpResponse
from django.http.response import JsonResponse
import queue

# Create your views here.


# 创建用户队列
USER_QUEUE = {}


def home(request):
# 获取当前访问用户uid
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
uid = request.GET.get('uid')
# 获取当前用户队列
q = USER_QUEUE[uid]
# 设置返回值
result = {'status': True, 'data': None}
try:
# 如果获取到数据,将数据传给 result['data'],队列超时时间为10s
data = q.get(timeout=10)
result['data'] = data
except queue.Empty as e:
# 如果10s没有新数据,将status设置为False返回
result['status'] = False
# 前端可以通过status 判断有没有新数据
return JsonResponse(result)

Q:服务端持有连接,服务器压力是不是很大?

A:如果基于IO多路复用 + 异步,可以解决这个问题

WebSocket

原来的web中:

http协议:无状态&短连接

    客户端主动连接服务端

    客户端向服务端发送消息,服务端收到返回数据

    客户端接收到数据

    断开连接

https对数据进行加密

我们在开发过程中想要保留一些状态信息,基于cookie来做

现在支持:

  http协议:一次请求一次响应

  websocket协议创建持久的连接不断开,基于这个连接可以进行收发数据【服务端向客户端主动推送消息】

channels 配置websocket

Django默认不支持websocket,需要Django支持的话需要安装第三方组件

django channelsdjango支持websocket的一个模块。

1.安装

1
pip3 install channels # 安装

2.settings.py配置

1
2
3
4
#INSTALLED_APPS 中注册channels
INSTALLED_APPS = (
'channels',
)
1
2
#添加ASGI支持websocket ,指向项目下asgi文件的application
ASGI_APPLICATION = "demo.asgi.application"

3.修改asgi文件

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
import os

from django.core.asgi import get_asgi_application

from channels.routing import ProtocolTypeRouter, URLRouter
from WebSocketPretty import routing

os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'DemoName.settings') # 添加项目配置

# application = get_asgi_application() 只支持http,注释掉该行

# 既支持http又支持websocket
application = ProtocolTypeRouter(
{'http': get_asgi_application(), # 自动找urls.py 视图函数 - > http请求
'websocket': URLRouter(routing.websocket_urlpatterns) # routing 相当于urls.py 路由
}
)

4.在app下创建routing.py (ws主路由)

Channels路由配置类似于Django URLconf,当通道服务器接收到HTTP请求时,它告诉通道运行什么代码。

在app目录下,创建一个文件 routing.py文件

1
2
3
4
5
6
7
8
from django.urls import path

from WebSocketPretty import consumers #consumers相当于ws视图

websocket_urlpatterns = [
# 请求地址匹配ws/ ,走websocket视图处理
path('ws/',consumers.ChatConsumer.as_asgi())
]

5.在app下创建consumers.py 文件

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
from channels.generic.websocket import WebsocketConsumer
from channels.exceptions import StopConsumer


class ChatConsumer(WebsocketConsumer):

def websocket_connect(self, message):
# 有客户端向后端发送websocket连接的请求时,自动触发

self.accept() # 服务端允许和客户端创建连接

def receive(self, text_data=None, bytes_data=None):
# text_data 是前端向后端发送数据,自动触发接收消息
print(text_data)
# send 是后端向前端发送数据
self.send('ok')

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

客户端主动向服务端发起websocket连接,服务端接收到连接后通过(握手)

客户端 websocket

1
socket = new WebSocket('ws://127.0.0.1/ws/');

服务端

1
2
3
4
def websocket_connect(self, message):
# 有客户端向后端发送websocket连接的请求时,自动触发

self.accept() # 服务端允许和客户端创建连接

收发消息(客户端向服务端发消息)

1
2
3
4
def receive(self, text_data=None, bytes_data=None):
# 前端基于websocket发送数据,自动触发接收消息

print('收到的消息->',text_data)

收发消息(服务端向客户端发送消息)

1
2
# 继承WebsocketConsumer类的函数内,self相当于当前websocket的连接对象
self.send('消息内容')

断开连接

1
2
#服务端主动关闭连接
self.close()
websocket self概念

self代表当前用户客户端与服务端的连接对象,比如两客户端发来了两个连接,我们可以把两个连接放在一起

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
# 定义全局变量
CONN_List = []

class LiveConsumer(WebsocketConsumer):

def websocket_connect(self, message):
# 将每个连接对象追加到列表
CONN_List.append(self)

def receive(self, text_data=None, bytes_data=None):
# 循环列表,每个连接对象发送消息
for conn in CONN_List:
conn.send('消息')

def websocket_disconnect(self, message):
CONN_List.remove(self)
raise StopConsumer
channels layers

settings.py配置

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
# 存储在内存里
CHANNEL_LAYERS = {
"default": {
"BACKEND": "channels.layers.InMemoryChannelLayer",
}
}
# 存储在redis
安装:pip3 install channels-redis

CHANNEL_LAYERS = {
"default": {
"BACKEND": "channels_redis.core.RedisChannelLayer",
"CONFIG":{
"hosts":['10.211.55.25',6379]
}
}}

channel_layer使用

连接

1
2
3
4
5
6
7
8
9
10
#异步连接
def websocket_connect(self, message):
# 将这个客户端的连接对象加入到某个地方(内存 or redis)
# channel_name 代表当前客户端连接创建到别名,放到名称A的组里
# 这种写法默认是异步的
self.channel_layer.grouo_add('名称A',self.channel_name)
# 如果不需要异步 ,没有写异步代码的话,我们可以将异步改为同步
from asgiref.sync import async_to_sync
def websocket_connect(self, message):
async_to_sync(self.channel_layer.grouo_add)('名称A',self.channel_name)

发送消息

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
#发送消息
def receive(self, text_data=None, bytes_data=None):
# 直接调用send,是给当前单个用户发送消息
self.send('1')
# 向名称A组内所有用户发送消息
async_to_sync(self.channel_layer.group_send)('名称A', {'messgae': '消息'})
#发送消息执行方法
def receive(self, text_data=None, bytes_data=None):
# 通知组内所有的客户端,执行ws_func方法,方法内可以自定义功能
async_to_sync(self.channel_layer.group_send)('名称A', {'type': 'ws_func','message':'消息'})

def ws_func(self,event):
# 向组内的每个用户发送消息
text = event['message']
self.send(text)

断开链接

1
2
3
4
def websocket_disconnect(self, message):
# 将当前用户从名称A的组内移除
async_to_sync(self.channel_layer.group_discard)('名称A',self.channel_name)_
raise StopConsumer()

动态获取路由匹配分组

1
2
3
4
5
6
7
# 路由
re_path(r'room/(?P<group>\w+)/$',Consumer)

def websocket_connect(self, message):

group = self.scope['url_route']['kwargs'].get('group')
async_to_sync(self.channel_layer.grouo_add)(group, self.channel_name)