25、WebSocket协议:WebSocket握手、数据帧格式、实现简单聊天室
聊到WebSocket,我得先坦白一件事。早些年我做网络编程时,一直觉得HTTP长轮询就够用了。直到有一次给一个金融行情系统做实时推送,服务器被轮询请求打到CPU飙升,用户体验还卡得要命。嗯,从那以后,我才真正开始研究WebSocket。
说白了,WebSocket就是给HTTP装了个「双向管道」。它让服务器能主动给客户端发消息,而不是等客户端来问。今天我们就把它拆开看看——握手怎么握、数据帧长什么样、最后手写一个简单的聊天室。
25.1 WebSocket握手:从HTTP升级而来
WebSocket的握手很有意思。它不走什么新端口,而是直接复用HTTP的80或443端口。客户端先发一个普通的HTTP GET请求,然后告诉服务器:「我想升级协议」。服务器同意后,连接就「变身」了。
我习惯把握手过程分成三步:
- 客户端发送升级请求——包含特殊的头部字段
- 服务器计算并返回握手响应——验证客户端身份
- 连接建立成功——之后双方可以随时互发数据
来看一个典型的客户端请求头:
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从握手到数据交换的完整流程画出来了。我建议你对照着代码看,会清晰很多。
25.5 实战中的几个坑
最后分享几个我在实际项目中踩过的坑:
- 分片消息处理:WebSocket允许一个消息分成多个帧发送。如果FIN=0,表示后面还有帧。我见过有人没处理这个情况,导致大消息被截断。解决方案是维护每个连接的接收缓冲区,直到收到FIN=1的帧才拼装完整消息。
- 心跳超时:有些网络设备(比如NAT网关)会断开长时间空闲的连接。我习惯每30秒发一次Ping帧,如果60秒没收到Pong,就主动断开。这个时间可以根据实际场景调整。
- 并发广播:聊天室广播时,如果某个客户端写缓冲区满了,send()会阻塞。我建议用非阻塞socket,或者把广播放到独立线程里做。否则一个慢客户端会拖慢所有人。
重要提醒:生产环境中不要自己从头实现WebSocket协议。用成熟的库(如libwebsockets)更安全。自己做协议实现,很容易在边界情况(比如超大帧、恶意掩码、分片攻击)上出问题。我当年为了练手写过一次,调试了整整一周才稳定下来。
好了,WebSocket的核心内容就这些。从握手到数据帧,再到一个简单的聊天室实现,你应该能感受到这个协议的设计思路——它不复杂,但每个细节都有它的道理。动手写一遍代码,比看十遍文档都管用。
公众号:蓝海资料掘金营,微信deep3321