什么是 webScoket
WebSocket是一种在单个TCP连接上进行全双工通信的协议。是HTML5提供的一种浏览器与服务器进行全双工通讯的网络技术,属于应用层协议。它基于TCP传输协议,并复用HTTP的握手通道。实现了客户端和服务端双向通信的能力。
它使得浏览器和服务器之间的实时数据传输变得更加容易。与HTTP请求不同,WebSocket连接是持久性的,可以在任何时候向其发送数据。
特点
- WebSocket可以在浏览器里使用
- 支持双向通信,实时性更强
- 使用简单、灵活高效、扩展性高
webScoket 的应用场景
WebSocket的实时通信特性使得它在许多应用中得到了广泛的应用。以下是一些常见的应用场景:
- 聊天室
聊天室是WebSocket最常见的应用之一。通过WebSocket,聊天室的用户可以实时地向其他用户发送消息,而无需刷新页面。
- 游戏
WebSocket也被广泛用于在线游戏中。游戏中的玩家可以使用WebSocket实时地向游戏服务器发送数据,并接收其他玩家的数据。
- 数据可视化
WebSocket还可以用于实时数据可视化。通过WebSocket,服务器可以将实时数据推送给客户端,客户端可以动态地更新数据并显示在页面上。
通信原理
WebSocket复用了HTTP的握手通道,客户端通过HTTP请求与WebSocket服务端协商升级协议。协议升级完成后,后续的数据交换则遵照WebSocket的协议。
1、客户端:申请协议升级
首先,客户端发起协议升级请求。
当客户端要和服务端建立 WebSocket 连接时,在客户端和服务器的握手过程中,客户端首先会向服务端发送一个 HTTP 请求,包含一个 Upgrade 请求头来告知服务端客户端想要建立一个 WebSocket 连接。
采用标准的HTTP报文格式,且只支持GET方法。
//请求头
GET / HTTP/1.1
Host: localhost:8080
Origin: http://127.0.0.1:3000
Connection: Upgrade // 表示该连接要升级协议
Upgrade: websocket // 表示要升级到 websocket 协议
Sec-WebSocket-Version: 13 //表示websocket的版本
Sec-WebSocket-Key: w4v7O6xFTi36lq3RNcgctw==
// 与响应头 Sec-WebSocket-Accept 相对应,提供基本的防护,比如恶意的连接,或者无意的连接。
注意,上面请求省略了部分非重点请求首部。由于是标准的HTTP请求,类似Host、Origin、Cookie等请求首部会照常发送。在握手阶段,可以通过相关请求首部进行 安全限制、权限校验等。
2、服务端:响应协议升级
如果服务器支持WebSocket协议,它将会响应一个HTTP 101状态码,并在响应头中包含一个Upgrade字段,表示正在升级到WebSocket协议。此时,浏览器和服务器之间的连接就已经成功升级到WebSocket协议了。
服务端返回内容如下,状态代码101表示协议切换。到此完成协议升级,后续的数据交互都按照新的协议来。
//响应头
HTTP/1.1 101 Switching Protocols
Connection:Upgrade
Upgrade: websocket
Sec-WebSocket-Accept: Oy4NRAQ13jhfONC7bP8dTKb4PTU=
//与请求头中的 Sec-WebSocket-Key 字段相对应
状态码 status code 是 101 Switching Protocols , 表示该连接已经从 HTTP 协议转换为 WebSocket 通信协议。 转换成功之后,该连接并没有中断,而是建立了一个全双工通信,后续发送和接收消息都会走这个连接通道。
客户端拿到服务端响应的 Sec-WebSocket-Accept 后,会拿自己之前生成的 Sec-WebSocket-Key 用相同算法算一次,如果匹配,则握手成功。然后判断 HTTP Response 状态码是否为 101(切换协议),如果是,则建立连接。
在WebSocket连接建立之后,浏览器和服务器之间就可以互相发送数据了。当其中一方发送数据时,它会将数据封装在一个WebSocket帧中发送给另一方。这些帧可以是文本或二进制数据,它们还可以被分成多个片段进行传输。
3、Sec-WebSocket-Key 与 Sec-WebSocket-Key
Sec-WebSocket-Accept根据客户端请求首部的Sec-WebSocket-Key计算出来。
Sec-WebSocket-Key 是客户端随机生成的一个 base64 编码,服务器会使用这个编码,并根据一个固定的算法得到 Sec-WebSocket-Key:
- 将
Sec-WebSocket-Key跟 GUID:258EAFA5-E914-47DA-95CA-C5AB0DC85B11拼接。
- 通过SHA1计算出摘要,并转成base64字符串。
GUID = "258EAFA5-E914-47DA-95CA-C5AB0DC85B11"; // 一个固定的字符串
accept = base64(sha1(key + GUID)); // key 就是 Sec-WebSocket-Key 值,accept 就是 Sec-WebSocket-Accept 值
Sec-WebSocket-Key 与 Sec-WebSocket-Key 的主要作用在于提供基础的防护,减少恶意连接、意外连接。
作用大致归纳如下:
- 避免服务端收到非法的websocket连接(比如http客户端不小心请求连接websocket服务,此时服务端可以直接拒绝连接)
- 确保服务端理解websocket连接。因为ws握手阶段采用的是http协议,因此可能ws连接是被一个http服务器处理并返回的,此时客户端可以通过Sec-WebSocket-Key来确保服务端认识ws协议。(并非百分百保险,比如总是存在那么些无聊的http服务器,光处理Sec-WebSocket-Key,但并没有实现ws协议。。。)
- 用浏览器里发起ajax请求,设置header时,Sec-WebSocket-Key以及其他相关的header是被禁止的。这样可以避免客户端发送ajax请求时,意外请求协议升级(websocket upgrade)
- 可以防止反向代理(不理解ws协议)返回错误的数据。比如反向代理前后收到两次ws连接的升级请求,反向代理把第一次请求的返回给cache住,然后第二次请求到来时直接把cache住的请求给返回(无意义的返回)。
- Sec-WebSocket-Key主要目的并不是确保数据的安全性,因为Sec-WebSocket-Key、Sec-WebSocket-Accept的转换计算公式是公开的,而且非常简单,最主要的作用是预防一些常见的意外情况(非故意的)。
强调:Sec-WebSocket-Key/Sec-WebSocket-Accept 的换算,只能带来基本的保障,但连接是否安全、数据是否安全、客户端/服务端是否合法的 ws客户端、ws服务端,其实并没有实际性的保证。
实现通信
通过 send() 方法可以发送消息,onmessage 事件用来接收消息,然后对消息进行处理显示在页面上。 当 onerror 事件(监听连接失败)触发时,最好进行执行重连,以保持连接不中断。
function connectWebsocket() {
ws = new WebSocket('ws://localhost:9000');
// 监听连接成功
ws.onopen = () => {
console.log('连接服务端WebSocket成功');
ws.send(JSON.stringify(msgData)); // send 方法给服务端发送消息
};
// 监听服务端消息(接收消息)
ws.onmessage = (msg) => {
let message = JSON.parse(msg.data);
console.log('收到的消息:', message)
elUl.innerHTML += `<li class="b">小秋:${message.content}</li>`;
};
// 监听连接失败
ws.onerror = () => {
console.log('连接失败,正在重连...');
connectWebsocket();
};
// 监听连接关闭
ws.onclose = () => {
console.log('连接关闭');
};
};
connectWebsocket();
项目应用
应本次项目的需求,需要实现订单的打印功能,通过 websocket 与菜鸟插件建立通信。
使用WebSocket 协议与网络共享打印机的打印服务端进行通信,传输ESC/POS指令集进行打印控制,实现多个HTML5应用客户端远程控制多台打印机正常打印输出。
- 首先设置基于WebSocket的客户端数据传输接口函数和变量;
- 设置共享打印机服务的各种接口函数和变量;
- 最后设置传输控制打印指令的逻辑控制方式;
// 初始化
async init () {
await this.getPrintData()
this.socket = new WebSocket('ws://localhost:13528')
this.socket.onopen = () => {
// 获取打印组件版本信息
this.getAgentInfo()
// 获取打印机列表信息
this.getPrinters()
}
this.socket.onerror = (e) => {
if (e.target.readyState !== 1) {
this.isInstalled = false
this.loading = false
}
}
this.socket.onmessage = (e) => {
console.log('message ===> ws', e)
if (JSON.parse(e.data).cmd === 'getPrinters') {
this.printersList = JSON.parse(e.data).printers
this.selectedPrinter = this.printersList[0].name
// 获取到打印机列表后执行后续指令
this.setPrinterConfig()
this.getPrinterConfig()
this.print()
}
if (JSON.parse(e.data).cmd === 'print' && JSON.parse(e.data).previewURL) {
this.previewURL = JSON.parse(e.data).previewURL
}
}
},
// 设置随机值
getUUID () {
return Math.random().toString(36).substr(2)
},
// 获取打印组件版本信息
getAgentInfo () {
if (this.socket.readyState !== 1) {
this.isInstalled = false
this.loading = false
return
}
const uuid = this.getUUID()
this.socket.send(JSON.stringify({
cmd: 'getAgentInfo',
requestID: uuid,
version: '1.0'
}))
},
// 通过 websocket getPrinters 指令 获取打印机信息
getPrinters () {
if (this.socket.readyState !== 1) {
this.isInstalled = false
this.loading = false
return
}
const uuid = this.getUUID()
this.socket.send(JSON.stringify({
cmd: 'getPrinters',
requestID: uuid,
version: '1.0'
}))
},
// 设置打印配置
setPrinterConfig () {
if (this.socket.readyState !== 1) {
this.isInstalled = false
this.loading = false
return
}
const uuid = this.getUUID()
this.socket.send(JSON.stringify({
cmd: 'setPrinterConfig',
printer: {
autoOrientation: true,
autoPageSize: false,
forceNoPageMargins: true,
horizontalOffset: 0,
name: this.selectedPrinter,
needBottomLogo: false,
needTopLogo: false,
orientation: this.printLayout,
paperSize: { height: 30, width: 70 },
verticalOffset: 0
},
requestID: uuid,
version: '1.0'
}))
},
getPrinterConfig () {
if (this.socket.readyState !== 1) {
this.isInstalled = false
this.loading = false
return
}
const uuid = this.getUUID()
this.socket.send(JSON.stringify({
cmd: 'getPrinterConfig',
printer: this.selectedPrinter,
requestID: uuid,
version: '1.0'
}))
},
print () {
if (this.socket.readyState !== 1) {
this.isInstalled = false
this.loading = false
return
}
const uuid = this.getUUID()
this.socket.send(JSON.stringify({
cmd: 'print',
requestID: uuid,
task: {
documents: [
{
contents: this.printData,
documentID: this.getUUID()
}
],
firstDocumentNumber: 0,
notifyType: ['render', 'print'],
preview: this.preview,
previewType: 'pdf',
printer: this.selectedPrinter,
taskID: this.getUUID(),
totalDocumentCount: parseInt(this.totalDocumentCount)
},
version: '1.0'
}))
},
// 获取订单号及商品名称数据
async getPrintData () {
const data = await getInboundShipmentListByIdApi({ idList: this.$route.query.ids })
this.printData = []
data.forEach(el => {
el.childrenList.forEach(item => {
this.printData.push({
templateURL: 'https://example.com/template', // 打印模板路径
data: {
orderId: item.orderId,
title2: item.itemName
}
})
})
})
},
- 在浏览器端使用JavaScript编写WebSocket客户端代码,与菜鸟打印插件建立WebSocket连接。
- 使用WebSocket传输协议将订单数据传输到菜鸟打印插件。
- 在菜鸟打印插件中,使用JavaScript编写WebSocket服务器端代码,接收来自浏览器端的订单数据。
- 解析订单数据,并调用打印机API来实现打印功能。
需要注意的是,您需要确保菜鸟打印插件已经正确安装并配置。
心跳保活
WebSocket为了保持客户端、服务端的实时双向通信,需要确保客户端、服务端之间的TCP通道保持连接没有断开。然而,对于长时间没有数据往来的连接,如果依旧长时间保持着,可能会浪费包括的连接资源。
但不排除有些场景,客户端、服务端虽然长时间没有数据往来,但仍需要保持连接。这个时候,可以采用心跳来实现。
ping/pong 其实是一条与业务无关的假消息,也称为心跳包。
-
发送方->接收方:ping
-
接收方->发送方:pong
ping、pong的操作,对应的是WebSocket的两个控制帧,opcode分别是0x9、0xA。
举例,WebSocket服务端向客户端发送ping,只需要如下代码(采用ws模块)
ws.ping('', false, true);
或者在连接成功之后,每隔一个固定时间发送心跳包,比如 60s:
setInterval(() => {
ws.send('这是一条心跳包消息');
}, 60000)
总结
- 发送 HTTP 请求时报文中携带 upgrade 字段,请求协议升级
- Switching 响应协议升级,将 HTTP 协议转换为 WebSocket 协议
- 建立全双工通信, onSend 和 onMessage 通过通信连接交换信息。
参考资料:
- 【WebSocket 原理浅析与实现简单聊天】juejin.cn/post/684490…
- 【WebSocket 教程】www.ruanyifeng.com/blog/2017/0…
-
【WebSocket:5分钟从入门到精通】juejin.cn/post/684490…
-
【菜鸟插件文档】blog.csdn.net/weixin_3435…