WebSocket 协议全揭秘:从零开始构建实时应用

309 阅读10分钟

随着互联网技术的飞速发展,实时通信应用越来越受到用户的青睐。实时通信已经成为许多应用的核心需求。无论是在线聊天、实时协作还是游戏,低延迟和高效的通信机制都是不可或缺的。

传统的 HTTP 协议由于其无状态和请求-响应模型,难以满足这些实时应用的需求。这时,WebSocket 协议应运而生,它提供了一种全双工的通信通道,使得客户端和服务器之间可以进行实时、双向的数据交换。


协议握手过程

WebSocket协议的握手过程是建立连接的第一步,它基于HTTP协议进行,目的是将普通的HTTP连接升级为WebSocket连接。下面是握手过程的基本步骤:

客户端发起连接请求:

客户端通过发送一个HTTP的GET请求到服务器指定的WebSocket URL(通常是ws://或wss://开头)。这个请求包含了特殊的头信息,表明这是一个WebSocket连接请求。其中关键的头信息包括但不限于:

  • Upgrade: 必须设置为websocket,表明客户端希望将连接升级为WebSocket。
  • Connection: 必须设置为Upgrade,同样指示了升级连接的意图。
  • Sec-WebSocket-Key: 包含一个随机生成的Base64编码的密钥。服务器将使用这个密钥加上一个固定的字符串,然后进行SHA-1哈希,并进行Base64编码,作为响应的一部分返回,以此来验证客户端。
  • Sec-WebSocket-Version: 指定WebSocket协议版本,确保客户端和服务器支持相同的协议版本。

服务器响应:

服务器收到请求后,如果同意升级,则返回一个HTTP响应,状态码为101 Switching Protocols,表示服务器同意切换协议。响应中同样包含特定的头信息:

  • Upgrade: 响应中也必须包含此字段,并设置为websocket。
  • Connection: 同样设置为Upgrade,确认连接升级。
  • Sec-WebSocket-Accept: 服务器根据客户端发送的Sec-WebSocket-Key,加上一个固定的GUID字符串(258EAFA5-E914-47DA-95CA-C5AB0DC85B11),进行SHA-1哈希后进行Base64编码得到的值。这是服务器对客户端握手请求的确认。

握手完成:

当客户端收到服务器的正确响应后,握手过程完成,此时HTTP连接就被转换成了WebSocket连接。接下来,双方就可以开始发送和接收WebSocket数据帧,进行全双工的实时通信。

客户端(JavaScript):

在客户端,我们通常使用WebSocket API来建立连接。以下是一个简单的WebSocket连接初始化代码:

var socket = new WebSocket("ws://example.com/socketserver");

socket.onopen = function(event) {
  console.log("Connection open!");
  socket.send("Hello Server!");
};

socket.onmessage = function(event) {
  console.log("Message from server:", event.data);
};

socket.onerror = function(error) {
  console.error("Error detected: " + error);
};

socket.onclose = function(event) {
  if (event.wasClean) {
    console.log(`[close] Connection closed cleanly, code=${event.code} reason=${event.reason}`);
  } else {
    // e.g. server process killed or network down
    // event.code is usually 1006 in this case
    console.log('[close] Connection died');
  }
};

这段代码中,new WebSocket(...)就是发起WebSocket连接请求的地方,握手过程自动由浏览器处理,无需手动编写HTTP请求头。onopenonmessageonerroronclose是WebSocket对象的事件处理器,分别对应连接成功、接收到消息、发生错误、连接关闭的情况。

服务器端(Node.js + ws库):

在服务器端,可以使用各种库来处理WebSocket连接,这里以Node.js的ws库为例:

const WebSocket = require('ws');

const wss = new WebSocket.Server({ port: 8080 });

wss.on('connection', (ws) => {
  console.log('Client connected');

  ws.on('message', (message) => {
    console.log(`Received message => ${message}`);
    ws.send(`Hello! You sent -> ${message}`);
  });

  ws.on('close', () => {
    console.log('Client disconnected');
  });

  ws.on('error', (err) => {
    console.error('WebSocket error observed:', err);
  });
});

这段代码创建了一个监听8080端口的WebSocket服务器。当有客户端连接时,connection事件会被触发,服务器可以开始接收和发送消息。在这个例子中,服务器简单地将接收到的消息原样回复给客户端。

数据帧格式解析

WebSocket的数据传输基于一种称为数据帧(frame)的结构化格式,它允许在连接上高效、可靠地传输数据。每个数据帧都包含一个固定格式的头部和可选的负载数据。下面简要介绍数据帧的结构:

数据帧结构:

  1. Fin: 1位,表示是否是消息的最后一个帧。如果是单帧消息,该位为1。
  2. RSV1, RSV2, RSV3: 各1位,通常用于扩展定义,如协议协商时的掩码位或压缩标识。默认情况下应为0。
  3. Opcode: 4位,定义了帧的类型。常见的有:
    • 0x0:表示延续帧,用于多帧消息的后续部分。
    • 0x1:表示文本帧。
    • 0x2:表示二进制帧。
    • 0x8:表示连接关闭。
    • 0x9:表示ping。
    • 0xA:表示pong。
  4. Mask: 1位,客户端发往服务器的数据帧必须设置为1,并附带掩码密钥。
  5. Payload length: 可变长度字段,表示负载数据的长度。根据长度的不同,有不同的编码方式。
  6. Masking-key: 如果Mask位为1,则存在这四个字节的掩码密钥,用于解码负载数据。
  7. Payload data: 负载数据,可以是文本或二进制数据。

一个简单的WebSocket服务器,需要解析接收到的数据帧,可以遵循以下步骤:

  1. 读取第一个字节:这个字节包含了Fin、RSV*以及Opcode的信息。你可以通过位操作(如位与、位移)来提取这些信息。

  2. 检查Mask位:第二个字节的最高位表示Mask。如果为1,表示接下来有4个字节的掩码密钥。

  3. 确定Payload length:接下来的字节或字节序列表示负载长度。长度小于126时,直接使用该字节的值;长度为126时,接下来的两个字节表示长度(16位);长度为127时,接下来的8个字节表示长度(64位,一般很少使用)。

  4. 处理Masking-key:如果有掩码,读取接下来的4个字节作为掩码密钥。

  5. 解码Payload data:根据Payload length读取相应长度的数据。如果存在掩码,使用掩码密钥对数据进行解码。解码方法通常是按字节异或掩码密钥。

  6. 处理Fin和Opcode:根据Fin位判断是否还有更多帧需要处理;根据Opcode决定如何处理当前帧的数据,如文本帧需要解码为字符串,二进制帧则直接处理。

在JavaScript中,如果你使用的是WebSocket API,通常不需要直接处理数据帧的解析,因为WebSocket API已经为我们抽象了这些底层细节。然而,为了提供一个概念性的理解,我们可以模拟一个简化的数据帧解析过程,假设你已经收到了WebSocket帧的原始字节数据(这在实践中很少直接发生,但有助于理解内部机制)。

function parseWebSocketFrame(frameData) {
    let byteIndex = 0;

    // 解析Fin和Opcode
    const fin = (frameData[byteIndex] & 0b10000000) !== 0;
    const opcode = frameData[byteIndex++] & 0b00001111;

    // 解析Mask和Payload Length
    const mask = (frameData[byteIndex] & 0b10000000) !== 0;
    let payloadLength = frameData[byteIndex++] & 0b01111111;

    // 根据Payload Length的值确定实际长度
    if (payloadLength === 126) {
        payloadLength = (frameData[byteIndex++] << 8) | frameData[byteIndex++];
    } else if (payloadLength === 127) {
        // 这里简化处理,实际应用中需要处理64位长度
        throw new Error("Handling of 127-length not implemented.");
    }

    // 获取Masking Key(如果有)
    let maskingKey;
    if (mask) {
        maskingKey = new Uint8Array([frameData[byteIndex++], frameData[byteIndex++], frameData[byteIndex++], frameData[byteIndex++]]);
    }

    // 提取Payload Data
    const payloadStart = byteIndex;
    const payloadEnd = payloadStart + payloadLength;
    const payloadData = new Uint8Array(frameData.slice(payloadStart, payloadEnd));

    // 如果有Mask,解码Payload
    if (mask) {
        for (let i = 0; i < payloadLength; i++) {
            payloadData[i] ^= maskingKey[i % 4];
        }
    }

    // 根据Opcode处理Payload
    switch (opcode) {
        case 0x1:  // 文本帧
            return { type: 'text', data: new TextDecoder().decode(payloadData) };
        case 0x2:  // 二进制帧
            return { type: 'binary', data: payloadData };
        // ...其他类型的处理
        default:
            throw new Error(`Unsupported opcode: ${opcode.toString(16)}.`);
    }
}

// 示例使用(注意:在实际WebSocket连接中,你不会直接获得这样的frameData)
// let frameData = ...; // 假设这是从WebSocket连接中获取的原始帧数据
// try {
//     let parsedFrame = parseWebSocketFrame(frameData);
//     console.log(parsedFrame);
// } catch (e) {
//     console.error(e);
// }

连接建立与关闭流程

WebSocket协议的连接建立与关闭流程是其生命周期中的两个关键步骤,它们确保了客户端与服务器之间的稳定、可靠的双向通信。

连接建立流程(Handshaking)

客户端发起请求:

客户端通过HTTP的Upgrade请求向服务器发起连接,请求头中包含特殊的字段来指示这是一个WebSocket连接请求。典型的请求头包括:

  • Upgrade: websocket
  • Connection: Upgrade
  • Sec-WebSocket-Key: 一个Base64编码的随机字符串,用于握手验证。
  • Sec-WebSocket-Version: 表明客户端支持的WebSocket协议版本。

服务器响应:

服务器收到请求后,如果接受连接,会返回一个HTTP响应,状态码为101 Switching Protocols,表示协议升级成功。响应头中包含:

  • Upgrade: websocket
  • Connection: Upgrade
  • Sec-WebSocket-Accept: 服务器根据客户端发送的Sec-WebSocket-Key计算出的值,用作握手验证。

握手完成:

客户端收到正确的响应后,握手完成,WebSocket连接正式建立,双方可以开始发送数据帧进行通信。

数据传输

握手成功后,WebSocket连接进入数据传输阶段,双方可以通过发送数据帧来进行数据交换。数据帧包含一个头部用于描述帧类型、长度等信息,以及可选的有效载荷数据。

连接关闭流程

  1. 关闭发起:

任何一端都可以发起关闭连接的请求。关闭请求通常包含一个关闭状态码(Close Code)和一个可选的原因说明(Close Reason),这些信息被封装在关闭帧中发送。

  1. 关闭响应:

接收方收到关闭请求后,会发送一个确认关闭的帧回给发起方,这个帧也可以包含状态码和原因说明。

  1. 四次挥手(TCP层面):

WebSocket是建立在TCP之上的,因此实际的连接关闭会经历TCP的四次挥手过程,确保数据的完整传输和资源的正确释放。

  1. 连接关闭:

双方都发送并接收到了关闭帧后,连接正式关闭。客户端和服务器端都会触发相应的关闭事件(如onclose事件),应用程序可以在此时进行清理工作。

关闭状态码(Close Codes)

  • 1000: 正常关闭,表示连接被成功关闭且没有异常。
  • 1001: 终端离开,比如页面关闭。
  • 1002: 协议错误,接收到不符合协议格式的数据。
  • 1003: 不可接受的数据类型,接收到的数据类型不受支持。
  • 1005: 未定义,没有状态码(通常是因为关闭帧没有携带状态码)。
  • 1006: 未定义,连接意外关闭(比如网络中断)。
  • 1007: 数据帧中的数据违反了约定的子协议。
  • 1008: 接收到无效的数据。
  • 1009: 数据帧过大,无法处理。
  • 1010: 客户端期望的服务端没有提供的扩展。
  • 1011: 由于服务器端错误导致的连接关闭。
  • 1015: TLS握手失败。

理解WebSocket的连接建立与关闭流程对于开发实时通信应用至关重要,它确保了通信的高效、有序和安全。