仿 muduo 高并发服务器项目:实现轻量级日志宏,方便后续网络库调试

前言

今天继续推进仿 muduo 的 C++ 高并发服务器项目。

在正式把 EventLoopChannelPollerTcpConnection 等核心模块串起来之前,我先补了一个很基础但很有用的工具:日志模块。

日志模块本身并不直接处理网络 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 的值是 0,DBG 的值是 1。

当执行:

复制代码
INF_LOG("server start");

实际上传入的 levelINF,也就是 0。

判断条件变成:

复制代码
0 < 1

条件成立,所以直接 break,这条普通信息日志不会输出。

如果执行:

复制代码
DBG_LOG("fd = %d", fd);

传入的等级是 DBG,也就是 1。

判断条件变成:

复制代码
1 < 1

条件不成立,所以会继续往下执行并打印日志。

五、输出当前时间

日志里需要带上时间,否则只能看到某条日志发生过,却不知道它是什么时候发生的。

这里通过 timelocaltimestrftime 生成当前时间字符串:

复制代码
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__

可以帮助快速定位日志是从哪里打印出来的。

假设后面在 ChannelPollerEventLoop 里都打印了类似的调试信息,如果没有文件名和行号,只看日志内容可能很难判断具体是哪一处触发的。

有了源码位置以后,日志就会更像这样:

复制代码
[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_LOGDBG_LOGERR_LOG 三个便捷接口

后续实现 EventLoopChannelPollerTcpConnection 时,就可以把这个日志宏穿插进去,用来观察事件分发、连接建立、连接关闭、错误处理等关键流程。

虽然它还不是一个完整的日志系统,比如还没有异步写日志、日志文件滚动、日志级别字符串、线程安全队列等功能,但对于当前阶段的项目调试已经够用了。

先把基础工具打好,后面写 Reactor 主体结构时会顺很多。

相关推荐
Huangjin007_1 小时前
【C++11篇(四)】新的类功能、lambda 与包装器详解
开发语言·c++
2zcode1 小时前
基于MATLAB图像处理的啤酒瓶口缺陷检测系统设计与实现
开发语言·图像处理·matlab
进击的程序猿~1 小时前
Go Zero源码阅读2
开发语言·后端·golang
hdsoft_huge1 小时前
JDK系列19:JVM全维度调优实战,内存、元空间、线程栈、GC参数压测调优完整方案
java·开发语言·jvm
哥不想学算法9 小时前
【C++】字符串字面量拼接
开发语言·c++
gugucoding11 小时前
21. 【C语言】打包不同类型:结构体
c语言·开发语言
Brookty11 小时前
【JavaEE】线程安全(一).4:写块串行保安全、CAS
java·开发语言·java-ee·多线程·线程安全
F202269748612 小时前
西门子 PLC 与 C# 通信
开发语言·c#