Content:


什么是WebSocket?

WebSocket是HTML5中定义的可以在单个TCP连接上进行双向通信(即全双工通信)的协议。借助WebSocket,浏览器和服务器只需要完成一次握手(handshaking就可以建立持久的连接,并进行双向数据传输

什么是Socket.IO?

Socket.IO(https://socket.io/)是一个基于WebSocket实现实时通信的开源JavaScript库。它可以简化实时Web程序(real-time application,RTA)的开发过程。它除了支持WebSocket之外,还支持许多种轮询(Polling)机制以及其他模拟实时通信的方式。Socket.IO会根据浏览器对通信机制的支持情况自动选择最佳的方式来实现实时通信,实现了对浏览器的“降级支持”。Socket.IO为这些通信方式实现了统一的接口,因此使用起来非常简单。除此之外,Socket.IO还提供了诸如广播、命名空间、房间、存储客户端数据、异步IO等非常方便的功能。

客户端一开始会先使用polling进行连接,并尝试跳转建立WebSocket连接,如果不行就会继续使用polling。

Long polling和Websocket间的区别

image-20210128150333198

在python中的应用

要实现Socket.IO,客户端和服务器端都需要使用Socket.IO框架。

客户端:使用socket.io. js;

服务器端:使用Python版本的python-socketio库。在flask开发中,使用集成了python-socketio的Flask-SocketIO

1. 安装依赖

flask-socketio库:

$ pip install flask-socketio

异步服务的依赖库:

flask-socketio依赖于异步服务器才能正常运行。异步服务器有

1). eventlet:原生支持长轮询和WebSocket,推荐选择

2). gevent:支持长轮询,但需要额外的配置才能支持WebSocket,安装gevent-websocket库或者使用uWSGI服务器;

3). Flask内置开发服务器:只支持长轮询,而且性能上要差于前两者。

所以建议安装eventlet或者gevent

$ pip install eventlet
or
$ pip install gevent gevent-websocket
  • 在调试模式下,无论是否安装了eventlet和gevent,都会默认使用Flask(Werkzeug)内置的开发服务器。
  • 在生产模式下,服务器调用的顺序是eventlet > gevent > Werkzeug


2. 如何启动服务器

flask启动服务器的命令是app.run()。如果使用socketio,需要替代为socketio.run()

此外,flask运行脚本的命令是flask run,但该命令只会启动基于Werkzeug的Flask-SocketIO服务器。所以,启动脚本的命令应写成python xxx.py


3. 服务器创建实例

创建Socketio实例

from flask import Flask, render_template
from flask_socketio import SocketIO

app = Flask(__name__)
socketio = SocketIO(app)
  • 支持init_app()初始化
  • 关于事件的导入在后面会提及


4. 客户端建立连接

客户端(譬如浏览器)需要加载socket.io. js脚本和创建对象,才能与SocketIO服务器建立连接。

脚本类型:

Name Size Description
socket.io.js 34.7 kB gzip Unminified version, with debug
socket.io.min.js 14.7 kB min+gzip Production version, without debug
socket.io.msgpack.min.js 15.3 kB min+gzip Production version, without debug and with the msgpack parser
  • 在生产环境中,请使用socket.io.min.js版本。

推荐使用CDN的方式加载脚本,链接:

<script src="https://cdn.socket.io/socket.io-3.0.1.min.js"></script>

Socket.IO is also available from other CDN:

  • cdnjs: https://cdnjs.cloudflare.com/ajax/libs/socket.io/3.0.1/socket.io.min.js
  • jsDelivr: https://cdn.jsdelivr.net/npm/socket.io-client@3.0.1/dist/socket.io.min.js
  • unpkg: https://unpkg.com/socket.io-client@3.0.1/dist/socket.io.min.js

创建对象:

<script src="//cdnjs.cloudflare.com/ajax/libs/socket.io/2.2.0/socket.io.js" integrity="sha256-yr4fRk/GU1ehYJPAs8P4JlTgu0Hdsp4ZKrx8bDEDC3I=" crossorigin="anonymous"></script>
<script type="text/javascript" charset="utf-8">
    var socket = io();
    socket.on('connect', function() {
        socket.emit('my event', {data: 'I\'m connected!'});
    });
</script>
  • io()方法可以显式传入Socket.IO服务器所在的URL,如果不传入任何参数,会默认使用服务器的根URL,相当于var socket=io('/');
  • socket实例用来执行发送、接收等事件操作


5. 通信事件

服务端和客户端都会涉及到两个动作:接收发送。在socket中,所有的通信通过触发事件event来完成。

5.1 命名规则

事件的命名很灵活,没有限制条件,一般根据功能来自定义命名。但是以下4个名称默认被保留,有其特殊的功能,不能用作自定义事件:

  • message:to define a handler that takes a string payload.
  • json:to define a handler that takes a JSON blob payload.
  • connect :create handlers for connection events.
  • disconnect :create handlers for disconnection events.

客户端的发送 ——> 服务端的接收

服务端的发送 ——> 客户端的接收

所以,客户端的发送事件名称,对应在服务端就应为接收事件名称。同理,服务端的发送事件名称,对应在客户端就应为接收事件名称。

Socket.IO中,on代表的是接收(响应)事件,sendemit代表的是发送事件。

5.2 服务端

接收事件

Flask-SocketIO中,使用on方法注册从客户端发来的事件,格式如下:

@socketio.on('my_event',namespace='/')
def handle_my_custom_event(arg1, arg2, arg3):
    print('received args: ' + arg1 + arg2 + arg3)
  • 当事件名称里没有python的标识符(不与其他定义的功能相冲突),还可以使用缩写装饰器的方式@socketio.event来代表所装饰的函数是事件(该函数名则为事件名)。

    @socketio.event
    def my_custom_event(arg1, arg2, arg3):
      print('received args: ' + arg1 + arg2 + arg3)
    
发送事件

在收到客户端发送过来的事件后,服务端根据需求处理请求,然后再使用emit()send()方法将结果发送或广播回客户端。

@socketio.on('receive_client_event')
def handle_my_custom_event(arg1, arg2, arg3):
    ...
    emit(
    'send_to_client',
    {'data:...'}
    )

  • 如果要广播,要使用broadcast=True。该namespace的所有人都会接收到,包括发送者。如果没有使用namespace,则在全局namespaced的客户端都会收到。
  • 支持callback,即当客户端收到消息时,服务端会执行指定函数,使用callback=<function>
绑定事件handles

如果是以工厂形式创建的app实例,要导入socket事件才能注册这些事件handles。可在create_app函数中导入包含事件函数的模块

def create_app():
    app = Flask('main')

    socketio.init_app(app)
    import main.blueprints.socket

    return app

5.3 客户端

客户端所有的事件都要以所创建的socket对象为基础。

接收事件

同样也是用on来绑定从服务端发来的事件

socket.on('xxx_event', function() {
    ...
});
发送事件

使用emit()方式发送事件

socket.emit('yyy_event', data);
  • 第一个参数用来指定事件名称,第二个参数是我们要发送的数据,要发送的数据根据客户端的需要而定。事件中包含的数据类型可以为字符串、列表或字典。当数据的类型为列表或字典时,会被序列化为JSON格式。

  • 也支持callback事件,在数据后面添加回调函数即可。eg:

    socket.emit('yyy_event', data, function(){...});
    

Socket.IO频道

Socketio是没有频道的概念,能实现该功能的有命名空间(Namespaces)和房间(Rooms),不过他们之间的概念比较模糊。

区别与共同处

共同处:

  1. Namespaces和Rooms均能在服务端应用
  2. 都是共用连接

区别:

  1. Rooms只是在服务端的概念,在客户端是无法应用。
  2. Namespaces是逻辑(物理)上隔离,Rooms是概念上隔离。
  3. Namespaces可以使用授权管理的方式进入。

命名空间Namespaces

默认的全局命名空间是/,其根URL并不是实际传输的网址,而是/socket.io/。不同的命名空间就是不同的URL路径。譬如命名空间'/customer',其URL就是/socket.io/customer

与不同命名空间建立连接的客户端发送的事件会被对应命名空间的事件处理函数接收,发送到某个命名空间的事件不会被其他命名空间下的客户端接收。命名空间通常会用来分离程序中的不同逻辑部分

image-20210129001231732

常用场景:

  1. 创建管理的命名空间只让指定授权的人连接,可以逻辑上隔离程序中的其他用户
  2. 给程序中的用户创建属于自己的空间

以匿名聊天和普通聊天举例:

  1. 客户端进入不同的命名空间需传入指定的url参数

    # 连接/anonymous命名空间
    var socket = io.connect('/anonymous');
    
    # 连接/根目录的命名空间
    var socket = io();
    
  2. 服务器端定义不同命名空间的逻辑处理。

    # 处理默认的全局命名空间下的new message事件
    @socketio.on('new message')
    def new_message(message_body):
    nickname = message_body.name
        ...
        
    # 处理/anonymous命名空间下的new message事件
    @socketio.on('new message', namespace='/anonymous')
    def new_anonymous_message(message_body):
        nickname = 'Anonymous'
        emit('new message',
             {message=message_body, nickname=nickname)},
             broadcast=True, namespace='/anonymous')
    

    针对"new message"事件来说,匿名聊天和普通聊天采用不同的处理逻辑。匿名聊天将所有人的昵称都改成了'Anonymous'。


房间Rooms

只是属于在服务端的概念。在客户端是没有直接方法获取到房间的信息。

实现在某个空间下对客户端进行分组。客户端加入/移出房间的操作都是在服务器端实现

image-20210128233807275

在Flask-SocketIO中:

使用join_room()leave_room()函数来实现把当前用户(客户端)加入和退出房间;

使用close_room()函数来删除指定房间,并清空其中的用户;

使用rooms()函数可以返回当前用户所加入的所有房间列表。

当用户成功连接到socket服务器时,默认会被分配一个个人房间。该房间是以连接的socket id命名(是唯一且随机的)命名,可从request.sid获得。

当用户离线时,该用户所进入的房间都会移除该用户。

注意事项:

  • 即使用户不在该房间,只要拥有该房间的名称,在没有限制的情况下,可以对该房间进行若干操作,包括发送消息,删除房间等等。所以必要时,应在服务端进行一个身份识别和权限的设置。
  • 每次刷新浏览器都会重新建立socket链接,即意味着用户的socket.id会变(创建了新的id),该用户已不再原来的房间,会造成无法接收原来房间信息的情况。所以在刷新的时候要重新加入房间。

部署

有以下几种主流方式:

  1. 使用已嵌入的服务器。在安装依赖包时,按照项目需求加载了譬如eventletgevent等库,部署时只需要运行程序脚本即会调用socketio.run(app)

  2. 使用Gunicorn服务器。除了需要先安装eventletgevent等库外,还需要安装gunicorn

    • 由于gunicron的负载均衡的算法不支持sticky sessions,所以部署时只能明确使用1个worker

    启动命令

    eventlet

    $ gunicorn --worker-class eventlet -w 1 module:app
    

    gevent

    $ gunicorn -k gevent -w 1 module:app
    

    如果需要支持WebSocket

    $ gunicorn -k geventwebsocket.gunicorn.workers.GeventWebSocketWorker -w 1 module:app
    
  3. 使用uWSGI服务器。需要加载gevent库。

    $ uwsgi --http :5000 --gevent 1000 --http-websockets --master --wsgi-file app.py --callable app
    

除此之外,还需要用nginx那样的工具做前端反向代理将请求传向程序。nginx1.4版本以上才支持WebSocket协议。基础设置如下:

server {
    listen 80;
    server_name _;

    location / {
        include proxy_params;
        proxy_pass http://127.0.0.1:5000;
    }

    location /static {
        alias <path-to-your-application>/static;
        expires 30d;
    }

    location /socket.io {
        include proxy_params;
        proxy_http_version 1.1;
        proxy_buffering off;
        proxy_set_header Upgrade $http_upgrade;
        proxy_set_header Connection "Upgrade";
        proxy_pass http://127.0.0.1:5000/socket.io;
    }
}

如果有多个Socket.IO服务器,可以利用ip_hash进行如下配置:

upstream socketio_nodes {
    ip_hash;

    server 127.0.0.1:5000;
    server 127.0.0.1:5001;
    server 127.0.0.1:5002;
    # to scale the app, just add more nodes here!
}

server {
    listen 80;
    server_name _;

    location / {
        include proxy_params;
        proxy_pass http://127.0.0.1:5000;
    }

    locaton /static {
        alias <path-to-your-application>/static;
        expires 30d;
    }

    location /socket.io {
        include proxy_params;
        proxy_http_version 1.1;
        proxy_buffering off;
        proxy_set_header Upgrade $http_upgrade;
        proxy_set_header Connection "Upgrade";
        proxy_pass http://socketio_nodes/socket.io;
    }
}

应用多个Socket.IO服务器

Flask-SocketIO从2.0版本开始支持多个workers功能。需要以下要求:

  1. 配置负载均衡让一个客户端的所有HTTP请求要一直连接同一个worker。在nginx中,使用ip_hash可以实现类似sticky sessions的原理(Gunicorn不支持该原理,所以无法运行多个工作节点)

    • 如果要使用gunicorn实现多socketIO服务器的功能,需要启动multiple single-worker gunicorns。

  2. 使用消息队列譬如redis,RabbitMQ或者Kafka来协助消息的传达

image-20210129131029184

如果使用了eventletgevent,python的标准包monkey patching需要被强制使用协程函数。

For eventlet:

import eventlet
eventlet.monkey_patch()

For gevent:

from gevent import monkey
monkey.patch_all()
  • 建议在主程序入口顶端就先声明

在创建socketio实例时,要配置message_queue参数,譬如:

socketio = SocketIO(app, message_queue='redis://')
  • 在flask-socketio中,默认的redis的URL是'redis://'。可以自定义redis地址,譬如:'redis://localhost:6379/8'

只要多个socket.IO服务器都配置相同的message_queue地址,就能互传信息。

另外,在启动服务器前,要确保消息队列服务已在运行。

待补充

参考文章

https://blog.miguelgrinberg.com/post/easy-websockets-with-flask-and-gevent

https://flask-socketio.readthedocs.io/en/latest/

https://socket.io/demos/chat/


There are 2 comments

  • Maxwell

    Use SendBulkMails.com to run email campaigns from your own private dashboard. Cold emails are allowed and won't get you blocked :) - 1Mil emails / mo @ $99 USD - Dedicated IP and Domain Included - Detailed statistical reports (delivery, bounce, clicks etc.) - Quick and easy setup with extended support at no extra cost. - Cancel anytime! Regards, www.SendBulkMails.com

  • igXKdqMLv

    CKNWMwrugBmI