前言
大家好,我们之前已经已经为大家展示了UDP与TCP的套接字,并写了几个实际的案例代码帮助大家了解。
今天,我将会带着大家重新将目光转到协议上去,并为大家带来序列化反序列化的知识点讲解。
一、重谈协议
我们程序员写的一个个解决我们实际问题, 满足我们日常需求的网络程序, 都是在应用层。大家之后会了解到,底层协议其实都被操作系统、硬件设备或云服务提供商实现,已经解决了可靠性、路由、寻址等通用问题。
我们之前说,协议是一种 “约定”。就跟socket api 的接口一样, 在读写数据时, 都是按 “字符串” 的方式来发送接收的. 如果我们要传输一些 “结构化的数据” 怎么办呢?
其实,协议就是双方约定好的结构化的数据。
为什么要这么说呢?
就以一个服务器版的计算器为例:
我们需要客户端把要计算的两个加数发过去, 然后由服务器进行计算, 最后再把结果返回给客户端。这个时候我们就要进行约定方案了。因为你需要传递信息过去。
约定方案一:
客户端发送一个形如"1+1"的字符串;
这个字符串中有两个操作数, 都是整形;
两个数字之间会有一个字符是运算符, 运算符只能是 + ;
数字和运算符之间没有空格;
…
约定方案二:
定义结构体来表示我们需要交互的信息;
发送数据时将这个结构体按照一个规则转换成字符串, 接收到数据的时候再按照相同的规则把字符串转化回结构体;
第一种方案明显有很强的局限性,适合短期、简单需求,追求极简实现。
在我们现代编程中,很明显大多都是选择的第二种方案,适合长期维护、需扩展的系统,尤其是需要跨语言或高性能的场景。
而方案二的第二步,这个互相转化的步骤,就叫做序列化与反序列化!!
序列化与反序列化
序列化(Serialization) 和 反序列化(Deserialization) 是计算机中用于将 数据结构或对象 转换为可存储或传输的格式(如字节流、字符串),以及从该格式还原回原始数据的过程。
我们上面说协议就是双方约定好的结构化的数据。是因为我们要传输的这个数据,必须要用数据结构来管理起来,这是因为我们的操作系统内核和网络协议栈的实现,本质上就是通过数据结构 + 算法来管理数据,所以你自己本身也要使用数据结构管理起来。
就以我们这里的计算器为例,我们想传递的信息为:1 + 3,那么,我们就要把这个数据用一个类管理起来,比如:
class Data
{
private:
int x;
int y;
char ch;
};
我们就需要用这样一个类型来管理起来。
再以这张图为例:
![[Pasted image 20250813170640.png]]
我们所谓的序列化,就是在传递信息之前,将我们的所有信息按照一定规则排列为一行完整的字符串信息进行传输。所谓反序列化,就是将这个字符串进行相同规则的拆分,转化为原本的字符信息。
但是,无论我们采用方案一, 还是方案二, 还是其他的方案, 只要保证, 一端发送时构造的数据,在另一端能够正确的进行解析, 就是 ok 的. 这种约定, 就是 应用层协议。
为了让我们深刻理解协议,我们打算自定义实现一下协议的过程。这里我们需要采用方案2.
序列化有两种方法,第一种就是我们自己定义规则去序列化,但是这个太麻烦,我们这里选择第二个方法,使用现成的一些方法,比如xml,json,protobuf。
我们这里选择json。
为什么我们需要序列化呢?
这就要涉及的到我们的read、write、recv、send 和 TCP 为什么支持全双工这个特性了。
全双工的支持原理
为什么我们的read、write、recv、send 和 TCP都支持全双工?
![[Pasted image 20250813191304.png]]
大家请看上面这一张图。
以TCP为例,服务端与客户端建立后,两端(客户端和服务器)各自维护独立的发送和接收缓冲区,可以同时发送和接收数据。所以为什么TCP支持全双工?因为他有两对缓冲区。
所以我们的send这些调用,本质上也还是拷贝。这些缓冲区的刷新原理是一样的,什么时候刷新缓冲区,由我们的操作系统进行决定。
所以OS的内部是不是可能存在大量的报文?
那么这些报文是不是就需要被管理起来?如何管理呢?
先描述,在组织!
在 Linux 内核中,网络报文(Packet)并不是以原始字节流直接传递,而是通过一个高度优化的数据结构 struct sk_buff
(Socket Buffer,简称 SKB)来管理。这个结构体是内核网络栈的核心,负责存储、操作和传递网络数据。
以下是 Linux 内核中 sk_buff
的部分定义(不同内核版本可能有差异):
struct sk_buff {
// 双向链表(用于管理 SKB 队列)
struct sk_buff *next;
struct sk_buff *prev;
// 网络设备(网卡)和协议栈状态
struct net_device *dev; // 发送/接收数据的网卡
int protocol; // 协议类型(ETH_P_IP, ETH_P_ARP 等)
// 数据缓冲区指针
unsigned char *head; // 缓冲区的起始地址
unsigned char *data; // 当前协议层数据的起始地址
unsigned char *tail; // 当前协议层数据的结束地址
unsigned char *end; // 缓冲区的结束地址
// 长度信息
unsigned int len; // 当前协议层的数据长度
unsigned int data_len; // 分片数据的长度
// 协议头指针(支持快速访问)
union {
struct tcphdr *th; // TCP 头
struct udphdr *uh; // UDP 头
struct icmphdr *icmph; // ICMP 头
struct iphdr *iph; // IP 头
struct ethhdr *eth; // Ethernet 头
} h;
// 控制信息(如校验和、时间戳)
__u32 csum; // 校验和
__u32 tstamp; // 时间戳
// 引用计数(避免重复拷贝)
atomic_t users; // 引用计数
};
通过对prev,next结构体指针,我们把对报文的增删查改管理,转化成为了对链表的增删查改管理。
至此,操作系统就管理上了缓冲区,而我们的TCP要进行流量控制,如果对方接受缓冲区不足,那么可能只发部分报文。
所以read可能只读取原始报文的一部分,剩下的部分无法发给对方的缓冲区,这就可能造成数据的缺失等问题,所以我们会对我们的信息串进行处理。(后面会讲)
TCP 不保证应用层消息边界,只保证字节的有序性和可靠性,这个特性就是面向字节流。所以,我们需要程序员写应用层代码,来保证报文的完整性。
实际数据什么时候发,发多少,出错了怎么办,由 TCP 控制,所以 TCP 叫做传输控制协议。
一个网络计算器的实现
Request与Response结构体
我们接下来就以网络计算器为例,实现数据的序列化与反序列化过程,带领大家梳理一遍。
依旧是以我们之前的TCP的远程命令执行器的代码为前提。我今天将其改造为我们的网络计算器。
首先我们需要创建一个新的头文件:Protocol.hpp,这个头文件如同他的名字一样,是用来实现协议的。我们最主要的就是先创建两个类Request 和 Response,为什么要创建这个两个类呢?
大家之后就能理解,这个行为主要体现了 结构化网络通信协议 的关键设计思想,目的是在客户端和服务端之间标准化数据交换格式,确保通信的 可靠性、可扩展性和跨平台兼容性。
Request结构体的作用主要是:
- 表示客户端发送给服务端的计算请求,包含:
_x
,_y
:两个整数操作数-
_oper
:运算符(如+
,-
,*
,/
)
- 实现与 JSON 字符串的互相转换
而Response结构体主要为我们实现:
- 表示服务端返回给客户端的计算结果,包含:
_result
:计算结果(如3 + 5 = 8
)_code
:错误码(如0
表示成功,1
表示除零错误)
- 同样支持 JSON 序列化/反序列化
#pragma once
class Request//用来表示表示客户端发送给服务端的计算请求
{
public:
Request(int x,int y ,char oper):
_x(x),
_y(y),
_oper(oper)
{}
private:
int _x;
int _y;//规定x与y都是int类型的整数
char _oper;//表示运算符号
};
class Response//表示服务端返回给客户端的计算结果
{
public:
Response(int result,int code):
_result(result),
_code(code)
{}
private:
int _result;//计算的结果返回
int _code;//约定好的返回码,0为计算成功,1表示一些计算错误
};
接下来,我们要实现数据的序列化与反序列化功能,注意,我们这里使用的是json标准的字符风格,所以就使用json提供的接口来完成序列化与反序列化。
jsoncpp的使用
要使用我们的json接口,就需要使用到jsoncpp库。
Jsoncpp 是一个用于处理 JSON 数据的 C++ 库。它提供了将 JSON 数据序列化为字符串以及从字符串反序列化为 C++ 数据结构的功能。Jsoncpp 是开源的,广泛用于各种需要处理 JSON 数据的 C++ 项目中。
特性
简单易用:Jsoncpp 提供了直观的 API,使得处理 JSON 数据变得简单。
高性能:Jsoncpp 的性能经过优化,能够高效地处理大量 JSON 数据。
全面支持:支持 JSON 标准中的所有数据类型,包括对象、数组、字符串、数字、布尔值和 null。
错误处理:在解析 JSON 数据时,Jsoncpp 提供了详细的错误信息和位置,方便开发者调试。
当使用 Jsoncpp 库进行 JSON 的序列化和反序列化时,存在不同的做法和工具类可供选择。
以我们的ubuntu系统为例,我们需要先用指令安装此库:
sudo apt-get install libjsoncpp-dev
之后,就可以在代码中使用了,由于不是系统的库,所以我们在编译时需要带上库的名字。
而使用json进行序列化的方式主要有以下三种:
使用 Json::Value 的 toStyledString 方法:
将 Json::Value 对象直接转换为格式化的 JSON 字符串。
#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;
}
$ ./test.exe
{
"name" : "joe",
"sex" : "男"
}
注意,这些使用方法是死板的,所以大家只需要记忆就行了。
使用 Json::StreamWriter:
这个提供了更多的定制选项,如缩进、换行符等。
#include <iostream>
#include <string>
#include <sstream>
#include <memory>
#include <jsoncpp/json/json.h>
int main()
{
Json::Value root;
root["name"] = "joe";
root["sex"] = "男";
Json::StreamWriterBuilder wbuilder; // StreamWriter 的工厂
std::unique_ptr<Json::StreamWriter>
writer(wbuilder.newStreamWriter());
std::stringstream ss;
writer->write(root, &ss);
std::cout << ss.str() << std::endl;
return 0;
}
$ ./test.exe
{
"name" : "joe",
"sex" : "男"
}
使用 Json::FastWriter:
比 StyledWriter 更快,因为它不添加额外的空格和换行符:
#include <iostream>
#include <string>
#include <sstream>
#include <memory>
#include <jsoncpp/json/json.h>
int main()
{
Json::Value root;
root["name"] = "joe";
root["sex"] = "男";
Json::FastWriter writer;
std::string s = writer.write(root);
std::cout << s << std::endl;
return 0;
}
$ ./test.exe
{
"name" : "joe",
"sex" : "男"
}
而反序列化主要有两种实现方式:
使用 Json::Reader:
提供详细的错误信息和位置,方便调试
#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.getFormattedErrorMessages() << 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;
}
$ ./test.exe
Name: 张三
Age: 30
City: 北京
使用 Json::CharReader 的派生类
在某些情况下,你可能需要更精细地控制解析过程,可以直接使用Json::CharReader 的派生类。
但是在绝大多数情况下,使用 Json::parseFromStream 或 Json::Reader 的 parse方法就足够了。
所以我们这里不再赘述这个方法。
序列化与反序列化的实现
知道了上面的json使用方法后,我们这里就照着尝试写一下我们的类的序列化与反序列化函数。这里的序列化方式我们就选择最麻烦的第二种方式,但一开始的步骤都是先创建一个Json::Value类。
#pragma once
#include <jsoncpp/json/json.h>
#include <memory>
#include <sstream>
#include <iostream>
class Request // 用来表示表示客户端发送给服务端的计算请求
{
public:
Request(int x, int y, char oper):
_x(x),
_y(y),
_oper(oper)
{}
// 序列化函数:将当前对象转换为 JSON 格式的字符串
// 参数:
// - out_string: 输出参数,用于存储生成的 JSON 字符串
// 返回值:
// - bool: 理论上可以返回是否序列化成功,但当前实现总是返回 true
bool Serialize(std::string &out_string)
{
// 1. 创建 JSON 根节点对象
Json::Value root;
// 2. 将成员变量填充到 JSON 对象中
// 注意:这里使用与成员变量相同的名字作为 JSON 字段名
root["_x"] = _x; // 序列化第一个操作数
root["_y"] = _y; // 序列化第二个操作数
root["_oper"] = _oper; // 序列化操作符
// 3. 创建 JSON 写入器
Json::StreamWriterBuilder wbuilder;// 写入器构建器
std::unique_ptr<Json::StreamWriter>writer(wbuilder.newStreamWriter()); // 创建写入器
// 4. 将 JSON 数据写入字符串流
std::stringstream ss; // 创建字符串流
writer->write(root, &ss); // 将 JSON 数据写入流
// 5. 获取最终的 JSON 字符串
out_string = ss.str(); // 将字符串流内容赋给输出参数
// 6. 返回成功
return true;
}
// 反序列化函数:将 JSON 格式的字符串解析并填充到当前对象
// 参数:
// - in_string: 输入参数,包含要解析的 JSON 字符串
// 返回值:
// - bool: 返回反序列化是否成功
bool Deserialize(std::string &in_string)
{
// 1. 创建 JSON 根节点对象和解析器
Json::Value root; // 用于存储解析后的 JSON 数据
Json::Reader reader; // JSON 解析器
// 2. 尝试解析输入的 JSON 字符串
bool parsingSuccessful = reader.parse(in_string, root);
// 3. 检查解析是否成功
if (!parsingSuccessful)
{
// 输出解析错误信息(用于调试)
std::cout << "Failed to parse JSON: "
<< reader.getFormattedErrorMessages() << std::endl;
return false; // 返回失败
}
// 4. 从 JSON 对象中提取数据并赋值给成员变量
// 注意:这里假设 JSON 字段名与代码中的不同(没有下划线前缀)
_x = root["x"].asInt(); // 提取第一个操作数并转为 int
_y = root["y"].asInt(); // 提取第二个操作数并转为 int
_oper = root["oper"].asInt(); // 提取操作符并转为 int(实际应为 char,但char可以当做ASCII码存储)
// 5. 返回成功
return true;
}
private:
int _x;
int _y; // 规定x与y都是int类型的整数
char _oper; // 表示运算符号
};
class Response // 表示服务端返回给客户端的计算结果
{
public:
Response(int result, int code) : _result(result),
_code(code)
{
}
// 序列化函数:将当前对象转换为 JSON 格式的字符串
// 参数:
// - out_string: 输出参数,用于存储生成的 JSON 字符串
// 返回值:
// - bool: 理论上可以返回是否序列化成功,但当前实现总是返回 true
bool Serialize(std::string &out_string)
{
// 1. 创建 JSON 根节点对象
Json::Value root;
// 2. 将成员变量填充到 JSON 对象中
// 注意:这里使用与成员变量相同的名字作为 JSON 字段名
root["_result"] = _result; // 序列化我们的返回值
root["_code"] = _code; // 序列化错误码
// 3. 创建 JSON 写入器
Json::StreamWriterBuilder wbuilder; // 写入器构建器
std::unique_ptr<Json::StreamWriter> writer(wbuilder.newStreamWriter()); // 创建写入器
// 4. 将 JSON 数据写入字符串流
std::stringstream ss; // 创建字符串流
writer->write(root, &ss); // 将 JSON 数据写入流
// 5. 获取最终的 JSON 字符串
out_string = ss.str(); // 将字符串流内容赋给输出参数
// 6. 返回成功
return true;
}
// 反序列化函数:将 JSON 格式的字符串解析并填充到当前对象
// 参数:
// - in_string: 输入参数,包含要解析的 JSON 字符串
// 返回值:
// - bool: 返回反序列化是否成功
bool Deserialize(std::string &in_string)
{
// 1. 创建 JSON 根节点对象和解析器
Json::Value root; // 用于存储解析后的 JSON 数据
Json::Reader reader; // JSON 解析器
// 2. 尝试解析输入的 JSON 字符串
bool parsingSuccessful = reader.parse(in_string, root);
// 3. 检查解析是否成功
if (!parsingSuccessful)
{
// 输出解析错误信息(用于调试)
std::cout << "Failed to parse JSON: "
<< reader.getFormattedErrorMessages() << std::endl;
return false; // 返回失败
}
// 4. 从 JSON 对象中提取数据并赋值给成员变量
// 注意:这里假设 JSON 字段名与代码中的不同(没有下划线前缀)
_code = root["_code"].asInt();
_result = root["_result"].asInt();
// 5. 返回成功
return true;
}
private:
int _result; // 计算的结果返回
int _code; // 约定好的返回码,0为计算成功,1表示一些计算错误
};
我们这里使用的是之前介绍的序列化的第二种方法,通过一个stringstream来进行序列化。这些都是固定的写法,对底层感兴趣的同学可以去了解一下Value类这些的实现。
添加报头与解析报头
光有序列化与反序列化可不够,这个序列化的操作只是统一了我们传输的形式。但是并没有保障我们传输信息时的完整与安全性。
在对方接收缓冲区不足时,仍然可能接收部分内容,所以我们需要程序员自己手动在应用层实现这个保障。
那么我们怎么保障传输信息的完整性与安全性呢?
:添加报头!
我们用一个报头来表示后面跟着信息的长度,严格要求接收信息,你空间判断后不够,你就不要接收,你空间够,才能来接收消息。
那么我们就需要保证我们的代码能够分辨清楚报头与报文,所以我们需要用一个分割符号隔开,我们这里就设定我们的分割符为:\r\n。
这样,我们传进来的信息的完整格式就应该严格遵守:len\r\n{json}\r\n这个形式,其中,len是后面json串的长度。比如:123\r\n{json}\r\n,我们需要严格以这个为传输信息的单位,其余格式一律都是错误的,我们不予处理。
所以,我们就需要两个接口,一个负责添加报头,一个负责解析报头。
具体实现如下,大家可以看我注释:
const std::string sep = "\r\n";
// 123\r\n{json}\r\n
bool Encode(std::string &message)
{
if (message.size() == 0)
{
return false; // 字符串长度为0就不予处理
}
std::string tmp = std::to_string(message.size()) + sep + message + sep;
message = tmp;
return true;
}
//进行解析报头
//我们的解析报头的工作要麻烦一点
bool Decode(std::string &pakage,std::string *cotent)
{
//开局我们先找我们规定好的分割符,分隔符存在,那么报头就一定存在。
auto pos= pakage.find(sep);
if(pos==std::string::npos)
{
//说明没找到
return false;//不用继续处理
}
//提取报头,知晓内容长度
std::string code_len_str=pakage.substr(0,pos);
int code_len=std::stoi(code_len_str);//转化为整形
//计算完整消息长度
int code_len_total=code_len_str.size()+code_len+2*sep.size();//这里要加上两个分隔符的长度
if(code_len_total>pakage.size())
{
//说明传过来的数据不完整,长度不符合预期,不予处理
return false;
}
//处理提取消息字符串
*cotent=pakage.substr(pos+sep.size(),code_len);
//从消息字符串中处理掉已经接收的部分,把传输失败的数据不予处理
pakage.erase(0,code_len_total);//其实这里还有处理,但是等我们后续网络代码时再来研究
return true;
}
这样一来,我们的准备工作就完成了,后面就是我们计算部分与服务端和客户端的代码的修改。
客户端代码修改
HandlerRequest修改
我们需要对客户端的代码进行一定程度的修改,我这里拿的是之前TCP远程执行命令的服务端代码。
在这个代码中,我们通过main函数传递了一个回调函数给我们的服务器类,通过多线程来执行这些命令请求。
这里我们同样使用这个思维,只不过要修改一下服务端的HandlerRequest调用接口。
因为我们的回调函数类型依旧是:
using handler_t = std::functionstd::string(std::string);
所以这些类型都不用变化,我们现在HandlerRequest中新增一个string类型的packge变量,这个变量主要是用来形成我们的传输信息的,而之前的buffer是用来接受客户端发来的信息。
同时,我们在传递回调函数的参数时也应该传递packge而不再是buffer,在这个新的回调函数中,我们约定好,会先进行我们的反序列化,解析报头的操作,随后再进行计算,最后再把计算结果序列化,添加报头,返回我们的客户端。
void HandlerRequest(int sockfd)
{
LOG(LogLevel::INFO) << "HandlerRequest, sockfd is : " << sockfd;
char buffer[4096];
std::string packge;
while (true)
{
// int n = ::read(sockfd, buffer, sizeof(buffer) - 1);
int n = ::recv(sockfd, buffer, sizeof(buffer) - 1, 0);
if (n > 0)
{
buffer[n] = 0; // 手动置入一个结束标记
LOG(LogLevel::INFO) << "\n" << buffer;
packge += buffer;
std::string cmd_result = _handler(buffer);
::send(sockfd, cmd_result.c_str(), cmd_result.size(), 0);
}
else if (n == 0)
{
// n=0的情况我们说过,就是指的是服务端那里的write挂掉了,也就是说服务端退出了,那么我们此时约定,服务端也不再继续循环去读客户端的消息
LOG(LogLevel::INFO) << "client quit: " << sockfd;
break;
}
else
{
break; // 表示读取失败
}
}
// 循环结束,不再使用fd
::close(sockfd);
}
所以我们接下来最主要任务就是对我们的服务端的main进行修改,改变他原本传进来的之前的执行命令的函数。
我们之前是定义了一个命名执行的类,利用lambda传递去接口函数。这里我们也是通过同样的思想,但是之前写的调用与参数类都不太合适,因为网络传输是流式的,数据可能被拆分成多个包到达,单个TCP包可能包含不完整的消息或多个消息的片段。
所以,我们还需要新建一个Parse类,跟踪不完整的数据,负责进行维护了package缓冲区,可以累积数据直到形成完整消息。
Parse类的实现
所以我们需要一个Parse类,这个类不需要什么其他参数,他唯一的参数只会是我们之后实现的计算方法的调用传参。
他的成员方法也只会有一个,在这个方法里,我们要实现:
- 判断报文的完整性!
- 反序列化
- 计算
- 序列化
- 添加长度报头字段!
- 拼接应答,并返回给客户端
把这个类定义好后,我们就只需要进lambda回调传参,让服务端的代码获取消息后进行以上六个逻辑。在计算时进入我们的计算逻辑(我们还没实现),从而实现一个分层的效果。
我们现在就来实现一下:
具体代码如下,可以看我注释:
class Parse
{
public:
std::string Entry(std::string &packge)
{
// 1判断报文的完整性
std::string message;
std::string respstr;
while (Decode(packge, &message)) // 由于我们传过来的报文可能会有多个len\r\n{json}\r\n,所以需要一直用while循环来判断
{
LOG(LogLevel::DEBUG) << "Content: \n"
<< message;
// 此时我们的message一定是成功的,因为如果解析报头出错会直接退出while循环
if (message.empty()) // 但是我们要判断获取的message信息是否为空,为空就没必要做以下运算了
break;
// 2进行反序列化
Request ret;
if (!ret.Deserialize(message))
{
// 反序列化失败
LOG(LogLevel::INFO) << "Deserialize failed \n";
break;
}
// 3计算,此时的我们的ret在反序列化的时候已经初始化完成了,可以传递这个ret用来计算
// 虽然不知道我们的计算具体实现,但是可以肯定的是,我们要用response类获取计算结果信息
Response res = cal(ret);//cal就是我们的回调函数的名字
// 4序列化,此时我们的返回信息都在res中,我们需要对其进行序列化
std::string res_str;
res.Serialize(res_str); // res_str是一个输出型参数,此时我们就拿到了序列化后的字符串
// 5添加报头
Encode(res_str);
// 6 拼接应答
// 还记得我们在外部定义的respstr吗,这是是负责返回给服务端的字符串信息,所以我们把需要返回的信息加给他
respstr += res_str;
}
LOG(LogLevel::DEBUG) << "respstr: \n"
<< respstr;
return respstr;
}
private:
//这里需要一个计算的方法回调函数,但是我们还没实现
};
计算类的实现
接下来我们就来实现我们的计算器的执行方法。
首先我们要明确的是,我们的计算过程中传递进来的是一个Request对象,所以我们可以通过这个对象来进行计算。
#pragma once
#include "Protocol.hpp"
class Calculator // 创建这个类的目的只是为了构造对象方便回调函数
{
public:
Calculator()
{
}
Response Execute(const Request &rq)
{
Response resp;
switch (rq.Get_oper()) // 这里为了拿到类的成员变量的值,我们还需要增加接口返回值
{
case '+':
// 这里为了给resp的成员变量赋值,我们同样需要补充几个接口:
resp.SetResult(rq.Get_x() + rq.Get_y());
break;
case '-':
resp.SetResult(rq.Get_x() - rq.Get_y());
break;
case '*':
resp.SetResult(rq.Get_x() * rq.Get_y());
break;
case '/':
{
if (rq.Get_y() == 0)
{
resp.SetCode(1); // 表示除0错误
}
else
{
resp.SetResult(rq.Get_x() + rq.Get_y());
}
}
break;
case '%':
{
if (rq.Get_y() == 0)
{
resp.SetCode(2); // 2 就是mod 0
}
else
{
resp.SetResult(rq.Get_x() % rq.Get_y());
}
}
break;
default:
resp.SetCode(3); // 3 用户发来的计算类型,无法识别
break;
}
return resp;
}
~Calculator()
{
}
};
值得注意的是,我们这里为了获取Response与Request的内部成员变量,顺手完善了几个接口来获取:
#pragma once
#include <jsoncpp/json/json.h>
#include <memory>
#include <sstream>
#include <string>
#include <iostream>
const std::string sep = "\r\n";
// 123\r\n{json}\r\n
bool Encode(std::string &message)
{
if (message.size() == 0)
{
return false; // 字符串长度为0就不予处理
}
std::string tmp = std::to_string(message.size()) + sep + message + sep;
message = tmp;
return true;
}
// 进行解析报头
// 我们的解析报头的工作要麻烦一点
bool Decode(std::string &pakage, std::string *cotent)
{
// 开局我们先找我们规定好的分割符,分隔符存在,那么报头就一定存在。
auto pos = pakage.find(sep);
if (pos == std::string::npos)
{
// 说明没找到
return false; // 不用继续处理
}
// 提取报头,知晓内容长度
std::string code_len_str = pakage.substr(0, pos);
int code_len = std::stoi(code_len_str); // 转化为整形
// 计算完整消息长度
int code_len_total = code_len_str.size() + code_len + 2 * sep.size(); // 这里要加上两个分隔符的长度
if (code_len_total > pakage.size())
{
// 说明传过来的数据不完整,长度不符合预期,不予处理
return false;
}
// 处理提取消息字符串
*cotent = pakage.substr(pos + sep.size(), code_len);
// 从消息字符串中处理掉已经接收的部分,把传输失败的数据不予处理
pakage.erase(0, code_len_total); // 其实这里还有处理,但是等我们后续网络代码时再来研究
return true;
}
class Request // 用来表示表示客户端发送给服务端的计算请求
{
public:
Request()
{
}
Request(int x, int y, char oper) : _x(x),
_y(y),
_oper(oper)
{
}
// 序列化函数:将当前对象转换为 JSON 格式的字符串
// 参数:
// - out_string: 输出参数,用于存储生成的 JSON 字符串
// 返回值:
// - bool: 理论上可以返回是否序列化成功,但当前实现总是返回 true
bool Serialize(std::string &out_string)
{
// 1. 创建 JSON 根节点对象
Json::Value root;
// 2. 将成员变量填充到 JSON 对象中
// 注意:这里使用与成员变量相同的名字作为 JSON 字段名
root["_x"] = _x; // 序列化第一个操作数
root["_y"] = _y; // 序列化第二个操作数
root["_oper"] = _oper; // 序列化操作符
// 3. 创建 JSON 写入器
Json::StreamWriterBuilder wbuilder; // 写入器构建器
std::unique_ptr<Json::StreamWriter> writer(wbuilder.newStreamWriter()); // 创建写入器
// 4. 将 JSON 数据写入字符串流
std::stringstream ss; // 创建字符串流
writer->write(root, &ss); // 将 JSON 数据写入流
// 5. 获取最终的 JSON 字符串
out_string = ss.str(); // 将字符串流内容赋给输出参数
// 6. 返回成功
return true;
}
int Get_x() const
{
return _x;
}
int Get_y() const
{
return _y;
}
char Get_oper() const
{
return _oper;
}
// 反序列化函数:将 JSON 格式的字符串解析并填充到当前对象
// 参数:
// - in_string: 输入参数,包含要解析的 JSON 字符串
// 返回值:
// - bool: 返回反序列化是否成功
bool Deserialize(std::string &in_string)
{
// 1. 创建 JSON 根节点对象和解析器
Json::Value root; // 用于存储解析后的 JSON 数据
Json::Reader reader; // JSON 解析器
// 2. 尝试解析输入的 JSON 字符串
bool parsingSuccessful = reader.parse(in_string, root);
// 3. 检查解析是否成功
if (!parsingSuccessful)
{
// 输出解析错误信息(用于调试)
std::cout << "Failed to parse JSON: "
<< reader.getFormattedErrorMessages() << std::endl;
return false; // 返回失败
}
// 4. 从 JSON 对象中提取数据并赋值给成员变量
// 注意:这里假设 JSON 字段名与代码中的不同(没有下划线前缀)
_x = root["x"].asInt(); // 提取第一个操作数并转为 int
_y = root["y"].asInt(); // 提取第二个操作数并转为 int
_oper = root["oper"].asInt(); // 提取操作符并转为 int(实际应为 char,但char可以当做ASCII码存储)
// 5. 返回成功
return true;
}
private:
int _x;
int _y; // 规定x与y都是int类型的整数
char _oper; // 表示运算符号
};
class Response // 表示服务端返回给客户端的计算结果
{
public:
Response()
{
}
Response(int result, int code) : _result(result),
_code(code)
{
}
// 序列化函数:将当前对象转换为 JSON 格式的字符串
// 参数:
// - out_string: 输出参数,用于存储生成的 JSON 字符串
// 返回值:
// - bool: 理论上可以返回是否序列化成功,但当前实现总是返回 true
bool Serialize(std::string &out_string)
{
// 1. 创建 JSON 根节点对象
Json::Value root;
// 2. 将成员变量填充到 JSON 对象中
// 注意:这里使用与成员变量相同的名字作为 JSON 字段名
root["_result"] = _result; // 序列化我们的返回值
root["_code"] = _code; // 序列化错误码
// 3. 创建 JSON 写入器
Json::StreamWriterBuilder wbuilder; // 写入器构建器
std::unique_ptr<Json::StreamWriter> writer(wbuilder.newStreamWriter()); // 创建写入器
// 4. 将 JSON 数据写入字符串流
std::stringstream ss; // 创建字符串流
writer->write(root, &ss); // 将 JSON 数据写入流
// 5. 获取最终的 JSON 字符串
out_string = ss.str(); // 将字符串流内容赋给输出参数
// 6. 返回成功
return true;
}
// 反序列化函数:将 JSON 格式的字符串解析并填充到当前对象
// 参数:
// - in_string: 输入参数,包含要解析的 JSON 字符串
// 返回值:
// - bool: 返回反序列化是否成功
bool Deserialize(std::string &in_string)
{
// 1. 创建 JSON 根节点对象和解析器
Json::Value root; // 用于存储解析后的 JSON 数据
Json::Reader reader; // JSON 解析器
// 2. 尝试解析输入的 JSON 字符串
bool parsingSuccessful = reader.parse(in_string, root);
// 3. 检查解析是否成功
if (!parsingSuccessful)
{
// 输出解析错误信息(用于调试)
std::cout << "Failed to parse JSON: "
<< reader.getFormattedErrorMessages() << std::endl;
return false; // 返回失败
}
// 4. 从 JSON 对象中提取数据并赋值给成员变量
// 注意:这里假设 JSON 字段名与代码中的不同(没有下划线前缀)
_code = root["_code"].asInt();
_result = root["_result"].asInt();
// 5. 返回成功
return true;
}
int Result() const
{
return _result;
}
int Code() const
{
return _code;
}
void SetResult(int res)
{
_result = res;
}
void SetCode(int c)
{
_code = c;
}
private:
int _result; // 计算的结果返回
int _code; // 约定好的返回码,0为计算成功,1表示一些计算错误
};
服务端最后完善
当我们把计算的功能写好后,就需要完善我们的Parse类的Entry方法,以及增加他的回调函数成员。
我们的回调函数的类型不是之前的string(string),所以我们要重新声明一下:
using cal_fun = std::function<Response(const Request &req)>;
并按照老方法,进行Parse的初始化功能。
并且,我们需要在main函数中,创建一个Calculator类对象以及Parse对象,并使用lambda传递回调函数,总体代码如下:
#include "TcpServer.hpp"
#include "Protocol.hpp"
#include <memory>
#include "Calculator.hpp"
using cal_fun = std::function<Response(const Request &req)>;//定义类型
class Parse
{
public:
Parse(cal_fun gcal)
: cal(gcal)
{
}
std::string Entry(std::string &packge)
{
// 1判断报文的完整性
std::string message;
std::string respstr;
while (Decode(packge, &message)) // 由于我们传过来的报文可能会有多个len\r\n{json}\r\n,所以需要一直用while循环来判断
{
LOG(LogLevel::DEBUG) << "Content: \n"
<< message;
// 此时我们的message一定是成功的,因为如果解析报头出错会直接退出while循环
if (message.empty()) // 但是我们要判断获取的message信息是否为空,为空就没必要做以下运算了
break;
// 2进行反序列化
Request ret;
if (!ret.Deserialize(message))
{
// 反序列化失败
LOG(LogLevel::INFO) << "Deserialize failed \n";
break;
}
// 3计算,此时的我们的ret在反序列化的时候已经初始化完成了,可以传递这个ret用来计算
// 虽然不知道我们的计算具体实现,但是可以肯定的是,我们要用response类获取计算结果信息
Response res = cal(ret); // cal就是我们的回调函数的名字
// 4序列化,此时我们的返回信息都在res中,我们需要对其进行序列化
std::string res_str;
res.Serialize(res_str); // res_str是一个输出型参数,此时我们就拿到了序列化后的字符串
// 5添加报头
Encode(res_str);
// 6 拼接应答
// 还记得我们在外部定义的respstr吗,这是是负责返回给服务端的字符串信息,所以我们把需要返回的信息加给他
respstr += res_str;
}
LOG(LogLevel::DEBUG) << "respstr: \n"
<< respstr;
return respstr;
}
private:
// 这里需要一个计算的方法回调函数,但是我们还没实现
cal_fun cal;
};
int main()
{
// 1. 计算模块
Calculator mycal;
// 2. 解析对象
Parse myparse([&mycal](const Request &req)
{ return mycal.Execute(req); });
// 3. 通信模块
// 只负责进行IO
std::unique_ptr<TcpServer> tcp_ptr = std::make_unique<TcpServer>([&myparse](std::string package) { return myparse.Entry(package); });
tcp_ptr->InitServer();
tcp_ptr->Start();
return 0;
}
至此,我们服务端的实现就告一段落。
为了运行我们的代码,我们还要对我们的客户端进行更改,主要是完成客户端发送信息的序列化与添加报头的操作。
客户端的修改
首先就是我们的读取方式变化了,不像以前直接通过getline获取整行数据,而是一个一个的输入x,y,oper,并借此初始化一个Request对象。
所以我们的Start函数应该修改为:
void Start()
{
is_running = true;
std::string message;
while (is_running)
{
// 设置一个缓冲区方便我们写入,读取数据
char buffer[1024];
int x, y;
char oper;
std::cout << "input x: ";
std::cin >> x;
std::cout << "input y: ";
std::cin >> y;
std::cout << "input oper: ";
std::cin >> oper;
Request req(x, y, oper);
// 进行序列化与添加报头
// 1. 序列化
req.Serialize(message);
// 2. Encode
Encode(message);
// 3. 之后,我们就可以进行写入数据给对方了
// int n=::write(_sockfd,message.c_str(),message.size());
int n = ::send(_sockfd, message.c_str(), message.size(), 0);
if (n > 0)
{
// 写入成功就要开始读了
int m = ::read(_sockfd, buffer, sizeof(buffer));
// 如果读取返回的消息也成功了
if (m > 0)
{
buffer[m] = 0;
std::string package = buffer;
std::string content;
// 4. 读到应答完整
Decode(package, &content);
// 5. 反序列化
Response resp;
resp.Deserialize(content);
// 6. 得到结构化数据
std::cout << resp.Result() << "[" << resp.Code() << "]" << std::endl;
}
else
{
break;
}
}
else
{
break;
}
}
}
这样,我们的代码就初步改造完成了,运行代码:
![[Pasted image 20250814211405.png]]
可以看见,已经能够初步运行了。
但是我们这里还是有一点小bug,因为我们的result是int类型,而除法很可能结果是浮点数,所以我们这里遇见除法可能会出现截断的错误。
重看OSI 七层模型
我们现在已经写完了一个简单的网络版计算器了,大家是不是又有了更深层次的体会了呢?
现在请大家重新来看一下我们的OSI七层模型:
![[Pasted image 20250814212025.png]]
![[Pasted image 20250814212032.png]]
首先,下面的四层我们都不用考虑,因为这是被硬件决定好的。
我想请大家着重关注上面三层,并结合这个我们写好的代码结构:
![[Pasted image 20250814212200.png]]
你会发现,这个计算模块的所作所为,不就对应着我们的应用层吗?解析对象,不就是我们表示层的效果吗?通信模块,不就是我们的会话层,负责管理连接的建立与中断吗??
希望对大家有所帮助,谢谢!!