尧图建网站 尧图建网站 YAOTU WEB BUILD 免费咨询
ARTICLE DETAIL

资讯详情

深耕网站建设与建站编程的一线实战洞察。

spdlog C++日志库完整实战:从基础API到MFC集成

spdlog C++日志库完整实战:从基础API到MFC集成 之前在做 C 项目日志模块时反复纠结于是用 printf 打点、OutputDebugString 输出还是自己封装一个文件日志类。前两者功能太弱自研的轮转、分级、线程安全都要从零实现费时费力还容易埋坑。后来换上了 spdlog整个日志模块的代码量从上千行降到几十行而且性能足够好、功能也完整。这篇文章就把 spdlog 的完整使用思路整理出来包含基础 API、关键配置、完整示例代码以及 MFC 项目里的集成过程新手可以直接照着落地有 C 基础的开发者也能快速对照排查问题。1. 背景与核心概念1.1 spdlog 是什么spdlog 是一个开源的 C 日志库由 Gabi Melman 开发并托管在 GitHub 上项目仓库为gabime/spdlog。它最大的特点是头文件为主、性能优秀、接口简洁在 C 社区中非常流行。用一句话概括spdlog 是 C 世界里开箱即用的日志方案提供格式化的日志输出、多级日志级别、多种输出目标控制台、文件、调试器、网络等并且内部处理了线程安全、日志轮转、异步写入等繁琐问题。与自研日志模块和传统日志库相比spdlog 的核心优势如下快速日志写入通过格式化与批量输出优化性能高于一般自己写的fprintf方案。零额外依赖默认实现不依赖第三方库使用 C11 及以上特性。功能完整支持日志级别、格式 pattern、轮转文件、按大小或时间分割、异步日志、异常回调。接入简单既可以源码编译也可以直接引入头文件使用。社区活跃GitHub 上 star 数量高issue 更新快版本迭代稳定。1.2 spdlog 解决什么问题在实际项目开发中日志至少承担这几个职责问题排查程序崩溃、逻辑异常时靠日志还原现场。运行监控记录请求耗时、接口状态、资源变化。业务审计保存关键操作记录方便追溯。开发调试替代临时 printf支持分级输出方便动态开关。但自研日志往往要面对以下问题多个线程同时写文件如果不加锁会出现内容交叉。日志文件越来越大没有轮转机制。日志格式五花八门很难统一解析。异步落盘与程序退出之间的数据丢失。spdlog 已经把这些问题全部处理好了我们要做的只是配置和使用这大大降低了日志模块的开发成本。1.3 常见应用场景spdlog 适合以下几种场景C 服务端程序Linux/Windows 下的后台服务需要稳定的文件日志。客户端软件比如基于 MFC、Qt 的桌面程序需要同时输出文件和调试窗口日志。工具脚本/中间件C 编写的命令行工具、数据中间件需要记录运行状态。游戏/嵌入式应用对性能要求较高的场景spdlog 的异步模式很适合。2. 环境准备与版本说明2.1 spdlog 下载与获取下载 spdlog 有三种常见方式读者可以根据项目情况选择。方式一GitHub 获取源码直接访问 GitHub 仓库https://github.com/gabime/spdlog在 Releases 页面下载最新稳定版的源码包例如spdlog-x.x.x.zip。这种方式最直观也方便查看源码和示例。方式二vcpkg/conan 包管理如果项目本身使用 vcpkg可以直接执行vcpkg install spdlog使用 conan 的话在conanfile.txt中声明[requires] spdlog/1.14.1 [generators] cmake需要说明的是具体版本号请以你执行命令时 vcpkg/conan 仓库中的最新版本为准。方式三系统包管理器Ubuntu/Debian 下可以尝试sudo apt-get install libspdlog-dev但系统仓库中的版本可能偏旧如果对版本有要求更推荐前两种方式。2.2 编译与集成方式spdlog 支持两种集成方式。仅头文件模式Header-onlyspdlog 默认是 header-only 的直接把include/目录加入头文件路径即可不需要单独编译库文件。这是最快的接入方式适合中小型项目。编译成静态库/动态库如果项目较大或者希望加快编译速度可以定义SPDLOG_COMPILED_LIB宏并编译 spdlog 库。CMake 方式如下cmake_minimum_required(VERSION 3.10) project(SpdlogDemo) set(CMAKE_CXX_STANDARD 17) add_executable(demo main.cpp) # 方式一header-only直接把 include 目录引进来 target_include_directories(demo PRIVATE path/to/spdlog/include) # 方式二使用 spdlog 提供的 CMake 目标 # add_subdirectory(path/to/spdlog) # target_link_libraries(demo PRIVATE spdlog::spdlog)建议使用方式二通过add_subdirectory引入 spdlog它会自动处理头文件路径和编译选项。如果你用 vcpkgCMake 中通过find_package(spdlog REQUIRED)也可以。2.3 版本与编译器说明不同版本的 spdlog 对 C 标准要求略有不同。老版本通常要求 C11新版本对 C17 支持更好。本文示例基于常见的 1.x 版本写法重点演示用法具体 API 名称在不同版本间基本稳定。如果遇到编译错误先检查版本对应文档再检查编译标准是否设置正确。示例环境大致如下操作系统Windows 10/11 或 Ubuntu 20.04。编译器MSVC 2019/2022或 GCC 9。CMake3.14 及以上。C 标准C17建议。如果项目中使用了 MFC请确保 spdlog 的头文件路径与 MFC 头文件路径不冲突这一点后面会专门说明。3. 核心用法拆解3.1 核心对象logger、sink、formatterspdlog 中最重要的三个概念是 logger、sink、formatter。logger日志器对外暴露日志接口的对象比如logger-info(...)。一个 logger 内部可以有多个 sink。sink输出目标日志实际写到哪里比如控制台、文件、MSVC 调试窗口。每种 sink 负责一种输出渠道。formatter格式化器决定日志行的格式比如是否包含时间戳、线程 id、日志级别。理解三者的关系后就可以明白 spdlog 为什么灵活logger 接收日志消息经过 formatter 格式化再交给一个或多个 sink 输出。这样我们可以让同一条日志同时输出到控制台和文件而不用写两遍代码。下面是一个典型的多 sink 示例#include spdlog/spdlog.h #include spdlog/sinks/stdout_color_sinks.h #include spdlog/sinks/basic_file_sink.h int main() { // 创建两个 sink一个彩色控制台一个普通文件 auto console_sink std::make_sharedspdlog::sinks::stdout_color_sink_mt(); auto file_sink std::make_sharedspdlog::sinks::basic_file_sink_mt(logs/multi.log, true); // 创建 logger并把两个 sink 都挂上去 spdlog::logger my_logger(multi, {console_sink, file_sink}); my_logger.set_level(spdlog::level::info); my_logger.info(这条日志会同时写入控制台和文件); my_logger.warn(警告信息也会同时输出); return 0; }注意stdout_color_sink_mt中的_mt表示 multi-thread即线程安全版本。spdlog 提供了_mt和_st两种版本前者适合多线程环境后者适合单线程环境性能略高。3.2 日志级别说明spdlog 的日志级别从低到高为级别枚举值说明tracespdlog::level::trace最详细一般用于调试debugspdlog::level::debug调试信息infospdlog::level::info普通信息warnspdlog::level::warn警告errspdlog::level::err错误criticalspdlog::level::critical严重错误可能导致程序退出设置 logger 级别后低于该级别的日志不会被输出。比如设置为info则trace和debug不会记录这样可以在发布版本中减少日志写入量。logger-set_level(spdlog::level::info); logger-trace(这条不会输出); logger-debug(这条不会输出); logger-info(这条会输出);3.3 格式化 pattern 详解在真实项目中我们不可能只记录一条孤立的文本通常需要包含时间、线程、日志级别、源文件位置等信息。spdlog 通过 pattern 语法控制输出格式。常用 pattern 标志标志含义%Y-%m-%d年-月-日%H:%M:%S时:分:秒%e毫秒%t线程 id%l日志级别%nlogger 名称%s源文件名%#源文件行号%!函数名%v日志内容本身%P进程 id示例#include spdlog/spdlog.h int main() { auto logger spdlog::stdout_color_mt(console); logger-set_pattern([%Y-%m-%d %H:%M:%S.%e] [%l] [thread %t] [%s:%#] %v); logger-info(用户登录成功); return 0; }输出效果类似[2025-01-18 14:22:31.128] [info] [thread 1234] [main.cpp:12] 用户登录成功注意%s、%#、%!这类源码位置信息需要在编译时启用SPDLOG_ACTIVE_LEVEL宏才能完整记录。后面的最佳实践章节会提到这一点。3.4 刷新策略与性能考虑spdlog 默认情况下的刷盘策略是为了平衡性能与实时性。如果你的日志非常重要希望每条日志都立即写入磁盘可以设置刷新策略// 每条日志都立即刷盘 logger-flush_on(spdlog::level::info); // 或者在程序退出时统一刷新 spdlog::shutdown();在异步模式下日志先进入队列由后台线程批量写入文件。这样做的好处是主线程不会因为磁盘 IO 而阻塞缺点是如果程序崩溃队列中尚未写入的数据可能会丢失。所以对关键系统要合理选择异步队列大小和 flush 策略。4. 完整实战案例4.1 控制台与文件日志基础案例先看一个最完整的入门示例。假设我们的项目结构如下spdlog_demo/ ├── CMakeLists.txt └── src/ └── main.cppCMakeLists.txt内容cmake_minimum_required(VERSION 3.14) project(SpdlogDemo) set(CMAKE_CXX_STANDARD 17) set(CMAKE_CXX_STANDARD_REQUIRED ON) add_executable(spdlog_demo src/main.cpp) # 这里假设你已经下载 spdlog 源码并放在 third_party/spdlog 目录 add_subdirectory(third_party/spdlog) target_link_libraries(spdlog_demo PRIVATE spdlog::spdlog)如果你的环境不想用 CMake也可以直接把 spdlog 的include目录加入 IDE 头文件包含路径然后包含头文件即可。src/main.cpp内容#include spdlog/spdlog.h #include spdlog/sinks/basic_file_sink.h #include spdlog/sinks/rotating_file_sink.h int main() { // 1. 初始化一个文件 logger第二个参数 true 表示追加写 auto file_logger spdlog::basic_logger_mt(file_logger, logs/app.log, true); file_logger-set_level(spdlog::level::debug); file_logger-set_pattern([%Y-%m-%d %H:%M:%S.%e] [%l] [%t] %v); // 2. 输出不同级别日志 file_logger-debug(这是一条 debug 日志); file_logger-info(这是一条 info 日志); file_logger-warn(这是一条 warn 日志); file_logger-error(这是一条 error 日志参数{}, 42); // 3. 使用花括号格式化 file_logger-info(用户名{}年龄{}, Alice, 25); // 4. 程序退出时刷新并释放资源 spdlog::shutdown(); return 0; }运行程序后logs/app.log文件中会出现类似内容[2025-01-18 14:30:01.123] [debug] [4421] 这是一条 debug 日志 [2025-01-18 14:30:01.123] [info] [4421] 这是一条 info 日志 [2025-01-18 14:30:01.124] [warn] [4421] 这是一条 warn 日志 [2025-01-18 14:30:01.124] [error] [4421] 这是一条 error 日志参数42 [2025-01-18 14:30:01.124] [info] [4421] 用户名Alice年龄25这段代码里最重要的两个点是spdlog::basic_logger_mt创建的 logger 默认是多线程安全的适合在多个线程中共享使用。{}是 spdlog 的格式化占位符相当于 C 语言printf里的%d、%s但类型安全。4.2 轮转文件日志按大小分割实际项目中单文件日志会越来越大既不便于查看也容易占满磁盘。spdlog 的rotating_logger_mt支持按大小自动轮转。#include spdlog/spdlog.h #include spdlog/sinks/rotating_file_sink.h int main() { // 参数说明 // 第一个参数logger 名称 // 第二个参数日志文件路径 // 第三个参数每个日志文件最大字节数这里 1MB // 第四个参数最多保留日志文件个数 auto logger spdlog::rotating_logger_mt(rotating, logs/rotate.log, 1024 * 1024, 3); logger-set_pattern([%Y-%m-%d %H:%M:%S.%e] [%l] %v); for (int i 0; i 10000; i) { logger-info(这是第 {} 条日志, i); } spdlog::shutdown(); return 0; }当logs/rotate.log超过 1MB 时spdlog 会自动把它改名为rotate.1.log并新建一个新的rotate.log。如果已经有rotate.1.log则继续滚成rotate.2.log。最多保留 3 个文件超出后最老的会被删除。这种机制在生产环境非常实用建议在服务类程序中优先使用轮转文件日志。4.3 异步日志的配置如果日志写入量很大或者在主线程中写日志不能阻塞业务逻辑可以用异步模式。异步模式下日志先进入队列后台线程负责批量写入。#include spdlog/spdlog.h #include spdlog/async.h #include spdlog/sinks/stdout_color_sinks.h #include spdlog/sinks/basic_file_sink.h int main() { // 初始化异步线程池参数1队列大小参数2后台线程数 spdlog::init_thread_pool(8192, 1); // 创建异步文件 logger auto logger spdlog::create_asyncspdlog::sinks::basic_file_sink_mt(async_logger, logs/async.log, true); logger-set_level(spdlog::level::info); logger-set_pattern([%Y-%m-%d %H:%M:%S.%e] [%l] [%t] %v); logger-info(异步日志写入开始); for (int i 0; i 100; i) { logger-info(异步日志{}, i); } // 异步模式下一定要调用 shutdown 或 drop否则可能丢失队列中的日志 spdlog::shutdown(); return 0; }需要提醒的是异步日志在程序崩溃时会存在丢失风险。对于记录崩溃原因的日志可以考虑单独使用同步模式确保数据马上落盘。4.4 MFC 集成 spdlog 的完整案例接下来看一个更贴近实际工程的案例在 MFC 项目中使用 spdlog 例子代码。MFC 项目与普通控制台程序有几个不同点MFC 项目通常使用 Unicode 字符集即CString内部是wchar_t。除了写文件我们还希望日志输出到 Visual Studio 的输出窗口方便调试。页面卡死或崩溃前往往需要将诊断信息写入本地文件。下面给出标准做法。4.4.1 创建 MFC 项目结构MfcSpdlogDemo/ ├── MfcSpdlogDemo.h ├── MfcSpdlogDemo.cpp ├── MfcSpdlogDemoDlg.h ├── MfcSpdlogDemoDlg.cpp └── spdlog/ └── include/ ...假设项目已经创建好了 MFC 对话框程序接下来做日志封装。4.4.2 添加头文件包含路径在项目属性 - C/C - 常规 - 附加包含目录中添加 spdlog 的 include 路径。如果要把 spdlog 编译成库需要在链接器中添加对应的 lib如果使用 header-only 模式则不需要额外链接。4.4.3 实现文件与调试窗口双输出这里我们会用到msvc_sink它可以把日志输出到 Visual Studio 的 Output 窗口对 MFC 调试非常方便。这里为了让类型兼容将宽字符内容转为 UTF-8 后再写入 spdlog。新建一个LogHelper.h#pragma once #include spdlog/spdlog.h #include spdlog/sinks/basic_file_sink.h #include spdlog/sinks/msvc_sink.h #include memory #include string class LogHelper { public: static void Init() { if (m_initialized) { return; } // 1. 文件 sink auto file_sink std::make_sharedspdlog::sinks::basic_file_sink_mt(logs/mfc_app.log, true); // 2. MSVC 调试窗口 sink auto msvc_sink std::make_sharedspdlog::sinks::msvc_sink_mt(); // 3. 组合成 logger auto logger std::make_sharedspdlog::logger(mfc_logger, spdlog::sinks_init_list{file_sink, msvc_sink}); logger-set_level(spdlog::level::debug); logger-set_pattern([%Y-%m-%d %H:%M:%S.%e] [%l] [%t] %v); spdlog::set_default_logger(logger); m_initialized true; } // 将 CString 转换为 UTF-8 字符串再输出 static void Info(const CString msg) { std::string utf8 CStringToUtf8(msg); spdlog::info(utf8); } static void Warn(const CString msg) { std::string utf8 CStringToUtf8(msg); spdlog::warn(utf8); } static void Error(const CString msg) { std::string utf8 CStringToUtf8(msg); spdlog::error(utf8); } private: static std::string CStringToUtf8(const CString str) { if (str.IsEmpty()) { return ; } #ifdef _UNICODE // 宽字符转 UTF-8 int len WideCharToMultiByte(CP_UTF8, 0, str.GetString(), -1, nullptr, 0, nullptr, nullptr); std::string result(len - 1, \0); WideCharToMultiByte(CP_UTF8, 0, str.GetString(), -1, result[0], len - 1, nullptr, nullptr); return result; #else // 多字节环境下直接返回但需要确保源码文件保存为 UTF-8 return std::string(str.GetString()); #endif } static bool m_initialized; };在LogHelper.cpp中初始化静态变量#include LogHelper.h bool LogHelper::m_initialized false;4.4.4 在 MFC 程序中使用在对话框初始化函数中调用LogHelper::Init()然后就可以在按钮事件、线程函数中使用void CMfcSpdlogDemoDlg::OnBnClickedButton1() { LogHelper::Init(); CString str; str.Format(_T(用户点击了按钮参数%d), 100)); LogHelper::Info(str); // 模拟一个错误 LogHelper::Error(_T(数据库连接失败错误码0x%08X), 1024); }这里把日志输出到 VS 的 Output 窗口开发调试非常直观。同时文件保存为logs/mfc_app.log用户反馈问题时可以快速收集日志。4.4.5 MFC 集成注意事项请将项目字符集设置为 Unicode确保CString与WideCharToMultiByte配合正常。如果日志文件中出现乱码优先检查CStringToUtf8是否正确设置CP_UTF8以及文件是否以 UTF-8 编码打开。MFC 项目中常见的ERROR、min、max等宏可能与 spdlog 产生冲突。如果遇到宏重定义问题可以在包含 spdlog 头文件之前定义NOMINMAX#ifndef NOMINMAX #define NOMINMAX #endif #include spdlog/spdlog.h不要在全局对象构造期间过早调用LogHelper::Init()因为 spdlog 内部可能依赖某些初始化状态。建议在InitInstance或对话框OnInitDialog中初始化。4.5 运行与结果验证无论使用控制台项目还是 MFC 项目验证日志是否正常主要看两个点日志文件是否正确生成内容是否完整。控制台或调试窗口是否出现预期输出。关于中文乱码只要保证三处编码一致即可源码文件编码建议 UTF-8 with BOM。spdlog 写入文件时使用的编码示例中统一转为 UTF-8。查看日志文件的编辑器编码比如 VSCode 或 Notepad 使用 UTF-8 打开。5. 常见问题与排查思路5.1 常见问题排查表问题现象常见原因解决思路编译错误找不到 spdlog.h头文件路径未配置检查附加包含目录是否正确编译错误C 标准版本过低项目编译器标准低于 C11设置 C14 或 C17链接错误无法解析外部符号header-only 模式与编译模式混用统一使用 header-only 或编译库模式中文日志乱码CString 与 UTF-8 转换不正确使用WideCharToMultiByte(CP_UTF8)转换日志没有写入文件文件路径错误或权限不足检查目录是否存在程序是否具备写权限异步日志丢失程序退出前未调用 shutdown确保程序退出时调用spdlog::shutdown()与 Windows 宏冲突ERROR、min、max 等宏定义定义NOMINMAX或调整包含顺序控制台不显示彩色信息Windows 旧版本控制台不支持 ANSI 转义使用 MSVC 调试窗口 sink或者输出纯文本5.2 典型报错细节说明错误 1无法解析的外部符号error LNK2019: 无法解析的外部符号 class std::shared_ptrclass spdlog::logger __cdecl spdlog::basic_logger_mtclass spdlog::sinks::basic_file_sink_mt(...) 原因项目同时混用了 header-only 与SPDLOG_COMPILED_LIB编译库模式。解决方法统一模式。如果全部使用 header-only不要定义SPDLOG_COMPILED_LIB如果定义了该宏就必须链接 spdlog 库。错误 2min/max 宏冲突在 Windows 下windows.h中的min、max宏与标准库冲突导致 spdlog 内部模板编译失败。解决方法#define NOMINMAX #include spdlog/spdlog.h错误 3日志文件打开失败如果程序没有创建日志目录的权限会抛出异常。建议在初始化之前做好目录创建和错误捕捉#include filesystem namespace fs std::filesystem; fs::create_directories(logs); try { auto logger spdlog::basic_logger_mt(file_logger, logs/app.log, true); } catch (const spdlog::spdlog_ex ex) { // 处理日志初始化失败 }错误 4CString 输出乱码原因多数是编码转换问题。请严格按前面的CStringToUtf8方式转换并确保项目字符集为 Unicode。6. 最佳实践与工程建议6.1 全局 logger 管理在实际项目中不建议在每次写日志时都创建一个新 logger而是应该通过封装类或全局函数统一管理。前面 MFC 示例中的LogHelper就是一种做法。更好的做法是创建一个线程安全、跨模块可用的日志单例将所有业务模块的日志都汇总到一起。这样写日志的代码只需要一行LOG_INFO(用户登录成功用户名{}, username);可以通过宏定义来隐藏 spdlog 的细节#define LOG_INFO(...) spdlog::info(__VA_ARGS__) #define LOG_WARN(...) spdlog::warn(__VA_ARGS__) #define LOG_ERROR(...) spdlog::error(__VA_ARGS__) #define LOG_DEBUG(...) spdlog::debug(__VA_ARGS__)在需要记录源文件位置的场景可以使用SPDLOG_ACTIVE_LEVEL与SPDLOG_LOGGER_CALL但这需要一定宏配置建议查阅对应版本的源码示例。6.2 编译期日志级别开关spdlog 允许在编译期定义一个激活级别低于该级别的日志连参数都不会求值从而获得更好的性能。#define SPDLOG_ACTIVE_LEVEL SPDLOG_LEVEL_DEBUG #include spdlog/spdlog.h此时可以用SPDLOG_LOGGER_DEBUG(logger, 调试信息变量{}, value);如果之后把SPDLOG_ACTIVE_LEVEL改为SPDLOG_LEVEL_INFO则所有SPDLOG_LOGGER_DEBUG会在编译期被直接移除。这在发布版本中能显著减少日志开销。6.3 日志文件管理建议使用rotating_logger_mt或daily_logger_mt按天生成文件避免单个文件无限增长。定期清理过期日志或者通过外部脚本归档。日志文件路径最好可配置比如从配置文件读取而不是写死在代码里。生产环境建议把日志目录和数据目录分开防止日志占满磁盘影响业务。6.4 敏感信息脱敏日志中不要直接输出密码、token、身份证号等敏感信息。如果业务需要记录参数建议在写入日志前做脱敏处理。std::string maskPassword(const std::string pwd) { if (pwd.size() 4) { return ****; } return **** pwd.substr(pwd.size() - 4); }这是一个容易被忽略但非常重要的工程习惯。6.5 多线程与性能建议多线程环境中使用_mt结尾的 sink保证线程安全。高频日志使用异步模型减少主线程阻塞。不要在生产环境打印超大对象或容器内容除非确定有必要。可以使用logger-should_log(spdlog::level::debug)预先判断级别避免构造昂贵日志参数。示例if (logger-should_log(spdlog::level::debug)) { logger-debug(大型数据{}, dumpLargeData()); }6.6 程序退出时的日志处理使用异步日志时必须保证程序退出前把队列中剩余日志写完。推荐在main函数末尾或进程退出钩子中调用spdlog::shutdown()。在 MFC 程序中可以在ExitInstance中调用。BOOL CMfcSpdlogDemoApp::ExitInstance() { spdlog::shutdown(); return CWinApp::ExitInstance(); }7. 总结与下一步到这里spdlog 的下载、编译、基本 API、文件轮转、异步日志、MFC 集成都已经完整过了一遍。核心要点可以归纳为几句话spdlog 通过 logger sink formatter 的组合把日志格式、输出目标和日志源解耦开灵活度高。生产环境中优先使用轮转文件日志避免单文件无限增长。异步日志能提升性能但必须正确 shutdown否则有丢失风险。MFC 项目集成时需要处理好 Unicode/UTF-8 转换以及 Windows 宏冲突。日志中做好脱敏、分级、路径可配置是工程化的基本前提。如果继续深入学习建议按以下顺序研究阅读 spdlog 源码中example/目录的官方示例。掌握自定义 sink 与自定义 formatter 的写法。熟悉SPDLOG_ACTIVE_LEVEL等编译级宏的使用。在异步模式下研究队列饱和时的丢弃策略与回调处理。日志系统是每个项目的地基之一早点选一个成熟的库能帮你省下很多后面排查问题的精力。自己动手编译一个 demo然后在真实项目中跑一周你就能感受到 spdlog 和手写日志之间的差距了。希望这篇文章对你有帮助。
返回列表