简介:这是一个面向 C++ 开发者的轻量级日志类资源,解决中小型项目或工具中日志记录引入过重依赖的问题。核心实现仅有一个 ylog.h 头文件,约 60 多行代码,不依赖第三方库,不定义宏和全局变量,使用标准库即可在 Windows 与 Linux 下编译运行;同时支持多线程安全输出,每条日志可携带级别、时间、所在文件名、行号及自定义信息。压缩包内含 5 个文件,包括头文件、示例 main.cc、makefile、README.md 以及 .gitignore,整体仅 4KB,结构简洁,方便直接整合进现有工程。资源已吸引 410 人浏览学习,适合希望快速理解日志封装原理、或在轻量场景中直接使用的 C++ 初学者及进阶开发者。通过阅读源码与示例,可掌握日志级别控制、文件写入方式以及线程安全设计思路。
1. 从需求出发:为什么还要写一个“新”日志类
先说点实际的。C++ 生态里日志库一抓一大把,log4cpp、spdlog、glog、easylogging++,哪个不是久经考验?刚接触这个项目的人第一反应基本都是:又造轮子?我一开始也这么想,但真正深入之后才明白,很多嵌入式、客户端、教学演示场景,对日志的需求其实非常朴素——能按级别输出、能带时间戳、能控制开关、编译别太重。这些需求用 spdlog 当然能实现,但转头一看,光是头文件展开就多出好几百 KB,交叉编译还要带着一堆依赖,就比较难受了。
ylog 这个项目要解决的正是这个问题:一个头文件加一个源文件,不依赖任何第三方库,只用 C++11 标准库,就能提供日常开发里 90% 的日志场景能力。这种需求在单片机上位机、Windows 桌面小工具、Linux 服务端辅助模块、甚至刷题验证算法时都很常见。适合的场景不是大型分布式系统,而是中小型项目里“我要看得见程序在干什么”这个基本诉求。
另外一个很现实的原因是可定制性。大型日志框架封装层次多,改动内部行为往往要翻很久源码。ylog 这种小类,核心代码就几百行,任何行为不满意,直接动手改就是,改完立刻生效。这种“代码量小到可以整体读一遍”的优势,在实际工程维护里其实非常值钱。
我当初用 ylog 的场景是一个串口数据采集工具,跑在 Windows 上,需要同时记录数据帧、错误信息、调试输出。原来 console 输出一多就乱,关键信息被刷掉,排查问题全靠猜。接手代码后第一件事就是把日志系统理顺,ylog 也是在这个背景下一点点打磨成型的。
2. 整体设计与核心实现
2.1 日志级别与模块划分
日志库的第一个核心问题就是级别。ylog 采用六级划分:TRACE、DEBUG、INFO、WARN、ERROR、FATAL。这个划分基本沿袭主流日志库惯例,好处是大家一看就懂,不需要额外培训。
设计上最关键的一点是日志级别过滤在编译期和运行期双重生效。编译期通过宏控制最低输出级别,运行期通过设置当前输出级别动态过滤。这样做的好处很实在:发布版本直接编译到 INFO 级别,所有 DEBUG 和 TRACE 的输出代码虽然还在,但编译后不产生任何开销。实话说,这个设计是从 Android 的 Log 系统借鉴过来的,很成熟,也很符合 C++ 的“零成本抽象”哲学。
模块划分上,ylog 支持一个程序内创建多个日志实例,每个实例有独立的名称、输出目标和级别设置。这个设计在小工具里看起来有点过度设计,但只要你的程序有“业务模块”和“底层通信模块”之分,日志分开管理的好处立刻就能体现出来——排查串口数据问题时,你只需要盯 communication 这个日志文件,不用在业务日志里翻来翻去。
2.2 流式接口与宏定义
用过 C++ 日志库的人都知道,流式接口是标配。:按位左移` 这种方式写日志,比 printf 格式串安全得多,类型不对在编译期就能发现。ylog 的接口也遵循这个惯例:
LOG_INFO << "user login, uid=" << uid << ", ip=" << ip;这里实际调用的是LOG_INFO宏。宏的设计是这个项目的一个亮点,直接决定使用体验。定义大致如下:
#define LOG_INFO ylog::LogMessage(__FILE__, __LINE__, ylog::LogLevel::INFO).stream()每一步拆开看:
LogMessage是一个临时对象,构造时记录文件名、行号、级别和时间戳;.stream()返回一个std::ostringstream引用;- 析构时把流里的内容格式化后写入输出目标。
临时对象在当前语句结束时自动析构,所以一行LOG_INFO就是一条完整日志,不需要手动 flush。这个 RAII 设计带来的好处是——即使你写日志的代码抛出异常,临时对象析构时依然会尝试输出日志,不会静默丢失(当然,如果ostringstream本身抛异常就另当别论了)。
FATAL 级别还有一点特殊处理:输出日志后直接调用abort()终止程序,模拟 assert 的行为。这在调试阶段特别有用,遇到不可恢复的错误直接崩溃并留下现场日志,比“程序继续跑然后各种诡异现象”要好排查得多。
2.3 单例与生命周期管理
日志库的全局访问方式,主流做法无非两种:单例模式或全局函数。ylog 用的是Meyers Singleton,C++11 之后这种写法是线程安全的,标准保证局部静态变量的初始化是线程安全的。代码很简单:
Logger& Logger::instance() { static Logger instance; return instance; }单例的生命周期问题在日志库上尤其需要小心。如果日志库内部还持有其他单例的引用,或反过来被其他单例持有时,析构顺序就会变得完全不可控。ylog 的应对策略是:析构时不强制 flush,而是依赖操作系统关闭文件句柄时刷新缓冲区。听起来有点偷懒,但实际测试中,只要不是_Exit()这类直接终止的手段,标准库的std::fstream析构是会正常刷盘关闭的。这也是一个踩过坑之后才加上的注释说明。
另外,ylog 显式提供了shutdown()方法,让使用者在 main 函数末尾按需调用。这是一个从实践出发的设计:嵌入式上位机有时需要断开串口后把日志彻底落盘再退出程序,有了这个方法就能精确控制日志的最终输出时机。
3. 关键功能实现解析
3.1 时间戳与格式化
日志里的时间戳看着是个小功能,真要做得顺手也不简单。ylog 内部使用<chrono>获取当前时间,再通过<ctime>格式化为字符串。有一个细节值得拿出来说:不要每次获取时间都调用localtime这种线程不安全的函数。ylog 里的做法是调localtime_r或使用std::put_time,具体取决于平台。
格式化输出方面,常用的控制项包括:
- 是否显示完整毫秒时间戳;
- 是否显示文件名和行号;
- 是否显示线程 ID;
- 日志前缀的样式(纯文本还是带颜色控制码)。
console 输出时默认带 ANSI 颜色,文件输出时则去掉颜色码。这两个场景需求不同,用一个设置项控制容易互相干扰。ylog 的处理是对不同输出目标分别设置,console 和文件独立控制。实际用起来很舒服,文件里干干净净,终端上一目了然。
有一点很多自研日志库没处理好而 ylog 处理了的:日志写入时对换行符的处理。Windows 下std::endl会输出\r\n,Linux 下只有\n。如果一份日志文件在 Windows 上生成后拿到 Linux 上看,会被\r干扰。ylog 内部统一用\n,并显式将std::fstream打开为二进制模式,避免平台相关的转换。这是个小细节,但能省掉不少跨平台查看日志时的烦躁。
3.2 日志文件管理与输出目标
文件输出是日志库的基本盘。ylog 支持以下输出目标组合:
| 输出目标 | 说明 | 典型场景 |
|---|---|---|
| Console | 标准输出/标准错误,可带颜色 | 开发调试 |
| File | 指定路径的单文件 | 服务端记录 |
| FileWithRotate | 按大小轮转多个文件 | 长时间运行的桌面工具 |
轮转日志是长时间运行程序的好帮手。实现逻辑在RotatingFileSink里:写入前检查当前文件大小,超过阈值就关闭旧文件、把旧文件改名加序号(如app.log.1),然后新建app.log。同时保留最大文件数量,超出后删除最老的。
有个细节是这个轮转逻辑不是按行判断的,而是按字节计数的。也就是说,一条超过阈值的日志可能会自己占满一个文件。这在极端场景(比如一条 10 MB 的 JSON 数据被打印出来)会有点意外,但这是所有按大小轮转日志框架的通病。解决方案也简单,在业务代码里给大对象单独处理,别直接用日志流输出。
3.3 线程安全设计
多线程环境下写日志,核心就是两件事:保证日志内容不交错、保证日志库内部状态不崩。ylog 的做法是全局一个std::mutex,所有写入操作持锁完成。简单粗暴,但在低并发场景(每秒几十到几百条日志)完全够用,锁竞争导致的性能损耗可以忽略不计。
更精细的设计是每日志实例一把锁,这样不同模块的日志之间不互相阻塞。但我实测下来,对 ylog 的定位来说全局锁就够了,还省掉一个“该锁哪个实例”的心智负担。有一条经验分享:日志库的线程安全是为了“不出错”,不是为了“高并发”。真的每秒写几万条日志,你会用 spdlog 的异步队列,而不是这种轻量级方案。
线程 ID 的获取在 C++11 标准里没有跨平台API,ylog 的做法是条件编译:Linux 用syscall(SYS_gettid),Windows 用GetCurrentThreadId()。没有用std::this_thread::get_id是因为它返回的是内部表示,在排查问题时很难对应到系统线程 ID(比如 gdb 里看到的 LWP 编号)。
4. 接入与使用实践
4.1 工程集成方式
ylog 的工程集成方式非常省心,只有两个文件:ylog.h和ylog.cpp。我实际用过的集成方式有三种:
- 直接拷贝源码到工程目录:适合源码级依赖,改动方便,最推荐;
- 编译成静态库:适合多个可执行文件共用同一份日志实现;
- 安装到系统路径:适合做公共组件,但不推荐,因为小库的迭代速度快,系统级安装反而限制更新。
对大多数中小项目,最简单的方式就是直接把两个文件丢进src/common目录,在构建系统里扫进源码列表。CMake 里大概是这样:
add_library(common STATIC src/common/ylog.cpp ) target_include_directories(common PUBLIC src/common)4.2 日常使用的配置技巧
实际项目中我推荐大家配日志时抓住下面几个点:
设置合理的运行级别。开发环境可以用 DEBUG,看看流程细节;发布时至少 INFO。这里的核心教训是——日志级别一定要支持运行期动态修改,最好做一个信号处理器或配置热加载机制。我见过很多系统,出问题时想开 DEBUG 日志,结果被迫重启服务,现场就丢了。
按模块拆分文件。程序里如果有网络收发和业务处理两大块,建议建两个 Log instance,分别写不同文件。排查问题时非常爽,一看文件名就知道该去哪找。
开头的初始化代码要固定。ylog 的初始化我一般放在程序入口最前面,全局配置集中在一起,方便维护:
ylog::Logger& logger = ylog::Logger::instance(); logger.setLevel(ylog::LogLevel::DEBUG); logger.setFileOutput("logs/app.log", 5 * 1024 * 1024, 10); logger.setConsoleOutput(true);需要注意setFileOutput的第二个参数是单文件大小上限,第三个是保留文件数。如果程序是系统服务,用 systemd 托管,文件路径记得用绝对路径,别依赖工作目录。
5. 踩坑记录与问题排查
5.1 常见问题速查表
| 现象 | 可能原因 | 解决办法 |
|---|---|---|
| 日志文件为空 | 程序异常终止,析构未执行 | 关键路径主动调用shutdown() |
| 日志时间戳少 8 小时 | localtime与gmtime混用 | 统一使用本地时间,避免混用 |
| 终端输出是乱码 | Windows 下中文编码问题 | 源文件保存为 UTF-8 with BOM,或使用/utf-8编译选项 |
| 多线程日志互相穿插 | 使用了多个日志实例且未统一锁 | 检查是否所有写入都走同一实例,或为每个实例独立加锁 |
| 轮转文件数量超过上限 | 多进程共用同一日志文件 | 日志文件名中加入 PID 或进程名 |
| FATAL 日志输出后程序未退出 | 自定义了 SIGABRT 处理 | 检查是否拦截了 abort 信号;如需定制,修改宏定义 |
5.2 具体踩过的几个坑
第一个坑:析构时崩溃。有一次在程序退出时出现偶发崩溃,排查了半天发现是某个全局对象析构时还在写日志,而单例日志对象已经先一步析构了。根治办法有两个:一是全局对象析构时避免写日志,二是保证日志单例比其他全局对象更晚析构。后者不好控制,所以 ylog 的析构函数里特意做了容错处理:捕获一切异常,保证析构不抛异常。这也是为什么我建议你不要写出“日志库析构时还会失败”的代码——日志库自身的健康必须保证。
第二个坑:被信号打断的日志。程序收到 SIGTERM 直接退出时,最后几次日志经常丢失。后来看了下,是因为默认的信号处理只做进程终止,不做清理。解决方式是在信号处理函数里调用ylog::Logger::shutdown()(注意:信号处理函数里调用非异步安全函数本身有风险,需要权衡),或者更简单:程序正常退出时统一调用 shutdown,信号处理只负责设置标志位,主循环检查后优雅退出。
第三个坑:频繁写日志导致的性能问题。早期版本在 console 输出时,每次都刷新标准输出缓冲区,跑一次数据采集循环要 30 多秒,去掉刷新后压缩到 2 秒不到。所以 ylog 的 Console 输出默认是不刷新的,只有显式调用flush()或者到达std::endl才会真正写入显示设备。这里有个权衡:程序崩溃时 console 最后几行可能没打出来。我的建议是:调试时打开自动 flush,发布时关闭,换取性能。
5.3 调试日志与性能的平衡
聊点实用的东西。写日志不是越多越好,无脑 DEBUG 输出到最后没人看,而且拖慢程序。我一直以来的习惯是:
- 函数入口出口不打日志,只在关键决策点打;
- 循环体内尽量别打日志,如果要打就加频控(每 1000 次打一条);
- 数据量大时先截断再输出,比如只打印前 64 字节;
- 错误日志一定要带上下文信息,比如 errno、指针值、网络状态,否则事后看日志等于看天书。
这些最佳实践和日志库本身无关,但却是让 ylog 这类工具发挥价值的核心。一个再轻量的日志类,被塞进高频循环里照样会拖垮性能。日志代码本身要克制,这是长期写日志系统的人最大的心得。
6. 实用扩展:从日志到多场景
ylog 虽然定位轻量,但做一点扩展后能适用的场景远不止“打日志”这么简单。我实际扩展过的方向有三个:
作为调试动态开关。通过环境变量控制日志级别,比如export YLOG_LEVEL=DEBUG,程序跑起来后临场查看逻辑细节。这个扩展只要在初始化时读一下环境变量就能实现,改动成本极低,收益却很直接。
接入网络上报。在Sink层加一个网络输出目标,把 WARN 以上级别的日志实时上报到集中日志服务,实现轻量告警。这个方向适合小服务集群,不用引入完整的日志采集链路。
录制回放工具。程序跑的过程中把关键输入输出按特定格式打印,之后写脚本解析日志、模拟输入回放。这种方式在算法调试时非常实用,等于给程序加了一个“录像机”。ylog 里的TRACE级别正好作为回放数据的载体,不干扰正常日志的阅读。
这些扩展本身都很简单,但能看出一个好的日志基础组件能带来的杠杆效应。核心代码稳定、接口清晰,扩展的能力就有了抓手,这也是轻量级组件的价值所在——不限制你,而是让你在它之上做出自己的方案。
最后再分享一个实际使用中的体会:日志类这种东西,代码量不重要,稳定性和可读性才重要。ylog 的代码总共就几百行,但它让我在最需要排查问题时能快速定位,在跨平台部署时没有额外依赖,在性能敏感场景下不至于拖后腿。对一个工具类组件来说,这已经算交出了一份让我满意的答卷。如果你也是一个人维护中小型 C++ 项目,找个时间把日志模块理顺,真的是性价比很高的一件事。
本文还有配套的精品资源,点击获取