Linux网络(七):从 Socket 封装到应用层协议:手写网络版计算器,彻底搞懂 TCP 通信、序列化与反序列化


◆ 博主名称: 小此方-CSDN博客 大家好,欢迎来到小此方的博客。
⭐️网络系列个人专栏: 【主题曲】计算机网络
⭐️此方的GitHub: github_此方
⭐️ 我们思考 (Rethink) · 我们重建 (Rebuild) · 我们记录 (Record)


文章目录


概要&序論

  Hello大家好,我是此方,上两篇我们讲解了TCP与UDP套接字的编程基础并写了四套简单的Demo。 本文我们将开始讲解自主设计一个"应用层协议"。并再这个基础上设计一个"网络计算器"。好。我们废话不多说,直接开始吧。

一、我们接下来到底要做什么

1.1自主设计的应用层协议需要解决的两个问题

  我们即将设计的应用层协议,本质上是一套结构化字段的编码约定。它需达成两个核心目标:

  • 提供完备的序列化与反序列化机制,确保结构化数据在字节流与内存对象间无损转换。
  • 解决因TCP字节流无边界特性导致的报文读取不完整问题

1.2再谈"协议"------到底是什么

  协议是一种"约定"。socket api的接口,在读写数据时,都是按"字符串"的方式来发送接收的。如果我们要传输一些"结构化的数据"怎么办呢?

其实,协议就是双方约定好的结构化的数据

1.3网络版计算器

  例如,我们需要实现一个服务器版的加法器。我们需要客户端把要计算的两个加数发过去,然后由服务器进行计算,最后再把结果返回给客户端。

1.3.1约定方案一:

  • 客户端发送一个形如"1+2"的字符串;
  • 这个字符串中有两个操作数,都是整形;
  • 两个数字之间会有一个字符是运算符,运算符只能是 +;
  • 数字和运算符之间没有空格;

1.3.2约定方案二:

  • 定义结构体来表示我们需要交互的信息;
  • 发送数据时将这个结构体按照一个规则转换成字符串,接收到数据的时候再按照相同的规则把字符串转化回结构体;
  • 这个过程叫做"序列化"和"反序列化"。

1.3.3序列化和反序列化

  无论我们采用方案一,还是方案二,还是其他的方案,只要保证,一端发送时构造的数据,在另一端能够正确的进行解析,就是ok的。这种约定,就是 应用层协议

  但是,为了让我们深刻理解协议,我们打算自定义实现一下协议的过程。

  • 我们采用方案2,我们也要体现协议定制的细节。
  • 我们要引入序列化和反序列化,只不过我们直接采用现成的方案--jsoncpp库。
  • 我们要对socket进行字节流的读取处理。

  面向字节流的时候,发送报文的次数和读取报文的次数不是严格的1:1对应关系。

  在TCP中,要读到完整的报文,有接收方应用层自己确定。只读取半个报文,或者多读取报文,这种问题我们叫做:数据报黏报问题

  TCP:因为是面向字节流的,所以 "什么时候发?发多少??出错了怎么办?"完全由操作系统结合TCP协议自主决定。

1.3.4我们要写的网络计算器的简单架构是怎么样的

二、准备工作

2.1重新理解read、write、recv、send

2.1.1为什么TCP支持全双工

  在任何一台主机上,TCP连接既有发送缓冲区,又有接受缓冲区,所以,在内核中,可以在发消息的同时,也可以收消息,即全双工。

  这就是为什么一个tcp sockfd读写都是它的原因。

  实际数据什么时候发,发多少,出错了怎么办,由TCP控制,所以TCP叫做传输控制协议。

2.1.2 重新理解 I/O 接口:写与读的本质是数据拷贝

  在编写套接字通信代码时,我们经常使用 write/sendread/recv 函数。然而,这些网络 I/O 接口本质上并不直接与网络或网卡打交道 ,它们的核心功能是在应用层缓冲区与内核缓冲区之间进行数据的内存拷贝

复制代码
应用层缓冲区 (User Space)  <--->  系统调用 (write/read)  <--->  内核缓冲区 (OS Kernel)
  1. write / send 的本质:内存拷贝

    • 当上层调用 write(sockfd, buffer, len) 时,它的作用仅仅是将用户态应用层缓冲区(buffer)中的数据,拷贝 到操作系统内核对应的 TCP 发送缓冲区 中。
    • 数据并非直接发送到网络write 调用成功返回,仅代表数据成功拷贝到了内核发送缓冲区,并不意味着数据已经发送到了对端主机或网络中。至于数据什么时候发、发多少、超时重传怎么处理,完全由操作系统内核的 TCP 协议栈自主决定。
  2. read / recv 的本质:内存拷贝与同步

    • 当上层调用 read(sockfd, buffer, len) 时,它的作用是检测 TCP 接收缓冲区 中是否有数据。
    • 如果接收缓冲区中有数据,read 会将内核接收缓冲区中的数据拷贝 到用户态的应用层缓冲区(buffer)中;如果接收缓冲区为空,调用通常会阻塞(阻塞模式下),直到网卡接收到网络数据包并由 OS 写入接收缓冲区后将其唤醒。
  3. 通信即拷贝(计算机世界的本质)

    • 两台主机之间的网络通信,本质上就是把发送方发送缓冲区内部的数据,跨越网络拷贝到了对端的接收缓冲区中
    • 在整个数据流转过程中:应用层 -> 内核发送缓冲区 -> 网卡 -> 网络 -> 对端网卡 -> 对端内核接收缓冲区 -> 对端应用层,每一步的核心动作都是数据拷贝

2.1.3 内核与用户态之间的生产者消费者模型

  基于系统内核缓冲区与 I/O 系统调用的工作机制,用户进程与操作系统内核之间构成了典型的生产者消费者模型 !

复制代码
[发送端应用层] ---> (生产者: write) ---> [发送缓冲区] ---> (消费者: TCP协议栈)
                                                                 |
                                                             (网络传输)
                                                                 |
[接收端应用层] <--- (消费者: read)  <--- [接收缓冲区] <--- (生产者: 网卡/OS)

2.2编写一个Socket封装

  封装一个套接字的各种接口可以方便我们接下来的代码编写。

2.2.1设计模式------模板方法模式

  基类创建一套模板同时提供一个完整模板组合调用接口,其他子类负责实现这个模板中的功能,这就是模板方法模式。

cpp 复制代码
class Socket
    {
    public:
        virtual void CreateSocket() = 0;
        virtual void BindSocket() = 0;
        virtual void ListenSocket() = 0;
        virtual void ConnectSocket() = 0;
        virtual void AcceptSocket() = 0;
        virtual void RecvMessage() = 0;
        virtual void SendMessage() = 0;
        virtual void GetSockFd() = 0;
        virtual void Close() = 0;

    public:
        void TcpClientSocketMethod()
        {
            CreateSocket();
        }
        void TcpServerSocketMethod()
        {
            CreateSocket();
            BindSocket();
            ListenSocket();
        }

    private:
        int _listensockfd;
    };

  写模板方法模式的时候,可以先把需要的函数全部设置为void返回值void参数的形式,在实现完成之后再回来修改返回值和参数。

2.2.2Socket.hpp完整源码参考

cpp 复制代码
#ifndef __SOCKET__
#define __SOCKET__
#include "Log.hpp"
#include "Common.hpp"
#include "InetAddr.hpp"
using namespace LogModule;
using namespace InetAddrModule;

namespace SocketModule
{
    const int DEFAULT_BACKLOG = 258;
    class Socket
    {
    public:
        virtual void CreateSocket()  = 0;
        virtual bool BindSocket(InetAddr &Server) = 0;
        virtual bool ListenSocket(int backlog = DEFAULT_BACKLOG) = 0;
        virtual bool ConnectSocket(InetAddr &peer) = 0;
        virtual std::shared_ptr<Socket> AcceptSocket(InetAddr &client) = 0;
        virtual int RecvMessage(std::string* Message) = 0;
        virtual bool SendMessage(std::string Message) = 0;
        virtual int GetSockFd() = 0;
        virtual void Close() = 0;

    public:
        void TcpClientSocketMethod()
        {
            CreateSocket();
        }
        void TcpServerSocketMethod(InetAddr &Server,
                                   int backlog = DEFAULT_BACKLOG)
        {
            CreateSocket();
            BindSocket(Server);
            ListenSocket(backlog);
        }

    private:
        int _listensockfd;
    };
    class TcpSocket : public Socket
    {
    public:
        TcpSocket(int listensockfd = 0)
            : _listensockfd(listensockfd)
        {}
        ~TcpSocket()
        {}
        void CreateSocket()
        {
            _listensockfd = ::socket(AF_INET, SOCK_STREAM, 0);
            if (_listensockfd < 0)
            {
                LOG(LEVEL::FATAL) << "致命!套接字创建失败。" << CAGE;
                exit(SOCKET_ERR);
            }
        }
        bool BindSocket(InetAddr &Server)
        {
            int n = ::bind(_listensockfd, Server.GetSockaddrPtr(),
                           Server.GetSockLine());
            if (n == -1)
            {
                LOG(LEVEL::FATAL) << "致命!绑定套接字失败。" << CAGE;
                exit(BIND_ERR);
            }
            return true;
        }
        bool ListenSocket(int backlog = DEFAULT_BACKLOG)
        {
            int n = ::listen(_listensockfd, backlog);
            if (n == -1)
            {
                LOG(LEVEL::FATAL) << "致命!监听套接字失败。" << CAGE;
                exit(LISTEN_ERR);
            }
            return true;
        }
        bool ConnectSocket(InetAddr &peer)
        {
            int n = ::connect(_listensockfd, peer.GetSockaddrPtr(),
                              peer.GetSockLine());
            if (n == -1)
            {
                LOG(LEVEL::FATAL) << "客户端连接服务器失败!" << CAGE;
                exit(CONNECT__ERR);
            }
            return true;
        }
        std::shared_ptr<Socket> AcceptSocket(InetAddr &client)
        {
            sockaddr_in peer;
            socklen_t len = sizeof(peer);
            int _sockfd = accept(_listensockfd, (sockaddr *)&peer, &len);
            if (_sockfd < 0)
            {
                LOG(LEVEL::FATAL) << "服务器获取连接失败!" << CAGE;
                exit(ACCEPT_ERR);
            }
            std::shared_ptr<Socket> ptr =
                std::make_shared<TcpSocket>(_sockfd);
            client.SetAddr(peer);
            return ptr;
        }
        int RecvMessage(std::string* Message)
        {
            int n = recv(GetSockFd(), Message->data(),Message->size(), 0);
            if(n > 0)
                Message->resize(n);
            else 
                Message->clear();
            return n ;
        }
        bool SendMessage(std::string Message)
        {
            LOG(LEVEL::DEBUG)<<"如果能看到这句,说明进入了发送请求串"<<CAGE ;
            int n = send(GetSockFd(),
                         Message.c_str(), Message.size(), 0);
            if (n == -1)
            {
                LOG(LEVEL::DEBUG)<<"发送请求串失败"<<CAGE;
                return false;
            }
            LOG(LEVEL::DEBUG)<<"发送请求串成功"<<CAGE;
            return true;
        }
        int GetSockFd(){ return _listensockfd;}
        void Close(){ close(_listensockfd);}
    private:
        int _listensockfd;
    };
}

#endif

2.3理解Jsoncpp------一种线程的序列反序列化工具

  序列化和反序列化的工具有很多,比如protobuf是谷歌开源的一种序列化与反序列化方案,在很多公司中应用非常广泛,此方在未来也会学习和出相关的博文。

  XML是一种老式的方案,现在使用场景相对较少。

  为什么我们使用jsoncpp?因为超级轻量化,学习成本非常低。

2.3.1Jsoncpp是什么

  Jsoncpp 是一个用于处理 JSON 数据的 C++ 库 。它提供了将 JSON 数据序列化为字符串以及从字符串反序列化为 C++ 数据结构的功能。Jsoncpp 是开源的,广泛用于各种需要处理 JSON 数据的 C++ 项目中。

2.3.2Jsoncpp的特性有哪些

  1. 简单易用:Jsoncpp 提供了直观的 API,使得处理 JSON 数据变得简单。
  2. 高性能:Jsoncpp 的性能经过优化,能够高效地处理大量 JSON 数据。
  3. 全面支持:支持 JSON 标准中的所有数据类型,包括对象、数组、字符串、数字、布尔值和 null。
  4. 错误处理:在解析 JSON 数据时,Jsoncpp 提供了详细的错误信息和位置,方便开发者调试。

  以下是对于 Jsoncpp 中序列化和反序列化操作的详细介绍:

2.3.3安装Jsoncpp

bash 复制代码
ubuntu: sudo apt-get install libjsoncpp-dev  
Centos: sudo yum install jsoncpp-devel

2.3.4Jsoncpp序列化

  序列化指的是将数据结构或对象转换为一种格式,以便在网络上传输或存储到文件中。Jsoncpp提供了多种方式进行序列化:

1. 使用 Json::Value 的 toStyledString 方法

  将 Json::Value 对象直接转换为格式化的 JSON 字符串。

cpp 复制代码
#include <iostream>
#include <string>
#include <jsoncpp/json/json.h>

int main() {
    Json::Value root;
    root["name"] = "joe";
    root["sex"] = "男";
    std::string s = root.toStyledString();
    std::cout << s << std::endl;
    return 0;
}
2. 使用 Json::StreamWriterBuilder(推荐)

  提供了更多的定制选项,如缩进、换行符等。

cpp 复制代码
#include <string>
#include <sstream>
#include <memory>
#include <jsoncpp/json/json.h>

int main()
{
    Json::Value root;
    root["name"] = "joe";
    root["sex"] = "男";
    Json::StreamWriterBuilder builder; // StreamWriter的工厂
    std::unique_ptr<Json::StreamWriter> writer(builder.newStreamWriter());
    std::stringstream ss;
    writer->write(root, &ss);
    std::cout << ss.str() << std::endl;
    return 0;
}
复制代码
{
    "name" : "joe",
    "sex" : "男"
}
3. 使用 Json::FastWriter 或 Json::StyledWriter

  FastWriter 更快,不添加额外的空格和换行符;StyledWriter 输出格式化字符串。

cpp 复制代码
#include <iostream>
#include <string>
#include <jsoncpp/json/json.h>

int main()
{
    Json::Value root;
    root["name"] = "joe";
    root["sex"] = "男";

    // 使用 FastWriter
    Json::FastWriter writer;
    std::string s = writer.write(root);
    std::cout << s << std::endl;

    // 使用 StyledWriter
    Json::StyledWriter styled;
    std::string s2 = styled.write(root);
    std::cout << s2 << std::endl;

    return 0;
}
复制代码
{"name":"joe","sex":"男"}

{
   "name" : "joe",
   "sex" : "男"
}
4. 使用 Json::StreamWriter(旧式,不推荐)

  (注意:此方式已不推荐,仅作参考):

cpp 复制代码
#include <iostream>
#include <string>
#include <sstream>

int main() {
    Json::StreamWriter writer;
    writer.setIndent(4);
    writer.write("Name: ");
    writer.write("Joe");
    writer.write("Sex: ");
    writer.write("Male");
    return 0;
}
5. 使用 Json::StreamReader(旧式)
cpp 复制代码
#include <iostream>
#include <string>
#include <sstream>

int main() {
    Json::StreamReader reader;
    reader.open("data.json");
    reader.read();
    return 0;
}

2.3.5反序列化

  反序列化指的是将序列化后的数据重新转换为原来的数据结构或对象。Jsoncpp提供了以下方法进行反序列化:

1. 使用 Json::Reader

  提供详细的错误信息和位置,方便调试。

cpp 复制代码
#include <iostream>
#include <string>
#include <jsoncpp/json/json.h>

int main() {
    // JSON 字符串
    std::string json_string = "{\"name\":\"张三\", \"age\":30, \"city\":\"北京\"}";

    // 解析 JSON 字符串
    Json::Reader reader;
    Json::Value root;
    // 从字符串中读取 JSON 数据
    bool parsingSuccessful = reader.parse(json_string, root);
    if (!parsingSuccessful) {
        // 解析失败,输出错误信息
        std::cout << "Failed to parse JSON: " << reader.getFormattedErrorMessage() << std::endl;
        return 1;
    }
    // 访问 JSON 数据
    std::string name = root["name"].asString();
    int age = root["age"].asInt();
    std::string city = root["city"].asString();

    // 输出结果
    std::cout << "Name: " << name << std::endl;
    std::cout << "Age: " << age << std::endl;
    std::cout << "City: " << city << std::endl;

    return 0;
}

  输出结果:

复制代码
Name: 张三
Age: 30
City: 北京
2. 使用 Json::CharReader 的派生类(不推荐)

  在某些情况下,你可能需要更精细地控制解析过程,可以直接使用 Json::CharReader 的派生类。但通常情况下,使用 Json::parseFromStream 或 Json::Reader 的 parse 方法就足够了。

  我们的Json::Value是一个介于序列与反序列化结果之间的介质

2.3.6关于Json::Value的补充内容(有需要可阅读,可直接跳过)

三、序列化与反序列化

  应用层是网络五层模型的最顶层。在网络通信中,上层应用通常需要处理结构化的业务数据,而底层传输层(TCP/UDP)只负责传输无结构的字节流。为了在网络中高效、准确地传输结构化数据,我们需要制定应用层协议并引入序列化与反序列化机制。

3.1 应用层协议与结构化数据

  所谓协议定制,本质上就是通信双方约定好一套彼此都能识别的结构化数据格式。

3.1.1 结构化数据与协议定义

  在 C/C++ 程序中,业务数据通常以结构体(struct)或类(class)的形式存在。例如一个聊天消息结构体:

cpp 复制代码
struct message {
    std::string path;      // 头像 -> 图片路径
    std::string nick_name; // 昵称
    std::string msg;       // 消息内容
};

  这个 struct message 就是通信双方共同认可的应用层协议。

3.1.2 为什么不能直接发送二进制结构体?

  在某些场景下(如操作系统内部通信),可以通过二进制指针直接传递结构体对象。但在跨网络的 Client-Server (CS) 通信中,非常不建议直接发送二进制结构体,主要原因包括:

  • 内存对齐差异 :不同的机器、不同的编译器或平台,其结构体的内存对齐规则可能不一致,导致同一种结构体在不同机器上的大小(sizeof)和内部偏移量不同。
  • 跨语言兼容性:网络通信的两端可能使用不同的编程语言(如 C++ 与 Java/Python),不同语言无法直接解析对方内存中的二进制结构体对象。
  • 大小端字节序问题:不同架构的 CPU(如 x86 与 ARM)在内存中保存数据的字节序(大端/小端)可能不同。

3.2 序列化与反序列化的核心概念

  针对二进制直接传输的缺点,网络通信在应用层引入了序列化与反序列化机制。

3.2.1 概念定义

  • 序列化(Serialization) :将结构化数据(如 struct message)转换为一整条包含分隔符或特殊编码格式的字节流/长字符串("多变一"),以便在网络中传输。
  • 反序列化(Deserialization):将接收到的字节流/长字符串解析还原为结构化数据("一变多"),以便上层业务代码处理。

3.2.2 核心价值与优势

复制代码
[结构化数据 struct] ---> (序列化) ---> [网络字节流/字符串] ---> (网络传输) ---> [网络字节流/字符串] ---> (反序列化) ---> [结构化数据 struct]
  • 保证报文完整性 :序列化时可以通过添加分隔符(如空格、\r\n 或特定字符)或打包长度头部,确保接收方能够精准拆分边界,解决 TCP 粘包与半包问题。
  • 方便上层业务处理:反序列化将流数据还原为对象结构,上层逻辑只需直接读取结构体成员,无需关心底层的解析细节。
  • 应用层与传输层解耦:序列化与反序列化有效地对应用层与传输层进行了解耦。上层协议修改或拓展时,只要序列化/反序列化规则同步更新,底层字节流传输机制完全不受影响。

3.3 应用层通信的标准化建议

  在现代网络编程中,如果不涉及极度特殊且受控的环境(如完全同构的硬件及语言体系),在应用层必须严格遵循以下原则:

  • 强推方案 :在应用层通信中,强烈建议使用标准的序列化与反序列化方案(如 JSON、Protobuf、XML 或自定义分隔符协议)。
  • 规避方案:除非特定场景,绝对不要直接向网络写入裸结构体二进制内存块。

二、附上源码

  都是手写代码,可能会有bug,如果有,非常欢迎大佬指出私信。量真的太大了,我把仓库贴一下: (目前仓库还在建设中,Readme还没有写。)Konata's Network-Programming


好的本期内容就到这里,如果对你有帮助,还不要忘记点赞三联支持。我是此方,我们下期再见。bye! Linux、C++、算法持续连载中,欢迎关注WeChat Official Account 【此方的技术栈】。

相关推荐
captain3761 小时前
网络初 识
运维·服务器·网络
ZzzZZzzzZZZzzzz…1 小时前
K8s---网络:从 Pod 网络模型到 Calico 三种模式
运维·网络·云原生·容器·kubernetes·calico·pod网络模型
kyle~1 小时前
Linux --- epoll (I/O就绪事件通知机制)
linux·运维·服务器
AlfredZhao1 小时前
进程都杀了,为什么 `netstat` 还能看到端口?
linux
国科安芯1 小时前
基于抗辐射MCU的卫星分布式控制系统CANFD总线架构研究
网络·人工智能·分布式·单片机·嵌入式硬件·架构·抗辐射
一条泥憨鱼1 小时前
【从0开始学习计算机网络】| 邮件协议入门:SMTP、POP3、IMAP
linux·运维·计算机网络·github
春风解人意9 小时前
从零开始学习嵌入式P32----网络基础之TCP
c语言·网络·学习·tcp/ip
云絮.11 小时前
网络原理---- 网络层,数据链路层,应用层
网络
Johny_Zhao12 小时前
网络安全等级保护测评实施方案
linux·网络·人工智能·网络安全·信息安全·云计算·等保测评·系统运维·itsm