webScoket 原理与应用

449 阅读8分钟

什么是 webScoket

WebSocket是一种在单个TCP连接上进行全双工通信的协议。是HTML5提供的一种浏览器与服务器进行全双工通讯的网络技术,属于应用层协议。它基于TCP传输协议,并复用HTTP的握手通道。实现了客户端和服务端双向通信的能力。

它使得浏览器和服务器之间的实时数据传输变得更加容易。与HTTP请求不同,WebSocket连接是持久性的,可以在任何时候向其发送数据。

特点

  1. WebSocket可以在浏览器里使用
  1. 支持双向通信,实时性更强
  1. 使用简单、灵活高效、扩展性高

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:

  1. Sec-WebSocket-Key跟 GUID:258EAFA5-E914-47DA-95CA-C5AB0DC85B11拼接。
  1. 通过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 的主要作用在于提供基础的防护,减少恶意连接、意外连接。

作用大致归纳如下:

  1. 避免服务端收到非法的websocket连接(比如http客户端不小心请求连接websocket服务,此时服务端可以直接拒绝连接)
  1. 确保服务端理解websocket连接。因为ws握手阶段采用的是http协议,因此可能ws连接是被一个http服务器处理并返回的,此时客户端可以通过Sec-WebSocket-Key来确保服务端认识ws协议。(并非百分百保险,比如总是存在那么些无聊的http服务器,光处理Sec-WebSocket-Key,但并没有实现ws协议。。。)
  1. 用浏览器里发起ajax请求,设置header时,Sec-WebSocket-Key以及其他相关的header是被禁止的。这样可以避免客户端发送ajax请求时,意外请求协议升级(websocket upgrade)
  1. 可以防止反向代理(不理解ws协议)返回错误的数据。比如反向代理前后收到两次ws连接的升级请求,反向代理把第一次请求的返回给cache住,然后第二次请求到来时直接把cache住的请求给返回(无意义的返回)。
  1. 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应用客户端远程控制多台打印机正常打印输出。

  1. 首先设置基于WebSocket的客户端数据传输接口函数和变量;
  2. 设置共享打印机服务的各种接口函数和变量;
  3. 最后设置传输控制打印指令的逻辑控制方式;
  // 初始化
    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
            }
          })
        })
      })
    },
    
  1. 在浏览器端使用JavaScript编写WebSocket客户端代码,与菜鸟打印插件建立WebSocket连接。
  2. 使用WebSocket传输协议将订单数据传输到菜鸟打印插件。
  3. 在菜鸟打印插件中,使用JavaScript编写WebSocket服务器端代码,接收来自浏览器端的订单数据。
  4. 解析订单数据,并调用打印机API来实现打印功能。

需要注意的是,您需要确保菜鸟打印插件已经正确安装并配置。

心跳保活

WebSocket为了保持客户端、服务端的实时双向通信,需要确保客户端、服务端之间的TCP通道保持连接没有断开。然而,对于长时间没有数据往来的连接,如果依旧长时间保持着,可能会浪费包括的连接资源。

但不排除有些场景,客户端、服务端虽然长时间没有数据往来,但仍需要保持连接。这个时候,可以采用心跳来实现。

ping/pong 其实是一条与业务无关的假消息,也称为心跳包。

  • 发送方->接收方:ping

  • 接收方->发送方:pong

ping、pong的操作,对应的是WebSocket的两个控制帧,opcode分别是0x90xA

举例,WebSocket服务端向客户端发送ping,只需要如下代码(采用ws模块)

ws.ping('', false, true);

或者在连接成功之后,每隔一个固定时间发送心跳包,比如 60s:

setInterval(() => {
    ws.send('这是一条心跳包消息');
}, 60000)

总结

  1. 发送 HTTP 请求时报文中携带 upgrade 字段,请求协议升级
  1. Switching 响应协议升级,将 HTTP  协议转换为 WebSocket 协议
  1. 建立全双工通信, onSend 和 onMessage 通过通信连接交换信息。

参考资料:

  1. 【WebSocket 原理浅析与实现简单聊天】juejin.cn/post/684490…
  1. 【WebSocket 教程】www.ruanyifeng.com/blog/2017/0…
  1. 【WebSocket:5分钟从入门到精通】juejin.cn/post/684490…

  2. 【菜鸟插件文档】blog.csdn.net/weixin_3435…