【C++ AI 大模型接入 SDK】— 日志模块
一、模块概述
日志模块是 SDK 中最底层的模块,被所有其他模块依赖。本项目使用spdlog—— 一个高性能的 C++ 日志库,支持异步写入、格式化输出、日志级别过滤等功能。
本模块包含两个文件:
| 文件 | 功能说明 |
|---|---|
| include/util/myLog.h | Logger 类声明 + 6 个日志宏(TRACE ~ CRIT) |
| src/util/myLog.cpp | Logger 类实现(初始化 spdlog、双检锁单例) |
二、Logger 类 — 声明(myLog.h)
2.1 完整源码
#pragmaonce#include<mutex>#include<spdlog/logger.h>#include<spdlog/spdlog.h>namespacebite{classLogger{public:staticvoidinitLogger(conststd::string&loggerName,conststd::string&loggerFile,spdlog::level::level_enum logLevel=spdlog::level::info);staticstd::shared_ptr<spdlog::logger>getLogger();private:Logger();Logger(constLogger&)=delete;Logger&operator=(constLogger&)=delete;private:staticstd::shared_ptr<spdlog::logger>_logger;staticstd::mutex _mutex;};}// end bite2.2 逐段解析
单例模式设计
classLogger{// ...private:Logger();// 私有构造,禁止外部实例化Logger(constLogger&)=delete;// 禁止拷贝构造Logger&operator=(constLogger&)=delete;// 禁止赋值private:staticstd::shared_ptr<spdlog::logger>_logger;// 唯一的日志器实例staticstd::mutex _mutex;// 用于线程安全};Logger 采用单例模式——整个程序只需要一个日志器。
实现要点:
- 私有构造函数:外部无法
new Logger()创建对象
比如:Logger log; // ❌ 不允许- 删除拷贝和赋值:防止通过拷贝产生多个实例
delete 表示:禁止这个函数使用。都是为了防止对象被复制,保证唯一实例。- 静态成员
_logger:持有spdlog的日志器对象,全局唯一
static属于类本身而不是某个对象,此时是整个类只有这一份,所有对象共享!
- 静态成员
_mutex:配合双检锁保证线程安全,同一时刻:只能一个线程写。
所有方法都是static的,通过Logger::initLogger(...)和Logger::getLogger()直接调用,无需创建 Logger 对象。
接口说明
// 初始化日志器(只能调用一次)staticvoidinitLogger(conststd::string&loggerName,conststd::string&loggerFile,spdlog::level::level_enum logLevel=spdlog::level::info);// 获取日志器实例staticstd::shared_ptr<spdlog::logger>getLogger();| 参数 | 说明 |
|---|---|
| loggerName | 日志器名称,会出现在日志输出中,如 “ChatServer” |
| loggerFile | 日志输出目标,传 “stdout” 输出到控制台,传文件路径输出到文件 |
| logLevel | 日志级别过滤,默认 info,低于该级别的日志不会被输出 |
三、Logger 类 — 实现(myLog.cpp)
3.1 完整源码
#include"../../include/util/myLog.h"#include<memory>//智能指针#include<spdlog/spdlog.h>//spdlog核心库#include<spdlog/sinks/basic_file_sink.h>//文件输出功能#include<spdlog/sinks/stdout_color_sinks.h>//彩色终端输出#include<spdlog/async.h>//异步日志支持namespacebite{std::shared_ptr<spdlog::logger>Logger::_logger=nullptr;std::mutex Logger::_mutex;Logger::Logger(){}voidLogger::initLogger(conststd::string&loggerName,conststd::string&loggerFile,spdlog::level::level_enum logLevel){if(nullptr==_logger){std::lock_guard<std::mutex>lock(_mutex);if(nullptr==_logger){// 日志级别 ≥ logLevel 时立即刷新spdlog::flush_on(logLevel);// 启用异步日志,队列大小 32768,后台线程数 1spdlog::init_thread_pool(32768,1);if("stdout"==loggerFile){// 输出到控制台(带颜色)_logger=spdlog::stdout_color_mt(loggerName);}else{// 输出到文件(异步)_logger=spdlog::basic_logger_mt<spdlog::async_factory>(loggerName,loggerFile);}}// 设置日志格式:[时分秒][日志器名][日志级别]消息内容_logger->set_pattern("[%H:%M:%S][%n][%-7l]%v");_logger->set_level(logLevel);}}std::shared_ptr<spdlog::logger>Logger::getLogger(){return_logger;}}// end bite3.2 逐段解析
静态成员初始化
std::shared_ptr<spdlog::logger>Logger::_logger=nullptr;std::mutex Logger::_mutex;类的静态成员变量需要在类外定义和初始化。_logger初始为nullptr,在initLogger中才创建真正的 spdlog 日志器。
双检锁(Double-Checked Locking)
voidLogger::initLogger(...){if(nullptr==_logger){// 第一次检查(不加锁,快速路径)std::lock_guard<std::mutex>lock(_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("stdout"==loggerFile){_logger=spdlog::stdout_color_mt(loggerName);}else{_logger=spdlog::basic_logger_mt<spdlog::async_factory>(loggerName,loggerFile);}根据loggerFile参数决定日志输出到哪里:
| 参数值 | 使用方式 | 说明 |
|---|---|---|
| “stdout” | spdlog::stdout_color_mt() | 输出到控制台,带颜色 |
| 文件路径字符串 | spdlog::basic_logger_mt<async_factory>() | 异步写入文件 |
stdout_color_mt创建一个带颜色输出的控制台日志器(mt表示 multi-thread 线程安全)basic_logger_mt<spdlog::async_factory>创建异步写入文件的日志器
日志格式设置
_logger->set_pattern("[%H:%M:%S][%n][%-7l]%v");_logger->set_level(logLevel);格式化占位符说明:
| 占位符 | 含义 | 输出示例 |
|---|---|---|
| %H:%M:%S | 时:分:秒 | 9:04:03 |
| %n | 日志器名称 | ChatServer |
| %-7l | 日志级别,左对齐,宽度 7 | info |
| %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__)日志级别从低到高:
| 宏 | 级别 | 使用场景 |
|---|---|---|
| TRACE | trace | 最详细的追踪信息,通常调试时使用 |
| DBG | debug | 调试信息 |
| INFO | info | 常规运行信息(默认级别) |
| WARN | warning | 警告,不影响运行但需关注 |
| ERR | error | 错误,操作失败 |
| CRIT | critical | 严重错误,可能导致程序崩溃 |
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]用户ID=1001这样做的最大好处就是:后期排查 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 初始化
在程序入口处调用一次:
#include<ai_chat_sdk/util/myLog.h>intmain(){// 初始化日志:名称 "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::string&modelName,std::unique_ptr<LLMProvider>provider){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, modelName=deepseek-chat[09:04:03][ChatServer][info][LLMManager.cpp:22]register provider success, modelName=doubao-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 名称等全局日志格式。这种分层设计提高了日志系统的灵活性、可维护性与扩展性。