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 协议状态机

为了让你更直观地理解整个流程,我画了一张状态机图:

监听 TCP 端口 等待 HTTP 握手 WebSocket 已连接 接收数据帧 发送数据帧 关闭连接 收到连接 握手成功 收到帧 处理完回复 关闭帧/异常 继续接收

从图中可以看到,WebSocket 的生命周期很清晰:监听 → 握手 → 收发数据 → 关闭。每个状态之间的转换都有明确的触发条件。

避坑指南

做 WebSocket 服务器,有几个坑我踩过,写出来给你参考:

  • 掩码处理:客户端发来的数据一定有掩码,服务器发的不需要。我曾经忘了解除掩码,结果收到的全是乱码。
  • 分片处理:大消息可能分成多个帧。FIN=0 表示还有后续帧。我一开始没处理分片,超过 64KB 的消息就丢了。
  • Ping/Pong:客户端可能会发 Ping 帧,服务器必须回复 Pong,否则连接会被断开。我见过有人忽略 Ping,结果连接莫名其妙断了。
  • 关闭握手:收到关闭帧后,服务器也应该回复一个关闭帧,然后再关闭 TCP 连接。直接 close 是不礼貌的。

我的建议:刚开始做的时候,先用浏览器的 WebSocket API 配合测试。打开 Chrome 开发者工具,在 Console 里写几行 JS 连接你的服务器,收发几条消息。这样调试起来非常直观。

总结

WebSocket 协议本身并不复杂。核心就三件事:握手、组帧、解帧。你想想看,只要把这三点搞明白,一个能用的 WebSocket 服务器也就几百行代码的事。

当然,生产环境要考虑的东西更多:并发连接、心跳保活、TLS 加密、协议扩展……但作为入门,今天这个实现已经够你理解 WebSocket 的本质了。

代码写完了,编译运行一下,用浏览器连上去试试。看到 "Echo: hello" 返回来的那一刻,还是挺有成就感的。


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