59、WebSocket服务器:实现简单的WebSocket服务器、消息收发
WebSocket 这东西,说白了就是让浏览器和服务器能“实时聊天”。
传统的 HTTP 请求,是客户端问一句,服务器答一句。问完就断。你想让服务器主动推送消息?没门。除非用轮询——客户端每隔几秒就问一次:“有新消息吗?” 效率低,延迟高,还浪费带宽。
WebSocket 不一样。它建立一条长连接,双方随时可以发消息。聊天室、实时行情、在线协作,底层都是它。
这一章,我们就用 C 语言手写一个简单的 WebSocket 服务器。不依赖第三方库,纯原生实现。嗯,这里要注意:WebSocket 的握手是基于 HTTP 的,但握手之后,协议就完全变了。
WebSocket 握手流程
客户端想升级到 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。服务器需要把这个 key 加上一个固定魔数 258EAFA5-E914-47DA-95CA-C5AB0DC85B11,然后做 SHA-1 哈希,再 Base64 编码。结果就是 Sec-WebSocket-Accept 的值。
响应头:
HTTP/1.1 101 Switching Protocols
Upgrade: websocket
Connection: Upgrade
Sec-WebSocket-Accept: s3pPLMBiTxaQ9kYGzzhZRbK+xOo=
状态码 101 表示协议切换。之后,这条 TCP 连接就变成了 WebSocket 通道。
核心要点:握手成功的关键,就是正确计算 Sec-WebSocket-Accept。我在项目中遇到过有人直接返回原 key,结果浏览器死活不认。嗯,魔数写错一个字符都不行。
数据帧格式
握手之后,数据就不再是 HTTP 报文了。WebSocket 有自己的帧格式。我刚开始看 RFC 6455 的时候,觉得这帧结构挺绕的。其实拆开看,就几个部分:
| 字段 | 长度 | 说明 |
|---|---|---|
| FIN | 1 bit | 是否为最后一帧 |
| Opcode | 4 bits | 0x1=文本, 0x2=二进制, 0x8=关闭, 0x9=Ping, 0xA=Pong |
| Mask | 1 bit | 客户端发来的数据必须掩码,服务器发的不需要 |
| Payload length | 7/7+16/7+64 bits | 数据长度,分三种情况 |
| Masking key | 0 or 4 bytes | 如果 Mask=1,这里有4字节掩码 |
| Payload data | 可变 | 实际数据 |
为什么客户端要掩码?这是为了防止缓存污染攻击。说白了,就是不让恶意网页通过 WebSocket 往中间代理里塞假数据。服务器发数据不需要掩码,因为服务器是可信的。
个人经验:解析帧的时候,最容易出错的是长度字段。如果长度小于126,直接读7位。如果等于126,后面还有2字节。如果等于127,后面有8字节。我曾经漏掉了这个分支,导致大包解析全乱套。
代码实现:握手
我们先实现握手部分。服务器监听 TCP 端口,收到 HTTP 请求后,提取 Sec-WebSocket-Key,计算响应,返回 101。
#include <stdio.h>
#include <string.h>
#include <stdlib.h>
#include <unistd.h>
#include <arpa/inet.h>
#include <openssl/sha.h>
#define WS_MAGIC "258EAFA5-E914-47DA-95CA-C5AB0DC85B11"
#define BUFFER_SIZE 4096
// Base64 编码(简化版,仅用于演示)
void base64_encode(const unsigned char *input, int len, char *output) {
// 实际项目中请使用标准库或完整实现
// 这里省略具体编码逻辑
}
int handle_handshake(int client_fd) {
char buf[BUFFER_SIZE] = {0};
int n = read(client_fd, buf, sizeof(buf) - 1);
if (n <= 0) return -1;
// 查找 Sec-WebSocket-Key
char *key_start = strstr(buf, "Sec-WebSocket-Key: ");
if (!key_start) return -1;
key_start += 19;
char *key_end = strstr(key_start, "\r\n");
if (!key_end) return -1;
// 提取 key
char key[256] = {0};
strncpy(key, key_start, key_end - key_start);
// 拼接魔数
char combined[512] = {0};
snprintf(combined, sizeof(combined), "%s%s", key, WS_MAGIC);
// SHA-1 哈希
unsigned char sha1_out[SHA_DIGEST_LENGTH];
SHA1((unsigned char*)combined, strlen(combined), sha1_out);
// Base64 编码
char accept_key[128] = {0};
base64_encode(sha1_out, SHA_DIGEST_LENGTH, accept_key);
// 构造响应
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);
write(client_fd, response, strlen(response));
return 0;
}
注意:上面的 base64_encode 函数是伪代码。实际使用时,请用 OpenSSL 的 EVP_EncodeBlock 或者自己实现完整的 Base64。我曾经图省事写了个简化版,结果编码结果不对,调试了一下午。
代码实现:数据收发
握手成功后,就可以收发 WebSocket 帧了。我们先实现一个简单的文本帧发送函数:
void send_websocket_frame(int client_fd, const char *data, int len) {
unsigned char frame[10 + len];
int pos = 0;
frame[pos++] = 0x81; // FIN=1, Opcode=0x1 (文本)
if (len < 126) {
frame[pos++] = len;
} else if (len < 65536) {
frame[pos++] = 126;
frame[pos++] = (len >> 8) & 0xFF;
frame[pos++] = len & 0xFF;
} else {
frame[pos++] = 127;
for (int i = 7; i >= 0; i--) {
frame[pos++] = (len >> (i * 8)) & 0xFF;
}
}
memcpy(frame + pos, data, len);
write(client_fd, frame, pos + len);
}
接收帧稍微复杂一点,因为要处理掩码:
int recv_websocket_frame(int client_fd, char *out_data, int *out_len) {
unsigned char header[2];
if (read(client_fd, header, 2) != 2) return -1;
int fin = (header[0] >> 7) & 1;
int opcode = header[0] & 0x0F;
int mask = (header[1] >> 7) & 1;
int len = header[1] & 0x7F;
// 处理扩展长度
if (len == 126) {
unsigned char ext[2];
read(client_fd, ext, 2);
len = (ext[0] << 8) | ext[1];
} else if (len == 127) {
unsigned char ext[8];
read(client_fd, ext, 8);
len = 0;
for (int i = 0; i < 8; i++) {
len = (len << 8) | ext[i];
}
}
// 读取掩码
unsigned char masking_key[4] = {0};
if (mask) {
read(client_fd, masking_key, 4);
}
// 读取数据
read(client_fd, out_data, len);
out_data[len] = '\0';
// 解除掩码
if (mask) {
for (int i = 0; i < len; i++) {
out_data[i] ^= masking_key[i % 4];
}
}
*out_len = len;
return opcode;
}
完整服务器流程
把上面几块拼起来,就是一个简单的 WebSocket 服务器了。主循环逻辑:
int main() {
int server_fd = socket(AF_INET, SOCK_STREAM, 0);
struct sockaddr_in addr = {
.sin_family = AF_INET,
.sin_port = htons(8080),
.sin_addr.s_addr = INADDR_ANY
};
bind(server_fd, (struct sockaddr*)&addr, sizeof(addr));
listen(server_fd, 5);
printf("WebSocket server listening on port 8080...\n");
while (1) {
int client_fd = accept(server_fd, NULL, NULL);
if (handle_handshake(client_fd) == 0) {
printf("Handshake successful!\n");
char msg[1024];
int len;
while (1) {
int opcode = recv_websocket_frame(client_fd, msg, &len);
if (opcode == 0x8) break; // 关闭帧
if (opcode == 0x1) { // 文本帧
printf("Received: %s\n", msg);
send_websocket_frame(client_fd, "Echo: ", 6);
send_websocket_frame(client_fd, msg, len);
}
}
}
close(client_fd);
}
close(server_fd);
return 0;
}
WebSocket 协议状态机
为了让你更直观地理解整个流程,我画了一张状态机图:
从图中可以看到,WebSocket 的生命周期很清晰:监听 → 握手 → 收发数据 → 关闭。每个状态之间的转换都有明确的触发条件。
避坑指南
做 WebSocket 服务器,有几个坑我踩过,写出来给你参考:
- 掩码处理:客户端发来的数据一定有掩码,服务器发的不需要。我曾经忘了解除掩码,结果收到的全是乱码。
- 分片处理:大消息可能分成多个帧。FIN=0 表示还有后续帧。我一开始没处理分片,超过 64KB 的消息就丢了。
- Ping/Pong:客户端可能会发 Ping 帧,服务器必须回复 Pong,否则连接会被断开。我见过有人忽略 Ping,结果连接莫名其妙断了。
- 关闭握手:收到关闭帧后,服务器也应该回复一个关闭帧,然后再关闭 TCP 连接。直接 close 是不礼貌的。
我的建议:刚开始做的时候,先用浏览器的 WebSocket API 配合测试。打开 Chrome 开发者工具,在 Console 里写几行 JS 连接你的服务器,收发几条消息。这样调试起来非常直观。
总结
WebSocket 协议本身并不复杂。核心就三件事:握手、组帧、解帧。你想想看,只要把这三点搞明白,一个能用的 WebSocket 服务器也就几百行代码的事。
当然,生产环境要考虑的东西更多:并发连接、心跳保活、TLS 加密、协议扩展……但作为入门,今天这个实现已经够你理解 WebSocket 的本质了。
代码写完了,编译运行一下,用浏览器连上去试试。看到 "Echo: hello" 返回来的那一刻,还是挺有成就感的。