【C++ AI 大模型接入 SDK】— 日志模块
一、模块概述日志模块是 SDK 中最底层的模块被所有其他模块依赖。本项目使用spdlog—— 一个高性能的 C 日志库支持异步写入、格式化输出、日志级别过滤等功能。本模块包含两个文件文件功能说明include/util/myLog.hLogger 类声明 6 个日志宏TRACE ~ CRITsrc/util/myLog.cppLogger 类实现初始化 spdlog、双检锁单例二、Logger 类 — 声明myLog.h2.1 完整源码#pragmaonce#includemutex#includespdlog/logger.h#includespdlog/spdlog.hnamespacebite{classLogger{public:staticvoidinitLogger(conststd::stringloggerName,conststd::stringloggerFile,spdlog::level::level_enum logLevelspdlog::level::info);staticstd::shared_ptrspdlog::loggergetLogger();private:Logger();Logger(constLogger)delete;Loggeroperator(constLogger)delete;private:staticstd::shared_ptrspdlog::logger_logger;staticstd::mutex _mutex;};}// end bite2.2 逐段解析单例模式设计classLogger{// ...private:Logger();// 私有构造禁止外部实例化Logger(constLogger)delete;// 禁止拷贝构造Loggeroperator(constLogger)delete;// 禁止赋值private:staticstd::shared_ptrspdlog::logger_logger;// 唯一的日志器实例staticstd::mutex _mutex;// 用于线程安全};Logger 采用单例模式——整个程序只需要一个日志器。实现要点私有构造函数外部无法new Logger()创建对象比如Logger log; // ❌ 不允许删除拷贝和赋值防止通过拷贝产生多个实例delete 表示禁止这个函数使用。都是为了防止对象被复制保证唯一实例。静态成员_logger持有spdlog的日志器对象全局唯一static属于类本身而不是某个对象此时是整个类只有这一份所有对象共享静态成员_mutex配合双检锁保证线程安全同一时刻只能一个线程写。所有方法都是static的通过Logger::initLogger(...)和Logger::getLogger()直接调用无需创建 Logger 对象。接口说明// 初始化日志器只能调用一次staticvoidinitLogger(conststd::stringloggerName,conststd::stringloggerFile,spdlog::level::level_enum logLevelspdlog::level::info);// 获取日志器实例staticstd::shared_ptrspdlog::loggergetLogger();参数说明loggerName日志器名称会出现在日志输出中如 “ChatServer”loggerFile日志输出目标传 “stdout” 输出到控制台传文件路径输出到文件logLevel日志级别过滤默认 info低于该级别的日志不会被输出三、Logger 类 — 实现myLog.cpp3.1 完整源码#include../../include/util/myLog.h#includememory//智能指针#includespdlog/spdlog.h//spdlog核心库#includespdlog/sinks/basic_file_sink.h//文件输出功能#includespdlog/sinks/stdout_color_sinks.h//彩色终端输出#includespdlog/async.h//异步日志支持namespacebite{std::shared_ptrspdlog::loggerLogger::_loggernullptr;std::mutex Logger::_mutex;Logger::Logger(){}voidLogger::initLogger(conststd::stringloggerName,conststd::stringloggerFile,spdlog::level::level_enum logLevel){if(nullptr_logger){std::lock_guardstd::mutexlock(_mutex);if(nullptr_logger){// 日志级别 ≥ logLevel 时立即刷新spdlog::flush_on(logLevel);// 启用异步日志队列大小 32768后台线程数 1spdlog::init_thread_pool(32768,1);if(stdoutloggerFile){// 输出到控制台带颜色_loggerspdlog::stdout_color_mt(loggerName);}else{// 输出到文件异步_loggerspdlog::basic_logger_mtspdlog::async_factory(loggerName,loggerFile);}}// 设置日志格式[时分秒][日志器名][日志级别]消息内容_logger-set_pattern([%H:%M:%S][%n][%-7l]%v);_logger-set_level(logLevel);}}std::shared_ptrspdlog::loggerLogger::getLogger(){return_logger;}}// end bite3.2 逐段解析静态成员初始化std::shared_ptrspdlog::loggerLogger::_loggernullptr;std::mutex Logger::_mutex;类的静态成员变量需要在类外定义和初始化。_logger初始为nullptr在initLogger中才创建真正的 spdlog 日志器。双检锁Double-Checked LockingvoidLogger::initLogger(...){if(nullptr_logger){// 第一次检查不加锁快速路径std::lock_guardstd::mutexlock(_mutex);if(nullptr_logger){// 第二次检查加锁后确认// ... 创建日志器}// ... 设置格式和级别}}//就是第一步检查日志为空的时候就进行上锁自动管理的智能锁为了以防万一在第一次上锁期间别的日志线程已经创建好进行第二次检查第二次检查还是为空说明别的日志没有创建好继续向下走这是经典的双检锁单例模式解决两个问题线程安全多线程可能同时调用initLogger需要加锁保护性能第一次检查不加锁为空然后加锁日志器已创建后直接返回避免每次加锁的开销线程A: 第一次检查 _logger nullptr → 加锁 → 第二次检查空 → 创建日志器 → 解锁 线程B: 第一次检查 _logger nullptr → 等待锁 → 获得锁 → 第二次检查已不为空→ 跳过创建 → 解锁 线程C: 第一次检查 _logger ! nullptr → 直接返回不加锁异步日志spdlog::init_thread_pool(32768,1);启用异步日志模式参数一队列大小参数二后台线程数量日志消息先写入一个大小为 32768 的队列由1 个后台线程负责将队列中的日志写入目标控制台/文件调用日志的线程不会因 IO 操作而阻塞提高性能业务线程INFO(xxx) → 写入队列 → 立即返回 ↓ 后台线程 从队列取出 → 写入控制台/文件日志输出目标选择if(stdoutloggerFile){_loggerspdlog::stdout_color_mt(loggerName);}else{_loggerspdlog::basic_logger_mtspdlog::async_factory(loggerName,loggerFile);}根据loggerFile参数决定日志输出到哪里参数值使用方式说明“stdout”spdlog::stdout_color_mt()输出到控制台带颜色文件路径字符串spdlog::basic_logger_mtasync_factory()异步写入文件stdout_color_mt创建一个带颜色输出的控制台日志器mt表示 multi-thread 线程安全basic_logger_mtspdlog::async_factory创建异步写入文件的日志器日志格式设置_logger-set_pattern([%H:%M:%S][%n][%-7l]%v);_logger-set_level(logLevel);格式化占位符说明占位符含义输出示例%H:%M:%S时:分:秒9:04:03%n日志器名称ChatServer%-7l日志级别左对齐宽度 7info%v实际日志消息init model success输出效果[09:04:03][ChatServer][info ][ DataManager.cpp:15] Database opened successfully: chat.db [09:04:04][ChatServer][error ][ DoubaoProvider.cpp:18] api_key not found四、日志宏定义4.1 六个日志宏#defineTRACE(format,...)bite::Logger::getLogger()-trace(std::string([{:10s}:{:4d}])format,__FILE__,__LINE__,##__VA_ARGS__)#defineDBG(format,...)bite::Logger::getLogger()-debug(std::string([{:10s}:{:4d}])format,__FILE__,__LINE__,##__VA_ARGS__)#defineINFO(format,...)bite::Logger::getLogger()-info(std::string([{:10s}:{:4d}])format,__FILE__,__LINE__,##__VA_ARGS__)#defineWARN(format,...)bite::Logger::getLogger()-warn(std::string([{:10s}:{:4d}])format,__FILE__,__LINE__,##__VA_ARGS__)#defineERR(format,...)bite::Logger::getLogger()-error(std::string([{:10s}:{:4d}])format,__FILE__,__LINE__,##__VA_ARGS__)#defineCRIT(format,...)bite::Logger::getLogger()-critical(std::string([{:10s}:{:4d}])format,__FILE__,__LINE__,##__VA_ARGS__)日志级别从低到高宏级别使用场景TRACEtrace最详细的追踪信息通常调试时使用DBGdebug调试信息INFOinfo常规运行信息默认级别WARNwarning警告不影响运行但需关注ERRerror错误操作失败CRITcritical严重错误可能导致程序崩溃4.2 宏的工作原理在实际项目中我们通常不会直接调用 spdlog 的info()、debug()等接口而是会进一步封装成自己的日志宏例如#defineINFO(format,...)\bite::Logger::getLogger()-info(std::string([{:10s}:{:4d}])format,__FILE__,__LINE__,##__VA_ARGS__)这段代码第一次看可能会觉得特别复杂但其实它本质上就是在帮我们“自动补全日志信息”。以后我们只需要简单写一句INFO(用户ID {},uid);日志系统就会自动帮我们把“文件名”和“代码行号”也一起打印出来比如[main.cpp:35]用户ID1001这样做的最大好处就是后期排查 Bug 的时候我们能立刻知道日志是从哪一行代码打印出来的而不用全局搜索大型项目里这个功能非常重要。接下来我们拆开来看。首先#defineINFO(format,...)这里的#define是 C/C 中的宏定义本质上就是“文本替换”。编译之前编译器会先把INFO(hello);替换成mylog::Logger::getLogger()-info(...);其中format表示格式字符串而...则表示**“可变参数”**也就是说参数数量不固定。比如下面这些写法都合法INFO(hello);INFO(id {},id);INFO({} {},a,b);随后重点来了__FILE__和__LINE__它们是 C 提供的预定义宏。__FILE__表示当前文件名而__LINE__表示当前代码所在行号。比如main.cpp35因此日志系统就能自动知道这是 main.cpp 第35行打印的日志再来看这部分[{:10s}:{:4d}]这里使用的是 fmt 风格格式化语法因为 spdlog 底层实际上就是基于 fmt 库实现的。其中{:10s}表示字符串右对齐占 10 个字符宽度而{:4d}表示整数左对齐占 4 个字符宽度。这样做是为了让日志输出更加整齐例如[ main.cpp:35 ]整体看起来会非常规范。最后##__VA_ARGS__表示把用户传入的可变参数继续转发给 spdlog。例如INFO(uid {},uid);最终会变成logger-info([{:10s}:{:4d}]uid {},__FILE__,__LINE__,uid);随后 spdlog 会自动把文件名填入第一个{}行号填入第二个{}uid 填入最后一个{}最终生成完整日志。实际上像 glog、spdlog 等成熟日志库底层都大量使用这种“宏 文件名 行号”的设计方式因为日志系统最核心的目标就是快速定位问题。五、使用方式5.1 初始化在程序入口处调用一次#includeai_chat_sdk/util/myLog.hintmain(){// 初始化日志名称 ChatServer输出到控制台INFO 级别bite::Logger::initLogger(ChatServer,stdout,spdlog::level::info);// 之后在任意位置使用日志宏INFO(服务器启动成功, 端口: {},8080);ERR(连接模型失败: {},timeout);return0;}//效果[14:25:31][ChatServer][info][main.cpp:8]服务器启动成功,端口:8080[14:25:31][ChatServer][error][main.cpp:9]连接模型失败:timeout5.2 在 SDK 各模块中的使用日志宏在整个 SDK 中被广泛使用以LLMManager为例boolLLMManager::registerProvider(conststd::stringmodelName,std::unique_ptrLLMProviderprovider){if(!provider){ERR(cannot register nullptr provider, modelName {},modelName);// 错误日志returnfalse;}_providers[modelName]std::move(provider);INFO(register provider success, modelName {},modelName);// 信息日志returntrue;}输出效果[09:04:03][ChatServer][info][LLMManager.cpp:22]register provider success, modelNamedeepseek-chat[09:04:03][ChatServer][info][LLMManager.cpp:22]register provider success, modelNamedoubao-pro[09:04:04][ChatServer][error][DoubaoProvider.cpp:18]api_key not found日志中的文件名和行号能帮助快速定位问题所在位置。六、设计总结myLog.h其中包括日志初始化、日志获取函数的声明通过私有构造、禁止外界实例化、禁止拷贝与赋值保证全局仅存在一个日志对象单例模式定义静态成员_logger日志器对象与_mutex线程锁实现全局共享同时定义日志宏实现“文件名 行号 格式化内容 可变参数”的自动拼接最终通过Logger::getLogger()间接获取_logger并调用真正的 spdlog 日志接口。myLog.cpp主要负责日志初始化与getLogger()函数的实现创建 logger 对象设置日志输出格式时间戳、日志器名称、日志等级、日志消息初始化异步线程池以及控制日志输出到控制台或文件。单例模式 双检锁保证全局唯一日志器同时兼顾线程安全与性能避免重复创建 logger 对象异步日志基于 spdlog 的异步线程池机制业务线程只负责将日志放入队列后台线程负责真正的 IO 输出从而避免日志阻塞业务线程灵活输出通过参数控制日志输出到控制台或文件方便不同环境下使用宏自动定位自动记录日志来源文件与代码行号无需手动填写fmt 风格格式化底层基于 fmt 的{}占位符格式化相比传统printf风格更加安全、清晰、现代化分层设计整个日志系统被拆分为“日志正文”和“日志元信息”两部分其中宏负责动态生成与代码位置相关的内容文件名、行号、用户日志而set_pattern()则统一控制时间戳、日志等级、logger 名称等全局日志格式。这种分层设计提高了日志系统的灵活性、可维护性与扩展性。