60、WebSocket客户端:实现WebSocket客户端、连接建立与关闭
WebSocket 这东西,说白了就是让客户端和服务器之间能「实时聊天」。不像 HTTP 那样一问一答,WebSocket 建立连接后,双方随时都能发数据。我在做物联网项目时,设备状态需要实时上报,用轮询太浪费资源,最后就是用 WebSocket 搞定的。
这一章,我们手写一个 WebSocket 客户端。重点就三个:握手建立连接、收发数据、优雅关闭。嗯,咱们一步步来。
WebSocket 协议核心流程
先看整体流程,心里有个谱:
你看,核心就四步:握手、建立、通信、关闭。其中握手用的是 HTTP 协议升级,后面就全是 WebSocket 自己的帧格式了。
第一步:HTTP 升级握手
客户端发一个特殊的 HTTP 请求,告诉服务器:「我想升级到 WebSocket 协议」。关键点是生成一个 Sec-WebSocket-Key,服务器会用它算出一个 Sec-WebSocket-Accept 返回。
握手请求格式(客户端 → 服务器):
GET /chat HTTP/1.1
Host: server.example.com
Upgrade: websocket
Connection: Upgrade
Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==
Sec-WebSocket-Version: 13
这里 Sec-WebSocket-Key 是随机生成的 16 字节 base64 编码。服务器收到后,把它和一个固定 GUID 拼接,做 SHA-1 哈希,再 base64 编码,就是 Sec-WebSocket-Accept。
我个人习惯:用 /dev/urandom 读 16 字节,然后 base64 编码。别用 rand(),那玩意儿不够随机,容易被预测。
第二步:用 C 实现握手
咱们用 libcurl 或者裸 socket 都能做。这里我演示裸 socket 的方式,更底层,也更能理解协议本质。
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <arpa/inet.h>
#include <openssl/sha.h>
#define WS_GUID "258EAFA5-E914-47DA-95CA-5AB5DC11B735"
// 生成随机 Sec-WebSocket-Key
void generate_key(char *out, size_t len) {
unsigned char rand_bytes[16];
FILE *f = fopen("/dev/urandom", "r");
if (!f) { perror("fopen"); exit(1); }
fread(rand_bytes, 1, 16, f);
fclose(f);
// base64 编码(简化版,实际用标准库)
// 这里假设你有 base64_encode 函数
base64_encode(rand_bytes, 16, out, len);
}
// 构造握手请求
void build_handshake_request(const char *host, const char *path,
const char *key, char *out, size_t len) {
snprintf(out, len,
"GET %s HTTP/1.1\r\n"
"Host: %s\r\n"
"Upgrade: websocket\r\n"
"Connection: Upgrade\r\n"
"Sec-WebSocket-Key: %s\r\n"
"Sec-WebSocket-Version: 13\r\n"
"\r\n",
path, host, key);
}
// 验证服务器响应
int verify_accept(const char *key, const char *accept) {
char concat[256];
unsigned char sha_digest[SHA_DIGEST_LENGTH];
char expected[64];
snprintf(concat, sizeof(concat), "%s%s", key, WS_GUID);
SHA1((unsigned char*)concat, strlen(concat), sha_digest);
base64_encode(sha_digest, SHA_DIGEST_LENGTH, expected, sizeof(expected));
return strcmp(accept, expected) == 0;
}
我曾经踩过一个坑:服务器返回的 Sec-WebSocket-Accept 末尾可能有换行符或空格,一定要先 trim 再比较。否则明明握手成功,你这边验证失败,白白浪费时间排查。
第三步:数据帧的读写
握手成功后,所有数据都走 WebSocket 帧格式。帧结构长这样:
| 位 | 0-3 | 4-7 | 8-15 | 16-... |
|---|---|---|---|---|
| 字节0 | FIN + RSV | Opcode | Mask + Payload Length | |
| 字节1+ | 扩展长度 / Masking-Key / 数据 | |||
几个关键点:
- Opcode:0x1 文本帧,0x2 二进制帧,0x8 关闭帧,0x9 Ping,0xA Pong
- Mask:客户端发往服务器的帧必须设置 Mask 位,服务器发客户端不用
- Payload Length:7 位、7+16 位、7+64 位三种编码方式
写一个发送文本帧的函数:
int ws_send_text(int sockfd, const char *data, size_t len) {
unsigned char frame[10 + len + 4]; // 最大头部 + 掩码 + 数据
size_t header_len = 2;
frame[0] = 0x81; // FIN=1, Opcode=0x1 (文本)
// 长度编码
if (len < 126) {
frame[1] = 0x80 | len; // 设置 Mask 位
} else if (len < 65536) {
frame[1] = 0x80 | 126;
frame[2] = (len >> 8) & 0xFF;
frame[3] = len & 0xFF;
header_len = 4;
} else {
frame[1] = 0x80 | 127;
// 64位长度,这里简化处理
memset(frame + 2, 0, 8);
frame[2] = (len >> 56) & 0xFF;
// ... 实际需要完整写入 8 字节
header_len = 10;
}
// 生成 4 字节掩码
unsigned char mask[4];
FILE *f = fopen("/dev/urandom", "r");
fread(mask, 1, 4, f);
fclose(f);
memcpy(frame + header_len, mask, 4);
// 掩码处理数据
for (size_t i = 0; i < len; i++) {
frame[header_len + 4 + i] = data[i] ^ mask[i % 4];
}
return write(sockfd, frame, header_len + 4 + len);
}
你想想看:为什么客户端要加 Mask?这是为了防止缓存污染攻击。早期 WebSocket 协议没这个要求,后来发现安全问题才加的。嗯,安全这东西,总是事后才补上。
第四步:关闭连接
关闭有两种方式:
- 优雅关闭:发送 Close 帧(Opcode 0x8),等对方也回一个 Close 帧,再关 TCP
- 直接关闭:直接 close socket,简单粗暴
优雅关闭的代码:
void ws_close(int sockfd, uint16_t status_code, const char *reason) {
unsigned char frame[4 + 2 + strlen(reason)];
size_t pos = 0;
frame[pos++] = 0x88; // FIN=1, Opcode=0x8
frame[pos++] = 0x80 | (2 + strlen(reason)); // 带 Mask
// 掩码
unsigned char mask[4];
FILE *f = fopen("/dev/urandom", "r");
fread(mask, 1, 4, f);
fclose(f);
memcpy(frame + pos, mask, 4);
pos += 4;
// 状态码(网络字节序)
frame[pos++] = (status_code >> 8) & 0xFF;
frame[pos++] = status_code & 0xFF;
// 原因(掩码处理)
for (size_t i = 0; i < strlen(reason); i++) {
frame[pos] = reason[i] ^ mask[(i + 2) % 4];
pos++;
}
write(sockfd, frame, pos);
// 等待对方回 Close 帧(超时 2 秒)
// 实际项目需要非阻塞 + select
usleep(2000000);
close(sockfd);
}
我曾经遇到过:服务器不回 Close 帧,直接关 TCP。这时候你傻等就会卡住。我的做法是:发完 Close 帧后,用 select() 等 2 秒,没收到就直接关。别死等。
完整客户端示例
把上面这些拼起来,就是一个能用的 WebSocket 客户端了。核心流程:
- TCP 连接服务器(80 或 443)
- 发送 HTTP 升级请求
- 验证服务器返回的 Accept
- 循环读写数据帧
- 收到 Close 帧或出错时关闭
我在项目中实际用这套代码做过一个实时行情推送的客户端,跑了几个月没出过问题。当然,生产环境建议用 libwebsockets 这种成熟库,自己手写主要是为了理解原理。
总结一下关键点:
- 握手靠 HTTP Upgrade,Key 和 Accept 的算法是固定的
- 数据帧有 Mask 要求,客户端必须加掩码
- 关闭要发 Close 帧,但也要做好对方不回复的准备
- Ping/Pong 是保活机制,建议每隔 30 秒发一次 Ping
嗯,WebSocket 客户端说白了就是「HTTP 握手 + 自定义帧协议」。你把这个框架搭好,剩下的就是业务逻辑了。下一章咱们聊聊服务端怎么实现,到时候你就能自己搭一个聊天室了。
公众号:蓝海资料掘金营,微信deep3321