C++工业级日志项目(三)日志格式化消息封装

1:上一篇

前两篇我们完成了整个日志库的基础支撑模块:

  1. 工具模块 util.hpp:实现了跨平台的时间获取、文件存在判断、递归目录创建
  2. 日志等级模块 level.hpp:采用类嵌套枚举定义了 7 级日志等级,提供等级转字符串接口
  3. 日志消息模块 message.hpp :封装了包含时间、等级、文件名、行号、线程 ID 等 7 个核心字段的LogMsg结构体

现在我们已经有了完整的日志原始数据,但是这些零散的结构体字段无法直接输出成可读的日志文本,现在我们着手编写日志库里面灵活的日志格式化板块format.hpp,支持自定义日志格式,将对LogMsg对象换成规整的日志字符串

2:整体设计思路

1:为什么不直接用硬编码拼接

LogMsg里加一个toString()方法,直接把所有字段按固定格式拼接起来。但很快就发现了致命问题:

  • 完全没有灵活性 :如果想改日志格式(比如调整字段顺序、新增 / 删除字段),必须修改LogMsg类的代码,违反开闭原则
  • 无法满足不同场景需求:开发环境可能需要详细的调试信息,生产环境只需要核心字段;控制台输出和文件输出可能需要不同的格式
  • 扩展性差:以后想新增自定义字段(比如进程 ID、模块名),必须修改拼接逻辑

2:最终方案:组合模式+占位符解析

核心思路是

  1. 将每个日志字段的格式化逻辑封装成独立的FormatItem子类(比如时间格式化、等级格式化、文件名格式化)
  2. 解析用户传入的格式模板字符串(如[%d{%Y-%m-%d}][%p] %m%n),将其拆分成一系列FormatItem对象的组合
  3. 格式化日志时,按顺序调用每个FormatItemformat方法,拼接成最终的日志字符串

这种设计的优势非常明显

  • 高度灵活:通过修改格式字符串即可任意调整日志格式,无需修改代码
  • 扩展性强 :新增字段只需要新增一个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 创建不同的格式化子项对象
  • 字符串解析的技巧:状态机思想、边界情况处理
  • 跨平台开发的最佳实践:用宏定义统一平台差异,避免业务逻辑中出现平台判断
  • 面向接口编程的思想:依赖抽象而不是具体实现,提高代码的扩展性和可维护性
相关推荐
开发小能手-roy21 小时前
StringBuilder vs StringBuffer:2024年还需要线程安全字符串吗?
开发语言·python·安全
开发小能手-roy21 小时前
Java集合框架选型指南:从ArrayList到ConcurrentSkipListMap
java·开发语言
凡人叶枫21 小时前
Effective C++ 条款41:了解隐式接口和编译期多态
java·开发语言·c++·effective c++
凡人叶枫1 天前
Effective C++ 条款42:了解 typename 的双重意义
java·linux·服务器·c++
小胖xiaopangss1 天前
BRpc使用
c++·rpc
2601_954706491 天前
云手机技术详解+Python实战调用|2026高稳云手机平台推荐
开发语言·python·智能手机
chushiyunen1 天前
java中的路径处理、左右斜杠
java·开发语言·python
2601_961875241 天前
决战申论100题2026|最新|范文
linux·容器·centos·debian·ssh·fabric·vagrant
java_cj1 天前
深入kube-apiserver认证机制:从Bearer Token到mTLS的完整认证链解析
linux·运维·服务器·云原生·容器·kubernetes
-森屿安年-1 天前
63. 不同路径 II
c++·算法·动态规划