欢迎访问 生活随笔!

生活随笔

当前位置: 首页 > 编程资源 > 编程问答 >内容正文

编程问答

WebSocket剖析

发布时间:2025/3/17 编程问答 48 豆豆
生活随笔 收集整理的这篇文章主要介绍了 WebSocket剖析 小编觉得挺不错的,现在分享给大家,帮大家做个参考.

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-Key
    • 将一个特殊字符串(magic_string)和Sec-WebSocket-Key先进行SHA-1摘要计算,之后进行BASE-64编码
    • 编码结果作为响应头Sec-WebSocket-Accept字段的值,返回给客户端
    • 客户端将这个值和本地计算的值对比,如果一致,则进行Websocket通信
  • 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(); //关闭websocketvar 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 somethingbreak;case WebSocket.OPEN:// do somethingbreak;case WebSocket.CLOSING:// do somethingbreak;case WebSocket.CLOSED:// do somethingbreak;default:// this never happensbreak; }
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_stringres = 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] & 127if 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 = Nonemask = 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_bytesconn.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:breakmsg = 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.websocketclass 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

本文参考了以下文章,在此表示感谢:

http://www.ruanyifeng.com/blog/2017/05/websocket.html

http://www.cnblogs.com/wupeiqi/p/6558766.html

总结

以上是生活随笔为你收集整理的WebSocket剖析的全部内容,希望文章能够帮你解决所遇到的问题。

如果觉得生活随笔网站内容还不错,欢迎将生活随笔推荐给好友。