WebSocket浅析
关于WebSocket的个人学习心得:WebSocket协议是基于TCP的一种新的协议。WebSocket最初在HTML5规范中被引用为TCP连接,作为基于TCP的套接字API的占位符。它实现了浏览器与服务器全双工(full-duplex)通信。其本质是保持TCP连接,在浏览器和服务端通过Socket进行通信。 本文将python去编写websocket服务端来去一步步分析请求过程~
WebScoket介绍:
HTTP是基于Socket实现的协议。它是短链接(发一个请求:连接响应断开),规定了由浏览器发起请求,服务端只负责响应这样的通讯模式
HTTP是一种在一个TCP连接上进行单工通信的协议。只有客户端能主动往服务端发送请求然后服务端返回响应,而服务端却不能主动的给客户端发送数据。
WebScoket是一个双工通道。也是通过Socket实现的协议。与HTTP不同的是:客户端和服务端连接后,客户端不会直接断开。而是双端相互之间都可以一直地随意地发送接收消息直到一方主动断开链接。
WebScoket让我们可以在客户端和Web服务端之间实现实时通信,不需要客户端发起请求,服务端可以主动直接向客户端推送数据!
如果还觉得有点迷糊,这里有个最常见的应用场景就是“聊天室”!
程序设计的需求是:某人往群里发了一条消息,所有人都会收到这条消息。
如果按照HTTP的请求响应模式去设计程序,那么每个人都需要一直往服务端长轮询的发请求来监测是否有消息发到群里。使用WebSocket就不一样了,某人往群里发了消息,服务端主动给每个人推送过去。卧槽,有没有突然醍醐灌顶的感觉!
WebScoket握手过程
WebSocket本质上就是一个Socket!两人建立连接,谁也不断开,我能给你发消息,你也能给我发消息!
WebSocket分为客户端和服务端,它们的握手(连接)过程是这样的:
-
服务端:有两种,一种是我们自己手写的Socket服务端。另外一种是web框架就是别人写好的socket服务端
-
服务端开启Socket,监听IP和端口等待客户端来连接
-
允许连接
-
*服务端接收到特殊的值,【把特殊值和magic string相加进行哈希加密sha1】
PS:magic string是一个固定的字符串!magic string=' 258EAFA5-E914-47DA-95CA-C5AB0DC85B11 ' -
*加密后的值发送给客户端
-
-
客户端:浏览器
-
客户端向服务端(IP和端口)发起连接请求,连接成功后就可以收发数据
-
*客户端生成一个特殊值(随机字符串),【把特殊值和magic string相加进行哈希加密sha1】,把加密值保留在本地用作验证,然后向服务端发送一段特殊值
PS:migic string是一个固定的字符串!migc string=' 258EAFA5-E914-47DA-95CA-C5AB0DC85B11 ' -
*客户端接收到加密的值 ,与本地的加密值作对比。如果一致两个加密值的话就表示服务端协议与客户端协议一样,即本次握手过程就是遵循websocket的!websocket连接建立成功!、
-
-
收发数据:websocket连接创建成功后双方就可以随意地收发数据:客户端可以主动向服务端发,服务端也可以主动向客户端发
PS:上面执行过程没有星号的是TCP的握手过程,【*】号的执行过程表示的是websocket的握手过程。
当客户端向服务端发送连接请求时,不仅连接 (TCP握手)。 还会发送【握手】信息,并等待服务端响应(WebSocket握手过程),至此连接才创建成功!
下面让我们来使用python去实现WebSocket握手过程:
"""1.启动服务端"""
import socket
sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
sock.bind(('127.0.0.1', 8002))
sock.listen(5)
# 等待用户连接
conn, address = sock.accept()
<!-- 2.客户端连接 -->
<script type="text/javascript">
var socket = new WebSocket("ws://127.0.0.1:8002/xxoo"); //ws表示的是websocket协议
//这句代码浏览器内部就帮我们做了客户端websocket的所有握手工作(发握手请求,拿到响应进行验证)
//当客户端向服务端发送连接请求时,不仅连接还会发送【Ws握手请求】信息,并等待服务端响应【Ws握手】,至此WS握手(连接)才创建成功!
</script>
"""
3.建立连接(ws握手)
请求和响应的【握手】信息需要遵循规则:
从请求【握手】信息中提取 Sec-WebSocket-Key
利用magic_string 和 Sec-WebSocket-Key 进行sha1加密,再进行base64加密
将加密结果响应给客户端
注:magic string为:258EAFA5-E914-47DA-95CA-C5AB0DC85B11
请求【握手】信息为(ws请求格式):
GET /chatsocket HTTP/1.1
Host: 127.0.0.1:8002
Connection: Upgrade
Pragma: no-cache
Cache-Control: no-cache
Upgrade: websocket
Origin: http://localhost:63342
Sec-WebSocket-Version: 13
Sec-WebSocket-Key: mnwFxiOlctXFN/DeMt1Amg==
Sec-WebSocket-Extensions: permessage-deflate; client_max_window_bits
...
...
"""
import socket
import base64
import haxshlib
def get_headers(data):
"""
将请求头格式化成字典
:param data:
:return:
"""
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['url'], header_dict['protocol'] = header_list[i].split(' ')
else:
k, v = header_list[i].split(':', 1)
header_dict[k] = v.strip()
return header_dict
sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
sock.bind(('127.0.0.1', 8002))
sock.listen(5)
#获取客户端socket对象
conn, address = sock.accept()
#获取客户端的【ws握手】请求消息
data = conn.recv(1024)
#提取【握手请求消息】中的websocket-key与magic string进行sha1加密
headers = get_headers(data) #提取请求头信息(与http请求格式一样请求头请求体以换行分割)
magic_string = '258EAFA5-E914-47DA-95CA-C5AB0DC85B11'
value = headers['Sec-WebSocket-Key'] + magic_string
ac = base64.b64encode(hashlib.sha1(value.encode('utf-8')).digest()) #返回给客户端的加密值
#把ws握手响应发送回客户端
#ws响应格式,
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"
#将生成的加密值放到Sec-webSocket-Accept请求头中所对应的值中
response_str = response_tpl % (ac.decode('utf-8'), headers['Host'], headers['url'])
# 响应【握手】信息
conn.send(bytes(response_str, encoding='utf-8'))
<!-- 检验是否ws握手(连接)成功 -->
<script type="text/javascript">
var socket = new WebSocket("ws://127.0.0.1:8002/xxoo"); //ws表示的是websocket协议
socket.onopen = function(){
console.log(1111)
} // 如果ws握手成功 就会触发该函数的执行。失败就不会触发
</script>
websocket连接建立成功后,接下来就可以进行客户端和服务端的收发数据了。这个过程里面也是十分的有门道!
在学习前先补充一个知识点:位运算和与运算
位运算:1001001
右移动 >> 右4位:00001001(相当于把低四位删掉,前后在高位补4个0)
左移动<< 左4位:100100010000 (直接在低位后面补零)
与运算:&
a,b是1,1:1
a,b是0,1:0
a,b是0,0:0
Websocket数据解析过程
websocket客户端和服务端收发数据时,需要对数据进行【封包】和【解包】。客户端的JavaScript类库已经封装【封包】和【解包】过程,但Socket服务端需要手动实现。
也就是说我们接收和发送数据都不能直接通过sent
和recv
去发送或者接收,websocket规定了收发消息的数据格式,即如果你要使用websocket进行通讯,那么久必须要去遵循它的规则去对收发的数据进行解析!
客户端即浏览器内部已经帮我们封装好了对websocket数据的解析的功能,我们直接调用websocket对象send
或者onmessage
方法就可以好似没有解析过程似的直接去收发消息。
<!-- websocket客户端发送消息 -->
<script type="text/javascript">
var socket = new WebSocket("ws://127.0.0.1:8002/xxoo"); //ws表示的是websocket协议
socket.onopen = function(){
socket.send('deehuang真帅!')
} // 如果ws握手成功 就会触发该函数的执行。失败就不会触发
socket.onmessage = function(event){
console.log(event); //event封装了关于消息的一些信息,通过event.data可以拿到数据
} // 如果接收到了服务端发送的数据会出发该函数的执行
ws.onclose = function(){
} //服务端主动断开连接的时候会触发该函数的执行!
</script>
但是在我们Socket服务端中却没有提供这样的功能。所以在服务端中如果我们要通过websocket向客户端发送数据,则需要对数据按websocket的规则进行封装,这个过程叫做【封包】。反之如果我们要接收客户端的数据的话需要按websocket的规则进行【解包】过程,否则拿到的数据我们是看不明白的!
下面是websocket服务端的数据解析过程:
一、解包过程:
下表是websocket数据包格式,让我们来解读一下它的意思
0 1 2 3
0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1
+-+-+-+-+-------+-+-------------+-------------------------------+
|F|R|R|R| opcode|M| Payload len | Extended payload length |
|I|S|S|S| (4) |A| (7) | (16/64) |
|N|V|V|V| |S| | (if payload len==126/127) |
| |1|2|3| |K| | |
+-+-+-+-+-------+-+-------------+ - - - - - - - - - - - - - - - +
| Extended payload length continued, if payload len == 127 |
+ - - - - - - - - - - - - - - - +-------------------------------+
| |Masking-key, if MASK set to 1 |
+-------------------------------+-------------------------------+
| Masking-key (continued) | Payload Data |
+-------------------------------- - - - - - - - - - - - - - - - +
: Payload Data continued ... :
+ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - +
| Payload Data continued ... |
+---------------------------------------------------------------+
"""
解读上表去实现解包:
客户端发送过来的数据实际上就是在要发送的数据的前面塞点头部信息进去!它就是按上表中的格式进行封包的!解包就是如何解析这个数据包去获取客户端发来的数据
PS:数据包传输过来就是一堆二进制格式的数字,这些二进制数字一位或者多位数字组成的值有不同的涵义。我们要学习解析数据包就是去了解它是怎么封包的:数据头包含了什么信息?如何去解读这些位都表示了什么?我们的客户端发送的数据封装在那些位上?在这里只是介绍怎么去获取到我们要的数据部分
假设info是从客户端发送来的数据包:
1.表头上面数字从左到右起每八位表示一个字节,例如开始的:0 1 2 3 4 5 6 7,这八个值就表示第一个字节中的八位。以此类推8 9 0 1 2 3 4 5第二个字节的八位......
2.info[0]获取数据包的第一个字节信息,info[1]获取数据包的第二个字节信息
3.每个字节中的一位和多位组成的值表达了不同的涵义(表的第一行写了名字),如第一个字节的第一位代表的是FIN。获取这些值可以通过位运算或者与运算的方式实现。
4.payload_len的值是告诉我们数据头所占的长度的【它的最大值是127(因为该值最多占7位)】
当payload_len<126的时候表示数据头就占0~15位即数据头占2字节的长度
当payload_len=126的时候表示数据头长度在0~15位的基础再延伸16位,即数据头总共占用4个字节长度
当payload_len=126的时候表示数据头长度在0~15位的基础再延伸64位,即数据头总共占用10个字节长度
5.数据包中的数据信息是被加密过的,通过payload_len我们可以定下数据头的长度。数据头往后的4个字节表示的是masking key的值,它是用来给数据解密的!
6.一个数据包的由三部分组成:数据头(payload告诉长度)-->masking key(4字节)-->数据
"""
#怎么获取特定位的值?
info = conn.recv(8096) #获取客户端发送的数据
#获取FIN可以采用位运算的方式,它的值是第一个字节的第一位
#info[0]表示获取的第一个字节(上表中的0~7)的数据,第一个字节(八个值)中向右移七位就可获得FIN的值
fin = info[0] >> 7
#获取opcode可以采用与运算的方式,它的值是第一个字节的后四位
#info[0]表示获取的第一个字节(上表中的0~7)的数据,op的最大值是1111即15,进行与运算就拿到后四位的值
opcode = info[0] & 15 #计算后四位的值
#获取Payload len同理,它的值是第二个字节的后七位
#info[1]表示获取第二个字节,Payload的最大值1111111的值是127,进行与运算就得到后七位的值
payload_len = info = info[1] & 127
下面是代码具体实现获取客户端的数据【解包】
info = conn.recv(8096) #获取客户端发送的数据(字节格式)
payload_len = info[1] & 127
if payload_len == 126: #数据头占4个字节(延长两个)
extend_payload_len = info[2:4] #获取延出的两个字节的值
mask_key = info[4:8] #在数据头的后面取4个字节
decoded = info[8:] #这就是我们的数据,因为它被mask_key加密了所以才命名decoded
elif payload_len == 127: #数据头占10字节(延出八个)
extend_payload_len = info[2:10] #获取延出的八个字节的值
mask_key = info[10:14]
decoded = info[14:]
else:
extend_payload_len = None #数据头没有延长
mask_key = info[2:6]
decoded = info[6:]
bytes_list = bytearray()
#解密数据,官方文档是通过这样解的
for i in range(len(decoded)):
#把数据部分每一个字节一个字节与mask_key进行异或运算
chunk = decoded[i] ^ mask_key[i % 4]
bytes_list.append(chunk) #把解码的数据先统一变成字节最后再一起转换成字符串
body = str(bytes_list, encoding='utf-8') #这就可以去解码中文了(utf8中文占三字节)!
print(body)
二、封包过程:
如果我们要向客户端发送数据就要对数据进行封包。
有了上面解包的基础!不难理解封包就是在按上面协议规定的数据包格式进行封装就完事!一个数据包一般是由三部分组成:数据头-->mask_key-->数据。对于封包我们可以使用struct模块帮助我们轻松实现!
def send_msg(conn, msg_bytes):
"""
WebSocket服务端向客户端发送消息
:param conn: 客户端连接到服务器端的socket对象,即: conn,address = socket.accept()
:param msg_bytes: 向客户端发送的字节
:return:
"""
import struct #封装二进制包的模块
token = b"\x81" #发送第一个字节的数据头即FIN这些位
length = len(msg_bytes)
#根据数据长度的不同使用不同模式的封包(模块内部就是加上payload_len和mask_key把数据放后面)
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客户端即我们的浏览器怎么去接收消息呢?接收到消息后会触发ws对象中的onmessage函数的执行:
<!-- 检验客户端是否接收到消息成功 -->
<script type="text/javascript">
var socket = new WebSocket("ws://127.0.0.1:8002/xxoo"); //ws表示的是websocket协议
socket.onopen = function(){
console.log(1111)
} // 如果ws握手成功 就会触发该函数的执行。失败就不会触发
socket.onmessage = function(event){
console.log(event); //event封装了关于消息的一些信息,通过event.data可以拿到数据
}// 如果接收到了服务端发送的数据会出发该函数的执行
</script>
关于websocket协议数据包解析的更多内容(如数据头中FIN,opcode的作用)可以参考下面的官方文档:
The MASK bit simply tells whether the message is encoded. Messages from the client must be masked, so your server should expect this to be 1. (In fact, section 5.1 of the spec says that your server must disconnect from a client if that client sends an unmasked message.) When sending a frame back to the client, do not mask it and do not set the mask bit. We'll explain masking later. Note: You have to mask messages even when using a secure socket.RSV1-3 can be ignored, they are for extensions.
The opcode field defines how to interpret the payload data: 0x0 for continuation,
0x1
for text (which is always encoded in UTF-8),0x2
for binary, and other so-called "control codes" that will be discussed later. In this version of WebSockets,0x3
to0x7
and0xB
to0xF
have no meaning.The FIN bit tells whether this is the last message in a series. If it's 0, then the server will keep listening for more parts of the message; otherwise, the server should consider the message delivered. More on this later.
Decoding Payload Length
To read the payload data, you must know when to stop reading. That's why the payload length is important to know. Unfortunately, this is somewhat complicated. To read it, follow these steps:
- Read bits 9-15 (inclusive) and interpret that as an unsigned integer. If it's 125 or less, then that's the length; you're done. If it's 126, go to step 2. If it's 127, go to step 3.
- Read the next 16 bits and interpret those as an unsigned integer. You're done.
- Read the next 64 bits and interpret those as an unsigned integer (The most significant bit MUST be 0). You're done.
Reading and Unmasking the Data
If the MASK bit was set (and it should be, for client-to-server messages), read the next 4 octets (32 bits); this is the masking key. Once the payload length and masking key is decoded, you can go ahead and read that number of bytes from the socket. Let's call the data ENCODED, and the key MASK. To get DECODED, loop through the octets (bytes a.k.a. characters for text data) of ENCODED and XOR the octet with the (i modulo 4)th octet of MASK. In pseudo-code (that happens to be valid JavaScript):
var DECODED = "";
for (var i = 0; i < ENCODED.length; i++) {
DECODED[i] = ENCODED[i] ^ MASK[i % 4];
}Now you can figure out what DECODED means depending on your application.
Tornado实现web群聊
上面是通过自己通过python写websocket服务端以此来学习和加深对websocket协议内部原理的理解!在实际运用中其实并不用自己去写,像客户端浏览器中的javascript的类库对websocket提供便捷的接口使得我们使用起来十分舒服。在服务端开发中许多web框架都支持websocket,给它封装了十分便捷的接口直接供我们调用!
Tornado是一个支持WebSocket的优秀框架!下面是一个使用Tornado去基于websocket实现web聊天室的例子!
"""服务端"""
#!/usr/bin/env python
# -*- coding:utf-8 -*-
import uuid
import json
import tornado.ioloop
import tornado.web
import tornado.websocket
class IndexHandler(tornado.web.RequestHandler):
def get(self):
self.render('index.html')
#继承的是websocket.WebSocketHandler,这样才能处理ws请求
class ChatHandler(tornado.websocket.WebSocketHandler):
# 用于存储当前聊天室用户
waiters = set()
# 用于存储历时消息
messages = []
def open(self):
"""
客户端与服务端已经完成Ws握手
客户端连接成功时,自动执行
"""
#self里面就封装了创建的对象,每个用户来连接都要把他们的socket保存到waiters列表中才能找得到每个用户的socket去sent
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):
"""
客户端连发送消息时,自动执行
有一个人(客户端)发消息,就要给群聊中的所有用户发消息
write_message()是给客户端发送的数据
"""
msg = json.loads(message)
ChatHandler.messages.append(message)
for client in ChatHandler.waiters:
#给聊天室的每一个用户发消息
#render会直接sent过去,我们是要通过websocket去sent的所以使用render_string表示的是只是拿到渲染模板引擎后的字符串再write_message过去,这样实现了给前端添加html标签了!
content = client.render_string('message.html', **msg)
client.write_message(content) #给客户端发数据
def on_close(self):
"""
客户端关闭连接时,,自动执行
"""
ChatHandler.waiters.remove(self)
def run():
settings = {
'template_path': 'templates',
'static_path': 'static',
}
application = tornado.web.Application([
(r"/", IndexHandler),
(r"/chat", ChatHandler),
], **settings)
application.listen(8888)
tornado.ioloop.IOLoop.instance().start()
if __name__ == "__main__":
run()
<!-- 客户端index.html -->
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>websocket聊天室</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 src="/static/jquery-2.1.4.min.js"></script>
<script type="text/javascript">
$(function () {
wsUpdater.start();
});
var wsUpdater = {
socket: null,
uid: null,
start: function() {
var url = "ws://127.0.0.1:8888/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) {
$('#container').append(content);
}
};
function sendMsg() {
var msg = {
uid: wsUpdater.uid,
message: $("#txt").val()
};
wsUpdater.socket.send(JSON.stringify(msg));
}
</script>
</body>
</html>
结语
以上是个人学习之路,如有误,欢迎指正!参考文献