我们团队最近对我们的WebSocket服务做了一些修改和升级,也积累了一些经验,借这个契机,我们想用这篇文章,和大家分享一下WebSocket的一些知识和应用经验。
WebSocket简介 说到WebSocket,其实不是一个新鲜的技术了,其标准最早在2011年就由IETF颁布,之后由W3C将WebSocket API定为标准。
简单概括一下,WebSocket是一个面向Web领域的网络通信协议,基于TCP提供客户端和服务器之间的全双工的通信。其目的主要是为了在Web中实现双向通信,避免了ajax轮询的查询方式,减少了网络资源损耗。
优点
较少的控制开销:在连接创建后,服务器和客户端之间交换数据时,用于协议控制的数据包头部相对较小;
更强的实时性:由于协议是全双工的,所以服务器可以随时主动给客户端下发数据;
保持连接状态;
更好的二进制支持;
可以支持扩展。Websocket定义了扩展,用户可以扩展协议、实现部分自定义的子协议;
更好的压缩效果;
接下来,我们来详细介绍一下WebSocket的通信协议:
为了创建Websocket连接,需要通过浏览器发出请求,之后服务器进行回应,这个过程通常称为“握手”(handshaking)。我们看一个握手的例子,然后详细讲解一下:
客户端请求:
GET / HTTP/1.1
Upgrade: websocket
Connection: Upgrade
Host: example.com
Origin: http://example.com
Sec-WebSocket-Key: sN9cRrP/n9NdMgdcy2VJFQ==
Sec-WebSocket-Version: 13
Sec-WebSocket-Extensions: permessage-deflate; client_max_window_bits=15
服务器回应:
HTTP/1.1 101 Switching Protocols
Upgrade: websocket
Connection: Upgrade
Sec-WebSocket-Accept: fFBooB7FAkLlXgRSz0BT3v4hq5s=
Sec-WebSocket-Location: ws://example.com/
Sec-WebSocket-Extensions: permessage-deflate; client_max_window_bits
客户端请求时使用101状态码,告诉服务器切换到WebSocket协议(Status Code: 101 );
Connection 必须设置Upgrade,并且Upgrade字段必须设置为WebSocket,表示希望切换到WebSocket协议;
Sec-WebSocket-Key 是随机的字符串,服务器端会用这些数据来构造出一个SHA-1的信息摘要。把“Sec-WebSocket-Key”加上一个特殊字符串“258EAFA5-E914-47DA-95CA-C5AB0DC85B11”,然后计算SHA-1摘要,之后进行BASE-64编码,将结果作为“Sec-WebSocket-Accept”头的值,返回给客户端。如此操作,可以尽量避免普通HTTP请求被误认为Websocket协议;
Sec-WebSocket-Version 表示支持的Websocket版本;
Sec-WebSocket-Extensions 在握手阶段使用,由浏览器发送到服务器,表示一个或多个WebSocket的扩展,服务器接收后返回其支持的扩展的子集。
WebSocket在握手过程结束后就可以进行通信,WebSocket的数据通信此时基于TCP,数据被封装进数据帧,其数据帧格式如下所示(本文不再详细解释数据帧),数据在数据帧中的payload处。
浏览器API
既然知道了WebSocket协议的原理,我们来看看浏览器的原生API是什么样的,下面是W3C的代码示例:
// protocals是可选参数,可以指定WebSocket的子协议
// url既可以是ws协议的,也可以是wss协议的,同http一样,wss协议的WebSocket使用443的端口
let socket = new WebSocket(url [, protocols ] );
// WebSocket连接成功时的回调
socket.onopen = function () {
setInterval(function() {
if (socket.bufferedAmount == 0)
socket.send(getUpdateData());
}, 50);
};
// WebSocket接收到消息的回调,此处假设接收到的是字符串
mysocket.onmessage = function (event) {
if (event.data == 'on') {
turnLampOn();
} else if (event.data == 'off') {
turnLampOff();
}
};
// 向服务器发送数据,data可以是string, Blob, ArrayBuffer, ArrayBufferView
// 这几种类型中的任何一种
socket.send( data );
// 浏览器主动关闭连接
socket.close();
可以看到WebSocket和服务器之间通信并没有要求数据的格式,在实际应用中,开发者会根据自己的需要开发符合自己要求的数据格式,或者使用现有的其他开源方案。
接下来我们就来说说,基于WebSocket进行高级封装的SocketIO。
Socket.IO 是一个面向实时 web 应用的 JavaScript 库。它使得服务器和客户端之间实时双向的通信成为可能。他有两个部分:在浏览器中运行的客户端库,和运行在服务器端的服务端库。
Socket.IO 主要使用WebSocket协议。但是如果需要的话,Socket.io也可以使用几种其他方法,例如Adobe Flash Sockets,JSONP,或是AJAX,并且提供完全相同的接口。尽管它可以被用作WebSocket的包装库,它还是提供了许多其他功能,比如广播至多个套接字,存储与不同客户有关的数据,和异步IO操作。
客户端使用SocketIOnpm安装SocketIO
npm install --save socket.io
创建连接并监听和发送数据
// 使用websocket协议创建连接
const socket = io.connect(’https://mydomain.com‘, {
transports: [ 'websocket' ]
});
// 监听连接事件
socket.on('connect', () => {
console.log('connected !')
});
// 监听服务端传来的事件和数据
socket.on('myevent', (data) => {
console.log(JSON.stringify(data));
});
// 向服务器发送数据
socket.emit('event_to_server', {
'name': ’foo‘,
'type': 'bar'
});
SocketIO使用WebSocket创建连接时,并不需要显式的把协议指定成为wss,SocketIO会根据通讯协议选择正确的协议字段。
SocketIO特性命名空间(namespace)
SocketIO允许我们在当前物理连接上划分多个命名空间,不同的命名空间之间的数据和事件相互分离。这个特性可以使我们在尽量少的TCP连接中区分我们不同的业务。
房间(room)
在每个命名空间内,我们可以定义逻辑意义上的房间,套接字可以加入和离开任意的房间。在房间内的通讯也只有房间内的套接字可以接收到。
会话id(sid)
连接创建后会生成一个唯一的id,该id在处于连接状态时一直生效,当连接发生终端并重新连接之后,该id就会更新。
安全性
由于不管WebSocket还是Ajax,建立连接都是通过 HTTP,所以在服务端可以应用多种的授权和验证方式,例如:Basic access authentication, Token, JWT等。
PING / PONG
PING / PONG用来检测客户端和服务器的存活状态,在使用ajax时可以有效的知晓当前的连接状态,当使用WebSocket时,PING/PONG的数据为递增的整型数字。
Python后端使用SocketIO我们的后端服务基于Flask,接下来我们看看SocketIO在Python后端中的使用。
就像Django中有Django-SocketIO一样,我们使用的包是:Flask-SocketIO,首先安装Flask-SocketIO:
pip install Flask-SocketIO
然后我们安装Flask:
pip install Flask
开始编码:
from flask import Flask, request
from flask_socketio import SocketIO
app = Flask(__name__)
app.config['SECRET_KEY'] = 'secret!'
socketio = SocketIO(app)
@socketio.on('myevent')
def event_handler():
sid = request.sid
print(f'收到sid: {sid}传来的消息')
send('I got your message') # 向单个客户端回复
@app.route('/broadcast', methods=['GET'])
def broadcast_notification():
socketio.emit('event', { # 向所有已连接客户端广播
'type': 'broadcast',
'status': 'success'
})
return jsonify({})
if __name__ == '__main__':
app.run('0.0.0.0', port=5001, debug=True)
之后运行该文件,我们就得到了一个非常简单的SocketIO服务器。
在我们这个简单的服务器程序中,实现了监听客户端事件,并向单个客户端回复消息,以及向所有已经连接的客户端广播事件。
当然,我们也可以实现向某个房间(room)内发送消息,这类似于聊天室的功能,只需要加入如下的代码:
from flask_socketio import join_room, leave_room
@socket.on('join')
def join(data):
room_id = data.get('room_id')
join_room(room_id) # 告知服务器加入房间
@socket.on('leave')
def leave(data):
room_id = data.get('room_id')
leave_room(room_id) # 离开房间
@app.route('/chat_in_room/', methods=['GET'])
def chat_in_room(room_id):
socketio.emit('room_event', {
'type': 'chat_in_room',
'status': 'success'
}, room=room_id) # 指定房间名称,只向一个房间内的套接字发送
return jsonify({})
同时,客户端也需要加入代码:
const socket = io.connect(’https://mydomain.com‘, {
transports: [ 'websocket' ]
});
socket.on('connect', () => {
socket.emit('join', {
'room_id': 'room_topic'
});
});
// 需要离开房间时调用该方法
function leave_room(){
socket.emit(’leave‘, () => {
’room_id‘: 'room_topic'
});
}
如上所示,当服务端的接口'/chat_in_room'被调用时,就会向指定的房间内发送消息,并且只有加入了该房间的客户端套接字可以接收到数据。
需要强调的是,向房间内发送消息的功能只能由服务器端程序发起。原因是客户端只和服务器端建立了连接,客户端并没有向其他客户端套接字发送数据的能力。想要实现客户端向房间发送数据的功能的话,只能由服务器代为转发。
接下来给大家演示服务端向指定客户端套接字发送数据的功能:
@app.route('/unicast/', methods=['GET'])
def unicast(sid):
socketio.emit('unicast_event', 'an unicast message', room=sid)
return jsonify({})
只要知道客户端套接字的sid,就可以只向该套接字发送数据。
Flask-SocketIO的部署 1. 最简单的部署方式是直接调用app.run()来启动服务,当服务启动后会尝试查找eventlet和gevent,如果eventlet或gevent当前环境下已安装的话,就使用eventlet或gevent做web服务器,并运行为product模式;如果两者都没有安装,就运行为development模式;
2. 使用gunicorn运行后台服务,这也是我们在使用的方式。当选用gunicorn时,可以使用eventlet或者gevent作为worker,这时gunicorn的启动命令为:
gunicorn --worker-class eventlet -w 1 module:app
或者
gunicorn -k gevent -w 1 module:app
这里需要特别注意的是,由于gunicorn负载均衡策略的问题,worker的数量应当设置成1,否则连接将会变得混乱而无法正常使用。
Flask-SocketIO多实例运行Flask-SocketIO在多个实例中运行时会遇到一点问题:每个客户端套接字只能连接一台服务器;同样,一台服务器中只维护了和自己建立连接的客户端列表。当服务器端想要向某一个或者一批客户端套接字发送消息时,发送方不能保证指定的客户端套接字和自己建立了连接。那么怎么解决这个问题呢?
Python-SocketIO为我们提供了一个很好的解决方案 —— 支持使用消息队列,在多个运行的实例间同步消息发送的状态。Python-SocketIO支持多种消息队列,例如: RabbitMQ, Redis, 或其他Kombu支持的消息队列。
我们以Redis举例:当服务端实例A要向C1,C2两个套接字发送数据时,在发送的同时向Redis发出一个Pub命令,此时所有其他Flask-SocketIO实例会接到Redis的Sub事件,C1,C2的信息以及待发送的数据会通过事件参数传递,该Flask-SocketIO实例根据参数中的信息,向C1,C2发送数据。这样,可以保证客户端不管连接到的是哪个服务实例,都能正确的接收到消息。
下面我们看看怎么通过配置实现这个功能:
1. Redis: 要使用Redis的消息队列,需要保证环境中有redis这个包:
# 安装redis包
pip install redis
# 配置redis消息队列
socketio = SocketIO(app, message_queue='redis://')
2. RabbitMQ: 要使用RabbitMQ的消息队列,需要安装Kombu这个包,Kombu支持包括RabbitMQ在内的多种消息队列:
# 安装Kombu
pip install kombu
# 配置使用RabbitMQ消息队列
socketio = SocketIO(app, message_queue='amqp://')
Flask-SocketIO和Python-SocketIO比较好的封装了对于消息队列的使用,这让使用者可以比较方便快速的实现一个高可用的SocketIO服务端集群。
总结我们刚才从WebSocket谈到了SocketIO,又从SocketIO谈到了服务端SocketIO的使用以及关键技术点。可以看到WebSocket及其衍生的技术还是很多的,其主旨都是为了解决Web领域通信的难题,但是在生产环境中使用时还是会遇到很多网络环境带来的问题。
本篇文章先做个铺垫,以后当我们有更深入地研究,或是对技术相关领域有些新的理解和体会时,也会第一时间分享给大家。