WebSocket剖析
http协议
在了解WebSocket之前,有必要简单复习一下http协议。
请求和响应
Http协议用于客户端与服务端的通信,客户端发出请求(request),服务端返回响应(response)。下面我们以访问https://www.sogou.com/搜狗首页为例,来看看请求报文和响应报文:
下面是从客户端访问服务器的请求报文的截取内容:
GET / HTTP/1.1
Host: www.sogou.com
Connection: keep-alive
Cache-Control: max-age=0
第一行的GET表示请求方法;随后的 / 表示请求访问的资源对象(request-URI),这里是根页面;最后的HTTP/1.1是协议和版本号。
第二行开始是首部字段:Host字段表示服务器域名。(这里只截取了部分首部字段,实际的字段更多)
http请求报文由请求方法、URI、HTTP版本、HTTP首部字段构成。
下面是服务器返回的响应报文的截取内容:
HTTP/1.1 200 OK
Server: nginx
Date: Tue, 19 Sep 2017 08:37:38 GMT
Content-Type: text/html; charset=UTF-8
Transfer-Encoding: chunked
Connection: keep-alive
<html>
......
第一行的HTTP/1.1表示服务器对应的HTTP版本; 200 OK表示请求处理结果的状态码和原因短语。
第二行开始是首部字段,包括服务器安装的软件版本,响应日期等。
响应报文包括响应头和响应体。
响应头由HTTP版本、状态码、原因短语、首部字段组成。
最下面的<html>
开始是响应体,也就是用户在浏览器上看到的具体网页,由两组\r\n
换行符与上面的响应头分隔。
http是被动的协议
使用http协议,通信只能由客户端发起,服务端返回响应永远是被动的,不能由服务端主动发起。
http是非持久的协议
使用http协议通信时,需要不断地建立,关闭http连接。每当有新请求到达时,就会有对应的新的响应产生。一次请求,一次响应,结束,这就是http的生命周期。http1.1中多了一个keep-alive,在一次http连接中,可以发送多个请求,接收多个响应。但是一个请求只能有一个响应。
轮询和长轮询
如果希望实现持久连接的效果,比如在聊天室应用中,就要借助轮询(poll)或者长轮询(long poll)。简单来说,轮询就是客户端每隔几秒,就向服务端发送一次请求,询问是否有新消息。而长轮询则是阻塞模式,客户端发起请求后,一段时间内(web微信是25秒的样子,可以打开浏览器的开发者工具,查看Network,有一个pending状态,定期会刷新一次),服务端只要没有新消息,就不返回响应。一旦新消息到达或者超时,就返回响应给客户端,一次连接结束,客户端重新发起请求,周而复始。web QQ 和 web 微信,都是用长轮询做的。
轮询和长轮询效率低,消耗资源:
- 轮询要求不停地连接,即浏览器隔几秒就要向服务器发出请求,然而HTTP请求可能包含较长的头部,其中真正有效的数据可能只是很小的一部分,显然这样会浪费很多的带宽资源。同理,服务器隔几秒就要返回响应,消息可能存在延时,不仅浪费带宽,还要求服务器有很快的处理速度。
- 长轮询要求http连接始终打开,也会对服务器造成很大压力,要求服务器处理大并发的能力。
http是无状态协议
http协议本身并不保留之前的一切请求或响应报文的信息。这是为了更快地处理大量事物,确保协议的可伸缩性,也能减轻服务器的压力。而且由于不需要保存状态,http协议本身比较简单,能被应用在各种场景里。如果需要管理状态,需要借助Cookie和Session,在每次连接中,告诉服务端你是谁。
WebSocket
是什么
WebSocket协议是在HTML5中定义的,目前主流浏览器都支持这一标准。它能更好的节省服务器资源和带宽,并且能够更实时地进行通讯。
WebSocket是一种在单个TCP连接上进行全双工通讯的协议。使得客户端和服务器之间的数据交换变得更加简单,允许服务端主动向客户端推送数据。在WebSocket API中,浏览器和服务器只需要完成一次握手,两者之间就直接可以创建持久性的连接,并进行双向数据传输。
主要特点
较少的控制开销。在连接创建后,服务器和客户端之间交换数据时,用于协议控制的数据包头部相对较小。在不包含扩展的情况下,对于服务器到客户端的内容,此头部大小只有2至10字节(和数据包长度有关);对于客户端到服务器的内容,此头部还需要加上额外的4字节的掩码。相对于HTTP请求每次都要携带完整的头部,此项开销显著减少了。
更强的实时性。由于协议是全双工的,所以服务器可以随时主动给客户端下发数据。相对于HTTP请求需要等待客户端发起请求服务端才能响应,延迟明显更少;即使是和Comet等类似的长轮询比较,其也能在短时间内更多次地传递数据。
保持连接状态。与HTTP不同的是,Websocket需要先创建连接,这就使得其成为一种有状态的协议,之后通信时可以省略部分状态信息。而HTTP请求可能需要在每个请求都携带状态信息(如身份认证等)。
更好的二进制支持。Websocket定义了二进制帧,相对HTTP,可以更轻松地处理二进制内容。
没有同源限制,客户端可以与任意服务器通信。
Websocket使用ws或wss的统一资源标志符,比如:
ws://example.com/path
。类似于HTTPS,其中wss表示在TLS之上的Websocket。Websocket使用和 HTTP 相同的 TCP 端口,可以绕过大多数防火墙的限制。默认情况下,Websocket协议使用80端口;运行在TLS之上时,默认使用443端口。
握手过程
为了创建Websocket连接,需要通过浏览器发出请求,之后服务器进行回应,这个过程通常称为“握手”(handshaking)。Websocket 通过 HTTP/1.1 协议的101状态码进行握手。过程如下:
客户端请求
GET / HTTP/1.1
Upgrade: websocket
Connection: Upgrade
Host: example.com
Origin: http://example.com
Sec-WebSocket-Key: sN9cRrP/n9NdMgdcy2VJFQ==
Sec-WebSocket-Version: 13
服务端回应
HTTP/1.1 101 Switching Protocols
Upgrade: websocket
Connection: Upgrade
Sec-WebSocket-Accept: fFBooB7FAkLlXgRSz0BT3v4hq5s=
Sec-WebSocket-Location: ws://example.com/
说明
- Connection必须设置Upgrade,表示客户端希望连接升级
- Upgrade字段必须设置Websocket,表示希望升级到Websocket协议
- Sec-WebSocket-Key是随机的字符串,作验证用的,为了避免和HTTP请求混淆:
- Sec-WebSocket-Version 表示支持的Websocket版本。RFC6455要求使用的版本是13,之前草案的版本均应当弃用
- 其他一些定义在HTTP协议中的字段,比如cookie,也可以在Websocket中使用。
客户端和服务端通过Websocket通信示例
客户端和服务端传输数据时,需要对数据进行【封包】和【解包】。客户端的JavaScript类库已经封装【封包】和【解包】过程,直接调用API即可。这里用python的Socket来手动实现服务端。
客户端示例代码
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Title</title>
</head>
<body>
<div>
<input type="text" id="txt">
<input type="button" id="submit" value="提交" onclick="sendMsg()">
<input type="button" id="close" value="关闭连接" onclick="closeConn()">
</div>
<div id="info"></div>
<script>
var ws = new WebSocket("ws://127.0.0.1:8000");
/* WebSocket 对象的回调函数:
* onopen 连接成功后自动执行
* onmessage 服务端向客户端发送数据时,自动执行
* onclese 服务端断开连接时,自动执行
* */
ws.onopen = function () {
var ele = document.createElement('div');
ele.innerText = '【服务端 连接成功】';
document.getElementById('info').appendChild(ele);
};
ws.onmessage = function (event) {
var response = event.data;
var ele = document.createElement('div');
ele.innerText = response;
document.getElementById('info').appendChild(ele);
};
ws.onclose = function (event) {
var ele = document.createElement('div');
ele.innerText = '【websocket 连接关闭】';
document.getElementById('info').appendChild(ele);
};
function sendMsg() {
var txt = document.getElementById('txt');
ws.send(txt.value); //发送数据
txt.value = '';
}
function closeConn() {
ws.close(); //关闭websocket
var ele = document.createElement('div');
ele.innerText = '【客户端 连接关闭】';
document.getElementById('info').appendChild(ele);
}
</script>
</body>
</html>
API说明
实例化WebSocket对象
var ws = new WebSocket('ws://127.0.0.1:8080');
readyState
readyState
属性返回实例对象的当前状态,共有四种:
- CONNECTING:值为0,表示正在连接。
- OPEN:值为1,表示连接成功,可以通信了。
- CLOSING:值为2,表示连接正在关闭。
- CLOSED:值为3,表示连接已经关闭,或者打开连接失败。
switch (ws.readyState) {
case WebSocket.CONNECTING:
// do something
break;
case WebSocket.OPEN:
// do something
break;
case WebSocket.CLOSING:
// do something
break;
case WebSocket.CLOSED:
// do something
break;
default:
// this never happens
break;
}
onopen
用于指定连接成功后的回调函数
ws.onopen = function() {
consoel.log('连接成功');
// do something
}
onclose
服务端断开连接时,要执行的回调函数
ws.onclose = function(event) {
var code = event.code;
var reason = event.reason;
var wasClean = event.wasClean;
// handle close event
};
onmessage
该属性用于指定收到服务器消息后的回调函数
ws.onmessage = function(event) {
var data = event.data;
// 处理数据
};
send()
用于webSocket对象向服务器发送数据
ws.send('hello world');
onerror
指定出错时的回到函数
ws.onerror = function(event) {
console.log('something wrong');
};
其它
如果要为webSocket对象的某个事件指定多个回调函数,可以使用addEventListener
方法来扩展:
function foo() {
console.log('do something');
}
ws.addEventListener('open', foo);
扩展阅读:
https://developer.mozilla.org/zh-CN/docs/Web/API/EventTarget/addEventListener
服务端
由于服务端是用python的Socket手动实现的,因此,握手,消息解包,消息封包,都需要手动完成。
客户端发送过来的是二进制数据,握手阶段必然要提取请求头信息,并进行websocket通信的验证。
提取请求头
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['uri'], header_dict['protocol'] = header_list[i].split(' ')
else: # 首部字段
k, v = header_list[i].split(':', 1)
header_dict[k] = v.strip()
return header_dict
websocket通信的验证
def handshaking_response(data):
"""
响应客户端websocket握手:1.提取请求头 2.计算Sec-WebSocket-Key 3.返回携带Sec-WebSocket-Accept的响应
:param data: 客户端握手请求数据
:return:
"""
headers = get_headers(data) # 提取请求头
# 从请求头提取Sec-WebSocket-Key
# 将magic_string和Sec-WebSocket-Key先进行SHA-1摘要计算,
# 之后进行BASE - 64编码,编码结果作为响应头Sec-WebSocket-Accept字段的值,返回给客户端
magic_string = '258EAFA5-E914-47DA-95CA-C5AB0DC85B11' # 协议规定的魔法字符串
value = headers['Sec-WebSocket-Key'] + magic_string
res = base64.b64encode(hashlib.sha1(value.encode('utf-8')).digest())
response_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://%s%s\r\n\r\n"
# 响应
response = response_tpl % (res.decode('utf-8'), headers['Host'], headers['uri'])
return response
消息解包
def get_msg(data):
"""
服务端手动解包客户端发来的数据
:param data: 客户端发来的原始bytes数据
:return: msg 解包后的请求体数据
"""
payload_len = data[1] & 127
if payload_len == 126:
extend_payload_len = msg[2:4]
mask = data[4:8]
decoded = data[8:] # decoded 是请求体数据
elif payload_len == 127:
extend_payload_len = data[2:10]
mask = data[10:14]
decoded = data[14:]
else:
extend_payload_len = None
mask = data[2:6]
decoded = data[6:]
bytes_list = bytearray()
for i in range(len(decoded)):
chunk = decoded[i] ^ mask[i % 4]
bytes_list.append(chunk)
msg = str(bytes_list, encoding='utf-8')
return msg
消息封包
def send_msg(conn, msg_bytes):
"""
服务端向客户端发送消息
:param conn: 客户端连接到服务器的socket对象
:param msg_bytes: 向客户端发送的字节
:return:
"""
token = b'\x81'
length = len(msg_bytes)
if length < 126:
token += struct.pack('B', length)
elif length <= 0xFFFF:
token += struct.pack('!BH', 126, length)
else:
token += struct.pack("!BQ", 127, length)
msg = token + msg_bytes
conn.send(msg)
return True
主程序
主程序中将调用以上函数,进行websocket通信
def main():
# 创建TCP套接字
tcpsock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
tcpsock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
tcpsock.bind(('127.0.0.1', 8000))
tcpsock.listen(5)
while True: # 连接循环
print('waitting for connection...')
# 收到握手请求
conn, addr = tcpsock.accept()
data = conn.recv(1024)
print('connected from', addr)
# 返回握手响应
response = handshaking_response(data)
conn.send(bytes(response, encoding='utf-8'))
# 通讯循环
while True:
try:
data = conn.recv(8096)
if not data:
break
msg = get_msg(data) # 解包收到的数据
print('收到信息:',msg)
send_msg(conn, ('服务端响应:'+ msg).encode('utf-8')) # 封包,发送数据
except Exception as e:
print('客户端异常断开')
conn.close()
tcpsock.close()
if __name__ == '__main__':
main()
在tornado中使用WebSocket
WebSocket作为一种较新的标准,并不被所有的web框架所支持,比如大名鼎鼎的Django,是不支持的。不过tornado框架原生支持tornado,并且简单易用,基本使用流程如下:
- 视图继承
tornado.websocket.WebSocketHandler
类 - 定义回调函数
open
, 客户端连接成功时,自动执行 - 定义回调函数
on_message
, 收到客户端消息时,自动执行 - 定义回调函数
on_close
, 客户端断开连接时,自动执行
实现基于WebSocket的实时聊天室
下面我们来实现一个简单的web聊天室,基本逻辑如下:
- 客户端通过http协议访问:
http://127.0.0.1:8000/
- 服务端返回
index.html
页面 index.html
页面加载完成后,会通过JS创建WebSocket对象,访问ws://127.0.0.1:8000/chat
- 服务端执行继承了
WebSocketHandler
的视图中的回调函数,开始进行websocket通信。
服务端代码:
#! user/bin/env python
# -*- coding: utf-8 -*-
import uuid
import json
import tornado.web
import tornado.ioloop
import tornado.websocket
class IndexHandler(tornado.web.RequestHandler):
"""处理客户端的http请求"""
def get(self):
self.render('index.html')
class ChatHandler(tornado.websocket.WebSocketHandler):
"""处理websocket请求"""
waiters = set() # 存储当前聊天室用户
messages = [] # 存储历史消息
def open(self):
print('连接建立')
ChatHandler.waiters.add(self)
uid = str(uuid.uuid4()) # 生成用户标识
self.write_message(uid)
# 将历史信息传入模板渲染,并将结果返回给客户端
for msg in ChatHandler.messages:
content = self.render_string('message.html', **msg)
self.write_message(content)
def on_message(self, message):
msg = json.loads(message)
ChatHandler.messages.append(msg)
# 给聊天室的所有用户返回刚收到的信息
for client in ChatHandler.waiters:
content = client.render_string('message.html', **msg)
client.write_message(content)
def on_close(self):
# 客户端断开连接后,移除该对象
ChatHandler.waiters.remove(self)
def main():
settings = {
'template_path': 'templates',
}
application = tornado.web.Application([
(r'/', IndexHandler),
(r'/chat', ChatHandler),
], **settings)
application.listen(8000)
tornado.ioloop.IOLoop.instance().start()
if __name__ == '__main__':
main()
message.html模板:
<div style="border: 1px solid #dddddd;margin: 10px;">
<div>游客{{uid}}</div>
<div style="margin-left: 20px;">{{message}}</div>
</div>
index.html模板:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Python聊天室</title>
</head>
<body>
<div>
<input type="text" id="txt"/>
<input type="button" id="btn" value="提交" onclick="sendMsg();"/>
<input type="button" id="close" value="关闭连接" onclick="closeConn();"/>
</div>
<div id="container" style="border: 1px solid #dddddd;margin: 20px;min-height: 500px;">
</div>
<script>
window.onload = function() {
wsUpdater.start();
};
var wsUpdater = {
socket: null,
uid: null,
start: function() {
var url = "ws://127.0.0.1:8000/chat";
wsUpdater.socket = new WebSocket(url);
wsUpdater.socket.onmessage = function(event) {
console.log(event);
if(wsUpdater.uid){
wsUpdater.showMessage(event.data);
}else{
wsUpdater.uid = event.data;
}
}
},
// 显示消息
showMessage: function(content) {
var container = document.getElementById('container');
var ele = document.createElement('div');
ele.innerHTML = content;
container.appendChild(ele);
}
};
//发送消息
function sendMsg() {
var msg = {
uid: wsUpdater.uid,
message: document.getElementById('txt').value
};
wsUpdater.socket.send(JSON.stringify(msg));
}
//关闭连接
function closeConn() {
wsUpdater.socket.close();
}
</script>
</body>
</html>
可以开多个浏览器窗口测试,真正做到了消息的即时推送。基本效果如下,比较简陋:
本文所有源码,可以在这里查看:https://github.com/Ayhan-Huang/WebSocket-Test
本文参考了以下文章,在此表示感谢: