跳到主要内容Linux 序列化与反序列化原理及自定义协议实现 | 极客日志C++算法
Linux 序列化与反序列化原理及自定义协议实现
Linux 网络编程中数据以字节流传输,直接传递结构体存在内存对齐、大小端及跨语言适配问题。序列化将结构体转为字符串便于发送,反序列化还原为结构体便于处理。通过封装 Socket 类、设计 TCP 服务端及自定义协议(含报头解决粘包半包),结合 Jsoncpp 库实现网络版计算器,演示了完整的序列化流程与通信机制。
草莓泡芙7.8K 浏览 
在 Linux 网络编程和系统开发中,序列化与反序列化几乎是绕不开的话题。无论是进程间通信,还是基于 Socket 的网络传输,数据最终都需要以字节流的形式在系统中流转。而很多初学者在实际开发中,往往只会'用协议',却并不清楚协议是如何设计的、数据又是如何被序列化和还原的。
今天我们将围绕 Linux 环境下的序列化与反序列化,从基本原理入手,逐步分析自定义协议的设计思路与实现方式,结合实际示例,帮助你真正搞懂数据在网络通信中的完整生命周期。
一、序列化与反序列化
在讲解什么是序列化与反序列化之前,我们先回顾一下之前我们讲解协议时的一些相关知识:

比如现在我要通过网络完成一个简单的计算器,即:完成两个数之间的运算,包括:加减乘除等,那么对于客户端和服务端而言,它们就要"约定"好所要传输的结构体中的内容,这其实就是"自定义协议"的体现。
就如上图所示的:两个要运算的数字和运算方式,即'+ - * /'等符号,这样客户端将结构体发给服务端,服务端中也有该结构体类型,通过指针就能提取到客户端发来的数据。
但是这种直接传递结构体的方式会面临许多问题,如:
**1.内存对齐问题。不同的编译器,不同的操作系统可能对于结构体的填充规则不同,既然不一致,那么双方在读取结构体中的数据时就会产生问题,可能就会读成乱码。
2.大小端问题。如果客户端是一个小端机器,服务端是一个大端机器,那么服务端在读取数据时就可能读反客户端所发来的数据。
3.适配性问题。就比如客户端是用 C 语言写的代码,所以传输时直接传递的也就是结构体,但是服务端却是用 java 或者 Python 语言写的,那么因为语言的差异性,服务端就很难完美模拟 C 内存中的物理布局。**
但是操作系统之间确实就是以直接传递结构体的方式进行通信的,并且虽然上面我们列举了很多的问题,但是这些问题都可以被解决,至于具体是通过哪些方式来解决的这里不过多赘述,这不是我们要讲的重点。
当然任何事物都有两面性,这种方式既然有缺点,当然也有优点,这种方式的优点就是:极致的效率,直接传递结构体的这种方式可以使接收方直接把内存地址"看成"结构体,那么 cpu 的占用率就会很低,就几乎不用干活。
那么下面我们就来看看自定义协议的另一种编码方式:序列化与反序列化。

通过上面一张图就能解释何为序列化与反序列化,这里我们传递的不再是两个数字和运算方式,而是换成了传递一条聊天消息。
在这条聊天消息中,共有三个部分组成,它们分别表示什么含义由上层软件来解释,而这里我们选择的不再是将聊天信息的三个部分以结构体的形式进行传输,而是将结构体中的三个信息以空格分隔开,转换成一个字符串,通过网络传递给接收方。
而接收方在收到字符串后,会按照相同的转换原则,将字符串中的信息一一提取出来,交给上层处理。
而将数据由结构体转化为字符串,即将信息由多变一,方便网络发送的过程就叫做:序列化,而通过相同的转化原则,将字符串转化为结构体,即将信息由一变多,方便上层处理的过程就叫做:反序列化。
那么为什么要进行序列化和反序列化呢?或者说序列化和反序列化相较于直接传递结构体有什么优点?
序列化:1.方便网络发送 2.方便协议的可扩展性和可维护性
微信扫一扫,关注极客日志
微信公众号「极客日志」,在微信中扫描左侧二维码关注。展示文案:极客日志 zeeklog
相关免费在线工具
- 加密/解密文本
使用加密算法(如AES、TripleDES、Rabbit或RC4)加密和解密文本明文。 在线工具,加密/解密文本在线工具,online
- Base64 字符串编码/解码
将字符串编码和解码为其 Base64 格式表示形式即可。 在线工具,Base64 字符串编码/解码在线工具,online
- Base64 文件转换器
将字符串、文件或图像转换为其 Base64 表示形式。 在线工具,Base64 文件转换器在线工具,online
- Markdown转HTML
将 Markdown(GFM)转为 HTML 片段,浏览器内 marked 解析;与 HTML转Markdown 互为补充。 在线工具,Markdown转HTML在线工具,online
- HTML转Markdown
将 HTML 片段转为 GitHub Flavored Markdown,支持标题、列表、链接、代码块与表格等;浏览器内处理,可链接预填。 在线工具,HTML转Markdown在线工具,online
- JSON 压缩
通过删除不必要的空白来缩小和压缩JSON。 在线工具,JSON 压缩在线工具,online
对于第一点相信我们都能体会到,将信息由多变一,这就和多一事不如少一事一样,事情少了,自然也就更加的方便。
对于第二点所提到的可扩展性和可维护性,我们先说可扩展性,对于这一点我们打个比方:上面我们要发送的信息共有三个部分,接收方也只会解析这三个部分,而未来我们在结构体中增加了一些字段,比如:头像,性别等。
如果是直接传输结构体的方式,那么接收方也必须要同步的修改结构体中的字段,保持二者结构体中的字段是相同的,不然就会解析失败,但是序列化不需要这种同步工作,即这种方式支持新旧版本同时存在,至于如何做到的我们在下面实现时再说。
而后面的**可维护性其实简单理解就是序列化没有上面列举的直接传输结构体的种种问题。
反序列化的优点就很明显了,它将所有的信息通过转化原则一个个提取出来,存入到相应的结构体中,那么上层在处理时直接访问该结构体拿到里面的数据即可,很方便。
所以所谓的自定义协议,本质其实就是制定双方都能认识的,符合通信和业务需要的结构化数据,所谓的结构化数据,其实就是 struct 或者 class。
至此我们就完成了对序列化和反序列化的基本认识,而要想对序列化和反序列化有更深刻的认识,就需要下面我们动手来实操了。
二、重新理解 read,write,recv,send 和 TCP 为什么支持全双工?
在有了上面对于序列化和反序列化的认识后,下面我们来看一张图:
通过这张图我们就要来重新认识 read,write,recv,send 这些 IO 函数。
首先我们要知道当我们在使用 Socket 编程相关的 api 接口时,会在内核中形成发送缓冲区和接收缓冲区。
所以当我们使用上面那些 IO 函数来传递消息时,并不是真的把数据直接传送给对方了,而是将你要发送的内容拷贝到缓冲区中。
所以这里我们就可以输出第一个结论:write 等 IO 函数的本质就是拷贝,发送数据是拷贝,读取数据也同样是拷贝!!!
那么下面就又会出现一个问题:那么我们通过 write 等函数将数据拷贝到缓冲区中,什么时候将数据发出去呢?发多少呢?
那么答案就是我们要输出的第二个结论:这些工作都由 TCP 自己来决定,所以 TCP 才叫做传输控制协议!!!
那么这里面还有一个隐性问题,那就是:发送的过程中数据发少了怎么办?
可能有人没看懂这个问题,就比如:当我们通过 write 函数向缓冲区中拷贝数据时,有没有可能缓冲区快满了,只能拷贝进去一部分?或者当 TCP 想要将数据发送到对方的接收缓冲区中时,对方的接收缓冲区也快满了,如果发送数据,也是只能发送一部分呢?
当然是有可能的,如果发送的数据缺斤少两,那么对方收到的消息也不会是完整的,那么可能就会出问题,而这种问题叫做:"半包"问题。
与半包问题相对应的还有一种问题叫做:**"粘包"问题,这个问题产生的原因正好与半包问题相反。
"粘包"问题产生的原因就是:发送方一次性发送了多组数据,但是这几组数据都很小,而接收方的缓冲区又很大,所以可能会出现接收方通过 read 函数来读取数据时,直接将这几组数据一次性全读了出来,但是代价就是这几组数据"粘"在了一起,不知道每一组数据的开始和结束,这就是"粘包"问题。**
而**"半包"问题或者"粘包"问题我们就需要自定义协议来解决**,而在这里我们得先弄清TCP 协议,UDP 协议等协议与自定义协议的区别:
这里用生活中送快递的例子来说明,TCP 协议等就像快递员一样,它们不关心包裹中的内容是什么,它们关心的只是将包裹从厂商送到你的手里,这中间出现的问题,如:包裹丢了,包裹少发了,包裹顺序乱了等这些问题它们会解决。
但是对于你收到的包裹中是否是你想要的东西,这与它们没有关系,这就与自定义协议有关,也就是你在网上下单,你买的是牛仔裤,厂家给你发的也是牛仔裤,这就相当于你和厂家"约定"好的一样,自定义协议管的就是包裹中的具体内容。
那么我们接着思考:TCP 协议发送数据的时候,是怎么让数据出现在接收方的接收缓冲区中的呢?
答案就是拷贝,这里我们就要输出第三个结论了:主机间通信的本质就是把发送方发送缓冲区内部的数据,拷贝到接收方的接收缓冲区中,一样是拷贝。
那么下面我们就来思考:为什么 TCP 通信的时候,是全双工的,即在发送数据的同时也可以读取数据?
其实经过我们上面的讲解答案已经很明显了,第四个结论:就是因为有两对发送和接收缓冲区啊,不管是发送方还是接收方,都有一个发送缓冲区,一个接收缓冲区,它们之间互不影响,那么自然就可以做到全双工通信。
那么这里还有一个衍生问题:一个文件描述符有一套缓冲区,那么我们通过 socket 接口创建的多个文件描述符呢?
答案就是每一个通过 socket 接口成功创建的文件描述符,内核都会为它们分配一套独立的缓冲区,这其实也很好理解,未来可能会有多个客户端和服务端进行通信,每个客户端都应该有自己的缓冲区,而不是和别人共用一个缓冲区。
那么我们最后再来思考一个问题:用户通过 write 函数向缓冲区中拷贝数据,TCP 从缓冲区中拿出数据将其拷贝到对端的缓冲区中,有没有感觉这种方式很熟悉呢?
当然熟悉了,因为这不就是我们之前讲的生产者消费者模型嘛,那么这里就要输出第五个结论:我们的任务,在每一个发送单元,都是一个 cp 问题,是用户和内核之间在进行生产和消费!!!
三、网络版计算器
3.1 约定方案
这里我们先来规定一下我们的网络版计算器该怎样去实现,也就是"约定"客户端和服务端之间的传输的一些规则,下面我们来看:
约定方案一:
1.客户端发送的数据形式为"1+2"的字符串
2.这个字符串中有两个操作数,且都是整型,不能是浮点型等其他类型
3.两个数字之间会有一个字符是运算符,不能是其他字符
4.数字和运算符之间没有空格
约定方案二:
1.定义结构体来标识我们需要交互的信息
2.发送数据时将这个结构体按照一个规则转化成字符串,接受到数据的时候再按照相同的规则把字符串转化会结构体
以上就是我们在实现网络版计算器中客户端和服务端要遵守的规则。
3.2 代码实现
在上一篇文章中我们使用了 Socket 编程的各种接口,而我们今天来玩点不一样的,我们自己来封装一个 Socket 接口类去使用。
而我们封装的 Socket 接口类我们使用一种常见的设计模式来实现,即:模板方法类,具体怎么来实现,下面我们就来看看吧。
3.2.1 准备工作
那么首先呢我们先创建出这些文件,其中 Main.cc 和 Client.cc 分别表示服务端和客户端,剩下的我们基本都认识,前面都见过,唯独这里面的 InetAddr.hpp 我们之前没有见过。
#pragma once
#include <iostream>
#include <string>
#include <cstring>
#include <sys/types.h>
#include <sys/socket.h>
#include <arpa/inet.h>
#include <netinet/in.h>
#define Conv(addr) ((struct sockaddr*)&addr)
class InetAddr {
private:
void Net2Host() {
_port = ntohs(_addr.sin_port);
char ipbuffer[64];
inet_ntop(AF_INET, &(_addr.sin_addr.s_addr), ipbuffer, sizeof(ipbuffer));
_ip = ipbuffer;
}
void Host2Net() {
memset(&_addr, 0, sizeof(_addr));
_addr.sin_family = AF_INET;
_addr.sin_port = htons(_port);
inet_pton(AF_INET, _ip.c_str(), &(_addr.sin_addr.s_addr));
}
public:
InetAddr() {}
InetAddr(const struct sockaddr_in &addr) : _addr(addr) { Net2Host(); }
InetAddr(uint16_t port, const std::string &ip = "0.0.0.0") : _port(port), _ip(ip) { Host2Net(); }
void Init(const struct sockaddr_in &addr) { _addr = addr; Net2Host(); }
std::string Ip() { return _ip; }
uint16_t Port() { return _port; }
struct sockaddr* Addr() { return Conv(_addr); }
socklen_t Length() { return sizeof(_addr); }
std::string ToString() { return _ip + "-" + std::to_string(_port); }
bool operator==(const InetAddr &addr) { return (_ip == addr._ip && _port == addr._port); }
~InetAddr() { }
private:
struct sockaddr_in _addr;
std::string _ip;
uint16_t _port;
};
这个类我们是对 Socket 套接字进行了封装,便于我们完成主机序到网络序和网络序到主机序的转换,并且里面的方法也能够方便我们拿到 IP 地址和端口号。
这个类不是我们要讲的重点,所以这里我们就不过多赘述,直接用就行。
3.2.2 Socket 封装
那么首先我们先定义一个 Socket 类,把它作为基类,里面有一些纯虚函数,未来是需要子类来实现的,包括:create,bind,listen 这些我们熟知的创建套接字的流程函数,子类都要将其实现。
而基类中还有一个函数将这三个函数整合在一起,按顺序执行,所以我们未来执行的思路就是让基类指针指向子类对象,然后再让子类对象去调用它继承下来的 Build 函数,一次性调用者三个函数完成创建套接字的流程,这就是模板方法类。
那么不说废话,下面我们就来实现一下里面的几个函数:
这里我们创建一个 TcpSocket 的子类,由它来完成基类中相关函数的实现。
这里我们对于创建套接字过程中如果失败了我们要直接终止进程,所以我们通过枚举列举了几个变量,让代码看起来更美观,更直接。
之后我们再实现这三个函数时,分别要用到 sockfd,port,backlog 这三种属性,这三个函数都要用到 sockfd,所以这里我们直接把 sockfd 给当成类内成员变量来使用,而对于 backlog,它是已完成连接队列(Completed Connection Queue)的最大长度,这里我们直接将其默认为一个固定值即可。
所以未来我们只需要传入一个端口号即可,这里我们可以去看一下 InetAddr.hpp 中的相关实现,我们只传端口号即可,IP 地址我们默认绑定的就是任意 IP 地址,即:0.0.0.0。
那么创建套接字的工作完成了,下面的工作自然就是连接了,那么下面我们就先来实现 accept 函数:
这就是目前要实现的 Accept 函数,对于 Accept 函数,我们不仅要它成功后返回的文件描述符,还要知道发送信息的客户端的相关属性,如:IP 地址和端口号,这些信息都会保存在 peer 中,然后我们通过传进来的 addr 来调用 Init 函数,实现赋值操作,即可拿到客户端的相关信息。
而我们上面在实现时我们将返回值设置为我们自定义的 Socket 类,这样做的原因可以让我们未来创建出来的 sockfd 都能够使用我们所设计的 Socket 类,不然就只有 listensockfd,即监听套接字能用,那我们就没有单独设计 Socket 类的意义了。
目前我们的 Socket 类就先实现到这里,后面我们有需要的功能再来实现。
3.2.3 TcpServer.hpp 的实现
有了上面铺垫,那么对于 TcpServer 我们就可以直接写出上面的内容,端口号不用多说,我们未来是要传给 build 函数的,所以将其作为成员变量无可厚非,而我们设计出 Socket 类,就是要用它,所以我们也不单独设计 Init 函数了,直接在构造函数中通过 build 函数完成初始化的工作。
那么完成了初始化的工作,下面就是让整个服务端运行起来,所以我们下面要实现一个 run 函数:
首先我们可以将 Run 函数实现到这个地步,这里我们采用多进程的方式来让服务端可以同时与多个客户端进行通信,来处理客户端的请求。
而要实现这一点我们就要解决父进程等待子进程结束的问题,如果不解决,父进程就必须得等待子进程结束才能接着执行,那么这种方式导致的结果就是服务端只能同时服务一个客户端。
可能有人不理解为什么,我们来设想一个场景:你作为客户端,是想让服务端能够长时间服务你还是服务一次直接挂掉了?
答案显然是第二种,那么服务端要处理客户端请求的函数或者功能一定是一个死循环,这样才能随时处理客户端的请求,那么既然是死循环,子进程什么时候结束就是未知数,难道父进程要一直等吗?或者说你愿意排队吗?
我们当然不想等,我们要的就是客户端随时给服务端发送请求,服务端就要立刻处理我们的请求,所以我们不能让父进程一直在等待子进程。
当然这里的做法很多,我选择了一种较为简单的一种,就是利用信号,也算是回顾一下我们前面所学的知识。
那么在上面的代码中我们想要打印出新创建的文件描述符是多少,但是这里我们还没有实现,所以我们要在 Socket 类中实现一下:
那么下面就是父进程和子进程要做的事了,那么它们首先要做的就是关闭他们用不到的文件,也就是通过 Close 函数来关闭文件描述符所对应的文件,因为是多进程,所以文件是有引用计数的,所以不必担心文件被彻底关闭,那么下面我们就来实现一下该函数:
思路同样很简单,我们实现之后就可以用来关闭文件,对于子进程而言,它用不到 listensocket,所以子进程要将其关闭,对于父进程而言,它用不到新创建的 sockfd,所以父进程要将其关闭。
那么接下来就是子进程要干的事了,那就是处理客户端传来的请求,也就是要计算的字符串,下面我们接着看:
那么首先既然要处理客户端的请求我们就要先拿到客户端传进来的数据,所以这里我又设计了一个 Recv 函数用来接收客户端传来的数据,并对读取到的字节进行了简单的判断。
那么既然接收到了数据,那么我们先不谈怎么处理客户端传来的数据,那么我们肯定要将处理后的数据传回给客户端,所以下面我们先实现 Send 函数:
send 函数实现完,我们就可以来实践一下了,看我们目前的代码能否支持客户端和服务端之间的通信。
上面就是我们所写的客户端和服务端可执行程序的代码,下面我们来简单运行一下试试:
从结果中我们可以看到此时我们的代码是可以进行通信的,但是我们也看到了我们通信的内容,也就是服务端返回的字符串一直在变长,这一点我们也能理解:
因为我们把要处理的字符串放在了外面,并且接收数据时我们是通过+=让其累加的,所以字符串会越来越长。
那么我这样写肯定是有原因的,我们在上面讲了可能会出现半包问题,那么我们如何判断读取的数据是否完整呢?
答案就是通过这种方式,只有这样我们才能判断读取的内容是否完整不是吗?
但是我们现在还没有说清楚,通过上面的讲述只是对于后面的设计有了一个大概的方向,那么下面我们就用三个问题确定最终的方向:
1.如何知道字符串 in 里面至少有一个完整的报文呢?
2.如何把这个完整的报文交给上层呢?
3.上层拿到了报文又该如何处理呢?
这三个问题就概括了我们后面要做的事,而这三个问题的答案又都与协议有关,所以下面我们就要将目光放在协议上面去了。
3.2.4 protocol 协议的实现
我们曾经说过协议就是一个结构体,但那是站在 C 语言的角度来解释的,而我们今天是用 C++ 来编写代码的,所以我们这里要用到的就是 class 类。
而在这里我们定义了两个类,分别是:Request 和 Response,这两个类分别用来处理请求和发送回复,因为我们要做的是一个计算器,所以 Request 类中要有要计算的两个数和运算符号。
而Response 类中我们要返回计算后的结果,而在该类中我又定义了一个_code 的成员变量,这个变量用来表示可信度,就比如:如果客户端传来的是 10 / 0,那么我们就无法给 result 一个准确的值,所以如果是这种情况,我们就需要_code 变量来表明此时的 result 是不可信的,是错误的。
那么这两个类还需要做的事情就是完成序列化和反序列化,由它们完成序列化和反序列化后,再交给上层去进行处理,也就是计算结果,那么下面我们就要来了解怎么进行序列化与反序列化。
我们要实现序列化和反序列化就要用到 Jsoncpp 库,那么下面我们就来简单认识一下 Jsoncpp 库:
Jsoncpp 是一个用于处理 JSON 数据的 C++ 库。它提供了将 JSON 数据序列化为字符串以及从字符串反序列化为 C++ 数据结构的功能。Jsoncpp 是开源的,广泛用于各种需要处理 JSON 数据的 C++ 项目中。
那么在该库中就提供了进行序列化和反序列化的相关接口,那么下面我们依次来认识一下。
1.使用 Json::Value 的 toStyledString 方法
我们首先要定义一个 Json 库中的 Value 对象,这是一个万能对象,然后用法就是利用 [] 在里面填入你要用到的属性,= 号后面就是该属性所对应的数据。
然后我们在通过 Value 对象的 toStyledString 函数进行转化,结果就如上面所示的那样,是一个结构化的数据,那么这时候有人就要问了:这怎么长得不像是一个字符串,更像是一个结构化的数据呢?
那是因为在生成的字符串中间利用了" \n "等特殊字符,让字符串看来更加美观,结构更加清晰。
如你所愿,我们可以定义一个 FastWriter 对象,通过调用该对象的 write 函数,将我们上面初始化后的 Value 对象给传进去,返回一个字符串,打印出的结果就是一个连续的长字符串。
序列化的方式还有很多种,剩下的我们就不再一一列举了,那么下面我们就来看看是如何进行反序列化的。
对于反序列化我们只介绍一种,那就是:使用 Json::Reader,下面我们就来看看它是怎么使用的:
我们要创建一个 Reader 对象,并调用该对象的 parse 函数,该函数我们需要传入两个参数,第一个就是我们要反序列化的字符串,第二个就是要将反序列化后的结果存入一个 Value 对象中,所以我们还要创建一个 Value 对象。
而我们要打印的时候要用到 Value 对象的相关方法,如果里面是 string 类型的参数,就用 asString,int 类型就用 asInt,其余皆是如此,但是 char 类型是没有的,我们可以用 asInt 代替。
至此我们对于序列化和反序列化的简单认识就到这里,下面我们就来着手写一下我们的 Request 和 Response 类:
那么即使我们完成了序列化和反序列化的工作,依旧没有解决我们上面的问题,即:我们怎么知道字符串中有一个完整的报文呢?
所以仅仅完成序列化和反序列化的工作是不够的,而我们的做法则是给我们经过序列化的字符串添加一个报头,即:有效载荷长度,用它记录我们序列化后长字符串的长度,作为我们判断报文是否完整的一个标准。
这里我们的做法是新创建一个 Procotol 类,在里面实现一个 Package 方法用来完成上面的工作,并且为了使我们最后的字符串可读性比较好,所以我们在中间添加了" \r\n ".
最终该函数将返回一个完全体状态的一个报文,但是我们依旧没有解决上面所说的判断完整报文的问题,所以下面我们还要接着完善。
所有工作准备就绪下面我们就要彻底解决该问题,那么我们的思路就是将我们上面包装好的字符串 origin_str 传进去,并将其设置为输入输出型参数,至于为什么后面我们就知道了,并且我们传进去一个字符串 package 作为输出型参数,用来接收一个完整的报文。
这里的实现思路我在上面已经通过注释进行了说明,这里我解释一下为什么要将 origin_str 设置成输入输出型参数:
在代码的上面我们其实解决的都是半包问题,即读取的报文不完整,但是我们上面也说了,不止有半包问题,还可能会有粘包问题,而将 origin_str 设置成输入输出型参数就是为了解决粘包问题。
如果是粘包问题,那么我们截取一个完整的 jsonstr 后,再次调用该函数,得到的依旧是上次已截取出来的结果**,所以为了避免影响后面的逻辑,这里必须要把一个完整的报文从 origin_str 中给去掉。
这里不理解的可以看上面我们当时在写 protocol 之前的测试,我们读到的字符串是一直在增加的,如果出现粘包问题,不将完整的报文去除掉,是会出问题的。
那么经过 Unpack 函数的调用后,我们就能拿到一个完整的 jsonstr,那么下面我们就要对这个客户端传来的请求进行处理了,所以我们下面的思路就是解析客户端传来的请求,那么在实现该功能之前我们先来捋顺一下思路:
因为服务端只负责 IO 工作,所以对于解析客户端请求的工作肯定是不能交给服务端来完成的,所以这里我们让服务端将该工作交给其他人去完成,即通过 function 来完成函数的回调工作。
而我们将回调的函数返回值设置为 string,参数也设置为 string,返回值返回的就是我们未来要发给客户端的结果,而传进去的参数就是通过 Recv 读取到的字符串。
这里我的做法是单独再创建一个 Parser 类来处理客户端的请求,里面的 Parse 函数就是用来完成该工作的。
上面实现的思路我也通过注释进行了说明,这里单独说一下最后为什么不直接将处理后的结果发给客户端而是要进行打包:
因为客户端未来也要从缓冲区中去读取服务端发来的数据,也同样会面临和服务端一样的半包问题和粘包问题,所以为了使客户端能够得到完整的结果,所以我们要对其进行打包,未来客户端也要执行和服务端一样的思路来保证报文的完整性。
上面我们也看到了,我们的解析工作中还缺少了处理客户端请求的工作,所以下面我们就要来实现该功能:
这里我的做法是创建一个 Calculator 类,在这个类中实现 Exec 函数来完成上面的工作,这里的逻辑也很简单,通过判断 req 对象中的运算符,通过 switch 语句就可以完成上面的工作,这里我只实现了常见的 +,-,*,/,% 五种运算。
当然,为了让该模块与 Parser 模块关联起来,我们要对 Parser 类做一些调整:
3.2.5 客户端代码 (Client.cc) 和服务端代码 (Main.cc) 的实现
下面我们先在 Main.cc,即服务端代码中将各个模块整合起来:
上面就是未来我们运行服务端的完整代码了,通过将服务器,Paser,Calculator 这三个模块关联起来,我们就可以完成与客户端建立连接,解析客户端请求,处理客户端请求的全部工作了。
上面服务端的代码写完了,下面我们就来看看客户端的代码该怎么写吧:
上面就是客户端的完整代码,客户端中需要用到的一些额外的函数我都已经放在了上面,这些函数没什么难度,就不多讲了。
在实现客户端代码的过程中我们操作的可以看着下面这张图来一步一步进行:
这张图我们在最上面是见过的,按照这张图的顺序我们就能顺利地把客户端要做的事给顺下来。
那么所有工作已经准备就绪,下面我们就来看看我们实现的网络版计算器的实践效果:
从结果中可以看到网络计算器的功能已经实现了,包括除 0 问题等特殊情况我们也可以通过打印出来的_code 来判断结果正确与否。