从Socket到WebSocket

前言

不知道大家在学习网络编程的时候都是怎样的一种方式,我谨以此文章来记录我自己从头开始学习C++网络编程时的经历,中间有许多我自己的一些想法和思考。当然作为一个刚开始学习的新手来说,有些内容也许不那么正确,只是代表了我在写这篇文章时的看法。对错相信看到本篇文章的人也能够通过自己的知识自行判断。

关于Socket

我在真正接触TCP/IP网络编程之前,我曾经粗略的浏览过Linux系统编程的课程,当时这门课时长不长,整体介绍了进程线程的概念,涉及了管道,共享内存,消息队列,信号量等知识。并且在课程最后给我们实现了最基本TCP的双端通信。可以说我学习TCP/IP网络编程时已经不算真正意义上的小白了。虽然我希望尽我所能的将这些东西以更简单的方式讲清楚(因为我在学习的时候就发现了网上的大部分教程都是不知道在那里复制了一点相关概念,然后就直接给出了很它们的代码,然而并不会解释这些代码是如何产生的),但是我还是希望你们在学习网络编程之前可以看看相关Linux方面的知识,不至于理解不了常用的名词。

注意,本片文章着重讲述学习完成socket后向websocket过渡的过程,所以本篇不会着重讲解socket编程,此外这篇文章所有的代码实现均在Linux上实现。

在开始学习TCP/IP网络编程的阶段,有一本书绕不过去,那就是那本由韩国人尹圣雨所编写的《TCP/IP网络编程》,这本书可以说是我学习网咯编程的启蒙老师,这本书最大的有点就是讲解清晰易懂,示例代码均可以运行,并且注释非常清楚。可以说如果你是刚刚接触网络编程,这本书能够很好的带你入门。我是比较推荐新手看看这本书的。

认识WebSocket

如果你已经看完了《TCP/IP网络编程》这本书的Linux部分,那么证明你已经有了一些socket编程基础。这时候可能你恰巧需要使用websocket,于是就开始在网络上搜索websocket相关的教程,寄希望发现某个教程能够从最基础开始讲解websocket,从而帮助自己在第一次接触websocket的情况下理解websocket。

很可惜你失败了,确实找到了一些教程,但是都不是你所需要的。这些教程要不然就是对websocket协议的照本宣科的描述,毫无价值;要么就是使用的js等和java相关的代码来编写,不仅代码中莫名奇妙会出现一些不知道从那里凭空产生的websocket类,而且就连发送接收消息这种websocket协议中的关键性代码的详解也是没有的。

同样的,我在学习websocket时也遇到了上面的问题,所以我决定自己来写一份使用C++语言的websocket学习记录,希望能够帮助到你们。所以说在这篇文章之后学习websocket的人们是幸运的,因为你们极有可能看到我的这篇文章而免受搜索到大量无用文章的痛苦。

使用websocket第三方库

如果你尝试使用ChatGPT等AI工具帮你生成一个websocket服务端代码,它们大概率会给出一小段非常精简,并且易于理解的代码。大部分这类代码使用了websocket的第三方库,第三方库将具体的实现方法进行了封装,使得我们在使用起来特别方便。我个人是比较喜欢直接使用boost库来实现websocket服务的。下面我给出一段服务器代码例子

cpp 复制代码
#include <iostream>
#include <boost/asio.hpp>
#include <boost/beast.hpp>

int main() {
    // 创建IO上下文
    boost::asio::io_context io_context;

    // 创建TCP端口并监听在9002端口
    boost::asio::ip::tcp::acceptor a(io_context, { boost::asio::ip::tcp::v4(), 9002 });

    // 创建TCP端口并绑定到特定IP地址和端口
    // boost::asio::ip::tcp::endpoint endpoint(boost::asio::ip::make_address("192.168.32.124"), 9002);
    // boost::asio::ip::tcp::acceptor a(io_context, endpoint);

    while (true) {
        // 接受客户端连接
        boost::asio::ip::tcp::socket socket(io_context);
        a.accept(socket);

        try {
            // 使用WebSocket流包装TCP套接字
            boost::beast::websocket::stream<boost::asio::ip::tcp::socket&> ws{ socket };

            // 启动WebSocket握手
            ws.accept();

            // 发送消息给客户端
            std::string response = "Hello, WebSocket!";
            ws.write(boost::asio::buffer(response));

            while (true) {
                // 读取客户端发送的消息
                boost::beast::flat_buffer buffer;
                ws.read(buffer);
                std::string received_msg = boost::beast::buffers_to_string(buffer.data());

                // 输出接收到的消息
                std::cout << "Received message from client: " << received_msg << std::endl;

                // 如果收到 "quit" 消息,则关闭连接
                if (received_msg == "quit") {
                    break;
                }

                // 发送消息给客户端
                response = "I received your message: " + received_msg;
                ws.write(boost::asio::buffer(response));
            }

            // 关闭WebSocket连接
            ws.close(boost::beast::websocket::close_code::normal);
        }
        catch (const boost::beast::system_error& se) {
            std::cerr << "Error: " << se.what() << std::endl;
        }
    }

    return 0;
}

使用第三方库提供的函数能够大大减少我们编写代码的难度,但是作为初学者对于理解websocket协议点三方库就不是那么友好了,因为我们不容易看到具体的处理细节。

使用原生的C++socket编程

在学习websocket时我们知道,websocket的实质其实还是基于socket进行通信的,只不过在通信的开始需要确认一下请求信息。在确认请求信息过后,之后的数据收发完全就是socekt通信。唯一需要注意的就是收到的数据并不是之前我们的那种简单的字符串了,websocket发送的数据是一个数据帧,简单来说就是我们收到的字符串中包含的不仅仅只有数据,他是遵循websocket协议的的不同字段的拼接。

简单解释一下这个收到的数据块(也就是字符串),其具体结构如下:

  • FIN(1位):表示这是消息的最后一个数据帧,如果消息可以被分割成多个数据帧,那么只有最后一个数据帧的FIN位为1,其他数据帧的FIN位为0。
  • RSV1, RSV2, RSV3(各1位):预留位,目前没有特定的使用规范,一般情况下应该为0。
  • Opcode(4位):表示数据帧的类型,包括文本帧、二进制帧、关闭连接帧等。
  • Mask(1位):标识是否对数据进行掩码处理,客户端发往服务器的数据帧需要进行掩码处理,而服务器发往客户端的数据帧不需要进行掩码处理。
  • Payload length(7位或16位或64位):表示负载数据的长度,如果长度在0~125字节之间,则使用7位表示;如果长度在126~65535字节之间,则使用16位表示,且紧随其后的两个字节表示真正的长度;如果长度超过65535字节,则使用64位表示。
  • Masking key(0或4字节):如果Mask为1,那么会有4字节的掩码密钥,用于对负载数据进行掩码处理。
  • Payload data(x字节):实际的负载数据内容,长度由Payload length字段指定,如果Mask为1,则需要使用Masking key对这部分数据进行解码。

这里面我们最需要注意的就是Opcode,Payload length。这个两个字段决定了我们在解析收到的数据的流程。

Opcode, 长度为 4 比特, 该字段将指示 frame 的类型, RFC 6455 定义的 Opcode 共有如下几种:

  • 0x0, 代表当前是一个 continuation frame
  • 0x1, 代表当前是一个 text frame
  • 0x2, 代表当前是一个 binary frame
  • 0x3 ~ 7, 目前保留, 以后将用作更多的非控制类 frame
  • 0x8, 代表当前是一个 connection close, 用于关闭 WebSocket 连接
  • 0x9, 代表当前是一个 ping frame (将在下面讨论)
  • 0xA, 代表当前是一个 pong frame (将在下面讨论)
  • 0xB ~ F, 目前保留, 以后将用作更多的控制类 frame

实际上最需要记得的就是0x00,0x01,0x02,0x08,分别对应着中间数据,文本数据,二进制数据,和关闭websocket。在收到字符串时,我们首先要取出字符串里面的Opcode,看其符合哪一种,然后依照每种的处理方式继续进行后续数据的处理工作。

我们这次使用的代码是以收到文本数据为例,下面是一个服务端代码,客户端我们可以使用websocket测试网站websocket在线测试

cpp 复制代码
#include <iostream>
#include <string>
#include <cstring>
#include <openssl/sha.h>
#include <openssl/bio.h>
#include <openssl/evp.h>
#include <netinet/in.h>
#include <unistd.h>

std::string generate_handshake_response(const std::string& key) {
    const std::string GUID = "258EAFA5-E914-47DA-95CA-C5AB0DC85B11";
    std::string concatenated = key + GUID;
    unsigned char hash[SHA_DIGEST_LENGTH];
    SHA1(reinterpret_cast<const unsigned char*>(concatenated.c_str()), concatenated.length(), hash);

    unsigned char encoded_hash[SHA_DIGEST_LENGTH*2]; // 预留足够的空间以容纳编码后的结果
    int encoded_length = EVP_EncodeBlock(encoded_hash, hash, SHA_DIGEST_LENGTH);

    std::string response_key(reinterpret_cast<char*>(encoded_hash), encoded_length);

    std::string response = "HTTP/1.1 101 Switching Protocols\r\n";
    response += "Upgrade: websocket\r\n";
    response += "Connection: Upgrade\r\n";
    response += "Sec-WebSocket-Accept: " + response_key + "\r\n\r\n";
    return response;
}

// 解析 WebSocket 消息内容
std::string parseWebSocketMessage(const std::string& message) {
    std::string decodedMessage;
    
    // 检查是否有掩码
    bool masked = (message[1] & 0x80) != 0;
    int payloadLength = message[1] & 0x7F;
    int maskOffset = 2;
    
    if (payloadLength == 126) {
        // 16位长度
        payloadLength = (static_cast<uint8_t>(message[2]) << 8) | static_cast<uint8_t>(message[3]);
        maskOffset = 4;
    } else if (payloadLength == 127) {
        // 64位长度,我们假设消息不会很大
        payloadLength = (static_cast<uint64_t>(message[2]) << 56) |
                        (static_cast<uint64_t>(message[3]) << 48) |
                        (static_cast<uint64_t>(message[4]) << 40) |
                        (static_cast<uint64_t>(message[5]) << 32) |
                        (static_cast<uint64_t>(message[6]) << 24) |
                        (static_cast<uint64_t>(message[7]) << 16) |
                        (static_cast<uint64_t>(message[8]) << 8) |
                        static_cast<uint64_t>(message[9]);
        maskOffset = 10;
    }
    
    std::string maskingKey = message.substr(maskOffset, 4);
    std::string payload = message.substr(maskOffset + 4, payloadLength);
    
    // 反掩码操作
    for (int i = 0; i < payloadLength; ++i) {
        decodedMessage.push_back(payload[i] ^ maskingKey[i % 4]);
    }
    
    return decodedMessage;
}

int main() {
    int server_fd, new_socket;
    struct sockaddr_in address;
    int opt = 1;
    int addrlen = sizeof(address);
    char buffer[1024] = {0};

    // 创建套接字
    if ((server_fd = socket(AF_INET, SOCK_STREAM, 0)) == 0) {
        perror("socket failed");
        exit(EXIT_FAILURE);
    }

    // 设置套接字选项
    if (setsockopt(server_fd, SOL_SOCKET, SO_REUSEADDR | SO_REUSEPORT, &opt, sizeof(opt))) {
        perror("setsockopt");
        exit(EXIT_FAILURE);
    }

    address.sin_family = AF_INET;
    address.sin_addr.s_addr = INADDR_ANY;
    address.sin_port = htons(9002);

    // 绑定地址和端口
    if (bind(server_fd, (struct sockaddr *)&address, sizeof(address))<0) {
        perror("bind failed");
        exit(EXIT_FAILURE);
    }

    // 监听连接
    if (listen(server_fd, 3) < 0) {
        perror("listen");
        exit(EXIT_FAILURE);
    }

    std::cout << "WebSocket server is listening on port 9002" << std::endl;

    if ((new_socket = accept(server_fd, (struct sockaddr *)&address, (socklen_t*)&addrlen))<0) {
        perror("accept");
        exit(EXIT_FAILURE);
    }

    // 接收握手请求
    int valread = read(new_socket, buffer, 1024);
    std::string key;
    std::string handshake_request(buffer, valread);
    std::size_t found = handshake_request.find("Sec-WebSocket-Key: ");
    if (found != std::string::npos) {
        key = handshake_request.substr(found + 19, 24);
        std::string response = generate_handshake_response(key);
        send(new_socket, response.c_str(), response.length(), 0);
        std::cout << "WebSocket handshake completed." << std::endl;
    }
    // std::cout << key << std::endl;

    // 接收和发送WebSocket消息
    while (true) {
        // memset(buffer,'\0',1024);
        valread = read(new_socket, buffer, 1024);

        // 处理WebSocket数据帧,这部分需要根据WebSocket协议规范进行解析
        unsigned char opcode = buffer[0] & 0x0F;
            switch (opcode) {
                case 0x01:  // 文本消息
                    {
                        // 解析文本消息内容
                        std::string str(buffer);
                        std::string message = parseWebSocketMessage(str);

                        //在服务器端回显
                        std::cout << "Received message: " << message << std::endl;
                        //假设收到的数据是文本消息,直接返回相同的消息
                        /*        
                            这行代码构造了返回消息。
                            首先,\x81 是WebSocket帧的控制位,表示这是一个文本消息帧。
                            然后是消息的长度信息,它的长度是一个字节,表示消息的长度。接着是消息的实际内容。
                        */
                        std::string response = std::string("\x81", 1) + static_cast<char>(message.length()) + message;
                        send(new_socket, response.c_str(), response.length(), 0);

                        break;
                    }
                case 0x08:  // 关闭连接
                    {
                        // 处理关闭连接请求
                        // handleCloseRequest();
                        close(new_socket);
                        // close(server_fd);
                        // std::cout << "closed\n";
                        break;
                    }
                // 其他消息类型的处理
                default:
                    {
                        // 其他处理逻辑
                        break;
                    }                
            }        
    }
    return 0;
}

大家可以看到在我们的主函数中,前面一直都是按照常规的socket编程进行,但是在accept之后,我们对首次接收到的数据(也叫做接收请求头)进行了提取Sec-WebSocket-Key:的操作,并对提取出来的Sec-WebSocket-Key与 WebSocket 魔数 (Magic Number) "258EAFA5-E914-47DA- 95CA-C5AB0DC85B11" 进行字符串连接, 将得到的字符串做 SHA-1 哈希, 将得到的哈希值再做 base64 编码,最终我们把得到的值作为服务器向客户端发送的Sec-WebSocket-Accept:字段值。并且一同发送给客户端的还有

"HTTP/1.1 101 Switching Protocols\r\n";

"Upgrade: websocket\r\n";

"Connection: Upgrade\r\n";

完成这样一来一回的请求回应操作后,websocket服务端和客户端就正式建立起连接了。

在代码中我们建立连接以后,主函数进入while死循环,不断接收发送websocket消息。我们下面思考这段代码的逻辑。正如上面介绍数据帧格式的时候所说,首先我们获取数据帧中Opcode的部分,这里使用的是unsigned char opcode = buffer[0] & 0x0F;使用与操作将opcode数据给取了出来,之后使用switch语句判断opcode属于那种情况,这里我实现了0x01文本数据的收发操作。

确定了数据发送过来的是文本数据,那么这个文本数据有多长,得到数据长度后我们就可以打印输出到服务器上或者回传给客户端了吗。显然远没有这么简单,RFC 6455 规定所有由客户端发往服务端的 WebSocket frame 的 Payload 部分都必须使用掩码覆盖。也就是说我们收到的数据是经过掩码覆盖的加密数据,直接使用会不能识别,必须经过解码操作。得到未被覆盖前的原始数据。

掩码覆盖的算法如下:

  1. 客户端使用熵值足够高的随机数生成器随机生成 32 比特的 Masking-Key
  2. 以字节为步长遍历 Payload, 对于 Payload 的第 i 个字节, 首先做 i MOD 4 得到 j, 则掩码覆盖后的 Payload 的第 i 个字节的值为原先 Payload 第 i 个字节与 Masking-Key 的第 j 个字节做按位异或操作

// 反掩码操作

for (int i = 0; i < payloadLength; ++i) {

decodedMessage.push_back(payload[i] ^ maskingKey[i % 4]);

}

经过反掩码之后我们就得到了正确的原始数据,使用原始数据就可以正常向客户端发送了。

看到这里或许你已经又发现了一些特别的地方,是的,原始数据只能用于服务器显示,并不能直接send到客户端,究其原因是因为websocket的收发双方都必须发送严格遵循websocket数据帧格式的数据,所以我们也必须构建出同样的字符串才行,代码中使用std::string response = std::string("\x81", 1) + static_cast<char>(message.length()) + message;这行代码构造了发往客户端的数据。

结语

至此我们已经完成了一个最基本的websocket服务器模型,相信对初学者的你来说,成功发送并接收websocket数据一定十分兴奋,当然对于之后的学习也不要懈怠。如果本篇教程帮到了你,那么我也感到非常开心。希望我们下下篇博客再见。

相关推荐
城南vision几秒前
计算机网络——TCP篇
网络·tcp/ip·计算机网络
海绵波波1073 分钟前
Webserver(4.9)本地套接字的通信
c++
@小博的博客9 分钟前
C++初阶学习第十弹——深入讲解vector的迭代器失效
数据结构·c++·学习
Ciderw28 分钟前
块存储、文件存储和对象存储详细介绍
网络·数据库·nvme·对象存储·存储·块存储·文件存储
石牌桥网管30 分钟前
OpenSSL 生成根证书、中间证书和网站证书
网络协议·https·openssl
残月只会敲键盘1 小时前
面相小白的php反序列化漏洞原理剖析
开发语言·php
ac-er88881 小时前
PHP弱类型安全问题
开发语言·安全·php
ac-er88881 小时前
PHP网络爬虫常见的反爬策略
开发语言·爬虫·php
yanwushu1 小时前
Xserver v1.4.2发布,支持自动重载 nginx 配置
mysql·nginx·php·个人开发·composer
爱吃喵的鲤鱼1 小时前
linux进程的状态之环境变量
linux·运维·服务器·开发语言·c++