60、WebSocket客户端:实现WebSocket客户端、连接建立与关闭

WebSocket 这东西,说白了就是让客户端和服务器之间能「实时聊天」。不像 HTTP 那样一问一答,WebSocket 建立连接后,双方随时都能发数据。我在做物联网项目时,设备状态需要实时上报,用轮询太浪费资源,最后就是用 WebSocket 搞定的。

这一章,我们手写一个 WebSocket 客户端。重点就三个:握手建立连接、收发数据、优雅关闭。嗯,咱们一步步来。

WebSocket 协议核心流程

先看整体流程,心里有个谱:

客户端 服务器 ① HTTP Upgrade 握手请求 ② 101 Switching Protocols 响应 连接已建立 ③ 双向数据帧传输 (文本帧 / 二进制帧 / Ping-Pong) ④ Close 帧(可选,然后 TCP 关闭) 连接关闭

你看,核心就四步:握手、建立、通信、关闭。其中握手用的是 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 协议没这个要求,后来发现安全问题才加的。嗯,安全这东西,总是事后才补上。

第四步:关闭连接

关闭有两种方式:

  1. 优雅关闭:发送 Close 帧(Opcode 0x8),等对方也回一个 Close 帧,再关 TCP
  2. 直接关闭:直接 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 客户端了。核心流程:

  1. TCP 连接服务器(80 或 443)
  2. 发送 HTTP 升级请求
  3. 验证服务器返回的 Accept
  4. 循环读写数据帧
  5. 收到 Close 帧或出错时关闭

我在项目中实际用这套代码做过一个实时行情推送的客户端,跑了几个月没出过问题。当然,生产环境建议用 libwebsockets 这种成熟库,自己手写主要是为了理解原理。

总结一下关键点:

  • 握手靠 HTTP Upgrade,Key 和 Accept 的算法是固定的
  • 数据帧有 Mask 要求,客户端必须加掩码
  • 关闭要发 Close 帧,但也要做好对方不回复的准备
  • Ping/Pong 是保活机制,建议每隔 30 秒发一次 Ping

嗯,WebSocket 客户端说白了就是「HTTP 握手 + 自定义帧协议」。你把这个框架搭好,剩下的就是业务逻辑了。下一章咱们聊聊服务端怎么实现,到时候你就能自己搭一个聊天室了。


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