1:上一篇

上一篇我们完成了项目的工具模块util.hpp的实现,实现了跨平台的时间获取,文件存在判断,目录创建等基础功能。

这一篇我要实现日志库最核心的两个基础模块:日志等级定义日志消息封装

这两个模块是整个日志库的骨架,所有后续的格式化、输出、调度逻辑都完全基于它们。

2:日志等级设计

1:为什么要有日志等级

日志等级是日志库最基础也是最重要的功能之一,没有等级过滤的日志库是完全不可用的。

在开发环境中,我们需要输出大量的 DEBUG 级别的调试信息来排查问题;但到了生产环境,这些调试信息不仅毫无用处,还会产生巨大的磁盘 IO 开销,甚至拖慢整个程序的性能。通过设置不同的日志等级,我们可以在不修改代码的情况下,灵活控制日志的输出详细程度。

2:最终确定的日志等级

等级 含义 使用场景
UNKNOW 未知等级 用于默认值和错误处理
DEBUG 调试信息 开发环境,记录程序内部的详细运行状态
INFO 普通信息 生产环境,记录程序的正常运行事件(如启动、停止、连接成功)
WARN 警告信息 不影响程序运行,但可能存在潜在问题(如配置缺失、参数异常)
ERR 错误信息 程序某个功能模块出错,但整体还能继续运行(如数据库连接失败、接口调用超时)
FATAL 致命错误 程序无法继续运行,必须立即退出(如内存耗尽、核心依赖缺失)
OFF 关闭所有日志 用于完全禁用某个日志器的输出

3:为什么用类嵌套而不是直接使用强制枚举

一开始我想直接用 enum class LogLevel,但后来发现了一个问题:如果以后要给日志等级添加辅助方法(比如判断等级高低、比较等级),直接用 enum class 就不太方便了。所以我最终采用了类嵌套枚举的设计:

class LogLevel {
public:
    enum class value {
        UNKNOW=0,
        DEBUG,
        INFO,
        WARN,
        ERR,
        FATAL,
        OFF
    };
    // 这里可以添加各种静态辅助方法
};

优势

  • 扩展性强:以后可以很方便地在 LogLevel 类中添加静态辅助方法,比如 isHigherThan()isLowerThan()
  • 封装性好:所有和日志等级相关的逻辑都集中在 LogLevel 类中,符合单一职责原则
  • 同样具备强类型安全:内部的 enum class 依然是强类型,不会有隐式转换的问题
  • 避免命名冲突:所有等级都在 LogLevel::value 作用域下,不会和其他同名常量冲突

4:等级转换字符串函数

我还实现了一个静态的 ToString 函数,把枚举值转换成对应的字符串,方便后续格式化输出。这里我返回的是 const char* 而不是 std::string,原因很简单:

  • 字符串都是字面量,生命周期和程序一样长,不需要动态分配内存
  • 返回 const char * 效率更高,避免了 std::string 的拷贝开销
  • 后续格式化的时候可以直接用,不需要再调用 c_str ()

5:完整的level.hpp的实现

#pragma once

namespace my_log {
    /**
     * @brief 日志等级类
     * 定义了所有日志等级,并提供等级转字符串的静态方法
     */
    class LogLevel {
    public:
        /**
         * @brief 日志等级枚举值
         * 从低到高依次为:UNKNOW < DEBUG < INFO < WARN < ERR < FATAL < OFF
         */
        enum class value {
            UNKNOW = 0, // 未知等级,用于默认值
            DEBUG,      // 调试信息
            INFO,       // 普通信息
            WARN,       // 警告信息
            ERR,        // 错误信息
            FATAL,      // 致命错误
            OFF         // 关闭所有日志
        };

        /**
         * @brief 将日志等级转换为对应的字符串
         * @param level 日志等级枚举值
         * @return const char* 等级对应的字符串字面量
         */
        static const char* ToString(LogLevel::value level)
        {
            switch (level){
            case LogLevel::value::DEBUG: return "DEBUG";
            case LogLevel::value::INFO:  return "INFO";
            case LogLevel::value::WARN:  return "WARN";
            case LogLevel::value::ERR:   return "ERR";
            case LogLevel::value::FATAL: return "FATAL";
            case LogLevel::value::OFF:   return "OFF";
            }
            return "UNKNOW";
        }
    };
}

踩过的坑

一开始我把错误等级命名为ERROR,结果在 Windows 下编译的时候,和windows.h里定义的#define ERROR 0宏冲突了,编译器报错。其实看VS2022的扩展可以很清楚看到ERROR本身就是宏了,最后改成了ERR才解决。这也让我深刻体会到了宏的危害,以后能不用宏就尽量不用。

3:日志消息封装LogMsg

1:为什么要封装日志消息

一条完整的日志绝不仅仅是一段文本,它包含了很多重要的元信息,这些信息在排查问题的时候至关重要。我最终确定了 7 个必须包含的字段:

  1. 日志时间戳:用于过滤日志输出时间,排查特定时间段的问题
  2. 日志等级:用于日志过滤和分析,快速定位错误等级
  3. 源代码文件名:定位出现错误的代码文件
  4. 源代码行号:精确到行号,直接跳转到出错的代码位置
  5. 线程 ID:用于过滤出错的线程,排查多线程并发问题
  6. 日志器名称:支持多日志器同时使用,区分不同模块的日志
  7. 日志主体消息:实际的日志内容

如果每次输出日志都要把这 7 个参数传来传去,不仅代码会变得非常冗长,而且很容易传错参数。把所有这些信息封装成一个LogMsg结构体,只需要传递一个结构体对象,就可以传递所有日志信息,非常简洁高效。

而且,日志消息一旦创建就不应该被修改,是一个不可变对象。这样在多线程环境下传递的时候,不需要加锁,天然线程安全。

2:LogMsg结构体字段设计

我设计的LogMsg结构体包含以下 7 个字段,每个字段都有明确的作用:

字段名 类型 作用
_ctime time_t 日志产生的时间戳(秒级)
_level LogLevel::value 日志的等级
_filename std::string 产生日志的代码文件名(纯文件名)
_line size_t 产生日志的代码行号
_tid std::thread::id 产生日志的线程 ID
_logger std::string 产生日志的日志器名称
_payload std::string 日志的实际内容

3:构造函数设计

构造函数是LogMsg结构体的核心,我在这里做了很多自动处理的工作,尽量减少用户的输入:

  1. 自动获取时间戳:调用util::Date::GetTime()获取当前时间,不需要用户手动传入
  2. 自动获取线程 ID:调用std::this_thread::get_id()获取当前线程 ID,不需要用户手动传入
  3. 参数顺序优化:把最常变化的参数放在前面,不常变化的放在后面,方便用户调用

4:完整代码实现

#pragma once
#include "level.hpp"
#include "util.hpp"
#include <iostream>
#include <string>
#include <thread>

namespace my_log {
    /**
     * @brief 日志消息结构体
     * 封装了一条完整日志的所有元信息和内容
     * 一旦创建即为不可变对象,天然线程安全
     */
    struct LogMsg {
        time_t _ctime;                // 日志产生时间戳(秒级)
        LogLevel::value _level;       // 日志等级
        std::string _filename;        // 源代码文件名(纯文件名)
        size_t _line;                 // 源代码行号
        std::thread::id _tid;         // 产生日志的线程ID
        std::string _logger;          // 产生日志的日志器名称
        std::string _payload;         // 日志实际内容

        /**
         * @brief 构造函数
         * @param level 日志等级
         * @param line 代码行号(由__LINE__宏传入)
         * @param filename 源代码文件名(纯文件名,由外部接口预处理后传入)
         * @param logger 日志器名称
         * @param msg 日志实际内容
         * @note 时间戳和线程ID会自动获取,不需要手动传入
         */
        LogMsg(
            LogLevel::value level,
            size_t line,
            const std::string filename,
            const std::string logger,
            const std::string msg)
            :_ctime(util::Date::GetTime()),
            _level(level),
            _filename(filename),
            _line(line),
            _tid(std::this_thread::get_id()),
            _logger(logger),
            _payload(msg){ }
    };
}

设计思考

  • 自动获取线程 ID:这是我这次新增的一个非常重要的字段。在多线程程序中,很多问题都是并发导致的,有了线程 ID,我们可以很方便地过滤出某个特定线程的所有日志,快速定位并发问题
  • 初始化列表:所有字段都在初始化列表中初始化,比在构造函数体内赋值效率更高
  • 不可变对象:所有字段都是 public 的,但一旦构造完成就不应该被修改。我没有把它们设为 private,是因为后续格式化模块需要直接访问这些字段,设为 public 更方便,而且日志消息只是一个数据载体,没有业务逻辑

4:测试代码(AI生成)

#include "message.hpp"
#include <iostream>

int main()
{
    std::cout << "==================== 日志等级与消息测试 ====================\n" << std::endl;

    // 1. 测试日志等级转换
    std::cout << "1. 日志等级转换测试:" << std::endl;
    std::cout << "   UNKNOW -> " << my_log::LogLevel::ToString(my_log::LogLevel::value::UNKNOW) << std::endl;
    std::cout << "   DEBUG  -> " << my_log::LogLevel::ToString(my_log::LogLevel::value::DEBUG) << std::endl;
    std::cout << "   INFO   -> " << my_log::LogLevel::ToString(my_log::LogLevel::value::INFO) << std::endl;
    std::cout << "   WARN   -> " << my_log::LogLevel::ToString(my_log::LogLevel::value::WARN) << std::endl;
    std::cout << "   ERR    -> " << my_log::LogLevel::ToString(my_log::LogLevel::value::ERR) << std::endl;
    std::cout << "   FATAL  -> " << my_log::LogLevel::ToString(my_log::LogLevel::value::FATAL) << std::endl;
    std::cout << "   OFF    -> " << my_log::LogLevel::ToString(my_log::LogLevel::value::OFF) << std::endl;
    std::cout << "   ✅ 日志等级转换正常\n" << std::endl;

    // 2. 测试日志消息封装
    std::cout << "2. 日志消息封装测试:" << std::endl;
    my_log::LogMsg msg(
        my_log::LogLevel::value::INFO,
        __LINE__,
        "test_message.cpp",
        "root_logger",
        "这是一条测试日志"
    );

    std::cout << "   时间戳:" << msg._ctime << std::endl;
    std::cout << "   等级:" << my_log::LogLevel::ToString(msg._level) << std::endl;
    std::cout << "   文件名:" << msg._filename << std::endl;
    std::cout << "   行号:" << msg._line << std::endl;
    std::cout << "   线程ID:" << msg._tid << std::endl;
    std::cout << "   日志器:" << msg._logger << std::endl;
    std::cout << "   内容:" << msg._payload << std::endl;
    std::cout << "   ✅ 日志消息封装正常\n" << std::endl;

    // 3. 测试多线程日志消息
    std::cout << "3. 多线程日志消息测试:" << std::endl;
    std::thread t1([](){
        my_log::LogMsg msg(
            my_log::LogLevel::value::DEBUG,
            __LINE__,
            "test_message.cpp",
            "thread_logger",
            "线程1的日志消息"
        );
        std::cout << "   线程1 ID:" << msg._tid << std::endl;
    });

    std::thread t2([](){
        my_log::LogMsg msg(
            my_log::LogLevel::value::DEBUG,
            __LINE__,
            "test_message.cpp",
            "thread_logger",
            "线程2的日志消息"
        );
        std::cout << "   线程2 ID:" << msg._tid << std::endl;
    });

    t1.join();
    t2.join();
    std::cout << "   ✅ 多线程日志消息正常\n" << std::endl;

    std::cout << "==================== 所有测试通过 ====================" << std::endl;
    return 0;
}

5:总结

完成的工作

  1. 设计并实现了日志等级模块level.hpp,采用类嵌套枚举的设计,定义了七级日志等级
  2. 实现了日志等级转字符串函数ToString,返回 const char * 字面量,效率更高
  3. 设计并实现了日志消息结构体LogMsg,封装了 7 个日志元信息字段
  4. LogMsg构造函数中自动获取时间戳和线程 ID,减少用户输入
  5. 编写了全面的测试用例,包括单线程和多线程场景
  6. 在 Windows 和 Linux 两个平台完成了跨平台测试

更多推荐