在线聊天——WebSocket 简介与实际实现

Posted 胖虎是只mao

tags:

篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了在线聊天——WebSocket 简介与实际实现相关的知识,希望对你有一定的参考价值。

html5 之前,浏览器和服务器的通信都是通过 HTTP 协议进行的,那么引入 WebSocket 有什么用呢?想像一下,有一个网页正在用文字直播某一场足球比赛的比分,在没有 WebSocket 时,客户端也就是浏览器实现比分的动态更新通常通过下面两种方式:AJAX 和长轮询。

这两种技术有什么区别呢?看下面的例子(S 代表服务器,C 代表客户端):

Ajax 方式:

C:告诉我最新的比分
S:比分还没更新

C:(5 秒后)告诉我最新的比分
S:比分还没更新

C:(5 秒后)告诉我最新的比分
S:比分还没更新
...

C:(5 秒后)告诉我最新的比分
S:2-1
(C 收到比分,显示比分)

C:(5 秒后)告诉我最新的比分
...(n 次后)
S:3-1
(C 收到比分,显示比分)

长轮询:

C:告诉我最新的比分
S:比分还没更新,等更新了告诉你
...

S:(5 分钟后)最新比分:2-1
(C 收到比分,显示比分)

C:告诉我最新的比分
S:比分还没更新,等更新了告诉你
...

S:(3 分钟后)最新比分:3-1
(C 收到比分,显示比分)C:告诉我最新的比分
S:比分还没更新,等更新了告诉你
...

S:(5 分钟后)最新比分:2-1
(C 收到比分,显示比分)

C:告诉我最新的比分
S:比分还没更新,等更新了告诉你
...

S:(3 分钟后)最新比分:3-1
(C 收到比分,显示比分)

Ajax 方式比较简单,就是让浏览器隔一段时间发送一个 Ajax 请求,服务器收到请求后立即返回,但返回的不一定是有效数据。客户端一直发送请求,直到获取到有效比分,然后更新页面。而使用长轮询方式,发送一个请求后,服务器不会立即返回,而是阻塞在那,直到比分有更新才返回

这两种方式有以下几个共同点:

  • 请求都是由客户端发起
  • 请求都是采用 HTTP 协议

这也是 HTTP 协议一个不足的地方,永远是客户端去联系服务器,服务器不能主动联系客户端。

不同点:

  • Ajax 请求,客户端会一直发请求,直到获得响应。
  • 长轮询方式,客户端发送请求后,会阻塞在那,直至获得响应。

WebSocket 协议的出现弥补了这个不足。有了 Websocket ,服务器可以主动联系客户端,给客户端发送消息。前提是客户端与服务器要先建立连接,因为 WebSocket 是基于 TCP 协议的,它的握手过程使用 HTTP 协议。

使用 WebSocket 的通信方式:

C:我需要建立 Websocket 连接。需要的服务: ... Websocket 版本: ...
S:ok,已经确认并建立连接
C:比分有更新告诉我
S:ok

S:最新比分:2-1
(C 收到比分,显示比分)
S:最新比分:3-1
(C 收到比分,显示比分)

使用 WebSocket 只需要建立一次连接,客户端不需要发送请求就可以接收到最新的比分。

二、实际实现

在实现后端代码之前先安装几个第三方库:

sudo pip3 install flask-sockets gunicorn redis

后端对 WebSocket 的支持使用 Flask-Sockets 库实现,该库支持为 WebSocket 注册一个蓝图。

创建蓝图

现在我们在 /handlers 目录下面新建 ws.py 文件,并创建同名蓝图:

from flask import Blueprint

ws = Blueprint('ws', __name__, url_prefix='/ws')

在 /handlers/init.py 文件中引入,并创建一个列表:

from .front import front
from .course import course
from .live import live
from .admin import admin
from .ws import ws


bp_list = [front, course, live, admin, ws]

然后在 simpledu/app.py 文件中注册:

from simpledu.handlers import bp_list,ws

def register_blueprints(app):
    """注册蓝图
    """

    for bp in bp_list:
        app.register_blueprint(bp)

创建 Sockets 实例并注册蓝图

接下来,在 /app.py 文件中注册套接字对象,也就是创建 Sockets 类的实例:

from flask_sockets import Sockets        # 新增代码


def register_blueprints(app):
    """注册蓝图
    """

    for bp in bp_list:
        app.register_blueprint(bp)

    sockets = Sockets(app)                    # 新增代码
    sockets.register_blueprint(ws)    # 新增代码

为了避免 Flask 主服务阻塞以提升聊天服务器的性能,服务器端的实现基于 gevent ,它是一个支持异步 I/O 的第三方库。

引入 gevent 后,我们需要考虑一个问题,如何同步消息?我们这里使用 Redis 的 Pub/Sub 发布订阅系统来做一个简单的消息队列。订阅者订阅频道,发布者发布消息到频道,频道负责收发消息队列。

终端执行如下命令,启动 Redis 服务:

sudo service redis-server start

后端首先实现了一个 Chatroom 类,主要用于管理客户端与频道的连接以及向所有客户端发送消息,然后用 gevent 异步启动聊天室。接着使用 Flask-Sockets 提供的方式实现一个接口(视图函数),用于将客户端加入到 Redis 的聊天室频道,然后接收客户端发来的消息并把消息发布到 Redis 的聊天室频道。

将以下代码写入 /handlers/ws.py 文件:

import redis
import gevent
from flask import Blueprint


ws = Blueprint('ws', __name__, url_prefix='/ws')

# 此对象是一个 Redis 客户端,它既可以发布消息到频道,也可以订阅频道
redis = redis.from_url('redis://127.0.0.1:6379')


class Chatroom:

    def __init__(self):
        self.clients = []               # 用户列表
        self.pubsub = redis.pubsub()    # 初始化发布订阅系统
        self.pubsub.subscribe('chat')   # 订阅 chat 频道

    def register(self, client):
        """注册用户,把用户添加到用户列表里

        :para client: geventwebsocket.websocket.WebSocket 类的实例
        """
        self.clients.append(client)

    def send(self, client, data):
        """发送数据给浏览器

        :para client: geventwebsocket.websocket.WebSocket 类的实例
        :para data: 要发送的消息,二进制字典字符串 b'"user": xxx'
        """
        # 调用 client 的 send 方法给浏览器发送消息
        # 如果出现异常,表示连接已关闭,将客户端移除
        try:
            client.send(data.decode())
        except:
            self.clients.remove(client)

    def run(self):
        # 下面一行代码中发布订阅系统对象 self.pubsub 的 listen 方法处于阻塞状态
        # 这里之所以使用 Redis 的发布订阅系统
        # 就是因为它能阻塞监听,并且消息队列功能保证消息按照先进先出的次序移动
        # 当用户在浏览器页面输入信息并点击「发言」按钮后
        # 浏览器调用 inbox 对象向服务器发送数据
        # 服务器的视图函数调用 geventwebsocket.websocket.WebSocket 类的
        # 实例的 receive 方法接收数据
        # 然后 redis 客户端调用 publish 方法向 chat 频道发送此数据
        # 此处收到消息,进入 for 循环
        for message in self.pubsub.listen():
            if message['type'] == 'message':
                # 要发送给浏览器的数据,二进制字典字符串 b'"user": xxx'
                data = message.get('data')
                # 向用户列表中的全部用户发送数据
                # 也就是 geventwebsocket.websocket.WebSocket 实例向浏览器发送数据
                for client in self.clients:
                    # 发送消息需要一小段时间,这里使用 gevent 异步发送
                    gevent.spawn(self.send, client, data)

    def start(self):
        # 因为 self.run 方法会阻塞运行,这里使用异步执行
        gevent.spawn(self.run)


chat = Chatroom()   # 初始化聊天室对象
chat.start()        # 异步启动聊天室

有同学可能会感到奇怪,Chatroom 类中已经定义了 run 函数,为什么又要定义一个 start 函数?在注释里面已经写了,它是用来异步执行 run 函数的。在真实的公司项目中,像这种 WebSocket 服务通常是独立出来作为一个单独的服务进行管理,或者直接使用专门做这些服务的公司的产品。在我们这个小项目中, WebSocket 服务是嵌入到 Web 服务内部的,如果不异步执行 run 函数,就会导致 Web 服务被阻塞,页面请求得不到响应。

接着上面的代码,继续在 /handlers/ws.py 中添加处理函数。我们的聊天功能实际上是为每个客户端建立了一个 WebSocket 连接,客户端其实就是浏览器。将如下代码写入其中:

@ws.route('/send')
def inbox(ws):
    # 注册用户,也就是把 ws 放到聊天室的 clients 列表里
    chat.register(ws)
    # 使用 Flask-Sockets,ws 连接对象会被自动注入到路由处理函数,该处理函数用来处理前端发过来的消息。
    # 注意下面的 while 循环,里面的 receive() 函数实际是在阻塞运行的,直到前端发送消息过来。
    # 消息会被放入到 chat 频道,也就是我们的消息队列中,这样一直循环,直到 websocket 连接关闭。
    while not ws.closed:
        message = ws.receive()
        if message:
            # 发送消息到 chat 频道
            redis.publish('chat', message)

Flask-Sockets 本身代码极为简单,创建了一个套接字类,将其实例作为与应用对象同等效力的对象,并将应用对象的 wsgi_app 属性改成 SocketMiddleware 类的实例。

其余的工作都是由依赖库完成的。创建遵循 WebSocket 协议的、充当服务器的对象 ws 是由 GeventWebSocket 库来完成的;处理前端发来的即时消息,是由 Redis 服务器的 PUB/SUB 系统的消息队列来做的;处理程序阻塞,实现异步执行代码的工作是由 Gevent 来完成的;最后多进程异步启动应用程序是由 Gunicorn 来完成的。

以上是关于在线聊天——WebSocket 简介与实际实现的主要内容,如果未能解决你的问题,请参考以下文章

netty实现websocket请求实战

Java和WebSocket开发网页聊天室

javaweb与websocket实现在线聊天功能总结

PHP webSocket实现网页聊天室

基于 Serverless 与 Websocket 的聊天工具实现

基于 Serverless 与 Websocket 的聊天工具实现