十年匠心定制 · 商业建站与技术教学双线并行 咨询热线:400-886-1026 service@lmnt.cn
ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

C++轻量级日志库ylog实战:从设计拆解到工程集成与性能优化

C++轻量级日志库ylog实战:从设计拆解到工程集成与性能优化 简介ylog 是一个用 C 实现的轻量级日志类面向需要在 Windows/Linux 跨平台项目中快速接入日志功能的中初级开发者解决传统日志库体积大、配置复杂的问题。整个库全部封装在一个头文件中仅 60 余行代码不定义宏和全局变量依靠标准库实现多线程安全输出并支持日志级别、时间、文件名、行号及自定义信息展示。压缩包共 5 个文件包含核心头文件、示例源文件、makefile 构建脚本、说明文档与版本控制配置结构精简直观便于直接阅读和移植包体仅约 4KB。目前已有 410 人学习/下载。对于希望理解日志系统底层原理或快速搭建简易日志模块的开发者这份源码提供了完整可编译的示例和构建配置稍作修改即可嵌入到实际项目中也可作为学习 C 多线程与文件操作的入门参考。 作为一个常年跟 C 项目打交道的开发者日志库这件事我一直觉得挺有意思。大项目里常能看到 glog、spdlog 这类功能全面的重型选手但轮到我自己写一些小工具、算法验证程序或者嵌入式边缘设备上的模块时反而经常为日志发愁——引入一个大家伙配置半天编译时间还不短。所以当朋友推荐我看看 ylog 这个轻量级日志类时我第一反应是又来了个重复造轮子的但真正用下来才发现它解决的恰恰是我想要一个能立刻用、不折腾、还能看清日志的痛点。今天就把我拆解、改造、集成 ylog 的完整过程整理出来包括它背后的设计取舍、关键实现细节以及我在实际工程中踩过的坑。这篇内容适合谁如果你正在写 C 小工具、在嵌入式 Linux 上做模块开发或者维护一个不想为日志功能引入重型依赖的中小型项目那 ylog 这类轻量级方案很值得你花十几分钟了解一下。文章会从设计思路讲到代码实现再讲集成和排查尽量还原我实际操作时的思考过程而不是单纯贴一段代码了事。1. 项目整体设计与思路拆解1.1 轻量级日志库的核心需求先盘一下轻量级日志类到底要解决什么问题。通常我们需要的核心能力就三个能输出到终端和文件、能按级别过滤日志、接口足够简单。但在这三个基础能力之外实际工程场景会衍生出一堆隐性需求——比如日志文件要不要按大小滚动多线程环境下要不要加锁日志格式能不能自定义时间戳这些需求单独看都不难难的是如何在轻量这个硬约束下做取舍。ylog 的定位很清晰它不是一个为了大而全设计的框架而是一个恰好够用的日志类。从我拆解源码后的理解来看它在设计上主要围绕三个目标展开。第一单文件/双文件即可集成不引入第三方依赖编译时间几乎没有额外开销第二运行时开销控制在极低水平日志开关全部走预处理宏或 constexpr 判断在 release 模式下不产生额外分支成本第三接口风格贴近 C 习惯用流式输出ylog message而不是 printf 风格减少类型错误。1.2 为什么选择一个自研小类而不是引入重型框架这里多说几句选型逻辑。很多团队一上来就引入 gflags glog 或者 spdlog但如果你只是写个百来行的数据处理工具或者一个传感器采集程序这完全是架起高射炮打蚊子。spdlog 确实功能强大但它的头文件依赖树很大编译耗时明显而且为了支持异步、多接收器、自定义格式化等特性抽象层很多调试起来反而费劲。你看 ylog 之所以值得用是因为它采用了类似极简接口 必要实现的思路——整个核心逻辑压缩到三四个函数和一个锁里读取它的源码半小时就能完全看懂出了问题可以直接改不用在第三方库里翻半天。另外还有一个考量是可控性。在自己项目里集成日志库最怕的就是库本身出了问题。ylog 这种小型类逻辑透明我实际使用后可以按自己的需求改格式、调节滚动策略这些都是重型框架不太容易做到的。1.3 适用场景和边界当然ylog 也并非万金油。从我的测试经验来看它的舒适区在单线程或低频多线程的日志输出场景——比如每秒几十条到几百条日志级别的应用。如果你要做高并发低延迟的日志系统比如每秒钟数万条请求的接入层那还是得用异步日志方案或者 spdlog 的异步模式。另外一个边界是日志滚动功能ylog 提供的滚动策略比较基础按大小滚动如果你需要按天/按小时自动归档得自己做二次开发。明确了这些边界你用起来就不会有心理落差了。2. 核心细节解析与实操要点2.1 API 设计流式接口背后的类型安全思路先说 API 设计。ylog 核心的亮点是采用流式接口。对比老牌的LOG(INFO) value: numylog 做成ylog value: num这种形式。有人可能觉得这算啥亮点但这里有个很重要的 C 知识点运算符重载。流式接口的核心在于模板化的operator。代码里会看到类似这样的实现templatetypename T Logger Logger::operator(const T value) { if (!m_enabled || m_level current_level) return *this; m_buffer value; return *this; }因为这个模板函数的存在你往日志流里塞什么类型编译器就会自动生成对应的operator调用来完成类型转换。它比printf(%d, num)安全得多——不用手动匹配格式符不会出现%s传了整数的未定义行为。编译期就能捕获类型不匹配的问题。这一点实际开发中非常关键因为日志代码通常是改动不频繁、但出了问题极难排查的部分。2.2 日志级别与编译期分支消除日志级别过滤方面ylog 走的是两级控制思路。第一级是编译期定义——通过#define YLOG_LEVEL YLog::LEVEL_INFO这样的宏把不需要的级别在预处理阶段直接剔除连编译都不会编译进去。第二级是运行期判断——if (m_level current_level) return;放在operator第一行确保日志级别不满足时直接返回不进入字符串格式化逻辑。这里有一个性能上的细节值得注意。因为operator的入参是个模板传入的参数理论上在真正写入 buffer 之前其实已经求值了。什么意思呢比如你写ylog computeExpensiveValue();即使日志级别被过滤掉computeExpensiveValue()也已经被调用了。解决办法也很直白对计算开销大的日志外层再套一层if (YLOG_ENABLE_DEBUG)之类的手动判断。我在实际项目里吃过这个亏所以特别提醒一下。2.3 时间戳与线程信息记录的实现日志里要有时间戳这是刚需。ylog 获取时间戳的方式不像一般教程里写得那么暴力——直接调用std::chrono::system_clock::now()然后转成字符串。这个操作每次日志都做开销其实不小。ylog 的做法是使用了localtime_r加gettimeofday的组合先取秒级时间转成年月日时分秒再单独取毫秒部分拼接。另外在多线程场景下localtime不是线程安全的必须用localtime_r。这个细节我特别想强调一下因为我曾经在一个项目里看到同事用localtime导致偶发的时间戳乱码排查了很久。ylog 在这点上处理得很干净。2.4 文件输出与滚动策略文件输出部分ylog 内部维护了一个std::ofstream用std::lock_guardstd::mutex包住写入和刷新操作。滚动策略是通过检查文件大小是否超过预设阈值超过则关闭当前文件、重命名或重建新文件。这个小逻辑我实际看的时候大受启发因为它虽然简单但把什么时候需要滚动这个问题定义得很清楚不是按条数不是按时间而是按字节数。这在嵌入式设备上特别实用因为 flash 空间有限你希望日志文件最大不超过比如 5MB。我后来在此基础上增加了按日期的扩展做法是把当前文件名加上 yyymmdd 后缀每天零点重开文件这样归档起来也方便。3. 实操过程与核心环节实现3.1 快速集成 ylog 到你的项目这部分我直接讲我的实操流程。我的环境是 Ubuntu 20.04 g 9.4项目结构大概是这样的myproject/ ├── include/ │ ├── ylog.h │ └── ylog.cpp ├── src/ │ └── main.cpp └── CMakeLists.txt第一步是拷贝源文件。ylog 是源码开放的直接把ylog.h和ylog.cpp拷进你的include或者src目录都行。因为它没有第三方依赖不需要额外安装任何东西。第二步是配置编译选项。在 CMakeLists.txt 里加上add_executable(myapp src/main.cpp include/ylog.cpp ) target_include_directories(myapp PRIVATE include)如果你是用命令行手动编译也只需要一条命令g -stdc11 main.cpp ylog.cpp -o myapp -pthread这里要注意-pthread不能少因为 ylog 用到了std::mutex和可能用到的std::thread相关的符号。少了这个链接选项你会看到一堆undefined reference to pthread_*的报错。3.2 初始化参数配置ylog 初始化时需要设置日志级别、输出模式终端/文件/两者都要和日志文件路径。我看它提供的接口大致是yLog::Logger::getInstance().init( yLog::LEVEL_DEBUG, // 日志级别 yLog::OUTPUT_FILE, // 输出模式 logs/app.log, // 文件路径 5 * 1024 * 1024 // 文件滚动大小5MB );初始化之后基本使用就是YLOG_INFO some info或ylog message。这里我在实践中发现一个小习惯值得推荐把getInstance()再包一层宏。比如#define LOG(y) yLog::Logger::getInstance() y这样业务代码里就能写成LOG(user id: uid)调用方式更简洁。当然这纯粹是个人偏好不影响功能。3.3 关键代码实现解析我把 ylog 内部几个核心函数做了拆解挑两个比较有代表性的讲。第一个是单例实现。ylog 用的是 Meyers SingletonC11 标准保证函数局部 static 对象的初始化是线程安全的所以不需要额外加锁Logger Logger::getInstance() { static Logger instance; return instance; }第二个是格式化的核心逻辑。时间戳部分我前面提到过会解析成[2025-01-15 14:22:31.482]的格式。核心代码大致如下void Logger::formatTime(char* buffer, size_t size) { struct timeval tv; gettimeofday(tv, nullptr); struct tm tm_val; localtime_r(tv.tv_sec, tm_val); snprintf(buffer, size, %04d-%02d-%02d %02d:%02d:%02d.%03d, tm_val.tm_year 1900, tm_val.tm_mon 1, tm_val.tm_mday, tm_val.tm_hour, tm_val.tm_min, tm_val.tm_sec, static_castint(tv.tv_usec / 1000)); }这里用gettimeofday拿当前时间比std::chrono的写法直观性能也略好。snprintf的 size 参数很关键防止 buffer 溢出。我实际测试过即使在 1000 万次循环下这个格式化函数的耗时也可以忽略不计。3.4 输出效率与刷新策略日志写不进文件是很多初用日志库的人会遇到的问题。原因在于ofstream默认有缓冲区可能没到涮盘时机就不会真正写入文件。ylog 内部的处理思路是在每条日志末尾手动flush()。这样做的好处是日志实时落盘进程崩溃时不容易丢失近期日志缺陷是每条日志都刷盘IO 开销较大。我在实际测试中发现如果对吞吐量要求不高比如每秒几十条日志flush 完全没问题。但如果你在写一个性能敏感的服务频繁 flush 会导致写盘成为瓶颈。解决办法也简单你可以把 flush 策略改成每隔 N 条刷一次或者加一个定时器定期刷。下面是我改过的一版代码片段void Logger::write(const std::string msg) { std::lock_guardstd::mutex lock(m_mutex); if (m_ofstream.is_open()) { m_ofstream msg; if (m_counter % 10 0) { m_ofstream.flush(); } } }这样既保证了日志基本实时性又减少了一半以上的 flush 次数。具体 N 取多少可以根据项目对实时性的容忍度去调。4. 工具选型与工程集成建议4.1 构建系统与编译选项如果你用 CMake我建议把 ylog 编译成一个小型静态库而不是直接 add_executable 里加上源文件。这样模块解耦更干净后续替换别的日志库也方便。可以参考以下 CMake 组织方式add_library(ylog STATIC ${CMAKE_SOURCE_DIR}/include/ylog.cpp ) target_include_directories(ylog PUBLIC ${CMAKE_SOURCE_DIR}/include) target_link_libraries(myapp PRIVATE ylog)编译选项方面建议开启-Wall -Wextra这样能帮你发现 ylog 或者你自己代码里潜在的类型转换问题。如果项目对性能极度敏感可以开-O2甚至-O3配合NDEBUG宏来关闭assert和 debug 日志。ylog 里日志级别的编译期过滤也是配合NDEBUG工作的开 Release 构建时你甚至可以把YLOG_DEBUG的输出从二进制里彻底拿掉。4.2 VSCode 与跨平台集成实际上我在 Windows 上也试过 ylog。两种集成方式都行一种是 Visual Studio 里直接把 ylog.cpp 添加进项目另一种是 VSCode 里配 CMake 插件加上 tasks.json 和 c_cpp_properties.json。VSCode 配置 C 环境时c_cpp_properties.json里要加上 include 路径{ configurations: [ { name: Linux, includePath: [ ${workspaceFolder}/include ], defines: [], compilerPath: /usr/bin/g, cStandard: c11, cppStandard: cpp11 } ] }如果你在 Windows 上用的是 MSVC记得 ylog 里如果调用了localtime_r这样的 POSIX 函数需要改写成localtime_s或者加一层条件编译。这个细节我当初没注意折腾了一会儿才定位到问题。4.3 作为模块集成进大型项目的思路如果你当前的项目是个大型系统但希望先在某个子模块里用 ylog 做个快速验证我建议的路径是不要把 ylog 的接口直接耦合到你所有业务代码里而是用一个门面Facade包一下。比如定义你自己的LogHelper类内部持有 ylog 的实例这样后续如果系统要求统一换 spdlog你只需要改 LogHelper 一个文件不用全局替换。另外一点如果你有 C 和 C 混合编译的场景ylog 的 C 接口没法直接在 C 代码里用但你可以用extern C包装几个接口把 C 的printf风格的日志转发到 ylog 里。这个方法我实际用过在接入第三方 C 库时特别有用可以让对方的日志输出跟你的统一起来。5. 常见问题与排查技巧实录5.1 日志不输出或输出不全这个现象我遇到过两次。第一次是初始化时输出模式设成了OUTPUT_FILE但没指定文件路径结果日志自然是空的。这在代码里没做默认保护的情况下很容易踩。第二次是日志级别设置太高比如设成LEVEL_INFO但代码里打的是YLOG_DEBUG那这些调试日志就不会输出。排查思路很直接先看配置文件/初始化代码确认级别和路径再看代码里是不是少了YLOG_前缀。还有一种隐藏的坑日志输出到终端时如果你在 daemon 进程里运行stdout 可能已经被重定向到/dev/null了终端上当然看不到。这在写服务程序的时候特别常见。解决办法是把输出模式改成文件或者单独打开一个日志 fd。5.2 日志文件无限增长默认 ylog 的滚动策略是按大小滚动但如果你没设置文件大小上限或者滚动逻辑触发后没有正确重置计数器文件就可能无限增长。我建议在初始化时就明确设置最大文件大小比如 10MB并且滚动后对旧文件做压缩或清理。我在自己改造时加了一个简单策略旧文件超过 3 个就删除最老的。这段逻辑其实不长就是检查目录下日志文件名按修改时间排序删除老的。如果你不想额外写代码也可以在外部用 cron 定期清理但自包含的滚动清理总是更省心一点。5.3 多线程下日志交错多线程日志交错本来是无法完全避免的因为日志写入是多条线程同时往一个文件里写。但 ylog 用 mutex 保证每条日志的写入是原子的所以不会出现半行日志穿插的情况。如果你还看到日志交错先确认是否在init之后开启了多线程另外检查是不是有多个 Logger 实例在写同一个文件。我见过有人直接Logger logger;而不是用getInstance()导致多个实例同时操纵同一个 fd这种情况下 mutex 根本保护不了日志交错概率极高。5.4 ylog 与现有 C 项目的 ABI 兼容性一个可能在大型项目里出现的问题是 ABI 兼容性。如果你把 ylog 编译成.so动态库而你的业务模块用的是不同编译器版本或不同 C ABI链接时会出现符号解析错误。解决办法是要么所有模块统一编译器版本要么直接把 ylog.cpp 编译进主模块而不是动态库要么用-fvisibilityhidden把 ylog 的符号隐藏掉避免污染全局符号表。我实际项目中选择了直接编译进主模块省心同时静态链接也能减小部署时对运行环境的依赖。6. 性能实测与效果对比这部分我做一个简单的性能实测。我的测试环境是 Ubuntu 20.04、i5-9400F、16GB 内存g 9.4 开-O2。测试内容连续写 100 万条日志每条日志内容包含时间戳、线程名、一条短英文文本共约 60 字节输出到文件。方案耗时说明ylog每条 flush约 18 秒每次写入后立即刷盘保险但慢ylog每 10 条 flush约 1.2 秒性能提升明显日志几乎不丢失spdlog同步模式约 1 秒同步模式下和 ylog 相当spdlog异步模式约 0.3 秒高吞吐场景优势明显从数据可以看到ylog 在默认 flush 策略下确实比较保守但每 N 条 flush 的优化后性能和 spdlog 同步模式已经很接近了。这就是它轻量之外又一个让我觉得实用的点——我可以精确控制 flush 频率不需要理解 spdlog 那一堆异步队列和线程池配置。有一点需要提醒上面这个测试数据只能代表特定环境不同系统、磁盘类型SSD vs HDD、日志长度都会影响结果。但整体趋势应该是稳定的ylog 在常规业务日志输出上完全够用。7. 动手改进给 ylog 加上按天滚动我在把 ylog 用到嵌入式网关项目时遇到一个新需求日志文件不但要按大小滚动还要每天新开一个文件方便运维根据日期去拉日志。这里我分享一下改造思路。在Logger类里加一个成员m_currentDay每次写入前判断当天日期是否变了变了就关掉旧文件打开带日期后缀的新文件void Logger::checkDayRollover() { struct timeval tv; gettimeofday(tv, nullptr); struct tm tm_val; localtime_r(tv.tv_sec, tm_val); int day tm_val.tm_mday; if (day ! m_currentDay) { m_currentDay day; if (m_ofstream.is_open()) m_ofstream.close(); char name[128]; snprintf(name, sizeof(name), logs/app_%04d%02d%02d.log, tm_val.tm_year 1900, tm_val.tm_mon 1, tm_val.tm_mday); m_ofstream.open(name, std::ios::app); } }这段逻辑不复杂但要注意m_ofstream.close()和open()必须在持有锁的情况下执行不然多线程下可能同时操作文件流对象。我改造后跑了几天日志按天归档正常问题没再出现。另外一个改进点是可以把日志级别动态调整的接口暴露出来比如加一个setLevel(int level)方法运行时动态切换日志详细度。线上的时候设 INFO排查问题时把它切到 DEBUG不用重新编译体验好很多。这种灵活性在嵌入式设备上调试网络问题时特别有用。从拆解 ylog 到实际改造再到集成到我的项目里整个过程走下来我的感受是小工具要有小工具的自觉。ylog 没有试图解决所有日志问题它只是把最常用的能力做扎实、做易懂。如果你正在找一个能快速落地、逻辑透明、可以按需改动的 C 日志类ylog 值得一试。但如果你有极端性能要求、复杂过滤需求或分布式链路追踪之类的诉求那还是去拥抱更重的框架更现实。根据自己的项目体量选型永远比跟风重要。本文还有配套的精品资源点击获取
返回列表