websocket 使用

服务端给客户端推送消息的效果

  • 轮询(伪)
  • 长轮询(伪)
  • websocket(真)

轮询(效率低、基本不用)

"""
让客户端浏览器每隔一段时间(每隔5s)主动朝服务端偷偷的发送请求
缺点:
	消息延迟(5S+网络延迟)
	请求次数多(24小时消耗资源都很高)
"""

长轮询(使用广泛、兼容性好)

"""
服务端给每一个客户端浏览器创建一个队列,浏览器通过ajax偷偷的朝服务器索要队列中的数据,如果没有数据则会原地阻塞30s但是不会一直阻塞而是利用timeout参数加异常处理的方式,如果超出时间限,客户端在此发送请求数据的请求(递归调用)
优点:
	消息基本没有延迟
	请求次数降低了,节省资源
"""
# 目前大公司都喜欢使用长轮询,比如网页版的qq和微信

基于长轮询原理实现简易版本的群聊功能

ps:当你使用pycharm创建django项目的时候会自动帮你创建模版文件夹,但是你在终端或者服务器上创建项目的时候是没有该文件夹的

当全局没有模版文件夹的时候,那么在查找模版的时候顺序是按照配置文件中注册了的app的顺序,从上往下一次查找(实操演示)

INSTALLED_APPS = [
    'django.contrib.admin',
    'django.contrib.auth',
    'django.contrib.contenttypes',
    'django.contrib.sessions',
    'django.contrib.messages',
    'django.contrib.staticfiles',
    'app01.apps.App01Config',
]
# 后端
from django.shortcuts import render,HttpResponse
import queue
from django.http import JsonResponse
# Create your views here.
q_dict = {}  # {'jason':"jason的队列对象",...}
def home(request):
    # 获取自定义的客户端唯一标示
    name = request.GET.get('name')
    # 给字典添加键值对 键是唯一表示 值是队列对象
    q_dict[name] = queue.Queue()
    # 返回给前端一个聊天室页面
    return render(request,'home.html',locals())
def send_msg(request):
    # 获取用户发送的消息
    content = request.POST.get('content')
    # 给多有的队列放一份当前数据
    for q in q_dict.values():
        q.put(content)
    return HttpResponse('OK')
def get_msg(request):
    # 获取客户端唯一标示
    name = request.GET.get('name')
    # 获取唯一标示对应的队列对象
    q = q_dict.get(name)
    back_dic = {'status':True,'msg':''}
    try:
        data = q.get(timeout=10)
        back_dic['msg'] = data
    except queue.Empty as e:
        back_dic['status'] = False
    return JsonResponse(back_dic)
<h1>聊天室:{{ name }}</h1>
<div>
    <input type="text" id="txt">
    <button onclick="sendMsg()">发送消息</button>
</div>
<h1>聊天记录</h1>
<div class="record">
</div>
<script>
    function sendMsg() {
        $.ajax({
            url:'/send_msg/',
            type:'post',
            data:{'content':$('#txt').val()},
            success:function (args) {
            }
        })
    }
    function getMsg() {
        $.ajax({
            url:'/get_msg/',
            type:'get',
            data:{'name':'{{ name }}'},
            success:function (args) {
                if (args.status){
                    // 将消息通过DOM操作渲染到前端页面
                    // 1 创建标签 p标签
                    var pELe = $('<p>');
                    // 2 给p标签添加文本内容
                    pELe.text(args.msg)
                    // 3 将该p标签渲染到div内部
                    $('.record').append(pELe)
                }
                // 再次朝后端发请求
                getMsg()
            }
        })
    }
    // 等待页面加载完毕之后 立刻执行
    $(function () {
        getMsg()
    })
</script>

websocket(主流浏览器都支持)

"""
HTTP协议
	数据交互式明文的 没有做加密处理
HTTPS协议
	数据交互式加密的 有加密处理 更加安全
上述的两个协议都是短链接协议:你请求我 我响应你 
	
websocket协议
	数据交互式加密的 有加密处理 更加安全
websocket协议是长链接协议:建立连接之后默认不断开,双方都可以主动的收发消息
websocket的诞生真正的实现了服务端给客户端主动推送消息
"""

内部原理

"""
1.握手环节:验证当前客户端或者服务端是否支持websocket协议
	客户端浏览器访问服务端之后,会立刻在本地生成一个随机字符串,并且将该随机字符串自己保留然后给服务端也发送一份(基于HTTP协议进行数据交互 请求头里面)
	客户端和服务端都会对该随机字符串做以下处理
		1.先将该随机字符串与magic string做字符串的拼接操作(这个magic string是全球统一固定的一个字符串)
		2.再对拼接之后的结果用sha1和base64算法加密
	服务端将处理好的结果发送给客户端(基于HTTP协议  响应头里面)
	客户端获取到服务端发送过来的随机字符串之后与自己本地处理好的随机字符串做比对,如果两者一致说明当前服务端支持websocket协议,如果不一致说明不支持,直接报错,如果一致那么双方就会创建websocket连接
	
2.收发数据:数据的解密过程
	ps:
		1.数据基于网络传输都是二进制格式 对应到python里面可以用bytes类型标示
		2.二进制转十进制
	1.先读取第二个字节的后七位(payload)
	2.根据payload不同做不同的处理
		payload = 127:继续往后读取8个字节(报头占10个字节)
		payload = 126:继续往后读取2个字节(报头占4个字节)
		payload <= 125:不再往后继续读取(报头占2个字节)
	
	3.继续往后读取固定4个字节的数据(masking-key)
		依据该值解析出真实数据	
"""
"""
请求头
Sec-WebSocket-Key: HCLzbZUPQCdMiVf3Oc8r3g==
"""

![截屏2021-08-08 下午8.42.43](/Users/yubin/Desktop/django/django笔记/drf笔记/img/截屏2021-08-08 下午8.42.43.png)

代码验证(无需掌握,搂一眼即可)

import socket
import hashlib
import base64

# 正常的socket代码
sock = socket.socket()
# 针对mac本 重启服务端总是报端口被占用的情况
sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
sock.bind(('127.0.0.1', 8080))
sock.listen(5)
conn, address = sock.accept()
data = conn.recv(1024)  # 获取客户端发送的消息
print(data.decode('utf-8'))
# 一
### 三
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['url'], header_dict['protocol'] = header_list[i].split(' ')
        else:
            k, v = header_list[i].split(':', 1)
            header_dict[k] = v.strip()
    return header_dict
#### 四
def get_data(info):
    """
    按照websocket解密规则针对不同的数字进行不同的解密处理
    :param info:
    :return:
    """
    payload_len = info[1] & 127
    if payload_len == 126:
        extend_payload_len = info[2:4]
        mask = info[4:8]
        decoded = info[8:]
    elif payload_len == 127:
        extend_payload_len = info[2:10]
        mask = info[10:14]
        decoded = info[14:]
    else:
        extend_payload_len = None
        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
## 二
header_dict = get_headers(data)  # 将一大堆请求头转换成字典数据  类似于wsgiref模块
client_random_string = header_dict['Sec-WebSocket-Key']  # 获取浏览器发送过来的随机字符串
magic_string = '258EAFA5-E914-47DA-95CA-C5AB0DC85B11'  # 全球共用的随机字符串 一个都不能写错
value = client_random_string + magic_string  # 拼接
ac = base64.b64encode(hashlib.sha1(value.encode('utf-8')).digest())  # 加密处理

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://127.0.0.1:8080\r\n\r\n"
response_str = tpl %ac.decode('utf-8')  # 处理到响应头中

# 将随机字符串给浏览器返回回去
conn.send(bytes(response_str, encoding='utf-8'))


while True:
      data = conn.recv(1024)
      # print(data)  # b'\x81\x8b\xd9\xf7} \xb1\x92\x11L\xb6\xd7\nO\xab\x9b\x19'
      value = get_data(data)
      print(value)
<!--需要你掌握前端一行代码-->
<script>
    var ws = new WebSocket('ws://127.0.0.1:8080/')
    // 这一句话干了很多事
    // 1 自动生成随机字符串
    // 2 对字符串进行一系列的处理
    // 3 接受服务端返回的结果自动做比对校验
</script>

总结:实际生产中不会使用到上面的代码,直接使用别人封装好的模块

后端框架如何支持websocket

"""
并不是所有的框架都支持websocket
django
	- 默认不支持websocket
	- 可以借助于第三方的工具:channles模块
flask
	- 默认不支持
	- 可以借助于第三方的工具:gevetwebsocket模块
tornado
	- 默认就是支持的
"""

django如何支持websocket

安装channels模块需要注意

  • 版本不能使用最新版,如果直接安装了最新版,它可能会自动将你的django版本也升级为最新版
  • python解释器建议你使用3.6版本(官网的说法是:3.5可能有问题,3.7也有可能有问题)

安装

pip3 install channels==2.3

基于channels模块实现群聊功能

  • 配置文件中注册channles应用

    INSTALLED_APPS = [
        # 1 注册channles
        'channels'
    ]
    # 注册之后django项目无法正常启动了
    CommandError: You have not set ASGI_APPLICATION, which is needed to run the server.
    
  • 配置文件中定义配置ASGI_APPLICATION

    ASGI_APPLICATION = 'zm6_deploy.routing.application'
    # ASGI_APPLICATION = '项目名同名的文件夹名.项目名同名的文件夹内创建一个py文件(默认就叫routing.py).在该py文件内容定义一个变量(application)
    """
    django默认的http协议 路由视图函数对应关系
    urls.py >>> views.py
    django支持websocket之后 需要单独创建路由与视图函数对应关系
    routing.py  >>> consumers.py
    扩展:当你的视图函数特别多的时候 你可以根据功能的不同的拆分成不同的py文件
    views文件夹
    	user.py
    	account.py
    	shop.py
    	admin.py
    """
    
  • 在项目名同名的文件夹下创建routing.py文件书写以下固定代码

    from channels.routing import ProtocolTypeRouter,URLRouter
    application = ProtocolTypeRouter({
        'websocket':URLRouter([
            # 写websocket路由与视图函数对应关系
        ])
    })
    

    上述配置完成后,django就会由原来的wsgiref启动变成asgi启动,并且即支持原来的http协议又支持websocket协议

    # 源码
    class ProtocolTypeRouter:
        def __init__(self, application_mapping):
            self.application_mapping = application_mapping
            if "http" not in self.application_mapping:
                self.application_mapping["http"] = AsgiHandler
    

    群聊实现

    routing添加路由映射

    url(r'^chat/$',consumers.ChatConsumers)
    

    consumers书写相应代码

    # 先测试后端三个方法再讲解前端四个方法再实现单聊最后群聊
    from channels.generic.websocket import WebsocketConsumer
    from channels.exceptions import StopConsumer
    # 群聊
    consumers_object_list = []
    
    class ChatConsumers(WebsocketConsumer):
        def websocket_connect(self, message):
            """握手环节 验证及建立链接"""
            # print('建立链接')
            self.accept()  # 建立链接
            # 将所有链接对象添加到列表中
            consumers_object_list.append(self)
    
        def websocket_receive(self, message):
            """客户端发送消息到服务端之后自动触发该方法"""
            print(message)  # {'type': 'websocket.receive', 'text': 'hello world'}
            # 给客户端回复消息
            # self.send('你好啊')
            text = message.get('text')
            # 再给用户返回回去
            # self.send(text_data=text)  # self谁来就是谁 这里就相当于是单独发送
            # 循环出列表中所有的链接对象 发送消息
            for obj in consumers_object_list:
                obj.send(text_data=text)
    
        def websocket_disconnect(self, message):
            """客户端断开链接之后自动触发"""
            # print('断开了')
            # 应该将断开的链接对象从列表中删除
            consumers_object_list.remove(self)
            raise StopConsumer()
    

    前端四个方法

    <h1>聊天室</h1>
    <div>
        <input type="text" id="txt">
        <button onclick="sendMsg()">发送消息</button>
    </div>
    <h1>聊天记录</h1>
    <div class="record">
    </div>
    
    <script>
        var ws = new WebSocket("ws://127.0.0.1:8000/chat/");
        // 1 请求链接成功之后  自动触发
        ws.onopen = function () {
            {#alert('链接成功')#}
        }
        // 2 朝服务端发消息
        function sendMsg() {
            ws.send($('#txt').val())
        }
        // 3 服务端给客户端回复消息的时候 自动触发
        ws.onmessage = function (args) {
            {#alert(args)  // 是一个对象 #}
            var res = args.data
            {#alert(res)#}
            var pEle = $('<p>');
            pEle.text(res);
            $('.record').append(pEle)
        }
        // 4 断开链接之后自动触发
        ws.onclose = function () {
            console.log('断开链接了')
        }
    </script>
    

    上述的群聊功能实现,是我们自己想的一种比较low的方式

    其实channels模块给你提供了一个专门用于做群聊功能的模块channle-layers模块该模块暂时不讲,我们放到后面写代码的时候再来看实际应用

    channle-layers基本使用

    • 配置文件中配置参数

      CHANNEL_LAYERS = {
          'default':{
              'BACKEND':'channels.layers.InMemoryChannelLayer'
          }
      }
      
    • 如何获取无名有名分组中url携带的参数

      task_id = self.scope['url_route']['kwargs'].get('task_id')  #有名
      task_id = self.scope['url_route']['kwargs']  #无名
      
    • 链接对象自动加入对应的群聊

      from asgiref.sync import async_to_sync
      def websocket_connect(self, message):
              task_id = self.scope['url_route']['kwargs'].get('task_id')
      
              """握手环节 验证及建立链接"""
              # print('建立链接')
              self.accept()  # 建立链接
              async_to_sync(self.channel_layer.group_add)(task_id, self.channel_name)
              """
              第一个参数放一个字符串形式的群号
              第二个参数相当于当前链接对象的唯一标识
              """
              # consumers_object_list.append(self)
      
    • 给特定的群中发消息

          def websocket_receive(self, message):
              """客户端发送消息到服务端之后自动触发该方法"""
      
      				task_id = self.scope['url_route']['kwargs'].get('task_id')
      
              async_to_sync(self.channel_layer.group_send)(task_id, 
                 {'type': 'my.send','message': {'code':'init', 'data': data}})
      """
      					async_to_sync(self.channel_layer.group_send)(type,message)
                  后面字典的键是固定的 就叫type和message
                  type后面指定的值就是负责发送消息的方法(将message后面的数据交由type后面指定的方法发送给对应的群聊中)
                  针对type后面的方法名 有一个固定的变化格式
                  my.send     >>>    my_send
                  xxx.ooo     >>>    xxx_ooo
      """
      
    • 在类中需要定义一个专门发送消息的方法

      def my_send(self,event):
              message = event.get('message')  # {'code':'init','data':node_list}
              # 发送数据
              self.send(json.dumps(message))
              """
              内部原理就类似于是循环当前群组里面所有的链接对象 然后依次执行send方法
              for self in self_list:
                  self.send
              """
      
    • 断开链接之后去对应的群聊中剔除群成员

          def websocket_disconnect(self, message):
              """客户端断开链接之后自动触发"""
              # print('断开了')
              # 应该将断开的链接对象从列表中删除
              task_id = self.scope['url_route']['kwargs'].get('task_id')
              async_to_sync(self.channel_layer.group_discard)(task_id, self.channel_name)
              raise StopConsumer()
      

    如何区分不同的任务直接群发消息混乱的情况,针对群号应该做区分

    其实可以直接使用任务的主键值作为群号

二、vue项目如何引用websocket?

vue使用websocket需要注意以下几点:

(1) 部分浏览器不支持websocket

(2)在组件加载的时候连接websocket,在组件销毁的时候断开websocket

(3)后端接口需要引入socket模块,否则不能实现连接

不废话了,直接附上完整代码:

<template>
    <div>
        <button @click="send">发消息</button>
    </div>
</template>

<script>
export default {
    data () {
        return {
            path:"ws://192.168.0.200:8005/qrCodePage/ID=1/refreshTime=5",
            socket:""
        }
    },
    mounted () {
        // 初始化
        this.init()
    },
    methods: {
        init: function () {
            if(typeof(WebSocket) === "undefined"){
                alert("您的浏览器不支持socket")
            }else{
                // 实例化socket
                this.socket = new WebSocket(this.path)
                // 监听socket连接
                this.socket.onopen = this.open
                // 监听socket错误信息
                this.socket.onerror = this.error
                // 监听socket消息
                this.socket.onmessage = this.getMessage
            }
        },
        open: function () {
            console.log("socket连接成功")
        },
        error: function () {
            console.log("连接错误")
        },
        getMessage: function (msg) {
            console.log(msg.data)
        },
        send: function () {
            this.socket.send(params)
        },
        close: function () {
            console.log("socket已经关闭")
        }
    },
    destroyed () {
        // 销毁监听
        this.socket.onclose = this.close
    }
}
</script>

<style>

</style>
posted @ 2021-08-14 21:20  小俞先生  阅读(1068)  评论(0编辑  收藏  举报