「前言」文章内容是关于协议的,大致内容是再次认识协议及简单协议的定制,目的是帮助理解协议,下面开始讲解!
「归属专栏」网络编程
「笔者」枫叶先生(fy)
「座右铭」前行路上修真我
「枫叶先生有点文青病」「句子分享」
我与我周旋久,宁作我。
——烽火戏诸侯《剑来》
目录
一、再谈协议
1.1 结构化数据
1.2 序列化和反序列化
二、网络版本的计算器
2.1 服务端
2.2 定制协议
2.3 客户端
2.4 全部代码
2.5 代码测试
三、序列化和反序列化
一、再谈协议
协议是一种 "约定",双方都需要遵守。
在计算机网络中,协议(protocol)用于规定数据传输、通信和交互的一系列规则和约定。网络协议定义了计算机之间进行通信的方式、数据格式、传输速率、错误检测和纠正等细节,从而确保网络中的设备能够相互理解和正确地进行数据交换。
1.1 结构化数据
socket的api接口, 在读写数据时, 都是按 "字符串" 的方式来发送接收的,如果我们要传输一些"结构化的数据" 怎么办呢?
什么是结构化的数据??
比如QQ聊天,就不能单纯发送消息过去,还要把头像url、时间昵称等打包形成一个报文,把这个报文的数据一起发送给对方,这个打包形成的报文就是一个结构化的数据
1.2 序列化和反序列化
序列化和反序列化:
- 序列化是将数据结构或对象转换为字节流的过程。在序列化过程中,对象的状态信息被转换为字节序列,可以将其存储在文件中或通过网络传输
- 反序列化是将字节流或其他存储形式转换回数据结构或对象的过程。在反序列化过程中,字节序列被重新转换为对象的状态信息,以便可以重新创建对象并使用其数据
序列化和反序列化的目的
- 数据持久化:通过序列化,可以将对象的状态保存到文件或数据库中,以便在程序重新启动或重新加载时可以从中恢复对象的状态
- 数据传输:通过序列化,可以将对象转换为字节流,以便在网络传输中进行传递
- 跨平台和跨语言交互:通过序列化,可以将对象转换为通用的字节流格式,使得不同平台和不同编程语言之间可以进行数据交换和共享。无论是Java、Python、C++还是其他编程语言,只要能够进行序列化和反序列化操作,就可以实现跨平台和跨语言的数据交互
发送报文到网络时候,报文首先需要进行序列化,然后再发送,报文通过协议栈发送给对方后,接收报文的一方也需要对报文进行反序列化,才能正常使用该报文
二、网络版本的计算器
下面实现一个网络版的计算器,主要目的是感受一下什么是协议,以及了解简单的业务协议定制,序列化和反序列化的过程,重点不在计算器上
2.1 服务端
代码直接采用socket套接字TCP多线程版的,前面已经讲解过了,就不再解释
初始化服务器initServer函数步骤大致如下:
- 调用socket函数,创建套接字。
- 调用bind函数,为服务端绑定一个端口号
- 调用listen函数,将套接字设置为监听状态
启动服务器start函数步骤大致如下:
- 调用accept函数,获取新链接
- 为客户端提供服务
除了为客户端提供服务需要我们重新写,其他代码都是之前的
tcpServer.hpp
注:代码太多,只贴出一小部分
static const int gbacklog = 5;
typedef std::function<void(const Request &req, Response &resp)> func_t;
// 错误类型枚举
enum
{
UAGE_ERR = 1,
SOCKET_ERR,
BIND_ERR,
LISTEN_ERR
};
// 业务处理 -- 解耦
void handlerEntery(int sockefd, func_t func)
{
}
class tcpServer; // 声明
class ThreadDate
{
public:
ThreadDate(int sockfd, func_t func)
: _sockfd(sockfd), _func(func)
{}
public:
int _sockfd;
func_t _func;
};
class calServer
{
public:
calServer(const uint16_t &port)
: _listensock(-1), _port(port)
{}
// 初始化服务器
void initServer()
{}
// 启动服务器
void start(func_t func)
{
for (;;)
{
// 5. 为sockfd提供服务,即为客户端提供服务
// 多线程版
pthread_t tid;
ThreadDate *td = new ThreadDate(sockfd, func);
pthread_create(&tid, nullptr, threadRoutine, td);
}
}
static void *threadRoutine(void *args)
{
pthread_detach(pthread_self()); // 线程分离
ThreadDate *td = static_cast<ThreadDate *>(args);
handlerEntery(td->_sockfd, td->_func); // 业务处理
close(td->_sockfd); // 必须关闭,由新线程关闭
delete td;
return nullptr;
}
~calServer()
{}
private:
int _listensock; // listen套接字,不是用来数据通信的,是用来监听链接到来
uint16_t _port; // 端口号
};
我们需要进进行重新写的函数只有业务处理 handlerEntery
// 业务处理 -- 解耦
void handlerEntery(int sockefd, func_t func)
{}
传输控制(TCP)
假设左边的主机是服务端,右边的主机是客户端
服务端向客户端发送数据或者反过来:
- 发送函数(write等)在自己的应用层有自己的应用层缓冲区,调用发送函数实际上是把数据拷贝到传输层的缓冲区中,数据是否发送到网络中,由TCP协议自主决定,所以TCP协议称为传输控制协议,关键字:传输控制
- 接收函数(read等)在自己的应用层也有自己的应用层缓冲区,调用接收函数实际上是把传输层缓冲区中数据拷贝到自己应用层缓冲区中
所以我们调用的发送、接收函数,本质都是拷贝函数
所以一方在发送,另一方也在发送,双方根本就不会影响,因为它们有成对的缓冲区,一个负责发送,一个负责接收,所以TCP是全双工的
所以TCP在读取数据的时候会出现问题(面向字节流)
比如,对方一下子发来多个报文,这些报文都堆积在TCP的接收缓冲区中,应用层进行读取报文的时候,如何判断自己读到的是一个完整的报文?又或者只读到半个报文如何处理?又或者读到一个半的报文又如何处理?又或者读到两个报文呢??
所以,要明确报文的大小和报文的边界
解决方法:
- 对报文进行定长
- 用特殊符号区分
- 自描述方式
2.2 定制协议
定制的协议,必须保证通信双方(客户端、服务端)能够遵守协议的约定。
我们可以设计一套简单的协议,数据可以分为请求数据和响应数据,因此我们分别需要对请求数据和响应数据进行做约定,可以用两个结构体进行封装数据的请求和响应
填写好的业务处理函数handlerEntery
该函数大致分五个步骤:
- 读取接收到一个完整的报文,对报文进行解包
- 对报文反序列化
- 真正进行业务处理,计算处理数据
- 对数据处理的结果进行序列化
- 对报文进行添加报头,最后再发送响应结果的报文
// 业务处理 -- 解耦
void handlerEntery(int sockfd, func_t func)
{
std::string inbuffer; // 读取的报文全部放在inbuffer里面
while (true)
{
// 1.读取 -- 收取到一个完整报文
std::string req_text; // 用于接收一个完整报文
if (!recvPackage(sockfd, inbuffer, &req_text)) // 接收一个完整报文
return;
std::cout << "带报头的报文:" << req_text << std::endl;
std::string req_str; // 获取解包之后的结果
deLength(req_text, &req_str); // 解包
std::cout << "解包后的报文:" << req_str << std::endl;
// 2. 请求request -- 反序列化
Request req;
if (!req.deserialize(req_str)) // 请求反序列化
return;
// 3. 业务逻辑 -- 处理数据
Response resp; // 拿取计算结果
func(req, resp); // 计算,回调函数
// 4.响应Response -- 序列化
std::string resp_str; // 拿取响应序列化结果
resp.serialize(&resp_str); // 序列化
std::cout << "计算完成,响应序列化结果:" << resp_str << std::endl;
// 5. 发送响应的结果
std::string send_str = enLength(resp_str); // 构建成为一个完整的报文
std::cout << "构建成为一个完整的报文:" << send_str << std::endl;
send(sockfd, send_str.c_str(), send_str.size(), 0); // 发送也有bug,暂时不用理会
}
}
确保可以读到一个完整的报文
// 接收一个报文
bool recvPackage(int sockfd, std::string &inbuffer, std::string *text)
{
char buffer[1024];
while (true)
{
ssize_t n = recv(sockfd, buffer, sizeof(buffer) - 1, 0);
if (n > 0)
{
buffer[n] = 0;
inbuffer += buffer;
auto pos = inbuffer.find(LINE__SEP);
if (pos == std::string::npos)
continue; // 报文不完整,继续读取
std::string text_len_str = inbuffer.substr(0, pos);
int text_len = std::stoi(text_len_str); // 报文有效载荷大小
// "content_len" + "\r\n" + "exitcode result" + "\r\n"
int total_len = text_len_str.size() + 2 * LINE__SEP_LEN + text_len; // 一个完整报文的长度
if (inbuffer.size() < total_len)
continue; // 报文不完整,继续读取
std::cout << "处理前的inbuffer: " << inbuffer << std::endl;
// 走到这里,至少有一个完整的报文
*text = inbuffer.substr(0, total_len); // 拿走报文
inbuffer.erase(0, total_len); // 删除已拿走的报文
std::cout << "处理后的inbuffer: " << inbuffer << std::endl;
return true;
}
else
{
return false;
}
}
}
定制的协议
- 请求结构体中成员变量需要包括两个操作数,以及对应操作符
- 响应结构体中需要包括一个计算结果,除此之外,响应结构体中还需要包括一个状态字段,表示本次计算的状态
注意:序列化和反序列化的过程不属于协议的内容
class Request
{
public:
Request() : x(0), y(0), op(0){};
Request(int x_, int y_, char op_) : x(x_), y(y_), op(op_)
{}
// 请求序列化 -- 暂时自己写
bool serialize(std::string *out)
{
}
// 请求反序列化 -- 暂时自己写
bool deserialize(std::string &in)
{
}
public:
int x;
int y;
char op;
};
class Response
{
public:
Response() : exitcode(0), result(0)
{}
Response(int exitcode_, int result_) : exitcode(exitcode_), result(result_)
{}
// 响应结果序列化 -- 暂时自己写
bool serialize(std::string *out)
{
}
// 响应结果反序列化 -- 暂时自己写
bool deserialize(std::string &in)
{
}
public:
int exitcode;
int result;
};
注意: 协议定制好后必须要被客户端和服务端同时看到,这样它们才能遵守这个约定
函数介绍
send函数,用于TCP发送数据
send函数的函数原型如下:
ssize_t send(int sockfd, const void *buf, size_t len, int flags);
参数说明:
- sockfd:特定的文件描述符,表示将数据写入该文件描述符对应的套接字。
- buf:需要发送的数据。
- len:需要发送数据的字节个数。
- flags:发送的方式,一般设置为0,表示阻塞式发送。
返回值说明:
- 写入成功返回实际写入的字节数,写入失败返回-1,同时错误码会被设置。
该函数与write函数功能一致
recv函数,用于TCP接受数据
ecv函数的函数原型如下:
ssize_t recv(int sockfd, void *buf, size_t len, int flags);
参数说明:
- sockfd:特定的文件描述符,表示从该文件描述符中读取数据。
- buf:数据的存储位置,表示将读取到的数据存储到该位置。
- len:数据的个数,表示从该文件描述符中读取数据的字节数。
- flags:读取的方式,一般设置为0,表示阻塞式读取。
返回值说明:
- 如果返回值大于0,则表示本次实际读取到的字节个数。
- 如果返回值等于0,则表示对端已经把连接关闭了。
- 如果返回值小于0,则表示读取时遇到了错误。
该函数的功能与read一致
2.3 客户端
客户端的代码跟前面也差不多,修改start函数的读写数据即可,遵守我们定制的协议
// 启动客户端
void start()
{
// 客户端需要发起链接,链接服务端
struct sockaddr_in server;
memset(&server, 0, sizeof(server));
server.sin_family = AF_INET;
server.sin_port = htons(_serverport); // 主机转网络序列
server.sin_addr.s_addr = inet_addr(_serverip.c_str()); // 1.std::string类型转int类型 2.把int类型转换成网络字节序 (这两个工作inet_addr已完成)
if (connect(_sockfd, (struct sockaddr *)&server, sizeof(server)) != 0)
{
std::cerr << "socket connect error" << std::endl;
}
else // 连接成功
{
std::string line;
std::string inbuffer;
while (true)
{
// 发送请求
std::cout << "Input Cal>> ";
std::getline(std::cin, line);
Request req = parseLine(line); // 对输入的字符串做解析
std::string content; // 获取序列化的结果
req.serialize(&content); // 对输入的内容进行序列化
std::string send_str = enLength(content); // 添加报头
send(_sockfd, send_str.c_str(), send_str.size(), 0); // 发送,bug,不理会
std::string package, text;
if (!recvPackage(_sockfd, inbuffer, &package)) continue; // 获取一个完整的报文
if (!deLength(package, &text)) continue; // 对报文进行解包
Response resp;
resp.deserialize(text); // 对报文反序列化
std::cout << "exitcode: " << resp.exitcode << ", result: " << resp.result << std::endl;
}
}
}
2.4 全部代码
代码全部在gitee
code_linux/code_202306_27/protocol · Maple_fylqh/code - 码云 - 开源中国 (gitee.com)
2.5 代码测试
编译没有问题
新建窗口,先运行服务端,再启动客户端,客户端先用本地环回进行测试
客户端,正常
输入需要按照协议要求,测试结果正常
上面的代码只为体会什么是协议,序列化和反序列化的过程
三、序列化和反序列化
对于序列化和反序列化,我们不会自己去写,上面写只是为了体会该过程,序列化和反序列化有相应的库支持,都是现成的方案,我们直接使用库即可,协议就可能需要我们自己写
常见的序列化和反序列化的库:
- json
- protobuf
- xml
其中,json 是简单易上手的,C++、Java,Python等都支持,protobuf 和 json 是C++常用的,xml 是Java常用的
安装json库
install -y jsoncpp-devel
注意:普通用户需要 sudo 提权
安装完成
一般安装在这个路径下
使用json需要包含头文件
#include <jsoncpp/json/json.h>
编译需要带该库的名称
文章不太好写,有点水...
--------------------- END ----------------------
「 作者 」 枫叶先生
「 更新 」 2023.6.28
「 声明 」 余之才疏学浅,故所撰文疏漏难免,
或有谬误或不准确之处,敬请读者批评指正。