Linux-> TCP 编程3
目录
本文说明
一:网络计算器的重点
二:网络计算器代码
1:Socket.hpp
2:Protocol.hpp
3:CalCulate.hpp
4:TcpServer.hpp
5:TcpServerMain.cc
6:TcpClientMain.cc
7:InetAddr.hpp
8:Log.hpp
9:makefile
三:效果
1:一次单个请求
2:一次多个请求
本文说明
本文实现了一个网络计算器程序,
一:网络计算器的重点
1:重新理解read write recv send 以及TCP是全双工的原因
①:TCP是全双工的原因:
TCP我们知道是全双工的,因为我们的客户端和服务端都可以同时的进行接收和发送数据,但是本质是因为下图:
传输层选择了TCP协议,就意味着你的服务端和客户端都会拥有一个发送缓冲区和接收缓冲区,所以某A端的发送缓冲区的数据直接通过recv/write等接口发送到某B端的接收缓冲区,而同时可以进行某A端的接收缓冲区接收某B端的发送缓冲区发来的数据,这就是TCP是全双工的本质
②:重新理解read write recv send
所以read write recv send就是一种拷贝函数,调用read或recv的本质就是从接收缓冲区拷贝到了用户层的缓冲区罢了,同理调用write或send的本质就是从用户层的缓冲区拷贝到了发送缓冲区罢了,所以本质都是一种拷贝函数!
而至于为什么read write recv send会阻塞,本质不就是因为对应的缓冲区空了或者是满了吗?此时的进程就会被设置为S状态,等待缓冲区可以使用了,才会被唤醒
2:TCP出现粘报和报文不完整的原因
在任何使用TCP协议的程序中,都会出现粘报和报文不完整的问题,比如recv读取到的数据不完整
首先TCP是面向字节流的,而UDP是面向数据报的,对于UDP而言,只要读取到数据,则一定是完整的,好比收到一个快递,快递里面一定是完整的物品,要么就是收不到快递,不可能出现收到快递,物品不完整的情况!
而TCP是面向字节流的,不管什么样的数据,对于TCP来说都是一个一个的字节罢了,TCP的视角没有整体这个概念,所以当你recv读取数据的时候,此时的缓冲区的确有可能读取到不完整的数据,比如对方发送的数据大于你的接收缓冲区等原因
而粘报问题就是数据远小于你的接收缓冲区,导致你recv的时候,可能会一次读取到多个黏在一起的数据
而粘报和报文不完整的情况,要想解决,必然不可能和传输层的缓冲区有关系,我们只能在用户层,通过自己的代码约束来解决这两个问题
3:用模板方法模式去封装套接字接口
我们之前使用socket相关的接口,都是在服务端代码和客户端代码中使用的,每次新建一个服务端或者客户端都需要重新写一次大量类似的代码,所以我们干脆把套接字接口封装在Socket.hpp文件中,所以现在就存在一个问题了,我们可以选择在Socket.hpp文件中对每个socket接口进行封装,此时我们在服务端或者客户端直接调用封装的接口进行,内部的细节不用再写了!
但是这样不够优雅,因为对于TCP而言,服务端一定是创建套接字socket+bind绑定+监听listen,客户端一定是创建套接字socket+发送连接请求connect,这些都是固定的接口的顺序,所以我们干脆就再定义一个BuildListenSocket接口,服务端只需要调用该BuildListenSocket接口就可以完成所以的socket接口的调用,因为我们在BuildListenSocket内部调用了socket+bind+listen接口;而对于客户端则封装一个BuildClientSocket接口,内部调用了socket+connect接口!
但是新的问题在于对于UDP来说,我们的接口调用不一样的,所以我们选择多态的写法,TCP和UDP都只需去继承该父类,然后在自己的类中进行重写虚函数,而在父类中,我们就可以多封装几个组合,供不同的协议去使用了!
4:协议的实现
①:序列化和反序列化的必要性
首先我们的网络计算器程序,向网络中发送的都是结构体对象,因为结构体对象更能体现协议的强大之处,在之后的网络程序编码中,大多数都是发送的结构体/类对象!
我们不推荐直接向网络中发送结构体类型的变量,因为客户端和服务端不一定在同一个OS系统中,会导致结构体的内存对齐不一样,导致大小不一样,以及大小端差异的问题,跨平台性太差!所以我们采用对发送出去的结构体进行序列化,对接收到的结构体进行反序列化的做法,来消除对端的平台差异!
对于我们的网络计算器程序来说,序列化就是把结构体变成一个字符串,发序列化就是把字符串变成一个结构体,而变成了字符串,就不会存在平台差异带来的影响了
②:协议的引入
那怎么解决报文不完整和粘报的问题呢?很简单,我们共同约定一个协议即可,现在我们发送出去的结构体已经是被序列化成一个字符串了,所以我们约定以下格式:
报文 = 长度+分隔符+有效载荷+分隔符,报文是一个大字符串,其中的有效载荷就是序列化之后的字符串,所以我们发送的数据就是一个报文,我们只需对接收到的报文进行以下步骤的判断:
a:如果连第一个分割符都不存在,则报文一定不完整
b:发现长度字符为空,则格式错误
c:得到长度大小,计算出整个报文的大小,对比此次recv到的字符串大小,如果小于计算出的整个报文的大小,则代表数据不完整
d:来到这则代表数据一定完整,则提取有效载荷部分即可,
e:提取到效载荷部分之后,需要从缓冲区移除已处理的数据,也就是移除该有效载荷位于的报文,这样下一次读取到的就是往后的新的报文
所以a到e的操作,既解决了报文不完整,又解决了粘报问题
二:网络计算器代码
1:Socket.hpp
Socket.hpp就是封装socket接口的文件
#pragma once#include <iostream>
#include <string>
#include <functional>
#include <sys/types.h>
#include <sys/socket.h>
#include <netinet/in.h>
#include <arpa/inet.h>
#include <unistd.h>
#include <cstring>
#include <pthread.h>
#include <sys/types.h>
#include <memory>
#include "InetAddr.hpp"
#include "Log.hpp"// 模版方法模式 去 封装 套接字接口
namespace socket_ns
{class Socket; // 声明一下Socket类const static int gbacklog = 8; // listen的第二个参数设为8using socket_sptr = std::shared_ptr<Socket>; // 创建一个新的类型名 socket_sptr,它等价于 std::shared_ptr<Socket> 其为智能指针enum // 枚举几个错误类型{SOCKET_ERROR = 1, // 创建套接字错误BIND_ERROR, // 绑定错误LISTEN_ERROR, // 监听错误USAGE_ERROR // 调用程序错误};// Socket类中封装了套接字接口// Socket是基类 接口的定义由派生类去定义class Socket{public: // 都是虚函数 派生类/子类会去进行重写virtual void CreateSocketOrDie() = 0; // 用于创建套接字--->调用socket接口virtual void BindSocketOrDie(InetAddr &addr) = 0; // 用于bind绑定--->调用bind接口virtual void ListenSocketOrDie() = 0; // 用于监听--->调用listen接口virtual socket_sptr Accepter(InetAddr *addr) = 0; // 用于服务端连接--->调用accept接口virtual bool Connetcor(InetAddr &addr) = 0; // 用于客户端发起连接请求--->调用connect接口virtual int SockFd() = 0; // 用于返回成员变量fdvirtual int Recv(std::string *out) = 0; // 用于接收数据--->调用recv接口virtual int Send(const std::string &in) = 0; // 用于发送数据--->调用send接口public:// 把上面的几个虚函数接口 按照需要的组合 封装进一个新的接口// 也就是整合为TCP的服务器和客户端的代码逻辑// 服务端:创建套接字socket+bind绑定+监听listen// 客户端:创建套接字socket+发送连接请求accept// BuildListenSocket--->TCP服务端需要的接口组合// 创建套接字socket+bind绑定+监听listenvoid BuildListenSocket(InetAddr &addr){CreateSocketOrDie();BindSocketOrDie(addr);ListenSocketOrDie();}// BuildClientSocket--->TCP客户端需要的接口组合// 创建套接字socket+发送连接请求connectbool BuildClientSocket(InetAddr &addr){CreateSocketOrDie();return Connetcor(addr);}};// TcpSocket 继承 Socket类// 意义在于对虚函数进行重写 实现多态// 往后如果还有UdpSocket类也要继承 则自己实现自己的重写即可class TcpSocket : public Socket{public:// 构造函数 成员变量套接字缺省为-1TcpSocket(int fd = -1) : _sockfd(fd){}// 对基类虚函数CreateSocketOrDie进行重写void CreateSocketOrDie() override{_sockfd = ::socket(AF_INET, SOCK_STREAM, 0); // 创建套接字if (_sockfd < 0) // 创建失败{LOG(FATAL, "socket error"); // 打印消息提醒exit(SOCKET_ERROR); // 并退出 错误码为我们设计的枚举变量}LOG(DEBUG, "socket create success, sockfd is : %d\n", _sockfd); // 创建成功 打印语句提醒}// 对基类虚函数BindSocketOrDie进行重写void BindSocketOrDie(InetAddr &addr) override{// 填充sockaddr_in结构struct sockaddr_in local; // 网络通信 所以定义struct sockaddr_in类型的变量memset(&local, 0, sizeof(local)); // 先把结构体清空 好习惯local.sin_family = AF_INET; // 填写第一个字段 (地址类型)local.sin_port = htons(addr.Port()); // 填写第二个字段PORT (需先转化为网络字节序)local.sin_addr.s_addr = inet_addr(addr.Ip().c_str()); // 填写第三个字段IP (直接填写0即可,INADDR_ANY就是为0的宏)// 调用bind接口 进行绑定int n = ::bind(_sockfd, (struct sockaddr *)&local, sizeof(local));if (n < 0) // 失败{LOG(FATAL, "bind error"); // 打印语句提醒exit(BIND_ERROR); // 并退出 错误码为我们设计的枚举变量}LOG(DEBUG, "bind success, sockfd is : %d\n", _sockfd); // 成功 打印语句提醒}// 对基类虚函数ListenSocketOrDie进行重写void ListenSocketOrDie() override{// 调用listen接口 进行监听int n = ::listen(_sockfd, gbacklog);if (n < 0) // 监听失败{LOG(FATAL, "listen error"); // 打印语句提醒exit(LISTEN_ERROR); // 并退出 错误码为我们设计的枚举变量}LOG(DEBUG, "listen success, sockfd is : %d\n", _sockfd); // 成功 打印语句提醒}// 对基类虚函数Accepter进行重写socket_sptr Accepter(InetAddr *addr) override // 参数为输出型参数 用于接收accept中的输出型参数得到的结果{// 创建peer,用于接收客户端的网络属性结构体struct sockaddr_in peer; // peer是输出型参数 会被填入信息socklen_t len = sizeof(peer);// 调用accept接口进行连接 返回值sockfd才是调用recv和send接口的参数int sockfd = ::accept(_sockfd, (struct sockaddr *)&peer, &len);if (sockfd < 0) // 连接失败{LOG(WARNING, "accept error\n"); // 打印语句提醒return nullptr; // 但不退出 会等待下一次连接}*addr = peer; // 把客户端网络属性结构体peer 赋给*addr 成为一个类的对象// 创建一个sock 其为接收派生类指针的基类指针类型 以便实现多态// 后续调用Accepter 意味着调用accept 后面一般会紧跟着调用recv或send// 所以本意就是使用sock去实现多态 去调用Recv和Send// 所以返回sock,就可以实现多态 去调用Recv和Send(父类的虚函数 执行的定义是子类的定义)// 而参数为sockfd是点睛之笔 此时的sock的对象的成员变量就是accept返回的套接字了 该fd才可以用于recv和send的参数!socket_sptr sock = std::make_shared<TcpSocket>(sockfd);return sock;}// 对基类虚函数Connetcor进行重写virtual bool Connetcor(InetAddr &addr){// 构建目标主机 也就是服务端的网络属性结构体 方便后续的读取和发送数据struct sockaddr_in server;// 构建网络属性结构体server的信息memset(&server, 0, sizeof(server));server.sin_family = AF_INET;server.sin_port = htons(addr.Port());server.sin_addr.s_addr = inet_addr(addr.Ip().c_str());// 客户端调用connect 发送连接请求int n = connect(_sockfd, (struct sockaddr *)&server, sizeof(server));if (n < 0) // 发送失败{std::cerr << "connect error" << std::endl; // 打印信息提醒return false;}return true; // 发送成功}// 对基类虚函数Recv进行重写int Recv(std::string *out) override{// inbuffer存储recv读取到的信息char inbuffer[1024];ssize_t n = ::recv(_sockfd, inbuffer, sizeof(inbuffer) - 1, 0);if (n > 0) // 接收成功{inbuffer[n] = 0; // 及时设置\0*out += inbuffer; // 把inbuffer+=到*out中 +=是为了防止信息不完整时,可以把下一次的后半部分信息+=上去}return n;}// 对基类虚函数Recv进行重写int Send(const std::string &in) override{// 调用send 进行数据的发送int n = ::send(_sockfd, in.c_str(), in.size(), 0);return n;}// 对基类虚函数SockFd进行重写int SockFd() override{return _sockfd; // 返回成员变量 _sockfd套接字}private:int _sockfd; // 套接字 用于接收Socket的返回值 然后在其余的成员变量中被使用};}
解释:
①:基类/父类只是对虚函数接口进行声明,以及TCP下的服务端的接口组合和客户端的接口组合的封装,具体的虚函数怎么重写,有去继承的派生类/子类去决定!
②:所以如果现在UDP也要使用该文件,我们只需在父类中在封装两种在UDP下的服务端的接口组合和客户端的接口组合,然后让UDP类去继承父类,再在UDP类内部进行虚函数的重写即可
③:关键在于ACcepter接口需要返回一个父类类型的指针(Socket类),这是因为往往在accept连接之后,就会紧接着进行recv和send的调用,而我们知道recv和send所需要的参数套接字fd,这个fd可不是监听listen_fd,而是我们调用accept返回的值,所以你要想在调用ACcepter接口之后,再调用Recv和Send,那你则需要"使用 accept返回的值sockfd 构造一个 TcpSocket 子类对象,并通过 std::make_shared 创建其智能指针,然后赋值给基类 Socket 的智能指针类型 socket_sptr",这样你才可以正确的调用Recv和Send,Recv和Send需要的参数就是此时的子类对象的成员变量_sockfd!
④:而之前的socket,bind,listen和accept用的套接字是监听套接字,所以在accept之后,我们需要创建一个新的子类对象罢了,然后多态调用去调用Recv和Send!
2:Protocol.hpp
Protocol.hpp文件就是协议所处的文件,其中包含:
Request请求类:也就是客户端发送的类,里面成员变量组合在一起就是一个计算式子
Response响应类:这是服务端计算之后,返回的类,里面的成员变量是结果和错误码
而两个类的内部都有Serialize序列化和Deserialize反序列化的接口
Encode接口:封装序列化之后的字符串,封装为长度+分隔符+有效载荷+分隔符
Decode接口:拆解被Encode封装之后的字符串,得到有效载荷,也就是JSON字符串
Factory类:用于快速创建请求类
#pragma once#include <iostream>
#include <string>
#include <jsoncpp/json/json.h> //包含json接口所处的头文件// 命名空间
namespace protocol_ns // 译为协议
{// 声明分隔符 为 "\r\n"const std::string SEP = "\r\n";// Encode--->对已经序列化之后的json_str 进行封装// 使其成为 "len"\r\n"{json_str}"\r\n 的样式std::string Encode(const std::string &json_str){// 第1步:计算原始JSON数据的长度int json_str_len = json_str.size();// 假设 json_str = "{\"x\":5,\"y\":3}",那么 json_str_len = 13// 第2步:将长度转换为字符串std::string proto_str = std::to_string(json_str_len);// 现在 proto_str = "13" (字符串形式)// 第3步:添加第一个分隔符proto_str += SEP; // SEP = "\r\n"// 现在 proto_str = "13\r\n"// 第4步:添加原始的JSON数据proto_str += json_str;// 现在 proto_str = "13\r\n{\"x\":5,\"y\":3}"// 第5步:添加结尾分隔符proto_str += SEP; // 再次添加 "\r\n"// 最终 proto_str = "13\r\n{\"x\":5,\"y\":3}\r\n"return proto_str;}// Decode--->对已经被封装之后的序列化的字符串inbuffer 进行拆解// 单独获取其中的有效载荷,也就是序列化字符串std::string Decode(std::string &inbuffer){// 第1步:查找第一个分隔符,pos定位到长度字段的结束位置auto pos = inbuffer.find(SEP); // SEP = "\r\n"if (pos == std::string::npos)return std::string(); // 没有找到分隔符,数据不完整 直接返回空字符串// 第2步:提取长度字段(第一个分隔符之前的部分)std::string len_str = inbuffer.substr(0, pos);if (len_str.empty())return std::string(); // 发现长度字段为空,协议格式错误// 第3步:将长度字符串转换为整数int packlen = std::stoi(len_str); // 得到序列化字符串的长度// 第4步:计算整个报文的总长度// 总长度 = 长度字段 + 分隔符 + JSON数据 + 分隔符int total = packlen + len_str.size() + 2 * SEP.size();if (inbuffer.size() < total) // 发现本次接收到的数据不是一个完整的报文,意味着缓冲区数据不足,需要等待下一次的报文去+=到本次不完整的报文的后面return std::string(); // 则返回空字符串// 第5步:提取JSON数据部分std::string package = inbuffer.substr(pos + SEP.size(), packlen);// 第6步:从缓冲区移除已处理的数据inbuffer.erase(0, total);return package;// 所以 但凡报文不完整则一律返回空字符串 只有完整才会返回JSON部分// 所以 调用该接口的 通过返回值即可判断是否完整}// Request请求类// 客户端使用该类 会生成请求类的对象// 类中有左计算数 计算符 右操作数// 一个类就是一个计算请求class Request{public:Request() // 无参构造{}Request(int x, int y, char oper) : _x(x), _y(y), _oper(oper) // 有参构造{}// Serialize-->序列化// 将结构体转换为JSON字符串// 写法是Json其中的一种 这是固定的写法bool Serialize(std::string *out){Json::Value root; // 创建JSON值的根对象// 将结构体成员填充到JSON对象中// 这相当于创建:{"x": 值, "y": 值, "oper": 值}root["x"] = _x; // 自动将int转换为JSON数值root["y"] = _y;root["oper"] = _oper; // char会自动转换为JSON数值(ASCII码)Json::FastWriter writer; // 创建JSON写入器// 将JSON对象转换为字符串 写入到*out中 此时的*out就是一个将结构体序列化之后得到的字符串*out = writer.write(root);return true;}// Deserialize-->反序列化// 把JSON字符串转化为结构体bool Deserialize(const std::string &in){// 代码同理Json::Value root; // 创建JSON根对象Json::Reader reader; // 创建JSON解析器// 解析JSON字符串bool res = reader.parse(in, root);if (!res)return false; // 解析失败// 复原到结构体_x = root["x"].asInt(); // 从JSON数值转换为int_y = root["y"].asInt(); // 从JSON数值转换为int_oper = root["oper"].asInt(); // 从JSON数值转换为int 字符本质也是整形intreturn true;}public:int _x; // 左计算数int _y; // 右计算数char _oper; // 运算符号};// Response响应类// 表示服务端返回的计算结果 服务端计算完成之后 返回该类的对象// 该类包含计算接口和错误码两个成员变量class Response{public:Response() // 无参构造{}Response(int result, int code) : _result(result), _code(code) // 有参构造{}// 同理序列化bool Serialize(std::string *out){// 转换成为字符串Json::Value root;root["result"] = _result;root["code"] = _code;Json::FastWriter writer;// Json::StyledWriter writer;*out = writer.write(root);return true;}// 同理反序列化bool Deserialize(const std::string &in){Json::Value root;Json::Reader reader;bool res = reader.parse(in, root);if (!res)return false;_result = root["result"].asInt();_code = root["code"].asInt();return true;}public:int _result; // 结果int _code; // 用于表示结果的可宝兴 0:可报 1: 除0错误 2: 非法操作(符号不属于运算符等)};// Factory工厂类// 客户端调用以便于生成请求类 && 服务端调用以便于生成响应类// 所以采用随机数原则 来生成不同的式子class Factory{public:Factory() // 构造函数{srand(time(nullptr) ^ getpid()); // 随机数种子opers = "+-*/%^&|"; // 符号字符数组}// 创建请求// 随机生成计算表达式std::shared_ptr<Request> BuildRequest() // 返回值为Request请求类类型的指针{int x = rand() % 10 + 1; // 生成[0,1,2,3,4,5,6,7,8,9,10]中随机的左计算数usleep(x * 10);int y = rand() % 5; // 生成[0,1,2,3,4]中随机的左计算数usleep(y * x * 5);char oper = opers[rand() % opers.size()]; // 生成"+-*/%^&|"中随时的运算符号 特意包含错误运算符 体现响应类错误码的作用std::shared_ptr<Request> req = std::make_shared<Request>(x, y, oper);//创建请求类对象 将三个随机数据填入return req;}// 创建响应// 根据客户端发来的随机计算式 生成响应std::shared_ptr<Response> BuildResponse() // 返回值为Response响应类类型的指针{return std::make_shared<Response>();}~Factory(){}private:std::string opers; //符号字符数组 };
}
解释:
①:Request请求类,成员变量int _x为左计算数,int _y为右计算数,char _oper为运算符号,我们下面的Factory类就是使用随机数来生成不同的式子赋给请求类对象
②:Response响应类,成员变量_result为结果,int _code为错误码,错误码用于之后在之后计算的时候出现除零错误,取模错误,异常运算符的时候,只有_code为0才代表式子是正确的!
③:二者都有Serialize序列化和Deserialize反序列化的接口,因为客户端需要把Request请求类对象进行序列化和Decode,需要对服务端发过来的报文进行Decode后,再进行反序列化;而服务端需要把Response响应类进行序列化和Decode,需要对客户端发过来的报文进行Decode后,再进行反序列化;而Serialize序列化和Deserialize反序列化接口内部的实现是基于第三方库JSON的,其写法有很多种,博主这个只是其中的一种
④:Decode拆解被Encode封装之后的字符串,此接口实现就是上文中的a~e步骤,我们可以根据返回值是否是空字符串来判断是否是一个完整的报文,若是不完整则继续调用recv接口即可
⑤:Encode是对被序列化之后的字符串进行封装的,格式为长度+分隔符+有效载荷+分隔符,所以一开始就要计算有效载荷的长度,然后把长度转化为字符串放在报文的最开始,在添加第一个分隔符,再+=有效载荷,再添加分隔符即可
⑥:Factory就是用来生成请求类对象的,这样我们的客户端就不用自己手动的生成了
3:CalCulate.hpp
#pragma once
#include <iostream>
#include "Protocol.hpp"using namespace protocol_ns;// 计算器类
class Calculate
{
public:Calculate(){}// Excute函数 负责计算// Excute就是被ervice类的成员函数ServiceHelper所bind的函数Response Excute(const Request &req){// 创建一个响应类对象Response resp(0, 0);// switch case语句// 对接收到的请求类的成员变量_oper进行判断switch (req._oper){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 = 1; // 则把响应类表示错误码的成员变量_code设为1}else // 正常除法{resp._result = req._x / req._y;}}break;case '%':{// 发生取模错误if (req._y == 0){resp._code = 2; // 则把响应类表示错误码的成员变量_code设为2}else // 正常取模{resp._result = req._x % req._y;}}break;default: // 运算符不是+-*/%的情况resp._code = 3; // 则把响应类表示错误码的成员变量_code设为3break;}return resp; // 所以_code--->0:正常 1:除零错误 2:取模错误 3:异常运算法}~Calculate(){}private:
};
解释:
①:Calculate(计算类),就是对计算式进行计算的类,所以肯定就是对接收到的请求类对象进行计算,只不过在使用这个类之前还需要进行Decode和反序列化等操作,当我们收到一个请求类对象的时候,我们就要先对其中的_oper成员变量进行判断,看下是哪种计算,然后再根据运算符去计算左运算数和右运算数即可
②:而计算完成则返回一个响应类response对象即可,其中的_code为0代表_result结果可靠,返回其他可能都是出错
4:TcpServer.hpp
#include <iostream>
#include <string>
#include <functional>
#include <sys/types.h>
#include <sys/socket.h>
#include <netinet/in.h>
#include <arpa/inet.h>
#include <unistd.h>
#include <cstring>
#include <pthread.h>
#include <sys/types.h>
#include <sys/wait.h>
#include <memory>#include "InetAddr.hpp" //包含网络属性结构体类
#include "Log.hpp" //包含日志
#include "Socket.hpp" //包含封装套接字的类using namespace socket_ns;// 声明一下TcpServer服务类 因为ThreadData需要用到 避免向上查找找不到
class TcpServer;// 回调函数io_service_t 父类指针 InetAddr对象
using io_service_t = std::function<void(socket_sptr sockfd, InetAddr client)>; // using socket_sptr = std::shared_ptr<Socket>
//此父类指针的成员变量是accept接口返回的套接字 所以可以用该指针对象去调用Recv Send等接口// 线程数据类
// 线程函数需要用到
class ThreadData
{
public:ThreadData(socket_sptr fd, InetAddr addr, TcpServer *s) : sockfd(fd), clientaddr(addr), self(s){}public:// sockfd是Socket类的智能指针对象:Accepter的返回值,Accepter内部调用accept,用accept返回的通信套接字创建的智能指针对象socket_sptr sockfd;InetAddr clientaddr; // 客户端的网络属性结构体TcpServer *self; // 服务类指针
};// 服务类
class TcpServer
{
public:// 构造函数 port端口号是用户输入 service回调函数我们会bindTcpServer(int port, io_service_t service): _localaddr("0", port), // 构造一个InetAddr类对象_listensock(std::make_unique<TcpSocket>()), // _listensock为父类指针去接收子类指针 以便实现多态_service(service), // 回调函数_isrunning(false) // 是否运行{// 调用BuildListenSocket(创建套接字socket+bind绑定+监听listen)// 这正是服务类需要的一系列的套接字接口// 体现了我们封装套接字接口的便捷之处// 而内部则会实现多态 去调用子类的定义_listensock->BuildListenSocket(_localaddr);}// 线程执行函数static void *HandlerSock(void *args){pthread_detach(pthread_self()); // 线程分离 防止阻塞ThreadData *td = static_cast<ThreadData *>(args); // 参数转换td->self->_service(td->sockfd, td->clientaddr); // 回调函数的调用 参数都是线程数据类的成员变量 这就是为什么要在线程数据类前声明一下服务类::close(td->sockfd->SockFd()); // 防止文件描述符泄漏delete td; // 释放资源return nullptr;}// 加载函数void Loop(){_isrunning = true;// 进行连接while (_isrunning){InetAddr peeraddr; // 用于接收客户端的网络属性结构体类对象// 使用成员变量_listensock去多态调用Accepter// 从而在连接成功之后,还得到了一个以accept返回的通信套接字创建的智能指针对象// 该对象才能够正确的调用recv send等函数socket_sptr normalsock = _listensock->Accepter(&peeraddr);if (normalsock == nullptr) // 连接失败continue; // 则等待下一次连接// 采用多线程pthread_t t;// 创建一个ThreadData类类型的指针 作为HandlerSock函数的参数 方便在线程函数中调用回调函数ThreadData *td = new ThreadData(normalsock, peeraddr, this);pthread_create(&t, nullptr, HandlerSock, td); // 每个线程都会去执行HandlerSock函数}_isrunning = false;}~TcpServer(){}private:InetAddr _localaddr; // 网络属性结构体类std::unique_ptr<Socket> _listensock; //_listensock父类指针 负责调用父类的Accepter之前的函数bool _isrunning; // 是否运行io_service_t _service; // 回调函数
};
解释:
①:成员变量_localaddr是一个网络属性结构体类对象,其中的IP肯定为0,但是端口号是接收用户输入的,既然我们只需要接收端口号就行,那为什么不把成员变量设为端口号,而是网络属性结构体对象呢,因为这个对象会用于我们在服务类中调用封装套接字的Socket类的BuildListenSocket接口的参数,我们该接口中的bind是需要一个服务器本地的网络属性结构体的信息的!所以我们先构建出一个本地的属性结构体,直接作为BuildListenSocket接口的参数即可
②:而成员变量_listensock就是用来调用BuildListenSocket这种接口的,_listensock是父类指针类型,其接收了一个子类的指针变量,所以尽管调用BuildListenSocket等接口,都是多态!不过_listensock是使用无参构造区构造一个 TcpSocket 子类对象,并通过 std::make_shared 创建其智能指针,然后在赋值给基类 Socket 的智能指针类型 socket_sptr。选择无参是因为我们本来就不知道套接字是多少,我们的socket接口是在BuildListenSocket接口中才被调用的!而子类对象的成员变量_sockfd一开始缺省为-1,当执行到socket接口时候,就会被赋值,符合预期
③:而当我们_listensock调用了accept之后,就不能再直接调用Recv和Send接口了,因为我们之前说过,Recv和Send的接口的参数不再是我们监听套接字,而是accept的返回值,所以Accepter内部在调用完accpet之后,会执行socket_sptr sock = std::make_shared<TcpSocket>(sockfd)代码,该代码意义为:"使用 accept返回的值sockfd 构造一个 TcpSocket 子类对象,并通过 std::make_shared 创建其智能指针,然后赋值给基类 Socket 的智能指针类型 socket_sptr",这样你才可以正确的使用sock去调用Recv和Send,因为Recv和Send需要的参数就是此时的子类对象的成员变量!
④:服务端代码逻辑,先调用BuildListenSocket接口(完成创建套接字socket,bind绑定,listen监听),然后再调用Accpeter接口进行连接,得到Accepter接口的返回值,然后再创建线程,在线程函数中去调用回调函数_service,而_service会被bind绑定在另一个类的成员函数中。所以可以想到的是_service所bind的那个函数一定会去调用父类的Recv和Send等接口,所以_service的参数一定包含Accepter的返回值!既然要调用Recv和Send等接口,必定还需要对端的网络属性结构体,所以我们的_service的参数一定还有一个对端网络属性结构体!而这个对端网络结构体就是Acceper的输出型参数!
注意:Acceper的输出型参数为peeraddr,而BuildListenSocket接口的参数为成员变量_localaddr,前者用于接收连接方也就是客户端的网络属性,后者是服务端的网络属性
⑤:所以我们调用Accepter的时候,为_listensock->Accepter(&peeraddr);其中的peeraddr就是对端网络属性结构体,其次我们的线程函数中调用的_service的参数为td->sockfd和td->clientaddr,前者为Accepter的返回值,后者为对端网络属性结构体,符合预期!
5:TcpServerMain.cc
TcpServerMain.cc负责调用服务类中的接口完成服务端的代码逻辑,并且我们的服务类中回调函数bind的对象也实现在了TcpServerMain.cc中
#include <iostream>
#include <functional>
#include <memory>
#include "TcpServer.hpp"
#include "Protocol.hpp"
#include "CalCulate.hpp"using namespace protocol_ns;// 运行程序错误时的提示
void Usage(std::string proc)
{std::cout << "Usage:\n\t" << proc << " local_port\n"<< std::endl;
}// 定义回调函数 参数为请求类对象 返回值为响应类对象
using callback_t = std::function<Response(const Request &req)>;// Service类
class Service
{
public:// 构造函数Service(callback_t cb) : _cb(cb) // 接收一个函数去赋给回调函数 即bind{}// ServiceHelper 就是网络计算器的核心函数// sockptr:父类指针类型 成员变量为accept的返回值 所以可以调用Recv和Send等接口// client:客户端的网络属性结构体void ServiceHelper(socket_sptr sockptr, InetAddr client){// 其先调用当前父类指针对象sockptr的成员函数SockFd()// 获取到accept返回的套接字 该套接字才能作为send和recv的参数int sockfd = sockptr->SockFd();// 来到这个已经获取到了连接// 所以打印信息 显示一下接收到的是哪个客户端的连接LOG(DEBUG, "get a new link, info %s:%d, fd : %d\n", client.Ip().c_str(), client.Port(), sockfd);// clientaddr为客户端的标识符 也就是前缀std::string clientaddr = "[" + client.Ip() + ":" + std::to_string(client.Port()) + "] ";// inbuffer用来存储Recv读取到的内容std::string inbuffer;while (true){sleep(5);Request req; // 创建请求类对象 用于接收对客户端发来的报文进行Decode+Deserialize后的结构体变量// 1.调用成员函数Recv// 其内部的recv函数的参数就是accept的返回值// 读取到的内容会被存储进inbuffer中int n = sockptr->Recv(&inbuffer);// recv没有接收到信息if (n < 0){// 打印客户端退出的信息LOG(DEBUG, "client %s quit\n", clientaddr.c_str());break;}// 来到这,代表接收到了客户端发来的信息,所以要确认报文是否完整!std::string package;while (true){sleep(1);std::cout << "inbuffer(读取到的封装字符串): " << inbuffer << std::endl;// 2.对不完整的json串的处理// 对接收到的报文进行Decode拆解分析// 此时的报文已经是被Encode封装后的序列化的字符串,只是不知道是否完整package = Decode(inbuffer);if (package.empty()) // 返回为空字符串 则不完整break; // 跳出当前while 回到外层的while 继续Recv接收!直到完整// 来到这里,代表一定读到了一个完整的json串.std::cout << "------------------------begin---------------" << std::endl;std::cout << "resq string(拆解之后得到的请求序列化字符串:):\n"<< package << std::endl; // 打印一下完整的json串// 3.反序列化// 得到请求类结构体变量req.Deserialize(package); // 此时的req的成员变量_x __oper _y 已经被正确填充// 4. 业务处理// 调用回调函数_cb 参数就是req 返回一个响应类对象respResponse resp = _cb(req); // _cb函数调用之后 该resp的成员变量_result和 _code已经被正确填充// 5. 对应答做序列化// 以便传回给客户端std::string send_str;resp.Serialize(&send_str); // 序列化std::cout << "resp Serialize(响应结构体序列化:):" << std::endl;std::cout << send_str << std::endl; // 打印一下结果被序列化之后的字符串// 6. 对序列化后的send_str添加长度报头send_str = Encode(send_str); // 封装序列化的字符串std::cout << "resp Encode(响应结构体序列化再封装:):" << std::endl;std::cout << send_str << std::endl; // 打印一下被封装序列化的字符串// 此时的字符串为:"len"\r\n"{ }"\r\n"// 再调用Send发送到客户端sockptr->Send(send_str); // 本次不对发送做处理, EPOLL}}}private:callback_t _cb; // 回调函数
};// ./tcpserver port
int main(int argc, char *argv[])
{if (argc != 2){Usage(argv[0]);return 1;}uint16_t port = std::stoi(argv[1]); // 从main的参数列表中得到端口号// daemon(0, 0);EnableFile(); // 日志打印到屏幕上Calculate cal; // 定义Calculate(计算机类)对象 方便Service类去bind计算机类的成员函数Service calservice(std::bind(&Calculate::Excute, &cal, std::placeholders::_1)); // bind完成// TcpServer(服务器类)也需要bind绑定Service类的成员函数ServiceHelperio_service_t service = std::bind(&Service::ServiceHelper, &calservice, std::placeholders::_1, std::placeholders::_2);// 最后再创建TcpServer类对象 也就是服务器类对象tsvrstd::unique_ptr<TcpServer> tsvr = std::make_unique<TcpServer>(port, service);// tsvr调用Loop函数 (服务器类的创建套接字+bind+监听listen都已经在服务器类的构造函数中被自动调用了)// 进行连接accept+创建线程+线程函数内部执行我们bind的Service类的成员函数ServiceHelper// 而Service类的成员函数ServiceHelper 也是bind的计算机类的成员函数Excutetsvr->Loop();// 所以main的代码重点在于两次bind 环环相扣!return 0;
}
解释:
①:我们的服务类的回调函数bind的就是TcpServerMain.cc中的类的成员函数,很显然我们需要双重bind,服务器类的回调函数bindTcpServerMain.cc中类的成员函数ServiceHelper,该函数主要负责数据的IO,也就是对接收到的报文进行Decode去判断是否完整,不完整则继续recv,完整则进行反序列化Deserialize得到一个请求结构体req,然后把req作为参数传给我们的Service的回调函数_cb,_cb会返回一个计算完成之后的响应结构体resp,然后我们再对resp进行序列化和封装传给客户端即可!
②:所以必然的_cb回调函数所绑定的一定是CalCulate.hpp中CalCulate类的成员函数,也就是那个switch case的成员函数
③:所以我们下面main进行了两次绑定,在创建服务器类对象的时候,构造函数就会调用调用BuildListenSocket(创建套接字socket+bind绑定+监听listen),然后我们再调用服务器类的Loop函数进行连接即可。Loop完成连接,内部的线程会进行调用线程函数,线程函数调用回调函数,回调函数就是TcpServerMain.cc中Service类的成员函数ServiceHelper,然后ServiceHelper中会调用自己的回调函数,该回调函数就是CalCulate.hpp中CalCulate类的成员函数,双重bind,逻辑清晰
6:TcpClientMain.cc
TcpClientMain.cc就是客户端代码
#include <iostream>
#include <string>
#include <memory>
#include <ctime>
#include "Socket.hpp"
#include "Protocol.hpp"
#include "InetAddr.hpp"using namespace socket_ns;
using namespace protocol_ns;// 启动客户端 需要用户输入服务端的IP+PORT
void Usage(std::string proc)
{std::cout << "Usage:\n\t" << proc << " serverip serverport\n"<< std::endl;
}// ./tcp_client serverip serverport
int main(int argc, char *argv[])
{if (argc != 3){Usage(argv[0]);exit(1);}std::string serverip = argv[1]; // 从main的参数列表中获取到服务端IPuint16_t serverport = std::stoi(argv[2]); // 从main的参数列表中获取到服务端PORT// InetAddr类的构造方式2:使用ip和port构造InetAddr serveraddr(serverip, serverport);//创建工厂对象 方便我们后面调用成员函数BuildRequest 去随机生成计算式子了!Factory factory;//定义一个父类的之怎std::unique_ptr<Socket> cli = std::make_unique<TcpSocket>();//以便调用BuildClientSocket(创建套接字socket+发送连接请求connect)bool res = cli->BuildClientSocket(serveraddr);std::string inbuffer;while (res){sleep(1);// std::string str;// for (int i = 0; i < 5; i++)// {// // 1. 构建一个请求// auto req = factory.BuildRequest();// // 2. 对请求进行序列化// std::string send_str;// req->Serialize(&send_str);// std::cout << "Serialize(序列化): \n"// << send_str << std::endl;// // 3. 添加长度报头// send_str = Encode(send_str);// std::cout << "Encode(封装): \n"// << send_str << std::endl;// str += send_str;// }auto req = factory.BuildRequest();// 2. 对请求进行序列化std::string send_str;req->Serialize(&send_str);std::cout << "Serialize(序列化): \n"<< send_str << std::endl;// 3. 添加长度报头send_str = Encode(send_str);std::cout << "Encode(封装): \n"<< send_str << std::endl;// 4. "len"\r\n"{}"\r\ncli->Send(send_str);// 5. 读取应答int n = cli->Recv(&inbuffer);if (n <= 0)break;std::string package = Decode(inbuffer);if (package.empty())continue;// 6. 我能保证package一定是一个完整的应答!auto resp = factory.BuildResponse();// 6.1 反序列化resp->Deserialize(package);// 7. 拿到了结构化的应答吗std::cout <<"结果:"<< resp->_result << "[错误码:" << resp->_code << "]" << std::endl;}
}
解释:
①:屏蔽掉的部分是一次生成5个请求,用于体现粘报问题是可以解决的,未屏蔽部分是单个请求
②:所以客户端一开始直接可以使用封装的套接字去调用BuildClientSocket,当然我们需要和服务端调用BuildListenSocket一样,事先先把自己本地的属性结构体构造出来,然后作为参数传递给BuildClientSocket
③:然后调用factory.BuildRequest去制造请求,然后对请求进行序列化,再进行封装Encode,再Send发送给服务端,然后再Recv接收,Decode检测到接受不完整则continue去再次Recv,完整则进行反序列化即可
7:InetAddr.hpp
InetAddr.hpp就是网络属性结构体类
#pragma once#include <iostream>
#include <sys/types.h>
#include <sys/socket.h>
#include <arpa/inet.h>
#include <netinet/in.h>class InetAddr
{
private:// 把IP和PORT存放进两个输出型参数中 也就是两个成员变量中void GetAddress(std::string *ip, uint16_t *port){*port = ntohs(_addr.sin_port);*ip = inet_ntoa(_addr.sin_addr);}public:// 构造方式1:使用网络属性结构体构造InetAddr(const struct sockaddr_in &addr) : _addr(addr){GetAddress(&_ip, &_port);}// 构造方式2:使用ip和port构造InetAddr(const std::string &ip, uint16_t port) : _ip(ip), _port(port){_addr.sin_family = AF_INET;_addr.sin_port = htons(_port);_addr.sin_addr.s_addr = inet_addr(_ip.c_str());}InetAddr(){}// 返回IP项std::string Ip(){return _ip;}//==的重载bool operator==(const InetAddr &addr){if (_ip == addr._ip && _port == addr._port){return true;}return false;}// 返回整个网络属性结构体struct sockaddr_in Addr(){return _addr;}// 返回PORT项uint16_t Port(){return _port;}~InetAddr(){}private:struct sockaddr_in _addr;std::string _ip;uint16_t _port;
};
解释:
①:我们之前的InetAddr类的构造函数只有一种,就是接收一个网络属性结构体然后实例化出InetAddr.hpp的对象,用于返回IP或PORT或网络属性结构体本身
②:但是现在新增一种构造方式,为接收IP和PORT去构造出一个InetAddr的对象,结构体中的其余两个字段在带参构造中已经手动补充了,而为什么需要这种IP+PORT去构造InetAddr对象的构造方式呢?因为我们的套接字被封装了,去中的bind接口被封装,所以你必须给bind所处的接口传递一个本地的网络属性结构体,所以增加了带参构造!
8:Log.hpp
Log.hpp就是日志文件,博客:https://blog.csdn.net/shylyly_/article/details/151263351
#pragma once#include <iostream> //C++必备头文件
#include <cstdio> //snprintf
#include <string> //std::string
#include <ctime> //time
#include <cstdarg> //va_接口
#include <sys/types.h> //getpid
#include <unistd.h> //getpid
#include <thread> //锁
#include <mutex> //锁
#include <fstream> //C++的文件操作std::mutex g_mutex; // 定义全局互斥锁
bool gIsSave = false; // 定义一个bool类型 用来判断打印到屏幕还是保存到文件
const std::string logname = "log.txt"; // 保存日志信息的文件名字// 日志等级
enum Level
{DEBUG = 0,INFO,WARNING,ERROR,FATAL
};// 将日志写进文件的函数
void SaveFile(const std::string &filename, const std::string &message)
{std::ofstream out(filename, std::ios::app);if (!out.is_open()){return;}out << message << std::endl;out.close();
}// 日志等级转字符串--->字符串才能表示等级的意义 0123意义不清晰
std::string LevelToString(int level)
{switch (level){case DEBUG:return "Debug";case INFO:return "Info";case WARNING:return "Warning";case ERROR:return "Error";case FATAL:return "Fatal";default:return "Unknown";}
}// 获取当前时间的字符串
// 时间格式包含多个字符 所以干脆糅合成一个字符串
std::string GetTimeString()
{// 获取当前时间的时间戳(从1970-01-01 00:00:00开始的秒数)time_t curr_time = time(nullptr);// 将时间戳转换为本地时间的tm结构体// tm结构体包含年、月、日、时、分、秒等字段struct tm *format_time = localtime(&curr_time);// 检查时间转换是否成功if (format_time == nullptr)return "None";// 缓冲区用于存储格式化后的时间字符串char time_buffer[1024];// 格式化时间字符串:年-月-日 时:分:秒snprintf(time_buffer, sizeof(time_buffer), "%d-%02d-%02d %02d:%02d:%02d",format_time->tm_year + 1900, // tm_year: 从1900年开始的年数,需要加1900format_time->tm_mon + 1, // tm_mon: 月份范围0-11,需要加1得到实际月份format_time->tm_mday, // tm_mday: 月中的日期(1-31)format_time->tm_hour, // tm_hour: 小时(0-23)format_time->tm_min, // tm_min: 分钟(0-59)format_time->tm_sec); // tm_sec: 秒(0-60,60表示闰秒)return time_buffer; // 返回格式化后的时间字符串
}// 日志函数-->打印出日志
// 格式:时间 + 等级 + PID + 文件名 + 代码行数 + 可变参数
void LogMessage(int level, std::string filename, int line, bool issave, const char *format, ...)
{std::string levelstr = LevelToString(level); // 得到等级字符串std::string timestr = GetTimeString(); // 得到时间字符串pid_t selfid = getpid(); // 得到PID// 使用va_接口+vsnprintf得到用户想要的可变参数的字符串 存储与buffer中char buffer[1024];va_list arg;va_start(arg, format);vsnprintf(buffer, sizeof(buffer), format, arg);va_end(arg);std::lock_guard<std::mutex> lock(g_mutex); // 引入C++的RAII的锁 保护打印功能// 保存格式为时间 + 等级 + PID + 文件名 + 代码行数 + 可变参数 的日志信息 到message中std::string message = "[" + timestr + "]" + "[" + levelstr + "]" +"[" + std::to_string(selfid) + "]" +"[" + filename + "]" + "[" + std::to_string(line) + "] " + buffer;// 打印到屏幕if (!issave){std::cout << message << std::endl;}// 保存进文件else{SaveFile(logname, message);}
}// 宏定义 省略掉__FILE__ 和 __LINE__
#define LOG(level, format, ...) \do \{ \LogMessage(level, __FILE__, __LINE__, gIsSave, format, ##__VA_ARGS__); \} while (0)// 用户调用则意味着保存到文件
#define EnableScreen() (gIsSave = false)// 用户调用则意味着打印到屏幕
#define EnableFile() (gIsSave = true)
9:makefile
.PHONY:all
all:cal_server cal_clientcal_server:TcpServerMain.ccg++ -o $@ $^ -std=c++14 -lpthread -ljsoncpp
cal_client:TcpClientMain.ccg++ -o $@ $^ -std=c++14 -ljsoncpp
.PHONY:clean
clean:rm -f cal_server cal_client
三:效果
1:一次单个请求
分析:
2:一次多个请求
解释:因为我们的a~e的e步骤能够解决粘报问题,所以没问题