1:上一篇
前两篇我们完成了整个日志库的基础支撑模块:
- 工具模块
util.hpp:实现了跨平台的时间获取、文件存在判断、递归目录创建 - 日志等级模块
level.hpp:采用类嵌套枚举定义了 7 级日志等级,提供等级转字符串接口 - 日志消息模块
message.hpp:封装了包含时间、等级、文件名、行号、线程 ID 等 7 个核心字段的LogMsg结构体
现在我们已经有了完整的日志原始数据,但是这些零散的结构体字段无法直接输出成可读的日志文本,现在我们着手编写日志库里面灵活的日志格式化板块format.hpp,支持自定义日志格式,将对LogMsg对象换成规整的日志字符串
2:整体设计思路
1:为什么不直接用硬编码拼接
LogMsg里加一个toString()方法,直接把所有字段按固定格式拼接起来。但很快就发现了致命问题:
- 完全没有灵活性 :如果想改日志格式(比如调整字段顺序、新增 / 删除字段),必须修改
LogMsg类的代码,违反开闭原则 - 无法满足不同场景需求:开发环境可能需要详细的调试信息,生产环境只需要核心字段;控制台输出和文件输出可能需要不同的格式
- 扩展性差:以后想新增自定义字段(比如进程 ID、模块名),必须修改拼接逻辑
2:最终方案:组合模式+占位符解析
核心思路是
- 将每个日志字段的格式化逻辑封装成独立的
FormatItem子类(比如时间格式化、等级格式化、文件名格式化) - 解析用户传入的格式模板字符串(如
[%d{%Y-%m-%d}][%p] %m%n),将其拆分成一系列FormatItem对象的组合 - 格式化日志时,按顺序调用每个
FormatItem的format方法,拼接成最终的日志字符串
这种设计的优势非常明显
- 高度灵活:通过修改格式字符串即可任意调整日志格式,无需修改代码
- 扩展性强 :新增字段只需要新增一个
FormatItem子类,原有代码完全不用动 - 逻辑清晰:每个类只负责一个字段的格式化,符合单一职责原则
3:核心代码实现
1:头文件与跨平台宏定义
这是整个模块最基础的部分,也是最容易踩跨平台坑的地方。我把所有平台相关的代码都集中在这里,避免业务逻辑中出现平台判断。
cpp
#pragma once
// 依赖的基础模块头文件
#include "level.hpp" // 日志等级定义
#include "message.hpp" // 日志消息结构体定义
// C++标准库头文件
#include <iostream> // 标准输入输出流
#include <string> // 字符串处理
#include <memory> // 智能指针(shared_ptr)
#include <vector> // 动态数组(存储格式化子项)
#include <sstream> // 字符串流(用于拼接日志)
#include <ctime> // 时间处理
#include <cassert> // 断言(用于调试阶段的错误检查)
/************************** 跨平台兼容宏定义 **************************/
#ifdef _WIN32
#define LOCAL_TIME_SAFE(timestamp,tm_ptr) localtime_s(tm_ptr,&(timestamp))
#else
#define LOCAL_TIME_SAFE(timestamp,tm_ptr) localtime_r(&(timestamp),tm_ptr)
#endif // _WIN32
namespace my_log {
一开始我直接用了C标准库的localtime函数,结果发现:localtime不是线程安全的,它使用全局静态变量存储结果,多线程环境下会出现数据竞争而且Windows和Linux的线程安全版本接口参数顺序完全相反:
-Windows:localtime_s(struct tm*, const time_t*) → 第一个参数是输出,第二个是输入
-Linux:localtime_r(const time_t*, struct tm*) → 第一个参数是输入,第二个是输出
解决:用宏统一两个平台的接口,业务代码中直接使用LOCAL_TIME_SAFE即可
2:格式化子项抽象基类
首先定义抽象基类FormatItem,所有具体的格式化子项都继承自它:
cpp
/************************** 格式化子项抽象基类 **************************
* 设计模式:组合模式(Composite Pattern)
* 作用:定义所有格式化子项的统一接口,每个子项负责格式化日志的一个特定字段
* 扩展:新增字段只需要新增一个继承自FormatItem的子类,无需修改原有代码
***********************************************************************/
class FormatItem{
public:
// 智能指针类型别名,简化代码书写
using ptr = std::shared_ptr<FormatItem>;
/**
* @brief 纯虚函数:格式化日志消息的对应字段到输出流
* @param out 输出流(可以是std::cout、文件流、字符串流等)
* @param msg 待格式化的日志消息对象
*/
virtual void format(std::ostream& out, const LogMsg& msg) = 0;
// 虚析构函数:确保子类对象通过基类指针析构时能正确调用子类析构函数
// 如果不写虚析构函数,子类对象通过基类指针delete时会发生内存泄漏
virtual ~FormatItem() = default;
};
3:具体格式化子项实现
每个子类只负责一个字段的格式化,严格遵循单一职责原则。大部分子类的实现非常简单,只有时间格式化稍微复杂一点。
cpp
/************************** 具体格式化子项实现 **************************
* 每个子类对应一个日志字段的格式化逻辑,实现单一职责原则
* 对应关系:
* - MsgFormatItem → %m 日志消息内容
* - LevelFormatItem → %p 日志等级
* - TimeFormatItem → %d 时间(支持自定义格式)
* - FileNameFormatItem → %f 源代码文件名
* - LineFormatItem → %l 源代码行号
* - ThreadFormatItem → %t 线程ID
* - LoggerFormatItem → %c 日志器名称
* - TabFormatItem → %T 制表符
* - NLineFormatItem → %n 换行符
* - OtherFormatItem → 普通字符串(非格式化字符)
***********************************************************************/
// 日志消息内容格式化子项 %m
class MsgFormatItem : public FormatItem {
public:
void format(std::ostream& out, const LogMsg& msg) override
{
// 直接输出日志消息的有效载荷
out << msg._payload;
}
};
// 日志等级格式化子项 %p
class LevelFormatItem : public FormatItem
{
public:
void format(std::ostream& out, const LogMsg& msg) override
{
// 调用LogLevel类的静态方法将枚举值转换为字符串
out << LogLevel::ToString(msg._level);
}
};
// 时间格式化子项 %d(支持自定义格式,默认 %H:%M:%S)
class TimeFormatItem : public FormatItem
{
public:
/**
* @brief 构造函数
* @param fmt 时间格式字符串(遵循strftime函数的格式规范)
*/
TimeFormatItem(const std::string &fmt = "%H:%M:%S") : _time_fmt(fmt) {}
void format(std::ostream& out, const LogMsg& msg) override {
// 定义tm结构体存储分解后的时间信息
struct tm local_tm {};
// 调用跨平台安全的本地时间转换函数
LOCAL_TIME_SAFE(msg._ctime, &local_tm);
// 定义缓冲区存储格式化后的时间字符串(64字节足够存储所有常见时间格式)
char buf[64] = { 0 };
// 将tm结构体格式化为指定格式的字符串
strftime(buf, sizeof(buf), _time_fmt.c_str(), &local_tm);
// 输出格式化后的时间字符串
out << buf;
}
private:
std::string _time_fmt; // 存储用户自定义的时间格式
};
// 源代码文件名格式化子项 %f
class FileNameFormatItem : public FormatItem {
public:
void format(std::ostream& out, const LogMsg& msg) override {
out << msg._filename;
}
};
// 源代码行号格式化子项 %l
class LineFormatItem : public FormatItem {
public:
void format(std::ostream& out, const LogMsg& msg) override {
out << msg._line;
}
};
// 线程ID格式化子项 %t
class ThreadFormatItem : public FormatItem {
public:
void format(std::ostream& out, const LogMsg& msg) override {
// std::thread::id 支持直接通过流输出,非常方便
out << msg._tid;
}
};
// 日志器名称格式化子项 %c
class LoggerFormatItem : public FormatItem {
public:
void format(std::ostream& out, const LogMsg& msg) override {
out << msg._logger;
}
};
// 制表符格式化子项 %T
class TabFormatItem : public FormatItem {
public:
void format(std::ostream& out, const LogMsg&) override {
// 不需要使用日志消息对象,直接输出制表符
out << '\t';
}
};
// 换行符格式化子项 %n
class NLineFormatItem : public FormatItem {
public:
void format(std::ostream& out, const LogMsg&) override {
// 不需要使用日志消息对象,直接输出换行符
out << '\n';
}
};
// 普通字符串格式化子项(处理所有非格式化字符)
class OtherFormatItem : public FormatItem {
public:
/**
* @brief 构造函数
* @param str 要输出的普通字符串
*/
OtherFormatItem(const std::string& str) : _str(str) {}
void format(std::ostream& out, const LogMsg& msg) override
{
// 直接输出存储的普通字符串
out << _str;
}
private:
std::string _str; // 存储普通字符串内容
};
4:格式化器核心类
这是整个模块的核心,负责模板解析 和日志格式化两大核心功能,同时用到了简单工厂模式来创建格式化子项。
cpp
/************************** 格式化器核心类 **************************
* 作用:
* 1. 解析用户传入的格式模板字符串
* 2. 将模板字符串转换为一系列FormatItem对象的组合
* 3. 调用所有FormatItem的format方法,拼接成最终的日志字符串
* 设计模式:组合模式(管理多个FormatItem对象) + 工厂模式(createItem方法)
***********************************************************************/
class Formatter{
public:
using ptr = std::shared_ptr<Formatter>;
/**
* @brief 构造函数
* @param pattern 日志格式模板字符串,默认格式为:
* [%d{%Y-%m-%d %H:%M:%S}][%p][%c][%f:%l] %m%n
* @note 构造函数中会自动调用parsePattern解析模板,用assert确保解析成功
*/
Formatter(const std::string& pattern = "[%d{%Y-%m-%d %H:%M:%S}][%p][%c][%f:%l] %m%n")
: _pattern(pattern)
{
// 断言:模板解析必须成功,否则程序终止(仅在调试模式生效)
assert(parsePattern());
}
/**
* @brief 格式化日志消息,返回字符串
* @param msg 待格式化的日志消息对象
* @return std::string 格式化后的完整日志字符串
*/
std::string format(const LogMsg& msg)
{
// 使用字符串流作为中间缓冲区,拼接所有格式化子项的输出
// 比直接字符串拼接效率高很多,尤其是多个子项拼接的情况
std::stringstream ss;
format(ss, msg);
// 将字符串流中的内容转换为字符串返回
return ss.str();
}
/**
* @brief 格式化日志消息,输出到指定流
* @param out 输出流(可以是std::cout、文件流、字符串流等)
* @param msg 待格式化的日志消息对象
* 设计亮点:提供流输出接口,支持任意输出目标,灵活性极强
*/
void format(std::ostream& out, const LogMsg& msg)
{
// 按顺序遍历所有格式化子项,依次调用format方法输出
// 组合模式的核心:统一处理单个对象和对象组合
for (auto &it : _items)
{
it->format(out, msg);
}
}
/**
* @brief 解析格式模板字符串,生成格式化子项列表
* @return bool 解析成功返回true,失败返回false
* @note 这是整个模块最复杂的部分,需要处理各种边界情况
*/
bool parsePattern()
{
// 存储解析后的临时结果:pair<占位符key, 子规则val>
// key为空表示是普通字符串,val为字符串内容
// key非空表示是格式化占位符,val为子规则(如%d的时间格式)
std::vector<std::pair<std::string, std::string>> fmt_order;
size_t pos = 0; // 当前遍历的位置
std::string key, val; // 临时存储占位符key和子规则val
// 遍历整个格式模板字符串
while (pos < _pattern.size())
{
/************************** 步骤1:处理普通字符 **************************
* 逻辑:当前字符不是%,就作为普通字符加入val,继续下一个字符
***********************************************************************/
if (_pattern[pos] != '%')
{
val.push_back(_pattern[pos++]);
continue;
}
/************************** 步骤2:处理转义字符 %% **************************
* 逻辑:连续两个%表示要输出一个%本身,作为普通字符处理
* 踩坑记录:一开始没有处理这种情况,导致用户想输出%的时候出现格式错误
***********************************************************************/
if (pos + 1 < _pattern.size() && _pattern[pos + 1] == '%')
{
val.push_back('%');
pos += 2;
continue;
}
/************************** 步骤3:处理格式化占位符 **************************
* 逻辑:遇到单个%,表示后面是格式化占位符
* 1. 先把之前积累的普通字符串保存到fmt_order
* 2. 提取占位符key(%后面的第一个字符)
* 3. 检查是否有子规则(如%d{%Y-%m-%d}中的{}部分)
***********************************************************************/
// 3.1 保存之前积累的普通字符串
if (!val.empty())
{
fmt_order.push_back(std::make_pair("", val));
val.clear();
}
// 3.2 检查%是否在字符串末尾(边界情况处理)
if (++pos == _pattern.size())
{
std::cout << "格式错误:%之后没有对应的格式化字符\n";
return false;
}
// 3.3 提取占位符key(%后面的第一个字符)
key = _pattern[pos++];
/************************** 步骤4:处理占位符的子规则 **************************
* 逻辑:如果占位符后面跟着{,则{}中的内容是子规则
* 例如:%d{%Y-%m-%d} 中,子规则是%Y-%m-%d
***********************************************************************/
if (pos < _pattern.size() && _pattern[pos] == '{')
{
pos++; // 跳过{字符
// 遍历直到遇到}或字符串末尾
while (pos < _pattern.size() && _pattern[pos] != '}')
{
val.push_back(_pattern[pos++]);
}
// 边界情况:没有找到对应的}
if (pos == _pattern.size())
{
std::cout << "格式错误:子规则括号{}不匹配\n";
return false;
}
pos++; // 跳过}字符
}
// 3.4 将占位符和子规则保存到fmt_order
fmt_order.push_back(std::make_pair(key, val));
key.clear();
val.clear();
}
/************************** 步骤5:处理最后一段普通字符 **************************
* 逻辑:循环结束后,如果val不为空,说明最后一段是普通字符,需要保存
* 踩坑记录:一开始忘记处理这种情况,导致日志末尾的普通字符丢失
***********************************************************************/
if (!val.empty())
{
fmt_order.emplace_back("", val);
}
/************************** 步骤6:创建格式化子项列表 **************************
* 逻辑:遍历解析后的fmt_order,调用createItem方法创建对应的FormatItem对象
* 并将所有对象存储到_items成员变量中
***********************************************************************/
for (auto& it : fmt_order)
{
_items.push_back(createItem(it.first, it.second));
}
// 解析成功
return true;
}
private:
/**
* @brief 工厂方法:根据占位符key和子规则val创建对应的FormatItem对象
* @param key 占位符key(如d、p、m等)
* @param val 子规则(如时间格式)
* @return FormatItem::ptr 创建好的格式化子项智能指针
* @note 设计模式:简单工厂模式,封装对象创建逻辑
* 扩展:新增占位符只需要在这里加一行判断即可,非常方便
*/
FormatItem::ptr createItem(const std::string& key, const std::string& val)
{
if (key == "d") return std::make_shared<TimeFormatItem>(val);
if (key == "t") return std::make_shared<ThreadFormatItem>();
if (key == "c") return std::make_shared<LoggerFormatItem>();
if (key == "f") return std::make_shared<FileNameFormatItem>();
if (key == "l") return std::make_shared<LineFormatItem>();
if (key == "p") return std::make_shared<LevelFormatItem>();
if (key == "T") return std::make_shared<TabFormatItem>();
if (key == "m") return std::make_shared<MsgFormatItem>();
if (key == "n") return std::make_shared<NLineFormatItem>();
// 未知占位符:作为普通字符串处理,避免程序崩溃
return std::make_shared<OtherFormatItem>(val);
}
private:
std::string _pattern; // 用户传入的格式模板字符串
std::vector<FormatItem::ptr> _items; // 解析后生成的格式化子项列表
};
} // namespace my_log
4:完整占位符说明
| 占位符 | 含义 | 示例 |
|---|---|---|
| %d | 时间(支持自定义格式,默认 % H:% M:% S) | %d{%Y-%m-%d %H:%M:%S} → 2024-05-26 15:30:45 |
| %p | 日志等级 | INFO、WARN、ERR |
| %c | 日志器名称 | root、db_logger |
| %f | 源代码文件名 | main.cpp |
| %l | 源代码行号 | 25 |
| %t | 线程 ID | 140709832386368 |
| %m | 日志消息内容 | 数据库连接成功 |
| %T | 制表符 | \t |
| %n | 换行符 | \n |
| %% | 输出一个 % | % |
默认的日志格式为:
%d{%Y-%m-%d %H:%M:%S}%p%c%f:%l %m%n
输出效果示例:
2024-05-26 15:30:45INFOrootmain.cpp:25 数据库连接成功
5:测试代码(AI生成)
cpp
#include "format.hpp"
#include <iostream>
int main()
{
// 1. 构造测试日志消息
my_log::LogMsg msg(
my_log::LogLevel::value::INFO,
__LINE__,
"test_format.cpp",
"root_logger",
"这是一条测试日志"
);
// 2. 使用默认格式格式化
my_log::Formatter::ptr default_formatter = std::make_shared<my_log::Formatter>();
std::string default_log = default_formatter->format(msg);
std::cout << "默认格式日志:\n" << default_log << std::endl;
// 3. 使用自定义格式格式化
std::string custom_pattern = "[%d{%Y-%m-%d}][%t][%p] %f:%l - %m%n";
my_log::Formatter::ptr custom_formatter = std::make_shared<my_log::Formatter>(custom_pattern);
std::string custom_log = custom_formatter->format(msg);
std::cout << "自定义格式日志:\n" << custom_log << std::endl;
return 0;
}
结果
cpp
默认格式日志:
[2024-05-26 15:30:45][INFO][root_logger][test_format.cpp:12] 这是一条测试日志
自定义格式日志:
[2024-05-26][140709832386368][INFO] test_format.cpp:12 - 这是一条测试日志

6:总结
1:踩过的坑
- 跨平台时间函数不统一 :一开始直接用了
localtime,结果不仅线程不安全,而且 Windows 和 Linux 的安全版本函数参数顺序相反,最后用宏定义统一了接口 - 格式解析边界处理 :一开始没有处理
%在字符串末尾的情况,导致数组越界崩溃;还有%%转义的情况,一开始没有处理,导致输出错误 - 子括号匹配问题 :一开始没有处理子规则
{}不匹配的情况,比如%d{%Y-%m-%d没有闭合括号,导致解析死循环 - 最后一段普通字符遗漏:一开始循环结束后没有处理最后一段普通字符,导致日志末尾的内容丢失
- 虚析构函数缺失 :一开始忘记给
FormatItem基类加虚析构函数,导致子类对象通过基类指针析构时发生内存泄漏
2:学到的东西
- 组合模式的实际应用:将复杂的格式化任务拆分成多个简单的子任务组合
- 简单工厂模式的应用:根据不同的 key 创建不同的格式化子项对象
- 字符串解析的技巧:状态机思想、边界情况处理
- 跨平台开发的最佳实践:用宏定义统一平台差异,避免业务逻辑中出现平台判断
- 面向接口编程的思想:依赖抽象而不是具体实现,提高代码的扩展性和可维护性