25、WebSocket协议:WebSocket握手、数据帧格式、实现简单聊天室

聊到WebSocket,我得先坦白一件事。早些年我做网络编程时,一直觉得HTTP长轮询就够用了。直到有一次给一个金融行情系统做实时推送,服务器被轮询请求打到CPU飙升,用户体验还卡得要命。嗯,从那以后,我才真正开始研究WebSocket。

说白了,WebSocket就是给HTTP装了个「双向管道」。它让服务器能主动给客户端发消息,而不是等客户端来问。今天我们就把它拆开看看——握手怎么握、数据帧长什么样、最后手写一个简单的聊天室。

25.1 WebSocket握手:从HTTP升级而来

WebSocket的握手很有意思。它不走什么新端口,而是直接复用HTTP的80或443端口。客户端先发一个普通的HTTP GET请求,然后告诉服务器:「我想升级协议」。服务器同意后,连接就「变身」了。

我习惯把握手过程分成三步:

  1. 客户端发送升级请求——包含特殊的头部字段
  2. 服务器计算并返回握手响应——验证客户端身份
  3. 连接建立成功——之后双方可以随时互发数据

来看一个典型的客户端请求头:

GET /chat HTTP/1.1
Host: server.example.com
Upgrade: websocket
Connection: Upgrade
Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==
Sec-WebSocket-Version: 13

这里最关键的是 Sec-WebSocket-Key。它是一个随机生成的Base64字符串。服务器收到后,会把它和一个固定的GUID拼接,然后做SHA-1哈希,再Base64编码,返回给客户端。

固定GUID是:258EAFA5-E914-47DA-95CA-C5AB0DC85B11

服务器响应长这样:

HTTP/1.1 101 Switching Protocols
Upgrade: websocket
Connection: Upgrade
Sec-WebSocket-Accept: s3pPLMBiTxaQ9kYGzzhZRbK+xOo=

核心要点:握手完成后,HTTP连接就「升级」成了WebSocket连接。之后的通信不再走HTTP协议,而是走WebSocket自己的数据帧格式。这个切换是单向的——一旦升级,就不能再回到HTTP了。

我的经验:我曾经在调试一个WebSocket服务时,发现握手总是失败。查了半天,原来是服务器端忘了处理 Connection: Upgrade 这个头。很多新手只关注 Upgrade 字段,忽略了 Connection,结果握手一直卡在101状态码之前。记住,这两个字段缺一不可。

25.2 数据帧格式:二进制里的门道

握手搞定之后,数据怎么传?WebSocket的数据帧格式设计得很精巧。我刚开始看RFC 6455的时候,觉得这堆位操作挺绕的。但实际写几遍代码就明白了——说白了就是几个关键字段。

先看帧结构:

  0                   1                   2                   3
  0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1
 +-+-+-+-+-------+-+-------------+-------------------------------+
 |F|R|R|R| opcode|M| Payload len |    Extended payload length    |
 |I|S|S|S|  (4)  |A|     (7)     |             (16/64)           |
 |N|V|V|V|       |S|             |   (if payload len==126/127)   |
 | |1|2|3|       |K|             |                               |
 +-+-+-+-+-------+-+-------------+ - - - - - - - - - - - - - - - +
 |     Extended payload length continued, if payload len == 127  |
 + - - - - - - - - - - - - - - - +-------------------------------+
 |                               |Masking-key, if MASK set to 1  |
 +-------------------------------+-------------------------------+
 | Masking-key (continued)       |          Payload Data         |
 +-------------------------------- - - - - - - - - - - - - - - - +
 :                     Payload Data continued ...                :
 + - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - +
 |                     Payload Data (continued)                  |
 +---------------------------------------------------------------+

几个关键字段:

字段 长度 说明
FIN 1 bit 是否为最后一帧。1表示结束,0表示还有后续帧
opcode 4 bits 帧类型:0x1=文本,0x2=二进制,0x8=关闭,0x9=Ping,0xA=Pong
MASK 1 bit 客户端发往服务器的数据必须掩码,服务器发客户端不需要
Payload len 7/16/64 bits 数据长度。7位不够用就扩展
Masking-key 0或4 bytes 掩码密钥,MASK=1时存在

注意:客户端发送的数据必须设置MASK位为1,并带上4字节的掩码密钥。服务器收到后需要用这个密钥对payload做异或解码。如果客户端没掩码,服务器应该直接关闭连接。这个设计是为了防止缓存污染攻击——虽然实际中很少遇到,但协议就是这么规定的。

为什么要有掩码?我个人理解是:WebSocket设计时考虑到了浏览器环境。如果不掩码,恶意网页可以通过WebSocket向缓存服务器注入恶意数据。掩码让攻击者无法预测最终发送的字节流,从而降低了这种风险。

25.3 实现简单聊天室:从零开始写代码

理论说完了,我们来动手。我会用C语言实现一个最简单的WebSocket聊天室服务器。它只做三件事:接受握手、广播消息、处理断开。

先看握手处理的代码:

#include <stdio.h>
#include <string.h>
#include <openssl/sha.h>
#include <arpa/inet.h>

#define WS_GUID "258EAFA5-E914-47DA-95CA-C5AB0DC85B11"

int ws_handshake(int client_fd, const char *request) {
    // 查找Sec-WebSocket-Key
    const char *key_start = strstr(request, "Sec-WebSocket-Key: ");
    if (!key_start) return -1;
    
    key_start += 19; // 跳过字段名
    const char *key_end = strstr(key_start, "\r\n");
    if (!key_end) return -1;
    
    // 提取key值
    char key[64] = {0};
    int key_len = key_end - key_start;
    strncpy(key, key_start, key_len);
    
    // 拼接GUID并计算SHA-1
    char combined[128];
    snprintf(combined, sizeof(combined), "%s%s", key, WS_GUID);
    
    unsigned char sha1_out[20];
    SHA1((unsigned char*)combined, strlen(combined), sha1_out);
    
    // Base64编码(简化版,实际应使用完整实现)
    char accept_key[32];
    // ... Base64编码逻辑 ...
    
    // 发送响应
    char response[512];
    snprintf(response, sizeof(response),
        "HTTP/1.1 101 Switching Protocols\r\n"
        "Upgrade: websocket\r\n"
        "Connection: Upgrade\r\n"
        "Sec-WebSocket-Accept: %s\r\n\r\n",
        accept_key);
    
    send(client_fd, response, strlen(response), 0);
    return 0;
}

避坑指南:我曾经在Base64编码上栽过跟头。OpenSSL的SHA-1输出是20字节,Base64编码后应该是28字节(不含填充)。如果你用标准Base64库,记得检查输出长度。我见过有人用了错误的编码表,导致握手一直失败。

接下来是数据帧的解析和发送:

// 读取一个WebSocket帧
int ws_read_frame(int fd, char *buffer, int *frame_type) {
    unsigned char header[2];
    if (recv(fd, header, 2, MSG_WAITALL) != 2) return -1;
    
    *frame_type = header[0] & 0x0F;  // opcode
    int masked = (header[1] & 0x80) ? 1 : 0;
    int payload_len = header[1] & 0x7F;
    
    // 处理扩展长度
    if (payload_len == 126) {
        unsigned short ext_len;
        recv(fd, &ext_len, 2, MSG_WAITALL);
        payload_len = ntohs(ext_len);
    } else if (payload_len == 127) {
        // 64位长度,简化处理
        return -1;
    }
    
    // 读取掩码密钥
    unsigned char mask[4];
    if (masked) {
        recv(fd, mask, 4, MSG_WAITALL);
    }
    
    // 读取payload数据
    recv(fd, buffer, payload_len, MSG_WAITALL);
    buffer[payload_len] = '\0';
    
    // 如果掩码了,做异或解码
    if (masked) {
        for (int i = 0; i < payload_len; i++) {
            buffer[i] ^= mask[i % 4];
        }
    }
    
    return payload_len;
}

// 发送一个文本帧
int ws_send_text(int fd, const char *text) {
    int len = strlen(text);
    unsigned char frame[10 + len];
    int pos = 0;
    
    frame[pos++] = 0x81;  // FIN + 文本opcode
    
    if (len < 126) {
        frame[pos++] = len;
    } else if (len < 65536) {
        frame[pos++] = 126;
        frame[pos++] = (len >> 8) & 0xFF;
        frame[pos++] = len & 0xFF;
    } else {
        // 64位长度,简化
        return -1;
    }
    
    memcpy(frame + pos, text, len);
    send(fd, frame, pos + len, 0);
    return 0;
}

聊天室的核心逻辑其实很简单:

// 伪代码:聊天室主循环
void chat_server(int port) {
    int server_fd = create_tcp_server(port);
    client_list clients;
    
    while (1) {
        // 使用select/poll/epoll处理多客户端
        for each ready_fd {
            if (fd == server_fd) {
                // 新连接,完成握手
                int client = accept(server_fd);
                if (ws_handshake(client, request) == 0) {
                    add_client(&clients, client);
                    broadcast(&clients, "新用户加入了聊天室");
                }
            } else {
                // 读取客户端数据
                char msg[4096];
                int type;
                int len = ws_read_frame(fd, msg, &type);
                
                if (len <= 0 || type == 0x8) {
                    // 连接关闭
                    remove_client(&clients, fd);
                    broadcast(&clients, "用户离开了聊天室");
                    close(fd);
                } else if (type == 0x1) {
                    // 文本消息,广播给所有人
                    broadcast(&clients, msg);
                } else if (type == 0x9) {
                    // Ping,回复Pong
                    ws_send_pong(fd);
                }
            }
        }
    }
}

关键设计点:

  • 使用非阻塞I/O或多路复用(select/poll/epoll)处理多个客户端
  • 每个客户端维护一个缓冲区,处理分片消息
  • 心跳检测:定期发送Ping帧,超时未收到Pong则断开
  • 广播时注意加锁,避免并发问题

25.4 用SVG梳理WebSocket核心流程

下面这张图把WebSocket从握手到数据交换的完整流程画出来了。我建议你对照着代码看,会清晰很多。

WebSocket 核心流程 客户端 服务器 ① HTTP Upgrade 请求 GET /chat HTTP/1.1 Upgrade: websocket Sec-WebSocket-Key: xxx ② 101 Switching Protocols Sec-WebSocket-Accept: xxx 握手完成,连接建立 ③ 双向数据帧交换 文本帧(0x1) | 二进制帧(0x2) | Ping/Pong(0x9/0xA) 数据帧结构: FIN(1) | RSV(3) | opcode(4) | MASK(1) | Payload Len(7) | [扩展长度] | [掩码密钥] | Payload Data 客户端→服务器:必须掩码 | 服务器→客户端:禁止掩码 ④ 关闭帧(0x8) → 连接断开

25.5 实战中的几个坑

最后分享几个我在实际项目中踩过的坑:

  • 分片消息处理:WebSocket允许一个消息分成多个帧发送。如果FIN=0,表示后面还有帧。我见过有人没处理这个情况,导致大消息被截断。解决方案是维护每个连接的接收缓冲区,直到收到FIN=1的帧才拼装完整消息。
  • 心跳超时:有些网络设备(比如NAT网关)会断开长时间空闲的连接。我习惯每30秒发一次Ping帧,如果60秒没收到Pong,就主动断开。这个时间可以根据实际场景调整。
  • 并发广播:聊天室广播时,如果某个客户端写缓冲区满了,send()会阻塞。我建议用非阻塞socket,或者把广播放到独立线程里做。否则一个慢客户端会拖慢所有人。

重要提醒:生产环境中不要自己从头实现WebSocket协议。用成熟的库(如libwebsockets)更安全。自己做协议实现,很容易在边界情况(比如超大帧、恶意掩码、分片攻击)上出问题。我当年为了练手写过一次,调试了整整一周才稳定下来。

好了,WebSocket的核心内容就这些。从握手到数据帧,再到一个简单的聊天室实现,你应该能感受到这个协议的设计思路——它不复杂,但每个细节都有它的道理。动手写一遍代码,比看十遍文档都管用。


公众号:蓝海资料掘金营,微信deep3321