前言
思考一下,在TCP中,数据在客户端和服务端之间是怎样的传输关系呢?
我们都知道 TCP是双加工 的,所以在内核中存在着 发送缓冲区 和 接收缓冲区 ,write和read都仅是用户到内核的接口函数,也就是说,作为一个用户你只需要在应用层,把你的数据通过write写到发送缓冲区里就返回了,但是这个数据不一定就发给对方了。
所以我们会发现这两个函数其实就是拷贝函数!真正网络收发的协议是TCP决定的。write将数据从用户层拷贝到内核层就返回,对于read调用,就是把数据从接收缓冲区拷贝到用户层缓冲区
但是我们读上来的数据,就是完全不确定的,因为是TCP全权掌控的 可能因为网络问题,TCP说我先给你发一部分吧,那我们接收的就只能接收一部分 所以就必须要保证在应用层把协议定好。定好才能更好的进行数据分析
TCP是面向字节流的,你怎么保证,你读取上来的数据,是一个"完整"的报文呢?
答案是:通过协议来定制,序列化和反序列化
我可以规定好,通信双方使用固定大小的报文。比如说固定是64字节 如果发送方不够了,你给我填些垃圾数据也要凑够。接收方不够了我就把这个报文维持下来,等凑够了64字节再做处理
一. 再谈协议
协议是一种 "约定". socket api的接口, 在读写数据时, 都是按 "字符串" 的方式来发送接收的. 如果我们要传输一些 "结构化的数据" 怎么办呢?
我们知道同一个结构体在不同的编译器下编译的大小不一定一样(结构体的内存对齐),其实Linux的协议就是传结构体做到的,但是他底层可以把所有方方面面的情况都考虑到了,而我们用户去定的时候很难考虑得这么周全。
如果用的是互传结构体对象行吗?不可能。你这里发了一个请求万一对方给我发了两个呢?比如说网络现在特别差,TCP先不发给接收方,可是这个用户不知道,他一直拷一直拷,连续拷了四五个TCP请求在发送缓冲区里,后来网络好了,TCP一股脑把所有报文都发给接收缓冲区,用户读了之后怎么区分一个一个的报文呢?
二. 网络计算机的实现
客户端和服务端规定好,当我把request给你,永远都是x op y结果永远都放在result里。这个结果对还是不对.都放在code里。code为0怎么怎么样 为1是为啥 为2又是为啥
如果不用结构体的话,我们如果用字符串呢?
举个例子,我们聊天的时候我发了一句哈哈,但是发送的时候还会带上昵称以及发送时间,所以我们肯定不能把这三个字符串分开发,因为这也服务端就不知道你这话是谁说的,所以我肯定希望把三个字符串打包成一个字符串发过去,然后服务的把消息广播给所有客户端的时候,会再把这个包根据一定的方法解析分成三个字符串然后再给你显现出来!
也就是说:我们需要在类里面定义两种方法,一种是把类内的数据打包成一个字符串发送过去,另一种是解析的时候将这个字符串再拆分出来。 而这个过程就是序列化和反序列化
三. 序列化和反系列化
序列化是将数据结构或对象转化为可存储或传输的格式,而反序列化则是将这种存储或传输的格式重新转化为原始数据结构或对象。
1. 序列化
定义:序列化是将数据结构或对象的状态转化为一串字节,以便存储到文件、数据库、内存中,或者通过网络传输。这通常用于数据的持久化、网络传输等。
例子:
- 想象你有一本书,书里面有很多内容(章节、图片、表格等)。如果你想把这本书通过邮件发送给朋友,而邮件系统不支持直接发送书本,你就需要“序列化”这本书。例如,你把书内容拍成图片或者扫描成PDF格式,这样就能通过邮件发送了。
- 序列化就像是把信息转化成一种可以方便传输或存储的格式。
2. 反序列化
定义:反序列化是将序列化后的数据从字节流中恢复回原本的数据结构或对象。这是获取数据并重新使其可用的过程。
例子:
- 你的朋友收到了邮件后,他可以通过解压文件或打开PDF格式的书籍,将内容恢复为原始的书本内容。
- 反序列化就是把已经转化成其他格式的数据,还原回原始的形式。
四. 实现网络计算器
约定方案一:
客户端发送一个形如"1+1"的字符串;
这个字符串中有两个操作数, 都是整形;
两个数字之间会有一个字符是运算符, 运算符只能是 + ;
数字和运算符之间没有空格;
...
约定方案二:
定义结构体来表示我们需要交互的信息;
发送数据时将这个结构体按照一个规则转换成字符串, 接收到数据的时候再按照相同的规则把字符串转 化回结构体; 这个过程叫做 "序列化" 和”反序列化“
Socket.hpp
- Sock 类封装了套接字的创建、连接、监听、接收和关闭等常见操作。它的存在主要是为了简化套接字操作,使得程序员可以更高效、方便地进行网络通信开发,同时也能提高代码的可读性和可维护性。
#pragma once
// 该头文件用于实现一个基本的套接字类Sock,供后续网络通信使用
#include <iostream>
#include <string>
#include <unistd.h>
#include <cstring>
#include <sys/types.h>
#include <sys/stat.h>
#include <sys/socket.h>
#include <arpa/inet.h>
#include <netinet/in.h>
#include <string.h>
#include "Log.hpp" // 引入自定义的日志库,用于记录日志信息
// 定义一些常用的错误代码,用于套接字操作的错误处理
enum {
SocketError = 1, // 创建套接字错误
BindError = 2, // 绑定套接字错误
ListenError = 3, // 监听套接字错误
AcceptError = 4 // 接受客户端连接错误
};
// 默认的监听队列长度
const int defaultbacklog = 10; // 默认监听队列长度
// Sock类,封装了基本的套接字操作
class Sock
{
public:
// 默认构造函数,初始化套接字类对象
Sock(){}
// 默认析构函数,关闭套接字
~Sock(){}
// 创建套接字
void Socket()
{
// 创建一个IPv4的流式套接字,返回套接字描述符
_sockfd = socket(AF_INET, SOCK_STREAM, 0);
if (_sockfd < 0) // 如果套接字创建失败,返回错误
{
// 使用日志记录错误信息,并退出程序
lg(Fatal, "socket error, %s:%d", strerror(errno), errno);
exit(SocketError); // 退出程序,错误代码为SocketError
}
}
// 绑定套接字到指定端口
void Bind(uint16_t port)
{
struct sockaddr_in addr; // 创建一个IPv4地址结构体
bzero(&addr, sizeof(struct sockaddr_in)); // 将结构体初始化为0
addr.sin_family = AF_INET; // 设置地址族为IPv4
addr.sin_port = htons(port); // 设置端口号(将端口转换为网络字节序)
addr.sin_addr.s_addr = INADDR_ANY; // 绑定到所有可用网络接口
// 尝试绑定套接字,如果绑定失败,记录错误并退出
if (bind(_sockfd, (struct sockaddr*)&addr, sizeof(addr)) < 0)
{
lg(Fatal, "bind error, %s:%d", strerror(errno), errno);
exit(BindError); // 退出程序,错误代码为BindError
}
}
// 开始监听客户端连接
void Listen(int backlog = defaultbacklog)
{
// 启动监听,设置监听队列长度为backlog
if (listen(_sockfd, backlog) < 0)
{
lg(Fatal, "listen error, %s:%d", strerror(errno), errno);
exit(ListenError); // 退出程序,错误代码为ListenError
}
}
// 接受客户端的连接
int Accept(std::string *clientip, uint16_t *clientport)
{
struct sockaddr_in client; // 客户端的地址结构
socklen_t len = sizeof(client); // 客户端地址结构的长度
// 接受连接,返回一个新的套接字用于与客户端通信
int connfd = accept(_sockfd, (struct sockaddr*)&client, &len);
if (connfd < 0)
{
lg(Error, "accept error, %s:%d", strerror(errno), errno); // 如果接受失败,记录错误信息并退出
exit(AcceptError); // 退出程序,错误代码为AcceptError
}
char ipstr[64]; // 用于存储客户端的IP地址
// 将客户端的IP地址从网络字节序转换为点分十进制表示
inet_ntop(AF_INET, &client.sin_addr, ipstr, sizeof(ipstr));
*clientip = ipstr; // 将客户端IP地址传出
*clientport = ntohs(client.sin_port); // 将客户端端口从网络字节序转换为主机字节序,并传出
// 打印日志,记录已接受的客户端IP和端口
lg(Info, "accept a client %s:%d", *clientip, *clientport);
return connfd; // 返回新的套接字描述符,用于与客户端进行通信
}
// 获取套接字描述符
int GetSockfd()
{
return _sockfd; // 返回套接字描述符
}
// 关闭套接字
void Close()
{
close(_sockfd); // 关闭套接字
}
// 连接到指定IP和端口的服务器
bool Connect(const std::string &ip, const uint16_t &port)
{
struct sockaddr_in addr; // 服务器的地址结构
bzero(&addr, sizeof(struct sockaddr_in)); // 将结构体初始化为0
addr.sin_family = AF_INET; // 设置地址族为IPv4
addr.sin_port = htons(port); // 设置服务器端口(转换为网络字节序)
// 将服务器的IP地址从字符串转换为网络字节序
inet_pton(AF_INET, ip.c_str(), &addr.sin_addr);
// 尝试连接到指定的服务器,如果连接失败,记录警告日志并返回false
if (connect(_sockfd, (struct sockaddr*)&addr, sizeof(addr)) < 0)
{
lg(Warning, "connect error, %s:%d", strerror(errno), errno);
return false; // 连接失败,返回false
}
return true; // 连接成功,返回true
}
private:
int _sockfd; // 套接字描述符,用于标识套接字
};
TcpServer.hpp
- TcpServer 类是一个封装 TCP 服务器的类,用于处理客户端的连接和数据交换。它使用了套接字编程,通过监听指定端口,接受客户端连接,创建子进程处理每个客户端的请求,并通过回调函数来处理客户端发送的数据。
#pragma once
#include <functional>
#include <signal.h>
#include "Socket.hpp"
using func_t = std::function<std::string(std::string &package)>; // 回调函数
class TcpServer
{
public:
TcpServer(uint16_t port, func_t callback) : _port(port), _callback(callback), _isrunning(false)
{
}
~TcpServer() {}
void InitServer()
{
_listensock.Socket();
_listensock.Bind(_port);
_listensock.Listen(10);
lg(Info, "Server is running on port %d", _port); // 看看服务器在哪个端口上运行
}
void Start() // 启动服务器
{
_isrunning = true;
signal(SIGCHLD, SIG_IGN); // 忽略子进程结束信号 因为子进程结束时会产生SIGCHLD信号
signal(SIGPIPE, SIG_IGN); // 忽略管道错误信号 因为当网络连接中某个套接字被关闭或者重启时,该套接字已经发送缓冲区中的数据都发送完毕了,但是它仍然可以接收数据 此时该套接字就会产生SIGPIPE信号
while (_isrunning)
{
std::string clientip;
uint16_t clientport;
int sockfd = _listensock.Accept(&clientip, &clientport); // 接受客户端连接并返回套接字描述符
if (sockfd < 0)
continue; // 连接失败就继续尝试重连
pid_t pid = fork(); // 创建子进程 帮助我们处理每个客户端的请求
if (pid < 0) // 出错
{
lg(Error, "Fork error");
continue;
}
if (pid == 0) // 子进程
{
_listensock.Close(); // 关闭监听套接字 防止accept阻塞
std::string inbuffer_stream; // 用来获取客户端发来的所有数据
while (true)
{
char buffer[1024];
ssize_t n = read(sockfd, buffer, sizeof(buffer)); // 读取客户端数据
if (n == 0) // 客户端断开了
{
lg(Info, "Client %s:%d disconnected", clientip.c_str(), clientport);
break;
}
if (n < 0) // 读取出错
{
lg(Error, "Read error");
break;
}
// 拼接所有数据
buffer[n]=0;
inbuffer_stream+=buffer;
lg(Debug, "debug:\n%s", inbuffer_stream.c_str());// 调试看看整个流的信息
//有可能一个流里面有多个报文,所以我们要循环去处理
while(1)
{
std::string info =_callback(inbuffer_stream);// 调用回调函数获取服务端需要的数据
//看看剩余的报文信息
if(info.empty()) break;//说明读不到完整报文了
lg(Debug, "debug:\n%s", inbuffer_stream.c_str());// 调试看看整个流的信息
lg(Debug, "debug,response:\n%s",info.c_str());// 调试看看发给客户端数据
write(sockfd, info.c_str(), info.size()); // 发送数据给客户端
}
}
exit(0); // 子进程结束
}
close(sockfd); // 关闭套接字
}
}
private:
uint16_t _port; // 端口号
Sock _listensock; // 监听套接字
func_t _callback; // 回调函数 用来提供服务
// ip模式是0
bool _isrunning; // 是否运行
};
重点代码分析
回答: TCP是面向字节流的,你怎么保证,你读取上来的数据,是一个"完整"的报文呢?
这里我们让服务器去不断的读取发送过来的报文,然后进行判断,如果报文是完整的,我们就传出去。如果读出来的报文不是完整的,服务器就会一直循环,直到读到完整的为止
while (true)
{
char buffer[1280];
ssize_t n = read(sockfd, buffer, sizeof(buffer));
if (n > 0)
{
buffer[n] = 0;
inbuffer_stream += buffer;
lg(Debug, "debug:\n%s", inbuffer_stream.c_str());
while (true)
{
std::string info = callback_(inbuffer_stream);
if (info.empty())
break;
lg(Debug, "debug, response:\n%s", info.c_str());
lg(Debug, "debug:\n%s", inbuffer_stream.c_str());
write(sockfd, info.c_str(), info.size());
}
}
Protocol.hpp
- Protocol包含了 Encode 和 Decode 函数以及 Request 和 Response 两个类。它们的主要作用是实现一个简单的通信协议,用于客户端和服务器之间传输请求和响应数据。该类的设计封装了数据的格式化和解析(序列化与反序列化)功能,帮助系统更好地管理请求和响应的数据交换。
我们可以定一个简单的协议,规定好一个请求和请求直接用特殊符号分隔开。
不仅需要有序列化和反序列化的方法,还要有添加报头和解析报头(要有很多检查 将一个有效的报文提取出来)的方法
#pragma once
#include <iostream>
#include <string>
// 常量字符串,表示分隔符,用于格式化报文
const std::string blank_space_sep = " "; // 空格分隔符,用于分隔数值和操作符
const std::string protocol_sep = "\n"; // 换行符作为协议的分隔符,分隔包头、内容等
// 编码函数:将原始数据(内容)打包成带有长度信息的报文
std::string Encode(std::string &content)
{
// 获取内容的大小并将其转换为字符串
std::string package = std::to_string(content.size());
// 添加协议分隔符(换行符)
package += protocol_sep;
// 添加内容本身
package += content;
// 在内容后再添加协议分隔符
package += protocol_sep;
// 返回整个编码后的数据包(包含长度、内容和分隔符)
return package;
}
// 解码函数:将接收到的报文解析为内容
bool Decode(std::string &package, std::string *content)
{
// 查找第一个协议分隔符的位置(这里用的是换行符)
std::size_t pos = package.find(protocol_sep);
if (pos == std::string::npos) return false; // 如果找不到分隔符,说明报文格式错误,返回解码失败
// 提取包头中的内容长度信息(包头在第一个协议分隔符之前)
std::string len_str = package.substr(0, pos);
// 将长度字符串转换为数字(内容长度)
std::size_t len = std::stoi(len_str);
// 计算包的总长度,包括包头和内容,以及协议分隔符
std::size_t total_len = len_str.size() + len + 2; // 加2是为了包括前后两个协议分隔符
// 如果包的大小小于计算出的总长度,说明包不完整,返回解码失败
if (package.size() < total_len) return false;
// 从包中提取内容部分,内容位于协议分隔符之后,长度为len
*content = package.substr(pos + 1, len);
// 从报文中删除已经解析过的部分(包括长度和内容)
package.erase(0, total_len);
// 返回解码成功
return true;
}
// Request类:表示一个请求,包含两个操作数(_x 和 _y)和一个操作符(_op)
class Request
{
public:
// 构造函数:初始化请求内容,传入两个操作数和操作符
Request(int data1, int data2, char oper) : _x(data1), _y(data2), _op(oper)
{
}
// 默认构造函数:在没有传入参数时初始化一个空请求
Request()
{}
public:
// 序列化函数:将请求对象转换为字符串形式(例如:"x op y")
bool Serialize(std::string *out)
{
// 将第一个操作数转换为字符串并加入空格分隔符
std::string s = std::to_string(_x);
s += blank_space_sep; // 加入空格分隔符
// 加入操作符(如 +、-、*、/ 等)
s += _op;
s += blank_space_sep; // 加入空格分隔符
// 将第二个操作数转换为字符串并加入
s += std::to_string(_y);
// 返回拼接好的字符串作为有效载荷
*out = s;
return true;
}
// 反序列化函数:从字符串中解析出请求对象(例如:"x op y")
bool Deserialize(const std::string &in) // 输入格式为 "x op y"
{
// 查找第一个空格的位置,提取第一个操作数
std::size_t left = in.find(blank_space_sep);
if (left == std::string::npos)
return false; // 如果没有找到空格,说明格式错误
std::string part_x = in.substr(0, left);
// 查找最后一个空格的位置,提取第二个操作数
std::size_t right = in.rfind(blank_space_sep);
if (right == std::string::npos)
return false; // 如果没有找到最后一个空格,说明格式错误
std::string part_y = in.substr(right + 1);
// 如果第一个操作数和第二个操作数之间的字符数不是 1(即操作符的占位符),说明格式不合法
if (left + 2 != right)
return false;
// 提取操作符(操作符在两个空格之间)
_op = in[left + 1];
// 将操作数字符串转换为整数
_x = std::stoi(part_x);
_y = std::stoi(part_y);
return true; // 成功解析请求对象
}
// 调试打印函数:输出请求的内容(操作数和操作符)
void DebugPrint()
{
std::cout << "新请求构建完成: " << _x << _op << _y << "=?" << std::endl;
}
public:
int _x; // 第一个操作数
int _y; // 第二个操作数
char _op; // 操作符(+,-,*,/,%)
};
// Response类:表示一个响应,包含计算结果(_result)和状态码(_code)
class Response
{
public:
// 构造函数:初始化响应的结果和状态码
Response(int res, int c) : _result(res), _code(c)
{
}
// 默认构造函数:初始化一个空的响应对象
Response()
{}
public:
// 序列化函数:将响应对象转换为字符串形式(例如:"result code")
bool Serialize(std::string *out)
{
// 将计算结果转换为字符串并加入空格分隔符
std::string s = std::to_string(_result);
s += blank_space_sep; // 加入空格分隔符
// 将状态码转换为字符串并加入
s += std::to_string(_code);
// 返回拼接好的字符串作为有效载荷
*out = s;
return true;
}
// 反序列化函数:从字符串中解析出响应对象(例如:"result code")
bool Deserialize(const std::string &in) // 输入格式为 "result code"
{
// 查找空格分隔符的位置
std::size_t pos = in.find(blank_space_sep);
if (pos == std::string::npos)
return false; // 如果没有找到空格,说明格式错误
// 提取结果和状态码
std::string part_left = in.substr(0, pos);
std::string part_right = in.substr(pos + 1);
// 将结果和状态码转换为整数
_result = std::stoi(part_left);
_code = std::stoi(part_right);
return true; // 成功解析响应对象
}
// 调试打印函数:输出响应的内容(结果和状态码)
void DebugPrint()
{
std::cout << "结果响应完成, result: " << _result << ", code: " << _code << std::endl;
}
public:
int _result; // 计算结果
int _code; // 状态码(0表示成功,非0表示错误)
};
重点代码分析
Encode函数
- Encode 函数通常是将序列化后的数据(即字符串或其他格式的表示)进行某种编码处理,使其适合于特定的传输或存储格式。这是一个转换的过程,通常目的是确保数据的安全性、完整性或能在特定环境下顺利传输。
std::string Encode(std::string &content)
{
// 获取内容的大小并将其转换为字符串
std::string package = std::to_string(content.size());
// 添加协议分隔符(换行符)
package += protocol_sep;
// 添加内容本身
package += content;
// 在内容后再添加协议分隔符
package += protocol_sep;
// 返回整个编码后的数据包(包含长度、内容和分隔符)
return package;
}
假设输入的content为:"Hello";
加码过程:
- 第一步生成的
package
是"5"
。- 第二步将
"\n"
添加到package
,得到"5\n"
。- 第三步将
content
"Hello"
添加到package
,得到"5\nHello"
。- 最后再添加一个
"\n"
,得到最终的package
:"5\nHello\n"
。
Decode函数
- Decode 是 Encode 的逆过程,用于还原被编码的字符串或二进制数据。
// 解码函数:将接收到的报文解析为内容
bool Decode(std::string &package, std::string *content)
{
// 查找第一个协议分隔符的位置(这里用的是换行符)
std::size_t pos = package.find(protocol_sep);
if (pos == std::string::npos) return false; // 如果找不到分隔符,说明报文格式错误,返回解码失败
// 提取包头中的内容长度信息(包头在第一个协议分隔符之前)
std::string len_str = package.substr(0, pos);
// 将长度字符串转换为数字(内容长度)
std::size_t len = std::stoi(len_str);
// 计算包的总长度,包括包头和内容,以及协议分隔符
std::size_t total_len = len_str.size() + len + 2; // 加2是为了包括前后两个协议分隔符
// 如果包的大小小于计算出的总长度,说明包不完整,返回解码失败
if (package.size() < total_len) return false;
// 从包中提取内容部分,内容位于协议分隔符之后,长度为len
*content = package.substr(pos + 1, len);
// 从报文中删除已经解析过的部分(包括长度和内容)
package.erase(0, total_len);
// 返回解码成功
return true;
}
假设我们接收到以下数据包(换行符为协议分隔符):5\nHello\n
解码过程:
- 查找协议分隔符的位置,
pos = 1
(换行符的位置)。- 提取包头的长度信息:
len_str = "5"
,即内容长度是5
。- 计算总长度:
total_len = len_str.size() + len + 2 = 1 + 5 + 2 = 8
。- 检查包大小,包的实际大小是 8,符合预期。
- 提取内容部分:
content = "Hello"
。- 删除已解析的部分,剩下的
package
为空。- 返回
true
,解码成功。
Serialize 函数
作用:
- Serialize 函数将请求对象中的数据(例如操作数和操作符)转换为一个字符串表示。序列化过程将对象的内容“打包”成可以传输或存储的格式,通常是一个字符串。
bool Serialize(std::string *out)
{
// 将第一个操作数转换为字符串并加入空格分隔符
std::string s = std::to_string(_x);
s += blank_space_sep; // 加入空格分隔符
// 加入操作符(如 +、-、*、/ 等)
s += _op;
s += blank_space_sep; // 加入空格分隔符
// 将第二个操作数转换为字符串并加入
s += std::to_string(_y);
// 返回拼接好的字符串作为有效载荷
*out = s;
return true;
}
假设我们有以下数据:
_x = 5 _y = 3 _op = '+'
调用 Serialize 后
结果字符串为 "5 + 3"。
Deserialize 函数
作用:
- Deserialize 函数将一个格式化的字符串解析回原始的请求对象。这个过程通常称为反序列化,它的目的是将存储或传输的字符串数据转换回对象。
bool Deserialize(const std::string &in) // 输入格式为 "x op y"
{
// 查找第一个空格的位置,提取第一个操作数
std::size_t left = in.find(blank_space_sep);
if (left == std::string::npos)
return false; // 如果没有找到空格,说明格式错误
std::string part_x = in.substr(0, left);
// 查找最后一个空格的位置,提取第二个操作数
std::size_t right = in.rfind(blank_space_sep);
if (right == std::string::npos)
return false; // 如果没有找到最后一个空格,说明格式错误
std::string part_y = in.substr(right + 1);
// 如果第一个操作数和第二个操作数之间的字符数不是 1(即操作符的占位符),说明格式不合法
if (left + 2 != right)
return false;
// 提取操作符(操作符在两个空格之间)
_op = in[left + 1];
// 将操作数字符串转换为整数
_x = std::stoi(part_x);
_y = std::stoi(part_y);
过程
1. 查找第一个空格
std::size_t left = in.find(blank_space_sep); // 查找第一个空格
find()
方法会查找字符串中第一个出现的空格位置,并返回其索引。blank_space_sep
是空格" "
,即我们要查找的位置。- 假设我们有输入字符串
"10 + 20"
:
- 第一个空格出现在字符
' '
,即位置 2。- 所以,
left = 2
。
2. 提取第一个操作数
std::string part_x = in.substr(0, left); // 提取第一个操作数
- 使用
substr(0, left)
提取从索引 0 到left
位置(不包含left
)的子字符串。- 在字符串
"10 + 20"
中,left = 2
,因此我们提取从位置 0 到 2 之间的子字符串"10"
。结果为
part_x = "10"
3. 查找最后一个空格
std::size_t right = in.rfind(blank_space_sep); // 查找最后一个空格
rfind()
从字符串的末尾开始查找第一个出现的空格位置,并返回其索引。- 在字符串
"10 + 20"
中,最后一个空格位于位置 5。
- 注意:
rfind
会返回从后向前的第一个空格,所以right = 5
。结果
right = 5
4. 提取第二个操作数
std::string part_y = in.substr(right + 1); // 提取第二个操作数
- 使用
substr(right + 1)
提取从索引right + 1
开始的所有字符。right + 1
是因为我们想跳过空格。 - 在
"10 + 20"
中,right = 5
,所以我们从位置 6 开始提取子字符串,即"20"
。
结果
part_y = "20"
5. 确保操作符的位置
if (left + 2 != right) // 确保操作符在两个操作数之间
return false; // 如果不符合条件,返回false
- 我们要确保操作符位于两个操作数之间,即两个空格之间的字符。
- 通过检查
left + 2
是否等于right
来确认:left = 2
,right = 5
,left + 2 == 5
,表示操作符确实在两个操作数之间。- 如果不相等,说明字符串的格式不符合预期,返回
false
。
结果
(left + 2 == right) // 返回 true,继续执行
6. 提取操作符
_op = in[left + 1]; // 提取操作符
- 由于操作符位于两个操作数之间,且其位置为
left + 1
(在left
后面第一个位置),我们直接从该位置提取操作符。 - 在
"10 + 20"
中,left = 2
,所以操作符位于位置2 + 1 = 3
,提取in[3]
,即'+'
。
结果
_op = "+"
7. 转换操作数为整数
_x = std::stoi(part_x); // 将第一个操作数转换为整数
_y = std::stoi(part_y); // 将第二个操作数转换为整数
- 使用
std::stoi()
将字符串形式的操作数转换为整数。 part_x = "10"
,所以_x = 10
。part_y = "20"
,所以_y = 20
。
结果
_x = 10
_y = 20
总结
通过这些步骤,我们可以正确地从字符串 "10 + 20"
中提取出操作数和操作符,最终得到:
_op = "+"
_x = 10
_y = 20
ServerCal.hpp
- ServerCal 能够处理来自客户端的计算请求,并根据请求进行不同的算术运算。服务端通过一个回调方法 Cal 处理客户端请求,执行计算,并返回计算结果或错误信息。接下来,我将详细分析代码的每个部分。
// 计算器服务
#include <iostream>
#include "Protocol.hpp" // 引入协议头文件,必须遵守协议
// 定义错误代码
enum
{
Div_Zero = 1, // 除法除以零
Mod_Zero = 2, // 取模除以零
Other_Oper = 3 // 其他操作符(非算术运算)
};
// 计算器服务类
class ServerCal
{
public:
ServerCal() {} // 构造函数,初始化时没有任何操作
~ServerCal() {} // 析构函数,清理资源(目前为空)
// 计算帮助函数,根据传入的操作请求(Request)进行计算
Response Calhelper(const Request& req)
{
Response resp(0, 0); // 初始化一个默认响应,默认结果为0,错误码为0
switch (req._op) // 根据操作符执行不同的运算
{
case '+':
resp._result = req._x + req._y; // 加法
break;
case '-':
resp._result = req._x - req._y; // 减法
break;
case '*':
resp._result = req._x * req._y; // 乘法
break;
case '/': // 除法
if (req._y == 0)
resp._code = Div_Zero; // 如果除数为0,返回除以零错误
else
resp._result = req._x / req._y; // 否则进行除法运算
break;
case '%': // 取模运算
if (req._y == 0)
resp._code = Mod_Zero; // 如果除数为0,返回取模除以零错误
else
resp._result = req._x % req._y; // 否则进行取模运算
break;
default:
resp._code = Other_Oper; // 如果操作符不在预期范围内,返回其他操作错误
break;
}
return resp; // 返回计算结果或错误响应
}
// 回调方法,用来处理客户端发送的请求 假设"len"\n"10 + 20"\n
std::string Cal(std::string package) // 回调方法,接受一个包含计算请求的报文包
{
std::string content; // 用于存储解码后的内容
bool r = Decode(package, &content); // 解码传入的报文包 "len"\n"10 + 20"\n
if (!r)
return ""; // 如果解码失败,返回空字符串,表示报文不完整
// 反序列化报文,提取请求数据
Request req;
r = req.Deserialize(content); // 将报文中的内容反序列化为Request对象 "10 + 20" -> x=10 op =+ y=20
if (!r)
return ""; // 如果反序列化失败,返回空字符串,表示解析失败
content = ""; // 清空 content 字符串,准备存储计算结果
// 调用计算方法,计算结果
Response resp = Calhelper(req); // 计算请求的结果(可能包含错误码)result=30 code=0
// 将计算结果序列化回字符串
resp.Serialize(&content); // 将响应对象序列化为字符串 "30 0"
content = Encode(content); // 对结果进行编码,将报头加上去 "len"\n"30 0"
return content; // 返回编码后的计算结果
}
};
ServerCal.cc
- ServerCal实现了一个基于 TCP 协议的计算器服务器。通过使用 TcpServer 类和 ServerCal 类,服务器能够接收客户端的请求、处理请求并返回计算结果。
#include "ServerCal.hpp" // 引入计算器服务头文件
#include "TcpServer.hpp" // 引入 TCP 服务器头文件
// 显示程序的使用方法
static void Usage(const std::string &proc)
{
std::cout << "\nUsage: " << proc << " port\n" << std::endl; // 输出程序的用法说明,告诉用户需要提供端口号
}
int main(int argc, char *argv[]) // 主程序入口,接收命令行参数,例如:./servercal 8080
{
if (argc != 2) // 检查传入的命令行参数个数是否为2(程序名和端口号)
{
Usage(argv[0]); // 如果参数不正确,显示使用方法
exit(0); // 退出程序
}
uint16_t port = atoi(argv[1]); // 将命令行传入的端口号(字符串)转换为数字类型 uint16_t
ServerCal cal; // 创建一个 ServerCal 对象,用来处理计算请求
// 创建 TcpServer 对象,绑定回调函数 `ServerCal::Cal`
// 使用 `std::bind` 来将 `ServerCal::Cal` 方法和 `cal` 对象绑定,
// 这样每当有请求到达时,`ServerCal::Cal` 方法会被调用。
// `_1` 是一个占位符,表示传入 TcpServer 的第一个参数会被传递给 `Cal` 方法
TcpServer *tsvp = new TcpServer(port, std::bind(&ServerCal::Cal, &cal, std::placeholders::_1));
tsvp->InitServer(); // 初始化 TCP 服务器
// 让程序变成守护进程,后台运行
// `daemon(0, 0)` 表示创建一个守护进程
daemon(0, 0);
tsvp->Start(); // 启动服务器,开始接收客户端的请求并处理
return 0; // 程序结束,返回 0
}
重点代码分析
// 创建 TcpServer 对象,绑定回调函数 `ServerCal::Cal`
// 使用 `std::bind` 来将 `ServerCal::Cal` 方法和 `cal` 对象绑定,
// 这样每当有请求到达时,`ServerCal::Cal` 方法会被调用。
// `_1` 是一个占位符,表示传入 TcpServer 的第一个参数会被传递给 `Cal` 方法
TcpServer *tsvp = new TcpServer(port, std::bind(&ServerCal::Cal, &cal, std::placeholders::_1));
结合这些组件,我们可以想象一个简单的流程:
- 客户端请求数据:
假设客户端发送了一个数字请求,"10",通过 TCP 连接到服务器。
- TcpServer 监听并接收数据:
服务器在 port 端口上监听。接收到客户端发送的 "10"。
- 绑定并调用 ServerCal::Cal 函数:
服务器通过 std::bind 将 ServerCal::Cal 函数与 cal 对象绑定,并且传递接收到的数据 "10"。
绑定后的回调函数会调用 ServerCal::Cal,将 "10" 传递给它。
- 数据处理:
ServerCal::Cal 函数会将 "10" 转换为整数,执行计算(如乘以2),并返回 "20"。
- 返回结果给客户端:
服务器通过 TcpServer 将计算结果 "20" 发送回客户端。
ClientCal.cc
- ClientCal这段代码实现了一个 客户端,能够连接到计算器服务端,随机生成计算请求并发送给服务器,接收计算结果并打印。
#include <iostream>
#include <string>
#include <ctime>
#include <cassert>
#include <unistd.h>
#include "Socket.hpp" // 引入自定义的 Socket 类,用于套接字操作
#include "Protocol.hpp" // 引入协议头文件,客户端也需要了解协议
// 打印使用说明,帮助用户正确输入命令
static void Usage(const std::string &proc)
{
std::cout << "\nUsage: " << proc << " serverip serverport\n" << std::endl;
}
// 主函数入口
// 例如:./client 127.0.0.1 8080
int main(int argc, char *argv[])
{
if (argc != 3) // 检查传入的命令行参数是否为 3 个(程序名,服务器 IP,服务器端口)
{
Usage(argv[0]); // 如果参数不正确,调用 Usage 输出用法提示
return -1; // 返回错误,结束程序
}
// 获取服务器的 IP 地址和端口号
std::string serverip = argv[1]; // 从命令行参数中获取服务器 IP 地址
uint16_t serverport = std::stoi(argv[2]); // 从命令行参数中获取服务器端口,转换为 uint16_t 类型
Sock sockfd; // 创建一个套接字对象
sockfd.Socket(); // 创建套接字
bool r = sockfd.Connect(serverip, serverport); // 尝试连接到指定的服务器 IP 和端口
if (!r) // 如果连接失败
{
std::cout << "连接失败" << std::endl; // 输出连接失败的提示
return -1; // 返回错误,结束程序
}
// 连接成功
srand(time(nullptr) ^ getpid()); // 使用当前时间和进程 ID 来初始化随机数种子
int cnt = 1; // 初始化循环次数
const std::string opers = "+-*/%=-=&^"; // 定义一个字符串,包含所有支持的运算符
std::string inbuffer_stream; // 用于存储接收到的数据流
while (cnt <= 10) // 循环 10 次,模拟 10 次计算请求
{
std::cout << "===============第" << cnt << "次测试....., " << "===============" << std::endl;
// 随机生成操作数和运算符
int x = rand() % 100 + 1; // 随机生成第一个操作数 x,范围是 1 到 100
usleep(1234); // 睡眠 1234 微秒,模拟一些延迟
int y = rand() % 100; // 随机生成第二个操作数 y,范围是 0 到 99
usleep(4321); // 睡眠 4321 微秒,模拟一些延迟
char oper = opers[rand() % opers.size()]; // 随机选择一个运算符
Request req(x, y, oper); // 创建一个请求对象,用于存储运算请求
req.DebugPrint(); // 打印调试信息,查看请求的内容
std::string package;
req.Serialize(&package); // 将请求对象序列化成字符串格式,准备发送给服务器
package = Encode(package); // 对包进行编码,可能是加密或添加头部信息
write(sockfd.GetSockfd(), package.c_str(), package.size()); // 发送请求包到服务器
char buffer[128]; // 创建一个缓冲区来接收服务器的响应
ssize_t n = read(sockfd.GetSockfd(), buffer, sizeof(buffer)); // 读取服务器响应数据
if (n > 0) // 如果读取到数据
{
buffer[n] = 0; // 确保读取的数据是以 '\0' 结尾的
inbuffer_stream += buffer; // 将读取的数据追加到缓冲区流中
std::cout << inbuffer_stream << std::endl; // 输出接收到的内容
std::string content;
bool r = Decode(inbuffer_stream, &content); // 解码数据包中的内容
assert(r); // 确保解码成功
Response resp; // 创建一个响应对象
r = resp.Deserialize(content); // 反序列化内容到响应对象中
assert(r); // 确保反序列化成功
resp.DebugPrint(); // 打印响应的调试信息
}
std::cout << "=================================================" << std::endl;
sleep(1); // 每次循环间隔 1 秒
cnt++; // 增加循环次数
}
sockfd.Close(); // 关闭套接字连接
return 0; // 程序正常结束
}
Daemon.hpp
- Daemon实现了一个将程序转换为 守护进程(Daemon Process) 的函数 Daemon。守护进程是一种在后台运行的进程,通常没有控制终端,且独立于用户的会话。此函数通过一些操作使得程序能够在后台运行,执行一些长期的任务而不需要用户的干预。
#pragma once
#include <iostream>
#include <cstdlib>
#include <unistd.h>
#include <signal.h>
#include <string>
#include <sys/types.h>
#include <sys/stat.h>
#include <fcntl.h>
const std::string nullfile = "/dev/null";
void Daemon(const std::string &cwd = "")
{
// 1. 忽略其他异常信号
signal(SIGCLD, SIG_IGN);
signal(SIGPIPE, SIG_IGN);
signal(SIGSTOP, SIG_IGN);
// 2. 将自己变成独立的会话
if (fork() > 0)
exit(0);
setsid();
// 3. 更改当前调用进程的工作目录
if (!cwd.empty())
chdir(cwd.c_str());
// 4. 标准输入,标准输出,标准错误重定向至/dev/null
int fd = open(nullfile.c_str(), O_RDWR);
if(fd > 0)
{
dup2(fd, 0);
dup2(fd, 1);
dup2(fd, 2);
close(fd);
}
}
Makefile
.PHONY:all
all:servercal clientcal
//定义 Flag 和 Lib 变量
//Flag 设置了一个预处理宏,MySelf=1
Flag = #-DMySelf=1
//Lib 定义了需要链接的外部库,jsoncpp
Lib = -ljsoncpp
//'servercal' 目标,编译 ServerCal.cc 文件
servercal: ServerCal.cc
// 使用 g++ 编译器生成可执行文件 'servercal',链接库 $(Lib) 和宏 $(Flag)
g++ -o $@ $^ -std=c++11 $(Lib) $(Flag)
//'clientcal' 目标,编译 ClientCal.cc 文件
clientcal: ClientCal.cc
//使用 g++ 编译器生成可执行文件 'clientcal',链接库 $(Lib) 和宏 $(Flag),同时开启调试信息 (-g)
g++ -o $@ $^ -std=c++11 -g $(Lib) $(Flag)
.PHONY:clean
clean:
rm -f clientcal servercal
运行结果
json 介绍
大家明显感觉到序列化和反序列化都是字符串处理,而且还比较麻烦,如果手写比较丑陋,有没有现成的方法使用呢?
答案是有的。
市面上常见的解决方案是:json,protobuf都可以帮助我们自动序列,反序列化
json的安装
使用json前需要按照第三方库,才能进行使用
使用 apt
来安装 jsoncpp-devel
: 在 Ubuntu 系统中,应该使用 apt
包管理器来安装软件包。你可以尝试使用以下命令来安装 jsoncpp
开发库:
sudo apt update
sudo apt install libjsoncpp-dev
确认 jsoncpp
是否已安装: 安装后,你可以检查是否已经成功安装了 jsoncpp
开发库:
dpkg -l | grep jsoncpp
查看json的头文件和库文件
#include <jsoncpp/json/json.h>
json测试用例
对于 jsoncpp 我们只需要掌握三个类就可以覆盖开发时的绝大多数场景。
Json::Value:
- 这是 JsonCpp 中最核心的类,用于表示 JSON 值。Json::Value 可以包含任何 JSON 类型的数据:对象(一组键值对)、数组、字符串、数字、布尔值(true/false)、null 值。你可以使用这个类来创建、查询和修改 JSON 数据。
Json::Reader:
- Json::Reader 类用于解析 JSON 字符串。你可以使用这个类将一个符合 JSON 格式的字符串解析成一个 Json::Value 对象。这个过程称为 JSON 反序列化。
Json::Writer:
- Json::Writer 类用于将 Json::Value 对象转换回 JSON 字符串。这个过程称为 JSON 序列化。Json::Writer 还可以格式化 JSON 数据,使其以易于阅读的方式输出。
#include <iostream>
#include <jsoncpp/json/json.h> // 引入 JSONcpp 库,用于 JSON 操作
#include <unistd.h> // 引入 sleep 函数,用于程序暂停
// {a:120, b:"123"} 这个注释表示一个简单的 JSON 对象示例
int main()
{
// 创建一个 Json::Value 对象 part1,用于表示一个嵌套的 JSON 对象
//Json::Value part1;
//part1["haha"] = "haha"; // 设置 key "haha",值为 "haha"
//part1["hehe"] = "hehe"; // 设置 key "hehe",值为 "hehe"
// 创建根 JSON 对象 root
Json::Value root;
root["x"] = 100; // 设置 key "x",值为 100
root["y"] = 200; // 设置 key "y",值为 200
root["op"] = '+'; // 设置 key "op",值为 '+',表示一个运算符
root["desc"] = "this is a + oper"; // 设置 key "desc",值为描述字符串
root["test"] = part1; // 将嵌套的 part1 对象作为 "test" 键的值
Json::FastWriter w;
std::string res = w.write(root); // 将 root 对象序列化为字符串
std::cout << res << std::endl; // 输出序列化后的 JSON 字符串
sleep(3); // 程序暂停 3 秒(模拟一些延时)
//创建一个新的 Json::Value 对象 v,用于解析从字符串读取的 JSON 数据
Json::Value v;
Json::Reader r; [创建 JSON 解析器对象]
r.parse(res, v); [解析字符串 res,填充到 Json::Value 对象 v 中]
// 从解析后的 JSON 数据中提取各个字段的值
int x = v["x"].asInt(); // 提取整数类型的值 "x"
int y = v["y"].asInt(); // 提取整数类型的值 "y"
char op = v["op"].asInt(); // 提取整数类型的运算符 "op",注意这里会将 '+' 转换为其 ASCII 码值
std::string desc = v["desc"].asString(); // 提取字符串类型的值 "desc"
std::cout << x << std::endl;
std::cout << y << std::endl;
std::cout << op << std::endl;
StyledWriter和FastWriter会有不同的输出形式,下面是代码实例来展示区分
//StyledWriter
Json::Value root;
root["name"] = "John";
root["age"] = 30;
Json::StyledWriter writer;
std::string jsonStr = writer.write(root);
std::cout << jsonStr << std::endl;
//输出
{
"age" : 30,
"name" : "John"
}
//FastWriter
Json::Value root;
root["name"] = "John";
root["age"] = 30;
Json::FastWriter writer;
std::string jsonStr = writer.write(root);
std::cout << jsonStr << std::endl;
//输出
{"age":30,"name":"John"}
更详细的JsonCpp使用可以查看API:JsonCpp 使用指导
整体代码结果
五. Protocol.hpp [使用json的优化版]
//序列化
Json::Value root;
root["x"] = x;
root["y"] = y;
root["op"] = op;
Json::FastWriter w;
*out = w.write(root);
return true;
//反序列化
Json::Value root;
Json::Reader r;
r.parse(in, root);
x = root["x"].asInt();
y = root["y"].asInt();
op = root["op"].asInt();
return true;
总结
经过了对上面的学习,我们现在肯定这些有了新的认识了
会话层: 由服务端解决获取新链接,维护整个链接的使用情况,创建子进程来对外提供服务,相当于每次访问一次服务我就会创建一个新的会话,然后去处理新的连接,不需要就关掉
表示层:就相当于定义的协议和协议的序列化和反序列化
应用层:处理数据,不同的数据需要有不同的协议