Websocket

参考:官网 (opens in a new tab)(墙了),阮一峰老师的文章 (opens in a new tab)MDN (opens in a new tab)

背景:

HTTP 协议不够用吗?确实如此,只能由客户端发起请求,服务端只能被动接受请求,返回结果。

这样单向的请求注定了服务器如果有连续状态发生变化了(比如某个地图资源更新了),无法主动告知客户端,只能通过客户端的轮询。最典型的就是聊天室场景。轮询的效率非常低,也很浪费资源,当然也可以用长链接,总之这些方案要解决实时性场景都不太行。

简介

WebSocket 也是一种协议,在 2008 年诞生,2011 年成为国际标准,所有浏览器支持。

最大特点:

  • 服务器可以主动向客户端推送,真正的双向平等对话
  • 降低服务器的压力

其他特点:

  • 基于 TCP
  • 与 HTTP 协议良好兼容,默认端口也是 80 和 443,握手阶段采用 HTTP ?能通过各种 HTTP 代理服务器
  • 数据格式轻量,性能小,通信高效
  • 文本、二进制都可
  • 没有同源限制,可与任意服务器通信
  • 协议标识ws(加密为wss),服务器地址就是 URL,例如ws://example.com:80/some/path

来自阮一峰老师的图

简单的 demo

<script>
  const ws = new WebSocket("wss://echo.websocket.org");
  // 连接建立的回调
  ws.onopen = (e) => {
    console.log("connect open ...");
    ws.send("hello ws");
    console.log(e);
  };
 
  ws.onmessage = (e) => {
    console.log(`receive message: ${e.data}`);
    // 收完就关。。
    ws.close();
    console.log(e);
  };
 
  ws.onclose = (e) => {
    console.log(e);
    console.log("close");
  };
</script>

属性

WebSocket.readyState 状态常量

  • CONNECTING: 0,正在连接
  • OPEN: 1,连接成功,可以通信
  • CLOSEING: 2,正在关闭
  • CLOSED: 3,关闭 or 连接失败

WebSocket.onopen 连接成功后的 callback 函数,可以用 addEventListener方法

WebSocket.onclose 连接关闭的 callback

bubbles: false
cancelBubble: false
cancelable: false
code: 1005
composed: false
currentTarget: WebSocket {url: "wss://echo.websocket.org/", readyState: 3, bufferedAmount: 0, onerror: null, onopen: ƒ, …}
defaultPrevented: false
eventPhase: 0
isTrusted: true
path: []
reason: ""
returnValue: true
srcElement: WebSocket {url: "wss://echo.websocket.org/", readyState: 3, bufferedAmount: 0, onerror: null, onopen: ƒ, …}
target: WebSocket {url: "wss://echo.websocket.org/", readyState: 3, bufferedAmount: 0, onerror: null, onopen: ƒ, …}
timeStamp: 7022.464999987278
type: "close"
wasClean: true
__proto__: CloseEvent

WebSocket.onmessage 接收到服务器数据后的 callback ,注意,服务器数据可能是文本,也可能是二进制数据(blob对象或Arraybuffer对象)。

ws.onmessage = function (event) {
  if (typeof event.data === String) {
    console.log("Received data string");
  }
 
  if (event.data instanceof ArrayBuffer) {
    var buffer = event.data;
    console.log("Received arraybuffer");
  }
  if (event.data instanceof Blob) {
    var buffer = event.data;
    console.log("Received Blob");
  }
};

Message Event 大致属性如下:

bubbles: false
cancelBubble: false
cancelable: false
composed: false
currentTarget: WebSocket {url: "wss://echo.websocket.org/", readyState: 3, bufferedAmount: 0, onerror: null, onopen: ƒ, …}
data: "hello ws"
defaultPrevented: false
eventPhase: 0
isTrusted: true
lastEventId: ""
origin: "wss://echo.websocket.org"
path: []
ports: []
returnValue: true
source: null
srcElement: WebSocket {url: "wss://echo.websocket.org/", readyState: 3, bufferedAmount: 0, onerror: null, onopen: ƒ, …}
target: WebSocket {url: "wss://echo.websocket.org/", readyState: 3, bufferedAmount: 0, onerror: null, onopen: ƒ, …}
timeStamp: 6619.11999998847
type: "message"
userActivation: null
__proto__: MessageEvent

除了动态判断收到的数据类型,也可以使用binaryType属性,查看指定收到的二进制数据类型。

// 收到的是 blob 数据
ws.binaryType = "blob";
ws.onmessage = function (e) {
  console.log(e.data.size);
};
 
// 收到的是 ArrayBuffer 数据
ws.binaryType = "arraybuffer";
ws.onmessage = function (e) {
  console.log(e.data.byteLength);
};

WebSocket.send(String | Blob | ArrayBuffer)

WebSocket.bufferedAmount只读属性,查看在send()队列中还没有被传输到网络中的字节数,全部发送后 reset 到 0

WebSocket.onerror发生错误的 callback

赋能服务端推送

服务器的实现可以分为服务器和网关

websocket-architecture

One of the more unique features WebSockets provide is its ability to traverse firewalls and proxies, a problem area for many applications.

Comet-style applications: 用 Comet 技术(类似长连接),也就是延迟一个 HTTP 请求服务端返回信息的时机,直到有新内容更变了服务端才返回响应,然后客户端立即发送新的请求,维持这样的长链接,通常都是在前端用 JS 实现的。

对于一些有特定间隔的变化轮询是一个很好的坚决方案,只要客户端设定时间轮询就行了,但是在大多数的实时场景中,数据都是不可预测的,而且在变化频率比较低情况下,客户端需要发送很多不必要的请求。

streaming: 流方式,浏览器发送一个完整的请求,客户端在一定时间段内只返回并且维护一个响应,这个响应在信息变化的时候被更新,服务端不会关闭这个完整响应。但是由于 HTTP 防火墙、代理服务器会缓存响应,增加了信息传输的等待时间,所以也降级成长轮询了。

也有很多实现双工连接的方法,比如维护两个连接,但是其中需要同步协调的复杂度太大了。简单来说, HTTP 没有设计成实时、全双工的。 Comet 应用也很复杂。

在不支持 WebSocket 的浏览器上:Kaazing WebSocket Gateway makes HTML5 WebSocket code work in all the browsers today.

WebSocket 协议

协议连接的开始和 HTTP 连接一样,这样能确保是个完全后向兼容的(有些浏览器/服务器不支持 WebSocket 可以回退到 HTTP)。所以 WebSocket 协议升级到 HTTP 的时候需要一次升级(握手)

客户端发送请求:(省略了很多头内容)

GET ws://echo.websocket.org/?encoding=text HTTP/1.1
Origin: http://websocket.org
Cookie: __utma=99as
Connection: Upgrade
Host: echo.websocket.org
Sec-WebSocket-Key: uRovscZjNol/umbTt5uKmw==
Upgrade: websocket
Sec-WebSocket-Version: 13

注意 ConnectionUpgrade,表明这个 HTTP 协议要升级到 WebSocket。

  • Sec-WebSocket-Version表示协议版本
  • Sec-WebSocket-Key与服务端响应的Sec-WebSocket-Accept是配套的,提供基本的防护,比如恶意的连接,或者无意的连接。

服务端响应同意升级:

HTTP/1.1 101 WebSocket Protocol Handshake
Date: Fri, 10 Feb 2012 17:38:18 GMT
Connection: Upgrade
Server: Kaazing Gateway
Upgrade: WebSocket
Access-Control-Allow-Origin: http://websocket.org
Access-Control-Allow-Credentials: true
Sec-WebSocket-Accept: rLHCkw/SKsO9GAH/ZSFhBATDKrU=
Access-Control-Allow-Headers: content-type

Sec-WebSocket-Accept 的计算:根据客户端请求首部的Sec-WebSocket-Key计算出来。计算公式为:

  1. Sec-WebSocket-Key258EAFA5-E914-47DA-95CA-C5AB0DC85B11拼接。
  2. 通过 SHA1 计算出摘要,并转成 base64 字符串。

验证一下:

NodeJS 实现

const crypto = require("crypto");
const magic = "258EAFA5-E914-47DA-95CA-C5AB0DC85B11";
const secWebSocketKey = "5r+6Y3+R8T56oZow/24HdA==";
 
let secWebSocketAccept = crypto
  .createHash("sha1")
  .update(secWebSocketKey + magic)
  .digest("base64");
 
console.log(secWebSocketAccept);
// JqyAewJNS3LXOuT7VfSQQC/Mtt4=

Py 实现

import hashlib
import base64
 
sec_socket_key = '5r+6Y3+R8T56oZow/24HdA=='
magic = '258EAFA5-E914-47DA-95CA-C5AB0DC85B11'
 
sec_socket_accept = base64.b64encode(
    hashlib.sha1(
        f'{sec_socket_key}{magic}'.encode('utf8')
    ).digest()
).decode()
 
print(sec_socket_accept)
# JqyAewJNS3LXOuT7VfSQQC/Mtt4=

酷,为啥要用这个魔法字符串呢。。。

详细的数据帧格式啥的可以看知乎这篇 (opens in a new tab)

后端实现 TODO

Node.js

express:https://blog.csdn.net/feng98ren/article/details/86240287 (opens in a new tab)