前言
由来
初次接触 WebSocket
的人,都会问同样的问题:我们已经有了 HTTP 协议,为什么还需要另一个协议?它能带来什么好处?
了解计算机网络协议的人,应该都知道:HTTP 协议是一种无状态的、无连接的、单向的应用层协议。它采用了请求/响应模型。通信请求只能由客户端发起,服务端对请求做出应答处理。
这种通信模型有一个弊端:HTTP 协议无法实现服务器主动向客户端发起消息。
举例来说,我们想随时了解今天的天气变化,只能是客户端向服务器发出请求,服务器返回查询结果。HTTP 协议做不到服务器主动向客户端推送信息。
这种单向请求的特点,注定了如果服务器有连续的状态变化,客户端要获知就非常麻烦。
在 WebSocket
出现之前,我们想实现实时通信、变更推送、服务端消息推送功能,我们一般的方案是使用Ajax轮询。轮询是指由浏览器每隔一段时间向服务器发出 HTTP 请求,然后服务器返回最新的数据给客户端。
常见的轮询方式分为短轮询与长轮询,它们的区别如下图所示:
为了更加直观感受短轮询与长轮询之间的区别,我们来看一下具体的代码:
Polling(短轮询)
js
function checkUpdates(url) {
const xhr = new XMLHttpRequest()
xhr.open('GET', url)
xhr.onload = function() { ... }
xhr.send()
}
setInterval(function(){
checkUpdates('/poll')
}, 60000)
Long-Polling(长轮询)
js
function checkUpdates(url) {
const xhr = new XMLHttpRequest()
xhr.open('GET', url)
xhr.onload = function() {
checkUpdates('/poll')
}
xhr.send()
}
checkUpdates('/poll')
上面两种方案都有比较明显的缺点:
- HTTP 协议包含较长的请求头,有效数据只占很少一部分,频繁轮询浪费带宽。
- 短轮询频繁轮询对服务器压力较大,因为必须不停连接。即使使用长轮询方案,即HTTP 连接始终打开,客户端较多时仍会对服务端造成不小压力。
因此,工程师们一直在思考,有没有更好的方法。在这种情况下,HTML5 定义了 WebSocket
协议。它能更好的节省服务器资源和带宽,并且能够更实时地进行通讯。
简介
WebSocket
协议在2008年诞生,2011年成为国际标准,RFC6455 定义了它的通信标准,后由 RFC7936 补充规范。
WebSocket
是一种网络传输协议,可在单个 TCP 连接上进行全双工通信,位于 OSI 模型的应用层。
WebSocket
使得客户端和服务器之间的数据交换变得更加简单,允许服务端主动向客户端推送数据。客户端和服务器只需要完成一次握手,两者之间就可以创建持久性的连接,并进行双向数据传输。
它的最大特点就是,服务器可以主动向客户端推送信息,客户端也可以主动向服务器发送信息,是真正的双向平等对话,属于服务器推送技术的一种。
WebSocket
建立在 TCP 协议之上,服务器端的实现比较容易,与 HTTP 和 HTTPS 使用相同的 TCP端口,因此与 HTTP(S) 协议有着良好的兼容性,可以绕过大多数防火墙的限制。
默认情况下,WebSocket
协议使用 80端口,协议标识符是ws;运行在 TLS 之上时,默认使用 443端口,则协议标识符为wss。并且握手阶段采用 HTTP(S) 协议,因此握手时不容易屏蔽,能通过各种 HTTP(S) 代理服务器。
Web 浏览器和服务器都必须实现 WebSocket
协议来建立和维护连接。由于 WebSocket
连接长期存在,与典型的 HTTP 连接不同,对服务器有重要的影响。
基于多线程或多进程的服务器无法适用于 WebSocket
,因为它旨在打开连接,尽可能快地处理请求,然后关闭连接。任何实际的 WebSocket
服务器端实现都需要一个异步服务器。
优缺点
-
优点
- 更强的实时性: 由于协议是全双工的,所以服务器可以随时主动给客户端下发数据。相对于 HTTP 请求需要等待客户端发起请求服务端才能响应,延迟明显更少。
- 较小的数据传输开销:
WebSocket
的数据帧相比于 HTTP 请求用于协议控制的数据包头部相对较小,减少了在每个请求中传输的开销,特别适用于需要频繁通信的应用。 - 跨域通信: 与一些其他跨域通信方法相比,
WebSocket
更容易实现跨域通信。 - 较低的服务器资源占用: 由于
WebSocket
的长连接特性,服务器可以处理更多的并发连接,相较于短连接有更低的资源占用。 - 保持连接状态:与 HTTP 不同的是,
WebSocket
需要先创建连接,这就使得其成为一种有状态的协议,之后通信时可以省略部分状态信息; - 更好的二进制支持:
WebSocket
定义了二进制帧,相对 HTTP,可以更轻松地处理二进制内容; - 可以支持扩展:
WebSocket
定义了扩展,用户可以扩展协议、实现部分自定义的子协议。
-
缺点
- 连接状态保持: 长时间保持连接可能会导致服务器和客户端都需要维护连接状态,可能增加一些负担。
- 不适用于所有场景: 对于一些请求-响应模式较为简单的场景,
WebSocket
的实时特性可能并不是必要的,使用 HTTP 请求可能更为合适。 - 复杂性: 与传统的 HTTP 请求相比,
WebSocket
的实现和管理可能稍显复杂,尤其是在处理连接状态、异常等方面。
适用场景
- 推送服务: 用于实现消息推送服务,向客户端主动推送更新或通知。
- 实时数据展示: 对于需要实时展示数据变化的应用,例如股票行情、实时监控系统等,
WebSocket
提供了一种高效的通信方式。 - 实时聊天应用:
WebSocket
是实现实时聊天室、即时通讯应用的理想选择,因为它能够提供低延迟和高实时性。 - 在线协作和协同编辑: 对于需要多用户协同工作的应用,如协同编辑文档或绘图,
WebSocket
的实时性使得用户能够看到其他用户的操作。 - 在线游戏: 在线游戏通常需要快速、实时的通信,
WebSocket
能够提供低延迟和高并发的通信能力。
兼容性
在介绍 WebSocket
API 之前,我们先来了解一下它的兼容性:
由上图可知:目前主流的 Web 浏览器都支持 WebSocket
,所以我们可以在大多数项目中放心地使用它。
API介绍
构造函数
WebSocket
对象作为一个构造函数,用于新建实例。
语法:const ws = new WebSocket(url [, protocols]);
相关参数说明如下:
- url:表示连接的 URL,这是
WebSocket
服务器将响应的 URL; - protocols(可选):一个协议字符串或者一个包含协议字符串的数组。
针对protocols
:这些字符串用于指定子协议,这样单个服务器可以实现多个 WebSocket
子协议。
比如:你可能希望一台服务器能够根据指定的协议(protocol)处理不同类型的交互。如果不指定协议字符串,则假定为空字符串。
使用WebSocket
构造函数时,当尝试连接的端口被阻止时,会抛出 SECURITY_ERR
异常。
javascript
const ws = new WebSocket('ws://localhost:8080');
执行上面语句之后,客户端就会与服务器进行连接。
实例方法
send()
实例对象的send()
方法将需要通过 WebSocket
链接传输至服务器的数据排入队列,并根据所需要传输的数据的大小来增加 bufferedAmount
的值 。若数据无法传输(比如数据需要缓存而缓冲区已满)时,套接字会自行关闭。
语法:ws.send(data)
参数data
,用于传输至服务器的数据。它必须是以下类型之一:
-
String
文本字符串。字符串将以 UTF-8 格式添加到缓冲区,并且
bufferedAmount
将加上该字符串以 UTF-8 格式编码时的字节数的值。 -
ArrayBuffer
你可以使用字节数组对象发送底层二进制数据;其二进制数据内存将被缓存于缓冲区,
bufferedAmount
将加上所需字节数的值。 -
Blob
Blob 类型将队列 blob 中的原始数据以二进制传输。
bufferedAmount
将加上原始数据的字节数的值。 -
ArrayBufferView
你可以以二进制帧的形式发送任何 JavaScript 类数组对象;其二进制数据内容将被队列于缓冲区中。值
bufferedAmount
将加上必要字节数的值。
发送文本的例子。
javascript
ws.send('your message');
发送 Blob
对象的例子。
javascript
const file = document
.querySelector('input[type="file"]')
.files[0];
ws.send(file);
发送 ArrayBuffer
对象的例子。
javascript
// Sending canvas ImageData as ArrayBuffer
const img = canvas_context.getImageData(0, 0, 400, 320);
const binary = new Uint8Array(img.data.length);
for (const i = 0; i < img.data.length; i++) {
binary[i] = img.data[i];
}
ws.send(binary.buffer);
close()
语法:ws.close([code[, reason]])
该方法用于关闭 WebSocket
连接,如果连接已经关闭,则此方法不执行任何操作。
javascript
ws.close();
实例属性
ws.readyState(只读)
ws.readyState
属性返回实例对象的当前状态。共有四种数值,0|1|2|3。
WebSocket.CONNECTING
:值为0,表示正在连接。WebSocket.OPEN
:值为1,表示连接成功,可以通信了。WebSocket.CLOSING
:值为2,表示连接正在关闭。WebSocket.CLOSED
:值为3,表示连接已经关闭,或者打开连接失败。
下面是一个示例。
javascript
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;
}
ws.bufferedAmount(只读)
ws.bufferedAmount
是一个只读属性,用于返回已经被send()
方法放入队列中但还没有被发送到网络中的数据的字节数。一旦队列中的所有数据被发送至网络,则该属性值将被重置为 0,这可以用来判断发送是否结束。但是,若在发送过程中连接被关闭,则属性值不会重置为 0。如果你不断地调用send()
,则该属性值会持续增长。
javascript
const data = new ArrayBuffer(10000000);
ws.send(data);
if (ws.bufferedAmount === 0) {
console.log('发送完毕')
} else {
console.log('发送还没结束')
}
ws.binaryType
ws.binaryType
返回 WebSocket
连接所传输二进制数据的类型。
语法:const binaryType = ws.binaryType
返回值如下:
-
blob
:如果传输的是Blob
类型的数据。 -
arraybuffer
:如果传输的是ArrayBuffer
类型的数据。
extensions(只读)
ws.extensions
是只读属性,返回服务器已选择的扩展值。目前,链接可以协定的扩展值只有空字符串或者一个扩展列表。
protocol(只读)
ws.protocol
是个只读属性,用于返回服务器端选中的子协议的名字;这是一个在创建 WebSocket
对象时,在参数 protocols
中指定的字符串,当没有已建立的链接时为空串。
url(只读)
ws.url
是一个只读属性,返回值为当构造函数创建 WebSocket
实例对象时 URL
的绝对路径。
实例事件
使用 addEventListener()
或将一个事件监听器赋值给 WebSocket
对象的 oneventname
属性,来监听下面的事件。
onopen
实例对象的onopen
属性,用于指定连接成功后的回调函数。
javascript
ws.onopen = function () {
ws.send('Hello Server!');
}
如果要指定多个回调函数,可以使用addEventListener
方法。
javascript
ws.addEventListener('open', function (event) {
ws.send('Hello Server!');
});
onclose
实例对象的onclose
属性,用于指定连接关闭后的回调函数。
javascript
ws.onclose = function(event) {
const code = event.code;
const reason = event.reason;
const wasClean = event.wasClean;
// handle close event
};
ws.addEventListener("close", function(event) {
const code = event.code;
const reason = event.reason;
const wasClean = event.wasClean;
// handle close event
});
onmessage
实例对象的onmessage
属性,用于指定收到服务器数据后的回调函数。
javascript
ws.onmessage = function(event) {
const data = event.data;
// 处理数据
};
ws.addEventListener("message", function(event) {
const data = event.data;
// 处理数据
});
注意,服务器数据可能是文本,也可能是二进制数据(Blob
对象或Arraybuffer
对象)。
javascript
ws.onmessage = function(event){
if(typeof event.data === 'string') {
console.log("Received data string");
}
if(event.data instanceof Blob){
const buffer = event.data;
console.log("Received blob");
}
if(event.data instanceof ArrayBuffer){
const arrayBuffer = event.data;
console.log("Received arraybuffer");
}
}
如果收到的是二进制数据类型,可以设置binaryType
属性值,显式指定返回数据的类型。
javascript
// 收到的是 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);
};
onerror
实例对象的onerror
属性,当websocket
的连接由于一些错误事件的发生 (例如无法发送一些数据) 而被关闭时,一个error事件将被引发。
javascript
socket.onerror = function(event) {
// handle error event
};
socket.addEventListener("error", function(event) {
// handle error event
});
代码实例
WebSocket
服务器常用的 Node 实现有以下几种
具体的用法请查看它们的文档,这里不详细介绍了。
客户端
支持H5的浏览器均内置 WebSocket
对象,直接引用即可,具体代码如下:
html
<!DOCTYPE html>
<html>
<head>
<title>WebSocket Test</title>
</head>
<body>
<script>
const ws = new WebSocket("ws://127.0.0.1:3000"); //建立连接
ws.binaryType = 'arraybuffer'
ws.onopen = function(){ //发送请求
console.log("open");
// 发送UTF-8编码的文本信息
ws.send("This is string");
// 发送UTF-8编码的JSON数据
ws.send(JSON.stringify({msg: 'This is json'}))
// 发送二进制ArrayBuffer
const buffer = new ArrayBuffer(128)
ws.send(buffer)
// // 发送二进制Blob
const blob = new Blob([buffer])
ws.send(blob)
// 发送二进制ArrayBufferView
const intView = new Uint32Array(buffer)
ws.send(intView)
};
ws.onmessage = function(ev){ //获取后端响应
console.log('message', ev.data);
};
ws.onclose = function(ev){
console.log("close");
};
ws.onerror = function(ev){
console.log("error");
};
</script>
</body>
</html>
服务端
为与客户端一致,后端引入ws模块,构建服务器,监听对应事件,具体代码如下:
javascript
const ws = require("ws"); // 加载ws模块;
// 启动`WebSocket`服务器
const wsServer = new ws.Server({
host: "127.0.0.1",
port: 3000,
})
console.log('WebSocket sever is listening at port localhost:3000');
let closeTimer = null // 设置定时器
// 监听客户端请求,绑定对应事件;
function wsListener(wsObj) {
wsObj.on("message", function(reqData) {
// 当10s没有消息进来则对此次连接进行断开
clearTimeout(closeTimer)
closeTimer = setTimeout(() => {
wsObj.close()
}, 10 * 1000);
console.log("request message: ", reqData);
//收到请求,回复
setTimeout(() => {
wsObj.send("1秒延时,收到了,正在处理");
}, 1000);
// 处理业务逻辑
setTimeout(() => {
wsObj.send("3秒延时,返回数据");
wsObj.send(reqData)
}, 3000);
});
wsObj.on("close", function() {
console.log("request close");
});
wsObj.on("error", function(err) {
console.log("request error", err);
});
}
// 建立连接
function onServerConnection (wsObj) {
console.log("request comming");
wsListener(wsObj);
}
wsServer.on("connection", onServerConnection);
运行该js构建服务器
封装客户端库
对客户端WebSocket
方法进行简易封装,后端依然使用上面代码示例中的node websocket服务。
WebSocket.js
javascript
let Socket = null // websocket实例
let setIntervalWesocketPush = null // 心跳计时器
let onopenWS; let onmessageWS; let onerrorWS; let oncloseWS;
/**
* 建立WebSocket连接
* @param {string} url ws地址
*/
export const createSocket = (url) => {
if (Socket) {
Socket.close()
Socket = null
}
console.log('新建WebSocket连接')
Socket = new WebSocket(url)
Socket.onopen = onopenWS
Socket.onmessage = onmessageWS
Socket.onerror = onerrorWS
Socket.onclose = oncloseWS
}
/** 连接错误 */
onerrorWS = () => {
// 错误导致连接关闭则尝试重连
if (Socket.readyState !== 3) {
console.log('连接失败重连中')
createSocket(Socket.url)
}
}
/** WS数据接收统一处理 */
onmessageWS = (e) => {
window.dispatchEvent(new CustomEvent('onmessageWS', {
detail: {
data: e.data,
},
}))
}
/**
* 发送数据但连接未建立时进行处理等待重发
* @param {any} message 需要发送的数据
*/
const connecting = (message) => {
setTimeout(() => {
if (Socket.readyState === 0) {
connecting(message)
} else {
Socket.send(JSON.stringify(message))
}
}, 1000)
}
/**
* 发送数据
* @param {any} message 需要发送的数据
*/
export const sendWSPush = (message) => {
if (Socket?.readyState === 0) {
connecting(message)
} else if (Socket?.readyState === 1) {
Socket.send(JSON.stringify(message))
} else if (Socket?.readyState === 3) {
createSocket(Socket.url)
}
}
/** 断开重连 */
oncloseWS = () => {
clearInterval(setIntervalWesocketPush)
console.log('WebSocket已断开')
// 非正常关闭导致的断开,则尝试重连
if (Socket.readyState !== 2) {
console.log('正在尝试重连....')
createSocket(Socket.url)
}
}
/** 发送心跳
* @param {number} time 心跳间隔毫秒 默认5000
* @param {string} ping 心跳名称 默认字符串ping
*/
export const sendPing = (time = 5000, ping = 'ping') => {
clearInterval(setIntervalWesocketPush)
Socket.send(ping)
setIntervalWesocketPush = setInterval(() => {
Socket.send(ping)
}, time)
}
/** 打开WS之后发送心跳 */
onopenWS = () => {
sendPing()
}
使用
javascript
// 在main.js或需要使用的地方引入
import { createSocket, sendWSPush } from '@/utils/websocket'
// 创建接收消息函数
const getsocketData = (e) => {
console.log(e.detail.data)
}
mounted() {
// 建立连接
createSocket('ws://127.0.0.1:3001')
// 发送消息
sendWSPush('This is test string')
// 注册监听事件
window.addEventListener('onmessageWS', getsocketData)
},
beforeDestroy() {
// 在需要的时候卸载监听事件,比如离开页面
window.removeEventListener('onmessageWS', getsocketData)
}
易混淆常识
WebSocket 与 HTTP 有什么关系?
WebSocket
是一种与 HTTP 不同的协议。两者都位于 OSI 模型的应用层,并且都依赖于传输层的 TCP 协议。
虽然它们不同,但是 RFC 6455 中规定:WebSocket
被设计为在 HTTP 80 和 443 端口上工作,并支持 HTTP 代理和中介,从而使其与 HTTP 协议兼容。为了实现兼容性,WebSocket
握手使用 HTTP Upgrade 头,从 HTTP 协议更改为 WebSocket
协议。
既然已经提到了 OSI(Open System Interconnection Model)模型,这里分享一张很生动形象描述 OSI 模型的示意图(如下图所示)。
WebSocket 与长轮询有什么区别?
长轮询就是:客户端发起一个请求,服务器收到客户端发来的请求后,服务器端不会直接进行响应,而是先将这个请求挂起,然后判断请求的数据是否有更新。如果有更新,则进行响应,如果一直没有数据,则等待一定的时间后才返回。
长轮询的本质还是基于 HTTP 协议,它仍然是一个一问一答(请求 --- 响应)的模式。而 WebSocket
在握手成功后,就是全双工的 TCP 通道,数据可以主动从服务端发送到客户端。
什么是 WebSocket 心跳?
网络中的接收和发送数据都是使用 Socket 进行实现。但是如果此套接字已经断开,那发送数据和接收数据的时候就一定会有问题。
可是如何判断这个套接字是否还可以使用呢?这个就需要在系统中创建心跳机制。
所谓 "心跳" 就是定时发送一个自定义的结构体(心跳包或心跳帧),让对方知道自己 "在线",以确保链接的有效性。
而所谓的心跳包就是客户端定时发送简单的信息给服务器端告诉它我还在而已。代码就是每隔几分钟发送一个固定信息给服务端,服务端收到后回复一个固定信息,如果服务端几分钟内没有收到客户端信息则视客户端断开。
在 WebSocket
协议中定义了 心跳 Ping 和 心跳 Pong 的控制帧:
- 心跳 Ping 帧包含的操作码是 0x9:如果收到了一个心跳 Ping 帧,那么终端必须发送一个心跳 Pong 帧作为回应,除非已经收到了一个关闭帧。否则终端应该尽快回复 Pong 帧;
- 心跳 Pong 帧包含的操作码是 0xA:作为回应发送的 Pong 帧必须完整携带 Ping 帧中传递过来的 "应用数据" 字段。
针对第2)点:如果终端收到一个 Ping 帧但是没有发送 Pong 帧来回应之前的 Ping 帧,那么终端可以选择仅为最近处理的 Ping 帧发送 Pong 帧。此外,可以自动发送一个 Pong 帧,这用作单向心跳。
Socket 是什么?
网络上的两个程序通过一个双向的通信连接实现数据的交换,这个连接的一端称为一个 Socket(套接字),因此建立网络通信连接至少要一对端口号。
Socket 本质:是对 TCP/IP 协议栈的封装,它提供了一个针对 TCP 或者 UDP 编程的接口,并不是另一种协议。通过 Socket,你可以使用 TCP/IP 协议。
百度百科上关于Socket的描述是这样:
Socket 的英文原义是"孔"或"插座":作为 BSD UNIX 的进程通信机制,取后一种意思。通常也称作"套接字",用于描述IP地址和端口,是一个通信链的句柄,可以用来实现不同虚拟机或不同计算机之间的通信。
在Internet 上的主机一般运行了多个服务软件,同时提供几种服务。每种服务都打开一个Socket,并绑定到一个端口上,不同的端口对应于不同的服务。Socket 正如其英文原义那样,像一个多孔插座。一台主机犹如布满各种插座的房间,每个插座有一个编号,有的插座提供 220 伏交流电, 有的提供 110 伏交流电,有的则提供有线电视节目。 客户软件将插头插到不同编号的插座,就可以得到不同的服务。
关于 Socket,可以总结以下几点:
- 它可以实现底层通信,几乎所有的应用层都是通过 socket 进行通信的;
- 对 TCP/IP 协议进行封装,便于应用层协议调用,属于二者之间的中间抽象层;
- TCP/IP 协议族中,传输层存在两种通用协议: TCP、UDP,两种协议不同,因为不同参数的 socket 实现过程也不一样。
下图说明了面向连接的协议的套接字 API 的客户端/服务器关系: