仿 muduo 高并发服务器项目:实现轻量级日志宏,方便后续网络库调试
前言今天继续推进仿 muduo 的 C 高并发服务器项目。在正式把EventLoop、Channel、Poller、TcpConnection等核心模块串起来之前我先补了一个很基础但很有用的工具日志模块。日志模块本身并不直接处理网络 IO也不负责管理连接但是它会贯穿整个服务器项目。后面排查事件循环是否正常运行、某个 fd 是否触发读写事件、连接什么时候建立和关闭、错误路径有没有走到都需要靠日志来观察。所以今天实现了一个轻量级的日志宏目标不是做一个复杂的工业级日志库而是先满足当前项目调试需要支持日志等级过滤输出当前时间输出当前线程 ID输出源码文件名和行号支持像printf一样传入格式化参数使用宏封装调用起来足够简单一、为什么高并发服务器需要日志写普通小 demo 的时候很多问题可以直接打断点调试。但服务器项目不太一样。一个高并发服务器运行起来以后内部会同时处理很多事情监听 socket 是否有新连接到来处理客户端连接上的读事件和写事件管理多个连接的生命周期在事件循环中分发不同 fd 的事件多线程场景下区分任务到底在哪个线程执行出错时判断是系统调用失败、连接关闭还是业务逻辑问题这些行为往往不是线性发生的。如果只靠肉眼看代码很难判断程序运行时到底走到了哪里。这时候日志就非常重要。比如后面写到TcpServer时可以在新连接到来时打印一条日志写到Channel时可以在事件回调触发时打印一条日志写到EventLoop时可以打印当前 loop 所在线程。这样项目变复杂以后也能通过日志把运行过程串起来。二、日志等级设计这次先定义了三个日志等级#define INF 0 #define DBG 1 #define ERR 2 #define LOG_LEVEL DBG这里的等级数字越大表示日志越重要INF普通信息日志用来记录程序正常运行状态DBG调试日志用来观察关键变量、事件触发、执行流程ERR错误日志用来记录失败路径或异常情况LOG_LEVEL表示当前允许输出的最低日志等级。当前配置是#define LOG_LEVEL DBG也就是说只有等级大于等于DBG的日志才会输出。所以在当前配置下INF_LOG不会输出DBG_LOG会输出ERR_LOG会输出这个设计的好处是调试阶段可以输出更多信息如果后面觉得日志太多只要调整LOG_LEVEL就可以统一控制输出范围。三、核心 LOG 宏实现完整的核心宏如下#define LOG(level, format, ...) do{\ if (level LOG_LEVEL) break;\ time_t t time(NULL);\ struct tm *ltm localtime(t);\ char tmp[32] {0};\ strftime(tmp, 31, %H:%M:%S, ltm);\ fprintf(stdout, [%p %s %s:%d] format \n, (void*)pthread_self(), tmp, __FILE__, __LINE__, ##__VA_ARGS__);\ }while(0)这个宏虽然不长但是里面包含了几个很常用的 C/C 宏技巧。四、日志等级过滤第一句是日志过滤if (level LOG_LEVEL) break;这里利用了前面定义的等级数字。如果当前日志等级小于全局的LOG_LEVEL说明这条日志不重要直接break跳出宏内部的do while。比如现在#define LOG_LEVEL DBG而INF的值是 0DBG的值是 1。当执行INF_LOG(server start);实际上传入的level是INF也就是 0。判断条件变成0 1条件成立所以直接break这条普通信息日志不会输出。如果执行DBG_LOG(fd %d, fd);传入的等级是DBG也就是 1。判断条件变成1 1条件不成立所以会继续往下执行并打印日志。五、输出当前时间日志里需要带上时间否则只能看到某条日志发生过却不知道它是什么时候发生的。这里通过time、localtime和strftime生成当前时间字符串time_t t time(NULL); struct tm *ltm localtime(t); char tmp[32] {0}; strftime(tmp, 31, %H:%M:%S, ltm);time(NULL)获取当前时间戳。localtime(t)把时间戳转换成本地时间。strftime负责把时间格式化成字符串小时:分钟:秒例如最终输出可能是14:25:36在服务器调试时时间信息可以帮助判断某个事件是否按预期顺序发生比如连接建立、数据到达、连接关闭之间的先后关系。六、输出线程 ID日志格式里还输出了当前线程 ID(void*)pthread_self()对应的fprintf片段是[%p ...]在单线程 Reactor 中线程 ID 的作用可能暂时不明显。但是 muduo 的一个重要特点就是 one loop per thread。后续如果实现线程池、多个EventLoop就需要知道某段代码到底运行在哪个线程里。比如主线程负责监听新连接IO 线程负责处理已连接 socket 的读写事件某个回调是否被错误地放到了其他线程执行这些都可以通过日志里的线程 ID 辅助判断。七、输出文件名和行号日志里还加入了两个编译器内置宏__FILE__ __LINE__它们分别表示当前日志所在的源码文件和行号。最终日志格式中这一段__FILE__, __LINE__可以帮助快速定位日志是从哪里打印出来的。假设后面在Channel、Poller、EventLoop里都打印了类似的调试信息如果没有文件名和行号只看日志内容可能很难判断具体是哪一处触发的。有了源码位置以后日志就会更像这样[0x7f23a5d61740 14:25:36 EventLoop.hpp:85] loop start看到这条日志就能知道它来自哪个文件、哪一行。八、可变参数宏这个日志宏支持像printf一样传入格式化参数fprintf(stdout, [%p %s %s:%d] format \n, (void*)pthread_self(), tmp, __FILE__, __LINE__, ##__VA_ARGS__);其中format是格式化字符串...和__VA_ARGS__表示可变参数。比如DBG_LOG(fd %d, fd);展开后就相当于fprintf(stdout, ... fd %d\n, fd);如果没有额外参数也可以这样写ERR_LOG(accept error);这里的##__VA_ARGS__是一个常见写法。当可变参数为空时它可以帮助去掉前面多余的逗号避免宏展开后出现语法错误。这样日志宏既可以打印普通字符串也可以打印带变量的调试信息使用起来比较灵活。九、为什么使用 do while(0)宏最外层使用了do { ... } while(0)这个写法在 C/C 宏封装中非常常见。它的作用是把多条语句包成一个整体让宏在各种语句环境下都能像普通函数调用一样使用。比如如果直接把多条语句写进宏后面在if/else中使用时可能会出现语义问题。使用do while(0)后就可以安全地这样写if (fd 0) ERR_LOG(fd error); else DBG_LOG(fd %d, fd);宏展开后仍然是一个完整语句不容易破坏外层的if/else结构。同时前面日志等级过滤中的break也正好可以跳出这个do while(0)。十、三个便捷日志宏为了调用更方便最后又封装了三个宏#define INF_LOG(format, ...) LOG(INF, format, ##__VA_ARGS__) #define DBG_LOG(format, ...) LOG(DBG, format, ##__VA_ARGS__) #define ERR_LOG(format, ...) LOG(ERR, format, ##__VA_ARGS__)这样平时使用时就不需要每次都手动传日志等级。普通信息日志INF_LOG(server start);调试日志DBG_LOG(fd %d, fd);错误日志ERR_LOG(accept error);这样的调用方式更接近实际项目里的日志接口可读性也更好。十一、简单测试示例假设写一段简单测试int fd 10; INF_LOG(server start); DBG_LOG(fd %d, fd); ERR_LOG(accept error);由于当前日志等级是#define LOG_LEVEL DBG所以第一条INF_LOG会被过滤掉不会输出。后面两条日志会正常输出形式大致如下[0x7f23a5d61740 14:25:36 test.cpp:12] fd 10 [0x7f23a5d61740 14:25:36 test.cpp:13] accept error这里每条日志中都能看到当前线程 ID当前时间日志所在文件和行号用户传入的具体日志内容这对于后续调试网络库非常有帮助。十二、今日总结今天实现的是一个轻量级日志宏模块。它的代码量不大但对后续项目非常关键。因为仿 muduo 这种高并发服务器项目越往后写模块之间的调用关系越复杂只靠断点和肉眼看代码会越来越吃力。这次日志模块主要完成了日志等级过滤当前时间输出当前线程 ID 输出文件名和行号输出可变参数格式化输出do while(0)宏安全封装INF_LOG、DBG_LOG、ERR_LOG三个便捷接口后续实现EventLoop、Channel、Poller、TcpConnection时就可以把这个日志宏穿插进去用来观察事件分发、连接建立、连接关闭、错误处理等关键流程。虽然它还不是一个完整的日志系统比如还没有异步写日志、日志文件滚动、日志级别字符串、线程安全队列等功能但对于当前阶段的项目调试已经够用了。先把基础工具打好后面写 Reactor 主体结构时会顺很多。