
前言打印宏在我们的日常使用中具有重要的辅助作用。通过打印宏我们在调试代码的时候打印信息的管理就会变得异常容易。包括我们可以带上打印位置的行号与文件名还有时间信息方便我们快速定位位置。以前我们在Linux网络编程的学习中曾经写过一个log文件但是这个log文件整体还是偏臃肿了他的很多功能其实都可以被宏平替。准确的说我们不用再使用复杂的继承体系类来实现log只需通过宏的使用以及调整宏的打印等级就可以轻松的实现打印信息取消打印信息以及打印到日志文件的功能。我们本篇文章的前面紧接前一篇文章的Buffer代码设计在开始socket的代码设计之前我们先来讲一些打印宏代码的编写。打印宏的代码实现#define是打印宏的核心它的编译期文本替换功能让日志打印变得极其灵活和高效。日志等级一个打印宏的日志等级将会控制其是否可以被打印我们通常将其定义为常数从0开始。而日志等级通常可以被分为以下INF信息普通运行信息DBG调试调试信息开发时用ERR错误出错了必须打印数字越小级别越低数字越大级别越高。比如#define INF 0 #define DBG 1 #define ERR 2 #define LOG_LEVEL DBG // 当前日志级别设置为 DBG这里的LOG_LEVEL宏是我们定义出来的一个门槛等级。我们可以通过比较大小来选择性的打印。LOG_LEVEL决定了哪些级别的日志会被打印。比如设置为DBG时ERR和DBG都打印但INF不打印。获取相关信息我们在打印日志的时候为了快速获取当前测试代码的信息我们一般会要求打印出当前的文件行号还有时间信息甚至是进程信息。此时我们就需要一些功能函数来帮助我们获取信息但是如何打印呢宏只有一行那么我们此时就可以借助do while语句以及续行符’ \ 来使用一些功能函数。首先就是时间信息我们可以使用以下函数time(NULL)获取当前时间从1970年1月1日到现在的秒数localtime(t)把秒数转换成struct tm结构体包含年、月、日、时、分、秒等strftime()把时间格式化输出类似printf但针对时间再用%H:%M:%S分别表示时、分、秒24小时制并打印结果输出。比如#include stdio.h #include time.h int main() { time_t t time(NULL); struct tm *ltm localtime(t); char tmp[32] {0}; strftime(tmp, 31, %H:%M:%S, ltm); printf(当前时间: %s\n, tmp); return 0; }打印结果为当前时间: 14:23:45接下来就是获取线程ID和文件名行号我们需要熟练掌握可变参数宏的相关pthread_self()获取当前线程的ID返回pthread_t类型%p以十六进制打印指针/地址__FILE__预定义宏当前文件名字符串__LINE__预定义宏当前行号整数__VA_ARGS__可变参数宏接收...中的所有参数上面四个知识点都很好懂但是什么是可变参数宏呢在普通宏中我们的参数其实是固定了的当我们也不知道我们要传多少个参数的时候就需要可变参数宏来帮助我们就像是printf的调用他并不知道我们后面要打印多少个参数。其标准用法为#define宏名(固定参数,...)替换文本 __VA_ARGS__… 表示可变参数的位置__VA_ARGS__代表所有可变参数的内容我们通常在固定参数的位置上传入我们的格式化字符串(就是带%d,%s的那种字符串)。这个字符串通常是外界传入的因为我们不知道有多少个打印的参数所以就让外界传入参数的同时把字符串也传进来。我们在后面的代码中可以理解这个行为。#include stdio.h #include pthread.h int main() { printf(线程ID: %p\n, (void*)pthread_self()); printf(当前文件: %s\n, __FILE__); printf(当前行号: %d\n, __LINE__); return 0; }打印结果类似线程ID: 0x7f8a9b0c0d00 当前文件: test.c 当前行号: 6那么接下来我们就尝试一下来编写一个简单的日志打印宏在这个例子中我们外部需要传入format格式化字符串随后我们将会使用fprintf依次向显示器打印线程信息时间文件名与行号。而__VA_ARGS__就是我们前面用来接收不定参的...中的参数使其进行一个打印的效果。其打印所需的对应的格式占位符我们已经通过format传入。但是这里还是有个问题当我们的不定参数是无的时候也就是没有参数的时候我们的代码就会报错这是因为fprintf的参数列表中的末尾多了一个,那么我们此时就需要##__VA_ARGS__来连接可变参数。当我们在其前面加上##时其对应的不定参数不存在时就会忽略这个逗号。但是我们的日志等级在上面的代码中并没有真实的用上此时我们可以通过简单的多一层宏封装固定的日志等级加上我们一开始定义的日志级别等级二者进行比较通过比较的结果来判断此次我们是否严格打印这里我们又定义了三个宏这是对其LOG的三个外部封装固定其宏的打印等级这样我们会在LOG内部通过传入的等级与我们一开始的定义的level等级相比较来判断此次结果是否被打印。我们用这样的一个代码来测试// 模拟一个网络函数 int fake_recv(int *sockfd, char *buf, int len) { // 模拟错误 errno EAGAIN; // 设置全局错误码 return -1; } int main() { INF_LOG(程序开始运行); int sockfd 3; char buffer[1024]; DBG_LOG(准备接收数据sockfd%d, buffer大小%d, sockfd, (int)sizeof(buffer)); int ret fake_recv(sockfd, buffer, sizeof(buffer)); if (ret 0) { ERR_LOG(recv失败错误码%d (%s), errno, strerror(errno)); } else { INF_LOG(recv成功收到 %d 字节, ret); } INF_LOG(程序结束); return 0; }运行结果为[0x7c8eba101500 10:47:37 test.cc:18] 准备接收数据sockfd3, buffer大小1024 [0x7c8eba101500 10:47:37 test.cc:22] recv失败错误码11 (Resource temporarily unavailable)你会发现我们默认设置的等级是DBG所以我们的INF等级的日志打印消息就不会打印出来。而当我们调整等级为ERR时就只会打印出一个[0x7c8eba101500 10:47:37 test.cc:22] recv失败错误码11 (Resource temporarily unavailable)Socket模块代码设计与实现那么接下来我们就来实现一下我们的socket模块。所谓的socket其实就是最底层的系统调用封装几乎所有上层组件都依赖Socket。我们要做的其实就是涉及一个socket类在这个类中实现配套函数一个socket与一个sockfd绑定在一起方便我们对其进行管理。他需要实现的基础功能有创建套接字 绑定地址信息 开始监听 向服务器发起连接 获取新连接 接收数据 发送数据 关闭套接字 创建一个监听连接 创建一个客户端连接 设置地址端口的重用 设置套接字是否阻塞的属性我们在Linux网络的知识点中学习过socket的使用所以大部分代码其实都是不用讲解的很详细的包括其函数的使用因此我只会对一些代码进行注释补充说明。首先就是我们基础的类成员变量肯定就是一个sockfd套接字的描述符一个套接字肯定是对应着一个套接字文件描述符的。随后就是基础的构造与析构函数。唯一我们要注意的就是socket不应该有拷贝构造函数这是因为Socket 是一个资源句柄Handle它和操作系统内核的套接字对象是 1:1 绑定的。所以我们需要禁用拷贝但是有时候我们需要把 Socket 从一个地方转移到另一个地方比如Accept返回新的客户端连接所以我们需要手动实现移动构造class Socket { private: int _sockfd; public: Socket() {} Socket(int fd) : _sockfd(fd) {} ~Socket() {} int Fd() { return _sockfd; } // 禁止拷贝,Socket 是一个资源句柄它和操作系统内核的套接字对象是 1:1 绑定的 Socket(const Socket ) delete; Socket operator(const Socket ) delete; };首先就是我们最基础的三个功能创建套接字绑定与监听bool Create() { _sockfd socket(AF_INET, SOCK_STREAM, IPPROTO_TCP); // 三个参数分别是协议族套接字类型协议 if (_sockfd 0) { ERR_LOG(CREATE SOCKET FAILED!!); return false; } return true; } bool Bind(const std::string ip, uint16_t port) { // 把套接字和本地 IP 地址、端口号关联起来服务器专用。 // bind的三个参数分别是套接字描述符指向 sockaddr_in 结构体的指针含 IP 和端口与 addr 结构体的大小 struct sockaddr_in _addr; // 定义一个 IPv4 地址结构体用来存放 IP 和端口信息。 _addr.sin_family AF_INET; // 告诉系统使用 IPv4 地址族。 _addr.sin_port htons(port); // 把端口号设置到地址结构体中。 _addr.sin_addr.s_addr inet_addr(ip.c_str()); // 把字符串形式的 IP 地址如 192.168.1.100转换成网络字节序的 32 位整数。 socklen_t len sizeof(struct sockaddr_in); // 计算结构体大小 int ret bind(_sockfd, (struct sockaddr *)_addr, len); // bind() 是通用函数设计用于 IPv4、IPv6、Unix域等多种地址。 // 所以它接受的是通用类型 struct sockaddr*而不是特定的 struct sockaddr_in*,因此我们要进行强转 if (ret 0) { ERR_LOG(BIND ADDRESS FAILED!); return false; } return true; } bool Listen(int backlog MAX_LISTEN) { int ret listen(_sockfd, backlog); // 开始监听backlog决定了全连接队列的长度 if (ret 0) { ERR_LOG(SOCKET LISTEN FAILED!); return false; } return true; }这里的代码都是固定的代码我们就不过多赘述。listen接口的时候会用到一个参数backlog我们这里设置为1024即全连接队列的最大长度。connect的接口的使用方法与bind类似只不过二者目的并不相同connect()是客户端在使用我们要将其连接到远程服务器地址和端口。bool Connect(const std::string ip, uint16_t port) { struct sockaddr_in _addr; // 定义一个 IPv4 地址结构体用来存放 IP 和端口信息。 _addr.sin_family AF_INET; // 告诉系统使用 IPv4 地址族。 _addr.sin_port htons(port); // 把端口号设置到地址结构体中。 _addr.sin_addr.s_addr inet_addr(ip.c_str()); // 把字符串形式的 IP 地址如 192.168.1.100转换成网络字节序的 32 位整数。 socklen_t len sizeof(struct sockaddr_in); // 计算结构体大小 int ret connect(_sockfd, (struct sockaddr *)_addr, len); if (ret 0) { ERR_LOG(CONNECT SERVER FAILED!); return false; } return true; }接下来就是Accept接受新连接的接口实现这个接口的返回值有两种第一种是我们可以返回文件描述符这样解耦合高代码灵活性高。但是你也可以返回一个socket对象。在现代c语法的移动语义的帮助下所花费的开销并不是很大。二者方法都可以我这里就直接返回一个socket对象。Socket Accept() // 这里也可以返回文件描述符 { int newfd accept(_sockfd, NULL, NULL); // 当我们并不关心客户端的地址信息时后面两个参数就可以置为空 if (newfd 0) { if (errno EAGAIN || errno EWOULDBLOCK || errno EINTR) { // 没有新连接这是正常情况不打印错误 return Socket(-1); } ERR_LOG(ACCEPT FAILED!); return Socket(-1); // 后续在处理时判断的也是其fd是否为-1 } return Socket(newfd); // RVO 优化 }随后就是socket重要的接收与发送两个接口我们要注意其返回值的处理因为这两个接口函数的返回值不同代表不同的情况。ssize_t Recv(void *buf, size_t len, int flag 0) // 这个接口我们默认为阻塞的 { ssize_t ret recv(_sockfd, buf, len, flag); if (ret 0) // 返回值 0对端正常关闭连接 { return -2; //返回-2表示对端关闭因为对端关闭我们必须单独判断虽然处理方法是大概是关闭连接 //但是有一种情况就是上一次错误码为EAGAINEINTR如果你不进行单独处理没有这个判断 //就会一直进入return 0的情况然后上层会一直尝试去不断读取陷入死循环但是此时你的对端已经关闭了无意义 } if(ret 0) { if (errno EAGAIN || errno EINTR) { return 0; // 特殊标记非阻塞模式无数据可读或者被信号被打断 } ERR_LOG(SOCKET RECV FAILED!!); return -1; } return ret;//返回实际接收到的长度 }ssize_t Send(const void *buf, size_t len, int flag 0)//我们不会修改buf所以可以用const方便调用该函数时使用的是string.c_str()传入的是个const char*无法变成void* { ssize_t ret send(_sockfd, buf, len, flag); // 向socket的发送缓冲区写入数据 if (ret 0) { if (errno EAGAIN || errno EINTR) { return 0; } ERR_LOG(SOCKET SEND FAILED!!); return -1; } return ret; // 实际发送的数据长度 }我们上面的两个基础版本的recv与send都是阻塞版本的事实上我们应该设置两个非阻塞版本的接口。根据之前Buffer的思想你控制非阻塞只需要替换flag就行了所以我们只需要单独列两个接口内部调用recv与send就行只是flag的传参不一样。ssize_t NonBlockRecv(void *buf, size_t len) { return Recv(buf, len, MSG_DONTWAIT); // MSG_DONTWAIT 表示当前接收为非阻塞。 } ssize_t NonBlockSend(void *buf, size_t len) { if (len 0) return 0; return Send(buf, len, MSG_DONTWAIT); // MSG_DONTWAIT 表示当前发送为非阻塞。 }然后就是关闭我们的套接字接口内部调用close关闭套接字// 关闭套接字 void Close() { if (_sockfd ! -1) { close(_sockfd); _sockfd -1; } }以上就是我们基础的几个接口但是我们还需要集成几个调用基础接口的整合接口以此直接创建出一个服务端套接字或者客户端套接字。并且不知道大家注意没有作为一个服务器如果他突然挂掉了我们是需要立即对他进行重启的。但是在 TCP 协议中当服务器主动关闭一个连接时这个连接不会立刻消失而是进入TIME_WAIT状态通常会持续 2 分钟即 2MSL。这是 TCP 协议为了保证网络中迟到的数据包被正确丢弃而设计的机制。**如果此时服务器重启或重新部署尝试再次绑定Bind刚刚关闭的那个端口操作系统会直接报错Address already in use导致服务器启动失败。因此我们还需要设置一下地址端口的复用功能。并且我们的Accept等接口我们要求服务器的sockfd在调用Accept的时候不能阻塞所以我们还需要一个接口来设置非阻塞属性因此// 创建一个服务端连接 bool CreateServer(uint16_t port, const std::string ip 0.0.0.0, bool block_flag false) { } // 创建一个客户端连接 bool CreateClient(uint16_t port, const std::string ip) { } // 设置套接字选项---开启地址端口重用 void ReuseAddress() { } // 设置套接字阻塞属性-- 设置为非阻塞 void NonBlock() { }先来实现CreateServer我们需要外界传入一个端口号对于服务器来说我们默认是绑定这个ip的全部端口号。创建服务端套接字接口的过程还是有点说法的对于一个标准的服务器初始化流程应该是创建套接字 (socket)设置地址重用 (setsockopt)绑定地址 (bind)开始监听 (listen)设置非阻塞 (fcntl)(注非阻塞设置在 Bind 之后、Listen 之前或 Listen 之后均可但地址重用必须在 Bind 之前)代码为// 创建一个服务端连接 bool CreateServer(uint16_t port, const std::string ip 0.0.0.0, bool block_flag false) { // 创建套接字 (socket) if (Create() false) { return false; } // 设置地址重用 (setsockopt) ReuseAddress(); // 绑定地址 (bind) if (Bind(ip, port) false) return false; // 开始监听 (listen) if (Listen() false) return false; // 设置非阻塞 (fcntl) (注非阻塞设置在 Bind 之后、Listen 之前或 Listen 之后均可但地址重用必须在 Bind 之前) if (block_flag) NonBlock(); return true; } // 创建一个客户端连接 bool CreateClient(uint16_t port, const std::string ip) { // 1. 创建套接字2.指向连接服务器 if (Create() false) return false; return Connect(ip, port); } // 设置套接字选项---开启地址端口重用 void ReuseAddress() { // int setsockopt(int fd, int leve, int optname, void *val, int vallen) int val 1; setsockopt(_sockfd, SOL_SOCKET, SO_REUSEADDR, (void *)val, sizeof(int)); val 1; setsockopt(_sockfd, SOL_SOCKET, SO_REUSEPORT, (void *)val, sizeof(int)); } // 设置套接字阻塞属性-- 设置为非阻塞 void NonBlock() { // int fcntl(int fd, int cmd, ... /* arg */ ); int flag fcntl(_sockfd, F_GETFL, 0); fcntl(_sockfd, F_SETFL, flag | O_NONBLOCK); }到这里我们的接口部分基本上就已经初步写好了但是我们之前遗落掉的小瑕疵我们还需要完善上就是我们的析构函数与自己写的移动语义接口并没有实现好Socket() : _sockfd(-1) {} Socket(int fd) : _sockfd(fd) {} ~Socket() { Close(); } int Fd() { return _sockfd; } // 禁止拷贝,Socket 是一个资源句柄它和操作系统内核的套接字对象是 1:1 绑定的 Socket(const Socket ) delete; Socket operator(const Socket ) delete; // 手动实现移动语义 Socket(Socket other) : _sockfd(other._sockfd) { other._sockfd -1; } Socket operator(Socket other) { if (this ! other) { Close(); // 释放当前资源 _sockfd other._sockfd; other._sockfd -1; } return *this; }至此那么我们的socket模块基本上就实现完全了接下来我们将会完成Channel事件管理的模块的代码设计与完善希望对大家有所帮助。完整代码如下#define MAX_LISTEN 1024 class Socket { private: int _sockfd; public: Socket() : _sockfd(-1) {} Socket(int fd) : _sockfd(fd) {} ~Socket() { Close(); } int Fd() { return _sockfd; } // 禁止拷贝,Socket 是一个资源句柄它和操作系统内核的套接字对象是 1:1 绑定的 Socket(const Socket ) delete; Socket operator(const Socket ) delete; // 手动实现移动语义 Socket(Socket other) : _sockfd(other._sockfd) { other._sockfd -1; } Socket operator(Socket other) { if (this ! other) { Close(); // 释放当前资源 _sockfd other._sockfd; other._sockfd -1; } return *this; } bool Create() { _sockfd socket(AF_INET, SOCK_STREAM, IPPROTO_TCP); // 三个参数分别是协议族套接字类型协议 if (_sockfd 0) { ERR_LOG(CREATE SOCKET FAILED!!); return false; } return true; } bool Bind(const std::string ip, uint16_t port) { // 把套接字和本地 IP 地址、端口号关联起来服务器专用。 // bind的三个参数分别是套接字描述符指向 sockaddr_in 结构体的指针含 IP 和端口与 addr 结构体的大小 struct sockaddr_in _addr; // 定义一个 IPv4 地址结构体用来存放 IP 和端口信息。 _addr.sin_family AF_INET; // 告诉系统使用 IPv4 地址族。 _addr.sin_port htons(port); // 把端口号设置到地址结构体中。 _addr.sin_addr.s_addr inet_addr(ip.c_str()); // 把字符串形式的 IP 地址如 192.168.1.100转换成网络字节序的 32 位整数。 socklen_t len sizeof(struct sockaddr_in); // 计算结构体大小 int ret bind(_sockfd, (struct sockaddr *)_addr, len); // bind() 是通用函数设计用于 IPv4、IPv6、Unix域等多种地址。 // 所以它接受的是通用类型 struct sockaddr*而不是特定的 struct sockaddr_in*,因此我们要进行强转 if (ret 0) { ERR_LOG(BIND ADDRESS FAILED!); return false; } return true; } bool Listen(int backlog MAX_LISTEN) { int ret listen(_sockfd, backlog); // 开始监听backlog决定了全连接队列的长度 if (ret 0) { ERR_LOG(SOCKET LISTEN FAILED!); return false; } return true; } bool Connect(const std::string ip, uint16_t port) { struct sockaddr_in _addr; // 定义一个 IPv4 地址结构体用来存放 IP 和端口信息。 _addr.sin_family AF_INET; // 告诉系统使用 IPv4 地址族。 _addr.sin_port htons(port); // 把端口号设置到地址结构体中。 _addr.sin_addr.s_addr inet_addr(ip.c_str()); // 把字符串形式的 IP 地址如 192.168.1.100转换成网络字节序的 32 位整数。 socklen_t len sizeof(struct sockaddr_in); // 计算结构体大小 int ret connect(_sockfd, (struct sockaddr *)_addr, len); if (ret 0) { ERR_LOG(CONNECT SERVER FAILED!); return false; } return true; } Socket Accept() // 这里也可以返回文件描述符 { int newfd accept(_sockfd, NULL, NULL); // 当我们并不关心客户端的地址信息时后面两个参数就可以置为空 if (newfd 0) { if (errno EAGAIN || errno EWOULDBLOCK || errno EINTR) { // 没有新连接这是正常情况不打印错误 return Socket(-1); } ERR_LOG(ACCEPT FAILED!); return Socket(-1); // 后续在处理时判断的也是其fd是否为-1 } return Socket(newfd); // RVO 优化 } ssize_t Recv(void *buf, size_t len, int flag 0) // 这个接口我们默认为阻塞的 { ssize_t ret recv(_sockfd, buf, len, flag); if (ret 0) // 返回值 0对端正常关闭连接 { return -2; // 返回-2表示对端关闭因为对端关闭我们必须单独判断虽然处理方法是大概是关闭连接 // 但是有一种情况就是上一次错误码为EAGAINEINTR如果你不进行单独处理没有这个判断 // 就会一直进入return 0的情况然后上层会一直尝试去不断读取陷入死循环但是此时你的对端已经关闭了无意义 } if (ret 0) { if (errno EAGAIN || errno EINTR) { return 0; // 特殊标记非阻塞模式无数据可读或者被信号被打断 } ERR_LOG(SOCKET RECV FAILED!!); return -1; } return ret; // 返回实际接收到的长度 } ssize_t Send(const void *buf, size_t len, int flag 0) // 我们不会修改buf所以可以用const方便调用该函数时使用的是string.c_str()传入的是个const char*无法变成void* { ssize_t ret send(_sockfd, buf, len, flag); // 向socket的发送缓冲区写入数据 if (ret 0) { if (errno EAGAIN || errno EINTR) { return 0; } ERR_LOG(SOCKET SEND FAILED!!); return -1; } return ret; // 实际发送的数据长度 } ssize_t NonBlockRecv(void *buf, size_t len) { return Recv(buf, len, MSG_DONTWAIT); // MSG_DONTWAIT 表示当前接收为非阻塞。 } ssize_t NonBlockSend(const void *buf, size_t len) { if (len 0) return 0; return Send(buf, len, MSG_DONTWAIT); // MSG_DONTWAIT 表示当前发送为非阻塞。 } // 关闭套接字 void Close() { if (_sockfd ! -1) { close(_sockfd); _sockfd -1; } } // 创建一个服务端连接 bool CreateServer(uint16_t port, const std::string ip 0.0.0.0, bool block_flag false) { // 创建套接字 (socket) if (Create() false) { return false; } // 设置地址重用 (setsockopt) ReuseAddress(); // 绑定地址 (bind) if (Bind(ip, port) false) return false; // 开始监听 (listen) if (Listen() false) return false; // 设置非阻塞 (fcntl) (注非阻塞设置在 Bind 之后、Listen 之前或 Listen 之后均可但地址重用必须在 Bind 之前) if (block_flag) NonBlock(); return true; } // 创建一个客户端连接 bool CreateClient(uint16_t port, const std::string ip) { // 1. 创建套接字2.指向连接服务器 if (Create() false) return false; return Connect(ip, port); } // 设置套接字选项---开启地址端口重用 void ReuseAddress() { // int setsockopt(int fd, int leve, int optname, void *val, int vallen) int val 1; setsockopt(_sockfd, SOL_SOCKET, SO_REUSEADDR, (void *)val, sizeof(int)); val 1; setsockopt(_sockfd, SOL_SOCKET, SO_REUSEPORT, (void *)val, sizeof(int)); } // 设置套接字阻塞属性-- 设置为非阻塞 void NonBlock() { // int fcntl(int fd, int cmd, ... /* arg */ ); int flag fcntl(_sockfd, F_GETFL, 0); fcntl(_sockfd, F_SETFL, flag | O_NONBLOCK); } };