第16章:WebSocket协议实战
WebSocket这玩意儿,说实话刚出来那会儿我还有点抵触。你想啊,HTTP用得挺好的,干嘛要搞个新协议?直到我在一个实时股票行情项目里被轮询搞到服务器CPU飙到90%,才真正体会到WebSocket的好。嗯,今天咱们就来聊聊这个让服务器能主动“推”数据的神奇协议。
16.1 WebSocket握手:从HTTP升级而来
WebSocket的握手过程,说白了就是一次特殊的HTTP请求。客户端发一个GET请求,服务器返回101状态码,连接就“升级”成功了。我刚开始学的时候总觉得这步很玄乎,其实拆开看就几个关键点。
核心要点:WebSocket握手不是重新建立连接,而是在现有HTTP连接上进行“协议升级”。
客户端请求头长这样:
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编码,返回给客户端。
服务器响应:
HTTP/1.1 101 Switching Protocols
Upgrade: websocket
Connection: Upgrade
Sec-WebSocket-Accept: s3pPLMBiTxaQ9kYGzzhZRbK+xOo=
我曾经踩过一个坑:忘了校验Sec-WebSocket-Accept的值。结果客户端连上了,但数据收发全乱套。后来排查了半天才发现是握手校验没做对。
16.2 数据帧格式:别看它小,门道不少
WebSocket的数据帧格式,我建议你把它记在脑子里。为啥?因为调试的时候你不可能每次都抓包看。我自己写服务器时,就经常对着帧结构逐字节分析。
| 位偏移 | 字段 | 说明 |
|---|---|---|
| 0-3 | FIN + RSV + Opcode | FIN=1表示最后一帧,Opcode=1文本,=2二进制 |
| 4-7 | Mask + Payload Length | Mask=1表示有掩码(客户端必须设置) |
| 8-15 | Masking Key(可选) | 4字节,用于数据掩码 |
| 16+ | Payload Data | 实际数据 |
这里有个容易搞混的地方:客户端发数据必须加掩码,服务器发数据不能加掩码。为什么?协议设计者认为客户端可能被恶意脚本控制,加掩码可以防止缓存污染攻击。我个人觉得这个设计挺巧妙的。
小技巧:解析帧长度时,如果Payload Length是126,后面还有2字节;如果是127,后面还有8字节。别搞错了偏移量。
16.3 WebSocket服务器实现:从零开始写一个
咱们用C语言实现一个简单的WebSocket服务器。核心就三步:握手、收发帧、保活。我习惯用libevent做事件驱动,这样性能好,代码也干净。
先看握手部分的代码:
// 计算Sec-WebSocket-Accept
#include <openssl/sha.h>
#include <string.h>
#define WS_GUID "258EAFA5-E914-47DA-95CA-C5AB0DC85B11"
void compute_accept(const char *key, char *output) {
char buf[256];
unsigned char sha[SHA_DIGEST_LENGTH];
snprintf(buf, sizeof(buf), "%s%s", key, WS_GUID);
SHA1((unsigned char*)buf, strlen(buf), sha);
// Base64编码
base64_encode(sha, SHA_DIGEST_LENGTH, output);
}
数据帧解析我建议写成状态机,这样不容易出错:
typedef enum {
FRAME_HEADER,
FRAME_MASK,
FRAME_PAYLOAD
} frame_state_t;
int parse_frame(ws_conn_t *conn, char *buf, int len) {
// 先读前2字节
if (len < 2) return -1;
unsigned char b0 = buf[0];
unsigned char b1 = buf[1];
conn->fin = (b0 >> 7) & 1;
conn->opcode = b0 & 0x0F;
conn->mask = (b1 >> 7) & 1;
// 解析长度
uint64_t payload_len = b1 & 0x7F;
int offset = 2;
if (payload_len == 126) {
payload_len = (buf[2] << 8) | buf[3];
offset = 4;
} else if (payload_len == 127) {
// 8字节长度,大端序
payload_len = 0;
for (int i = 0; i < 8; i++) {
payload_len = (payload_len << 8) | buf[2 + i];
}
offset = 10;
}
// 读取掩码
if (conn->mask) {
memcpy(conn->mask_key, buf + offset, 4);
offset += 4;
}
// 解码数据
for (uint64_t i = 0; i < payload_len; i++) {
buf[offset + i] ^= conn->mask_key[i % 4];
}
return offset + payload_len;
}
注意:解析帧时一定要校验payload_len是否超过缓冲区大小。我曾经在生产环境遇到过恶意客户端发送超大长度字段,导致内存溢出的情况。
16.4 聊天室应用:把理论串起来
好了,现在咱们用上面写的WebSocket服务器,搭一个简单的聊天室。架构其实很简单:
服务器端核心逻辑就两个:连接管理和消息广播。我习惯用一个链表维护所有活跃连接,收到消息就遍历链表逐个发送。
// 连接管理
typedef struct ws_conn {
int fd;
char name[64];
struct ws_conn *next;
} ws_conn_t;
ws_conn_t *conn_list = NULL;
void broadcast_message(ws_conn_t *sender, char *msg) {
ws_conn_t *cur = conn_list;
while (cur) {
if (cur != sender) {
send_frame(cur->fd, msg, strlen(msg));
}
cur = cur->next;
}
}
客户端JavaScript代码也很简单:
const ws = new WebSocket('ws://localhost:8080');
ws.onopen = function() {
console.log('连接成功');
ws.send(JSON.stringify({type: 'join', name: '小明'}));
};
ws.onmessage = function(event) {
const data = JSON.parse(event.data);
// 显示消息
showMessage(data.name + ': ' + data.content);
};
ws.onclose = function() {
console.log('连接关闭');
};
经验之谈:聊天室一定要做心跳保活。我见过太多连接因为NAT超时被断开,客户端还不知道。建议每30秒发一个Ping帧,60秒没收到Pong就断开重连。
嗯,WebSocket这块内容其实不少,但核心就这些。从握手到帧格式,再到服务器实现,最后串成一个聊天室。我个人觉得,只要把帧解析那部分写稳了,剩下的都是业务逻辑。你在实际项目中遇到WebSocket相关的问题,欢迎来交流。
本章核心要点:
- WebSocket握手本质是HTTP协议升级,注意校验Sec-WebSocket-Accept
- 数据帧格式:FIN、Opcode、Mask、Payload Length是关键字段
- 客户端发数据必须加掩码,服务器不能加
- 聊天室架构:连接管理 + 消息广播,注意心跳保活
公众号:蓝海资料掘金营,微信deep3321