序列化和反序列化
一 概念理解
先前已经可以利用sock套接字通信了,但是数据如何处理就是我们应用层协议的内容了,之前都是发送一些字符串,但是实际上我们发送的消息可能是个结构化的数据。
那我们能不能直接发结构体呢? 可以但是会浪费空间,你想想我们平时写的作文有固定格式和缩进,但是对于网络来说这些缩进是浪费空间,所以我们序列化是为了压缩发送的数据大小。
将结构化的数据转为一个大字符串,称为序列化,然后发给服务端,服务端解析字符串(这就是反序列化),然后服务端构建响应,又序列化发给客户端。
我们是凭什么对一个结构体序列化,对字符串反序列的,就是双方约定好了一个格式,这样才能解析发来的数据,我们在下面约定的格式就是一种协议,而且是应用层协议。
之前我们只是用了一下socket接口,根本没有对数据做序列化和反序列,也没有对数据做处理,因为无场景。接下来我们实现一个网络版本的计算器,从中我们会设计序列化和反序列化,本质是设计一个应用层协议。
二 编码实现
计算器客户端和服务端实现放在两个.cc文件,通信客户端和服务端实现放在了头文件中。
1 makefile
makefile:一同编译client.cc和server.cc。
.PHONY:all
all:client server
client:CalculatorClient.cc
g++ -o $@ $^ -std=c++11 -lpthread
server:CalculatorServer.cc
g++ -o $@ $^ -std=c++11 -lpthread
.PHONY:clean
clean:
rm -rf server client
2 封装系统调用
由于我们要进程调用和套接字相关的接口,所以就对这些接口做了封装。
class Sock
{
public:
int socket_;
Log log_;
};
不用cout,而是用日志打印,日志打印是我们之前封装的模块,下面直接展示代码,使用的时候我们直接用log_调用仿函数就可以了。
enum ErrorLevel
{
Info = 1,
Warning,
Fatal,
Debug
};
//接收输出的文件
enum PMethod
{
Screen = 1,//输出到屏幕
OneFile ,//输出到一个文件上
ClassFile//分类输出到多个文件中
};
class Log
{
public:
Log(int method = Screen)
:printmethod(method)
{
;
}
string leveltostring(int level)
{
switch (level)
{
case Info:
return "Info";
case Warning:
return "Warning";
case Fatal:
return "Fatal";
case Debug:
return "Debug";
default:
return "None";
}
}
//日志信息
void operator()(int level, const char *format, ...)
{
char leftbuffer[SIZE];
time_t t = time(NULL);
struct tm * ltime = localtime(&t);
//默认部分 事件等级和时间
snprintf(leftbuffer,sizeof(leftbuffer),"%s [%d %d %d %d:%d]",leveltostring(level).c_str(),
ltime->tm_year+1900,ltime->tm_mon+1,ltime->tm_mday,ltime->tm_hour,ltime->tm_min);
//可变部分
char rightbuffer[SIZE];
va_list s;
va_start(s,format);
vsnprintf(rightbuffer,sizeof(rightbuffer),format,s);
// printf("%s %s\n",leftbuffer,rightbuffer);
char Logbuffer[SIZE*2];
snprintf(Logbuffer,sizeof(Logbuffer),"%s %s",leftbuffer,rightbuffer);
LogPrint(level,Logbuffer);
}
void PrintOnefile( const char *filename,string& lbuffer)
{
lbuffer+='\n';
int fd = open(filename, O_CREAT|O_APPEND|O_WRONLY,0666);
if(fd < 0)
return;
write(fd,lbuffer.c_str(),lbuffer.size());
}
void PrintClassFile(int level,string& lbuffer)
{
string filename = Logname;//将不同错误信息分流到对应的文件
filename += ".";
filename += leveltostring(level);
PrintOnefile(filename.c_str(),lbuffer);
}
void LogPrint(int level,string lbuffer)
{
switch(printmethod)
{
case Screen://输出到屏幕
cout<<lbuffer<<endl;
break;
case OneFile: //输出到一个文件上
PrintOnefile(Logname,lbuffer);
break;
case ClassFile:
PrintClassFile(level,lbuffer);
break;
}
}
private:
int printmethod;
};
之前创建套接字通信的时候,我们定义了许多和错误信息相关的宏,现在直接拿来用。
接下来才到封装的实现。
创建套接字
绑定
监听
‘
接收链接
int Accept(string* ip,uint16_t* port) // 名字不能为accept
{
// 获取链接
struct sockaddr_in sock; // 头文件<netinet/in.h>
bzero(&sock, sizeof(sock));
socklen_t len = sizeof(sock);
int socket = accept(socket_, (sockaddr *)&sock, &len);
if (socket < 0)
{
log_(ErrorLevel::Info, "accept err");
exit(SOCKET_ERR);
}
else
{
*ip = inet_ntoa(sock.sin_addr);
*port = ntohs(sock.sin_port);
}
return socket;
}
connect,这个connect是客户端要用的,也是sock接口,就一同封装了。
Sock类中的套接字socket_含义由使用者来定义,可以是监听套接字,也可以是直接写的套接字。
三 服务端实现
我们是对服务端实现做了封装,封装在了该头文件中。
服务端main函数在该文件中。
我们在main函数中给服务类传端口号和可调用对象,服务端ip不用绑定,我这是云服务器,一绑定就会出错,然后初始化和启动服务端。
接下来看看服务器内部实现。成员如下,func_是接收可调用对象的。
1 初始化
初始化显然就是创建和监听套接字,显然我们此时的通信是基于tcp协议的。
2 初识start
暂时没链接时会链接失败,此时我们不能直接退出,要继续链接。
class Tcpserver;
class ThreadData
{
public:
ThreadData(std::string ip, uint16_t port,int socket,Tcpserver* ts)
:ip_(ip)
,port_(port)
,socket_(socket)
,ts_(ts)
{
;
}
std::string ip_;
uint16_t port_;
int socket_;
Tcpserver* ts_;
};
class Tcpserver
{
public:
using func_t = std::function<Response(const Request)>;
Tcpserver(func_t func, uint16_t port)
: port_(port), func_(func)
{
;
}
void start() // 接收链接,获取客户端端口号+ip,创建线程执行
{
while (true)
{
string ClientIp;
uint16_t clientport;
int socket_ = sock_.Accept(&ClientIp, &clientport);
if (socket_ < 0)
continue;
log_(ErrorLevel::Debug, "get a new client,client info:
[%s:%d]",ClientIp.c_str(),clientport);
创建线程,传递客户端端口号和ip
pthread_t id;
ThreadData *td = new ThreadData(ClientIp, clientport, socket_, this);
pthread_create(&id, nullptr, threadRoutine, td);
}
}
private:
func_t func_;
Log log_;
Sock sock_;
uint16_t port_;
};
我们给执行函数传了个ThreadData类对象。在threadRoutie掉用serverio函数,方便后续如果服务端收到请求要做其它处理,此时就将serverio函数换成其它函数即可。
static void* threadRoutine(void*arg)
{
ThreadData* td = static_cast<ThreadData*>(arg);
td->ts_->ServerIO(td->ip_,td->port_,td->socket_);
}
我们知道如果ServerIO函数要和客户端通信,肯定需要客户端ip,端口和套接字,所以我们先把这些参数合并传给该函数。写完后,但是客户端还没写好,如何测试,我们可以用可以指令向服务器发起一个链接。
void ServerIO(std::string ip, uint16_t port,int socket)
{
;
}
可以测试目前代码是否可以跑通,也就是套接字创建是否会问题。
在实现ServerIO函数前,我们先定协议,先记住我们要在这个函数读客户端消息,处理数据并返回,这个大致步骤方便我们实现完协议来理解调用逻辑。
3 设计协议
数据的流动前面提过:客户端发起request,序列化转为字符串发给服务端,服务端收到后反序列化,处理返回responce,将结果序列化再发给客户端,客户端收到后再反序列化转为Response对象,客户端直接读写对象成员就可以获得结果了。
所以我们要将request和Response序列化和反序列化。封装在下面这个头文件中。
Request序列化
协议就是规定:我们首先规定Request请求就是x + y,两个操作数,一个操作符,然后序列化的字符串必须是"x + y",有时候我们想让字符串变成"x + y",操作符之间间隔增加一个空格,这里要体会一下不用宏的话,如果要变更格式有多麻烦。
#define SEP " "
#define SEP_LEN strlen(SEP)
class Request
{
public:
Request()
{
;
}
Request(int x, int y, char op)
: x_(x), y_(y), op_(op)
{
;
}
// class -> string 序列化
bool serialize(std::string *res)
{
*res += std::to_string(x_);
*res += SEP;
*res += op_;
*res += SEP;
*res += std::to_string(y_);
return true;
}
~Request()
{
;
}
int x_;
int y_;
char op_;
};
反序列化
既然前面已经规定操作数之间,操作符和操作数之间是有间隔符的,那我们就用string的find接口查找间隔符SEP,把一个个操作数截取下来。
class util
{
public:
//"10 + 10"
static void StringSplit(const std::string res,const std::string sep,std::vector<std::string>*vs)
{
int pos = 0;
while(pos != -1)
{
int nextpos = res.find(sep.c_str(),pos);
if(nextpos == -1)
break;
vs->push_back(res.substr(pos,nextpos - pos));
pos = nextpos + sep.size();
}
截取最后一个操作数
vs->push_back(res.substr(pos,-1));
}
};
将"x + y"截取成"x","+","y"保存到vector中,此时我们可以更深刻的意识到协议就是规定。
string -> class "x + y" 反序列化
bool Deserialize(const std::string &res)
{
std::vector<std::string> vs;
util::StringSplit(res, SEP, &vs);
if (vs.size() != 3)
return false;
x_ = atoi(vs[0].c_str());
if (vs[1].size() != 1)
return false;
op_ = vs[1][0];
y_ = atoi(vs[2].c_str());
}
做判断,操作符大小必须是1,操作符和操作数的大小和为3,这,都是基于我们的规定的来的。
Response 序列化
class Response
{
public:
Response()
{
;
}
Response(int result, int exitcode)
: result_(result), exitcode_(exitcode)
{
;
}
// class -> string
bool serialize(std::string *msg)
{
*msg += std::to_string(result_);
*msg += SEP;
*msg += std::to_string(exitcode_);
return true;
}
~Response()
{
;
}
int result_;
int exitcode_;
};
我们在序列化Respnse的时候规定了,必须是"结果 + 退出码",这是反序列的时候截取字符串的基础。
反序列化,还可以复用Reuest实现的截取字符串,封装的妙处总是在不经意中体现。
string -> class
bool Deserialize(const std::string &res)
{
std::vector<std::string> vs;
if (vs.size() != 2)
return false;
util::StringSplit(res, SEP, &vs);
result_ = atoi(vs[0].c_str());
exitcode_ = atoi(vs[1].c_str());
return true;
}
可是设计了协议,怎么调用呢?调用顺序是什么呢? 我们前面说了,我们在threadRoutine内调用ServerIO函数来读写,所以接下来就在该函数内将上述实现用起来。
4 start完善
先读数据,既然是读数据,自然是从套接字中读取,有意思的是我们怎么保证一次读一个完整报文呢,也就是说我们怎么保证一次读出"x + y"这样的完整报文,我们调用read每次读固定大小,肯定会出现读取多个报文的情况,所以我们对协议做了修改,原先规定请求是"x + y",为了方便读取,我们添加了一个报头,5"\n"x + y",然后设计一个函数,保证能切割出一个完整报文给下一步执行。这个还和tcp面向字节流有些关系,导致tcp向上向下交付以字节为单位,而不是一个一个数据报交付,需要我们手动切割。
我们来看看函数内部实现。inbuffer保存了recv读到的所有数据。
这个是后面经常用的宏。
从socket读取数据,保存到inbuffer中,并解析出数据报放到package中
int Readpackage(int socket, std::string *inbuffer, std::string *package)
{
cout << "读取数据前:" << *inbuffer << endl;
Log log_;
char buffer[1024] = {0};
int n = recv(socket, buffer, sizeof(buffer), 0);
*inbuffer += buffer; 保存读到的数据
cout << "读取数据中:" << endl
<< *inbuffer;
if (n < 0) // 出错后返回
{
return -1;
}
截取一个数据报 "5\n"x + y"\n"
int pos = inbuffer->find(HEAD, 0);
if (pos == -1)
{
return -1;
}
// 截取的是记录着有效载荷长的字符串
std::string Size = inbuffer->substr(0, pos);
int lensize = atoi(Size.c_str()); 有效载荷长度
完整报文长度
int packagelen = lensize + 2 * HEAD_LEN + Size.size();
if ((*inbuffer).size() < packagelen) 读取的数据不够一个数据报
return 0;
截取有效载荷
*package = inbuffer->substr(pos + HEAD_LEN, lensize);
inbuffer->erase(0, packagelen);
cout << "读取数据后:" << endl
<< *inbuffer;
return lensize;
}
此时我们回到外部函数逻辑中。
void ServerIO(std::string ip, uint16_t port, int socket)
{
std::string inbuffer;
while (true)
{
1 读取数据
std::string package;
int n = Readpackage(socket,&inbuffer,&package);
if (n < 0)
{
close(socket);
exit(READ_ERR);
}
else if (n == 0)
continue;
到了这里就已经读取到了完整的数据报,先去除报头
其实已经不用去除了,因为前面我们读取的就是一个有效载荷
package = RemoveHead(package,n);
2 将字符串反序列化
Request rq;
rq.Deserialize(package);
3 处理一个请求,并返回结果
Response rp = func_(rq);
4 将结果序列化
string send_string;
rp.serialize(&send_string);
添加报头发送,响应也要有报头
send_string = AddHead(send_string);
//发送到网络中
write(socket,send_string.c_str(),send_string.size());
}
}
添加报头。
移除报头。
在第3步的时候我们用func回调了一个函数,这个函数是一开始外部传入的请求处理函数。
func_函数实现。
Response calculate(const Request &rq)
{
Response rp(0,0);
switch (rq.op_)
{
case '+':
rp.result_ = rq.x_ + rq.y_;
break;
case '-':
rp.result_ = rq.x_ - rq.y_;
break;
case '*':
rp.result_ = rq.x_ * rq.y_;
break;
case '/':
if (rq.y_ == 0)
{
rp.exitcode_ = 1;
break;
}
rp.result_ = rq.x_ / rq.y_;
break;
case '%':
if (rq.y_ == 0)
{
rp.exitcode_ = 2;
break;
}
rp.result_ = rq.x_ + rq.y_;
break;
default:
rp.exitcode_ = 3;
break;
}
return rp;
}
四 客户端编写
int main(int argc, char *argv[])
{
if (argc != 3)
{
exit(USAGE_ERR);
}
std::string ip = argv[1];
uint16_t port = atoi(argv[2]);
Log logs;
Sock socks;
socks.Socket();
socks.Connect(ip, port);
logs(ErrorLevel::Debug, "init success: sockt:%d", socks.socket_);
std::string inbuffer;
while (true)
{
std::cout << "data1# ";
Request rq;
std::cin >> rq.x_;
std::cout << "data2# ";
std::cin >> rq.y_;
std::cout << "data3# ";
std::cin >> rq.op_;
// 序列化
std::string ret;
rq.serialize(&ret);
// 添加报头
ret = AddHead(ret);
// 开始发送
send(socks.socket_, ret.c_str(), ret.size(), 0);
// 开始读取
std::string package;
START:
int n = Readpackage(socks.socket_, &inbuffer, &package);
if (n < 0)
{
close(socks.socket_);
exit(READ_ERR);
}
else if (n == 0)
goto START;
// 到了这里就已经读取到了完整的数据报,先去除报头
package = RemoveHead(package, n);
//将字符串反序列化
Response rp;
rp.Deserialize(package);
cout<<"result: "<<rp.result_<<endl;
cout<<"exitcode: "<<rp.exitcode_<<endl;
}
return 0;
}
首先获取到服务端ip和端口。
创建套接字,并且链接服务器。
开始获取构建一个请求,这个请求是关于x和y的计算,所以我们首先输入x和y的值,然后把操作数也输入进来,因为这个请求可能是x+y,或者x-y。
输入完后,要开始准备发送了。当然要先序列化了,把请求转成字符串,但是我们还要添加报头,这个报头是服务端读取一个完整报文的关键。
此时我们才可以发送,发送完我们还可以接收服务端的响应。
这个接收服务端的响应的实现就和服务端那边的实现差不多,都是调用Readpackage读取一个完整报文,然后就是去除报头,再反序列化,我们最终可以打印显示响应了,这个响应包括计算结果和错误码。
由于调用链比较长,我们增加了一些日志,一步步看看我们的序列化和反序列是否符合逻辑。
上一篇: 返回在 js 中的作用是什么?-return;将控件返回页面。
下一篇: springboot2 集成 swagger2 出现 guava 的 FluentIterable 方法不存在的情况
推荐阅读
-
使用 .NET7 和 C#11 构建最快的序列化器--以 MemoryPack 为例
-
谷歌序列化库 FlatBuffers 1.1 发布以及与 protobuf 的比较
-
数字图像处理实验(五)|图像修复 {反滤波和伪反滤波、维纳滤波 deconvwnr、大气湍流扰动建模、运动模糊处理 fspecial}(附 Matlab 实验代码和截图)
-
Java 序列化对象输入流(反序列化流)
-
解决 redis 序列化问题 java8 LocalDateTime
-
SYSLIB0011:二进制格式化序列化已过时
-
一步一步实现 SpringBoot 序列化和消息转换器(您需要什么)
-
什么是数据库事物?为什么需要数据库事物,事物有哪些特征?事物的隔离级别是什么?-1.什么是数据库事务? 1.事务是作为一个逻辑单元执行的一系列操作。一个逻辑工作单元必须具备四个属性,即ACID(原子性、一致性、隔离性和持久性)属性,只有这样才能成为事务: 原子性 2.事务必须是一个原子工作单元;它的数据修改要么全部执行,要么全部不执行。 一致性 3.事务完成时,所有数据必须保持一致。在相关数据库中,所有规则都必须适用于事务的修改,以保持所有数据的完整性。事务结束时,所有内部数据结构(如 B 树索引或双向链接表)必须正确无误。 隔离 4.并发事务的修改必须与其他并发事务的修改隔离。一个事务会在另一个并发事务修改之前或之后查看某一状态下的数据,而不会查看中间状态下的数据。这就是所谓的可序列化,因为它允许重新加载起始数据和重放一系列事务,从而使数据最终处于与原始事务执行时相同的状态。 持久性 5.事务完成后,它对系统的影响是永久性的。即使在系统发生故障的情况下,修改也会保留。 2. 为什么需要数据库事物,事物有哪些特征? 事物对数据库的作用是对数据进行一系列操作,要么全部成功,要么全部失败,防止出现中间状态,确保数据库中的数据始终处于正确、和谐的状态。 特征:原子性、一致性、隔离性、持久性,以及其他特征 原子性(Atomicity):所有操作在事务开始后,要么全部做完,要么全部不做,不可能停滞在中间环节。事务执行过程中出现错误时,会回滚到事务开始前的状态,所有操作就像没有发生一样。也就是说,事务是一个不可分割的整体,就像化学中的原子一样,是物质的基本单位。 一致性(Consistency):在事务开始之前和结束之后,数据库的完整性约束都没有被破坏。例如,如果 A 转钱给 B,A 不可能扣除这笔钱,但 B 却没有收到这笔钱。 隔离:在同一时间内,只允许一个事务请求相同的数据,不同事务之间没有干扰。例如,甲正在从一张银行卡上取款,在甲取款过程结束之前,乙不能向这张卡转账。 持久性(耐用性):事务完成后,事务对数据库的所有更新都将保存到数据库中,无法回滚 3.事务的隔离级别有哪些? 数据库事务有四种隔离级别,从低到高分别是未提交读取(Read uncommitted)、已提交读取(Read committed)、可重复读取(Repeatable read)、可序列化(Serializable)。此外,事务的并发操作中可能会出现脏读、不可重复读、幽灵读等情况。事务并发问题 脏读:事务 A 读取事务 B 更新的数据,然后事务 B 回滚操作,那么事务 A 读取的数据就是脏数据。 不可重复读取:事务 A 多次读取同一数据,事务 B 在事务 A 多次读取期间更新并提交数据,导致事务 A 多次读取同一数据时结果不一致。 幻影读取:系统管理员 A 将数据库中所有学生的具体分数改为 ABCDE 等级,但系统管理员 B 在此时插入了具体分数的记录,当系统管理员 A 更改结束后发现仍有一条记录未被更改,仿佛发生了幻觉,这称为幻影读取。 小结:不可重复读和幻读容易混淆,不可重复读侧重于修改,幻读侧重于增删。解决不可重复读问题只需锁定满足条件的行,解决幻读问题则需要锁定表 MySQL 事务隔离级别
-
利用反序列化漏洞
-
有办法用 YAML 对 Java 枚举进行(去)序列化吗?