flask实现双向通信需要借助第三方库flask-socketio,而此库又是基于js库socket.io库。安装flask-socketio
socket.io实现双向通信主要基于websocket。它可以简化实时Web程序(real-time application)的开发过程。不仅支持websocket协议,还支持许多种轮询机制及其他模拟实时通信的方式。
Socket.IO为这些通信方式实现了统一的接口,因此使用起来非常简单。除此之外,还提供了诸如广播、命名空间、房间、存储客户端数据、异步IO等功能。
使用flask-socketio
由于flask-socketio内部使用了flask的session对象,因此在使用时,需要指定secret_key。
from flask_socketio import SocketIO
# 简化了部分代码
app.secret_key = 'xds6d*/s/+d8s452264552ddsdsd95d5fv52cx6s9'
socketio = SocketIO(app)
建立Socket.io连接
flask-socketio依赖于异步服务器才能正常运行。异步服务器选择上有三个选项,eventlet为最佳选择,性能上最优,且原生支持长轮询和websocket;第二个选项为gevent,支持长轮询,需要额外配置才能支持websocket;最后选项为flask内置的开发服务器,它只支持长轮询,性能上要差于前两者。
flask-socketio覆写了flask的启动命令,因此可以向往常一样直接使用flask run 来启动服务器。
另外还可以使用socketio提供的run方法来启动服务器。
socketio = SocketIO(app)
if __name__ == '__main__':
socketio.run(app)
客户端建立连接
使用cdn或者本地js资源引入socket.io.js文件。
var socket = io(); // 建立连接, 这是必须的
连接后创建的socket实例将被用来执行发送事件,创建事件处理函数等操作。io()方法可以显式的传入Socket.IO服务器所在的URL,如果不传入任何参数,会默认使用服务器的根URL。
PS:在旧版本的socket.io.js中,使用io.connect()来建立连接,如果显式传入URL,需要指定绝对的URL,即io(http://.....)
一条消息发送后经过以下过程:
- 用户发送消息请求到服务端
- 服务端接收消息请求并把消息广播到所有客户端
- 所有客户端接收消息并显示
在Socket.io 中,服务端和客户端之间交流的数据被称为SocketIO事件,这里的事件包含特定的信息数据,类似常说的请求和响应。在Socket.io中,双向通信实现过程是这样的:客户端通过调用emit()函数将这一事件发送到服务端,并传入数据作为参数,这会触发服务端创建的对应事件处理函数。而服务端也可以通过emit()向客户端发送事件,并传入数据作为参数,类似也会触发客户端对应的事件处理函数。
在emit()函数中,第一个参数用来指定事件名称,当服务端的emit()函数被调用时,会触发已连接的客户端中对应的事件处理函数。第二个参数是我们要发送的数据,要发送的数据根据客户端需要而定。事件中包含的数据类型可以为字符串,列表或字典。当数据为列表或字典时会序列化json。broadcast参数设置为True时,会将事件发送给所有已连接的客户端。
当使用send()函数发送程序时,默认会触发message事件处理器。send()函数可以理解为简化版的emit()函数,它无法指定事件名称。
事件名称 | 说明 |
connect | 当客户端连接时触发 |
disconnect | 当客户端断开时触发 |
message | 客户端使用send()方法发送字符串数据时触发 |
json | 客户端使用send()方法发送json时触发 |
PS:message和json依然使用原因可能是为了兼容旧版本,如果你的版本较新,应该尽量避免使用。这四个事件之外的事件,称为自定义事件。
以下包含一个较为完整的示例,包含在线统计人数,群发消息
from flask import Flask, render_template, jsonify, session, url_for, redirect
from flask_socketio import SocketIO, emit
app = Flask(__name__)
app.secret_key = 'xds6d*/s/+d8s452264552ddsdsd95d5fv52cx6s9'
socketio = SocketIO(app)
@app.route('/user/login', methods=['GET', 'POST'])
def user_login(): # 使用session模仿用户登录
username = request.form.get('username')
password = request.form.get('password')
if username in ['admin', 'admin1', 'admin2', 'admin3', 'admin4'] and password == '123456':
session['username'] = username
return redirect(url_for('socket'))
return render_template('login.html')
@app.route('/socket', methods=['GET'])
def socket():
username = session.get('username')
return render_template('socket.html', username=username)
@socketio.on('new message') # 客户端事件会找到对应的处理函数
def new_message(message_body):
message = message_body
emit('new message', {"message": message}, broadcast=True) # # 向客户端发送消息
users = []
@socketio.on('connect') # socket连接
def connect():
global users
username = session.get('username')
# session.pop('username')
if username and username not in users and username.startswith('admin'):
users.append(username)
print("在线用户:", len(users))
emit('user count', {"count": len(users)}, broadcast=True)
@socketio.on('disconnect')
def disconnect():
global users
username = session.pop('username', '')
if username in users:
users.remove(username)
print('连接断开,用户数:', len(users))
emit('user count', {"count": len(users)}, broadcast=True)
# login.html
<form action="/user/login" method="post">
<input placeholder="请输入用户名" name="username" id="username" type="text">
<input placeholder="请输入密码" name="password" id="password" type="password">
<button type="submit">登录</button>
</form>
# socket.html
<p>在线人数:<span id="user-count">0</span></p>
{% if username %}
<p>当前用户:{{ username }}</p>
<div id="message">
</div>
<textarea rows="2" id="message-textarea" placeholder="输入消息"></textarea>
<button type="button" id="btn">提交</button>
{% else %}
<a href="/user/login">登录</a>
{% endif %}
</body>
<script src="https://cdn.bootcdn.net/ajax/libs/jquery/1.10.0/jquery.js"></script>
<script src="https://cdn.bootcdn.net/ajax/libs/socket.io/2.3.0/socket.io.js"></script>
<script>
var socket = io(); // 建立socket连接
$('#btn').click(function (e) { // 点击提交按钮,发送消息
var textarea = $('#message-textarea');
var message_body = textarea.val().trim(); // 获取消息正文
if (message_body) {
e.preventDefault();
socket.emit('new message', message_body); // 发送事件
textarea.val('');
}
});
socket.on('new message', function (data) { // 监听从服务器端发来的new message事件,
console.log(data);
$('#message').append('<p>' + data.message + '</p>');
});
socket.on('user count', function (data) { // 显示在线人数
console.log(data);
$('#user-count').html(data.count)
})
</script>
进阶部分:
11.3.4 通信频道分离
有些时候,我们需要对通信的频道(communication channel)进行
分离。比如,聊天室经常需要创建不同的聊天房间,每个用户可以加入
不同的房间,某个房间内用户发送的消息只会广播给该房间内的用户,
而不会被其他房间的用户收到。本节我们会简单介绍Socket.IO中的两种
频道分离概念:命名空间(Namespaces)和房间(Rooms)。
1.命名空间
在程序层面上,Socket.IO支持使用命名空间来分离通信频道。简单
地说,不同的命名空间就是不同的URL路径,比如/foo和/bar就是两个不
同的命名空间。与不同命名空间建立连接的客户端发送的事件会被对应
命名空间的事件处理函数接收,而发送到某个命名空间的事件不会被其
他命名空间下的客户端接收。命名空间通常会用来分离程序中的不同逻
辑部分。
提示
为了便于理解,你可以把SocketIO中的命名空间比作Flask中的蓝
本,蓝本可以定义不同的URL前缀,即创建不同的命名空间。不同蓝本
可以定义不同的处理逻辑,比如,发送到A蓝本中的视图函数的请求只
会触发A蓝本中注册的请求处理函数。
默认的全局命名空间是“/”,即根URL。为了作为示例,我们在
CatChat中创建了一个匿名聊天页面,它的自定义命名空间
为/anonymous。如果用户在匿名聊天页面发送消息,不会触发在服务器
端定义的new message事件处理函数,因为它只会接收默认命名空间
(即“/”)下的事件。为了接收/anonymous命名空间下发送的客户端事
件,我们需要注册一个新的事件处理函数,并在on()装饰器内使用
namespace参数指定命名空间为/anonymous,如代码清单11-7所示。
代码清单11-7 catchat/blueprints/chat.py:为/anonymous命名空间注
册new message事件处理函数
# 处理默认的全局命名空间下的new message事件
@socketio.on('new message')
def new_message(message_body):
...
# 处理/anonymous命名空间下的new message事件
@socketio.on('new message', namespace='/anonymous')
def new_anonymous_message(message_body):
avatar = 'https://www.gravatar.com/avatar?d=mm'
nickname = 'Anonymous'
emit('new message', {'message_html': render_template('chat/_anonymous_message.html',
message=message_body,
avatar=avatar,
nickname=nickname)}, broadcast=True, namespace='/anonymous')
在这个事件处理函数中,我们采用了不同的处理逻辑,消息没有保
存到数据库中,而是直接广播到所有客户端中,头像和昵称均为匿名,
对应的消息HTML在局部模板chat/_anony-mous_message.html中定义。最
后,在使用emit()函数发送事件时,我们仍然使用namespace参数指定
发送事件的目标命名空间,这样只有/anonymous命名空间下的客户端才
会接收到这个事件。
我们创建anonymous视图来渲染匿名聊天室的模板
anonymous.html:
@chat_bp.route('/anonymous')
def anonymous():
return render_template('chat/anonymous.html')
提示
命名空间不必和对应视图函数的URL规则相同。我们的视图函数处
理常规的HTTP请求,而事件处理函数使用WebSocket来沟通,两者的
URL互不干扰。
在本节一开始,我们曾介绍过在客户端使用io()方法建立连接,
可以用传入服务器的URL作为参数。要指定命名空间,那么传入对应的
路径即可:
var socket = io.connect('/anonymous');
为了支持新的自定义命名空间,我们将socket实例的定义移动到模
板home.html和anonymous.html中。home.html中的socket实例与全局命名
空间建立连接,而anonymous.html中socket实例与/anonymous命名空间建
立连接,如下所示:
{% block scripts %}
{{ super() }}
<script type="text/javascript">
var socket = io.connect('/anonymous');
</script>
{% endblock %}
客户端的几个事件处理函数对于这两个命名空间来说可以共用,因
此不用单独创建。如果你需要为不同的命名空间设置各自独立的事件处
理函数,那么创建多个socket实例,分别注册事件处理函数,实现不同
的处理逻辑即可。
注意
这里我们使用{{super()}}确保这行定义被追加到加载Socket.IO资
源的语句后,这样才可以被正常调用。
2.房间
在客户端层面上,Socket.IO支持房间(Room)的概念,它可以用
来对客户端进行分组,以便实现在某个命名空间下进一步的通信频道分
离。将客户端加入/移出房间的操作在服务器端实现,在Flask-SocketIO
中,我们使用join_room()和leave_room()函数来实现房间功能,这
两个函数分别用来把当前用户(客户端)加入和退出房间。你还可以使
用close_room()函数来删除一个房间,并清空其中的用户。另外,
rooms()函数可以返回某个房间内的客户端列表。
为了保持简单,CatChat中并没有添加房间功能,我们这里仅介绍
实现的基本方法。首先,你需要创建一个Room模型存储房间数据。房
间可以使用任意的字符串或数字作为标识,所以可以使用主键列作为标
识,另外再创建一个name列用于存储房间的显示名称。同时,我们还要
在程序中提供房间的创建、编辑和删除操作。Room模型和表示用户的
User模型建立一对多关系,分别建立Room.users和User.room关系属性。
在房间的入口页面中,我们可以创建一个下拉列表供用户选择要加
入的房间。用户提交表单后,程序会被重定向到房间聊天页面。在房间
聊天页面,我们可以在客户端的connect事件监听函数中使用emit()函
数触发服务器端自定义的join事件;同样,用户单击离开按钮离开房间
后在客户端的disconnect事件处理函数中使用emit()函数触发服务器端
定义的leave事件:
socket.on('connect', function() {
socket.emit('join');
});
socket.on('disconnect', function() {
socket.emit('leave');
});
在服务器端,自定义的join和leave事件分别用来将用户加入和移出
房间,这两个自定义事件的处理函数如下所示:
from flask_socketio import join_room, leave_room
@socketio.on('join')
def on_join(data):
username = data['username']
room = data['room']
join_room(room)
emit('status', username + ' has entered the room.', room=room)
@socketio.on('leave')
def on_leave(data):
username = data['username']
room = data['room']
leave_room(room)
emit('status', username + ' has left the room.', room=room)
在这两个事件处理器中,我们分别调用Flask-SocketIO提供的
join_room()和leave_room()函数,并传入房间的唯一标识符。
提示
房间也支持命名空间,通过join_room()和leave_room()函数的
namespace参数指定,默认使用当前正在处理的命名空间,可以通过
Flask-SocketIO附加在请求对象上的namespace属性获得,即
request.namesapce。
同样,在发送事件时,也要指定发到哪个房间,这通过使用
send()和emit()函数中的room参数来指定。比如,下面是创建广播
新消息的room message事件处理函数:
@socketio.on('room message')
def new_room_message(message_body):
emit('message', {'message': current_user.username + ':' + message_body}, room=current_user.room)
如果你仅需要对用户进行分组,那么房间是你的最佳选择。命名空
间是在程序层面上的频道分离。如果我们要在程序中同时实现全局聊
天、匿名聊天室、房间、私聊,这四类功能对消息的处理各自不同,所
以我们需要为这四类功能指定不同的命名空间(全局聊天可以使用默认
的全局命名空间)。在需要分离通信频道时,我们需要根据程序的特点
来决定方式:仅使用命名空间、仅使用房间或两者结合使用。
附注
你可以通过Flask-SocketIO作者Miguel Grinberg提供的这个聊天程序
(https://github.com/miguelgrinberg/Flask-SocketIO-Chat)示例了解关于
房间的具体实现。
顺便说一下,基于房间你也可以实现私信/私聊功能。只需要把
room设为代表某个用户的唯一值,在发送事件时,就只有目标用户的客
户端才能接收到事件。你可以把这种实现方法理解为“一个人的房间”。
这个能代表用户的唯一值可以是主键值、username或是Flask-SocketIO附
加到request对象上代表每个客户端id的session id(request.sid)。
提示
如果你使用request.sid作为唯一值,那么需要在User模型中添加一个
sid字段存储这个值,然后在服务器端的connect事件处理函数中更新这
个值。
部分概念和内容来自:《Flask Web开发实战(李辉)》
评论列表
已有0条评论