C++实现简易的日志库-TinyLog

TinyLog

完整代码在最下侧

整体结构

tinylog/
├── 1. LOG_LEVELS // 宏:定义所有日志级别
├── 2. enum class level // 枚举:日志级别类型
├── 3. to_string() // 函数:级别 → 字符串
├── 4. from_string() // 函数:字符串 → 级别
├── 5. config // 全局配置(级别、文件)
├── 6. color // ANSI颜色支持
├── 7. with_location // 模板:包装格式字符串+位置
├── 8. log() // 函数:核心日志逻辑
├── 9. log_xxx() // 函数:7个便捷日志函数
└── 10. set_level/set_file() // 函数:配置接口

问题场景

想象你在写一个日志库,需要处理7种日志级别:

  • trace, debug, info, critical, warning, error, fatal

你需要实现三个基本功能:

  1. 定义一个枚举类型表示这些级别
  2. 把枚举值转成字符串(用于打印)
  3. 把字符串转成枚举值(用于配置)

最直接的写法

// 1. 定义枚举
enum class log_level {
    trace, debug, info, critical, warning, error, fatal
};

// 2. 枚举转字符串
std::string log_level_name(log_level lev) {
    switch(lev) {
        case log_level::trace: return "trace";
        case log_level::debug: return "debug";
        // ... 写7次
        case log_level::fatal: return "fatal";
    }
}

// 3. 字符串转枚举
log_level log_level_from_name(const std::string& name) {
    if (name == "trace") return log_level::trace;
    if (name == "debug") return log_level::debug;
    // ... 再写7次
    return log_level::info;
}

问题:代码重复,添加新级别要改3个地方,很容易漏改。

发现本质

观察这三个地方,它们都有一个共同点:都在列举同样的7个名字

枚举定义:trace, debug, info, ...
转换函数:每个名字写一个case
反向转换:每个名字写一个if

编程的一个重要原则:Don't Repeat Yourself (DRY)。既然都在列举同样的东西,能不能只写一次列表?

宏的引入

C/C++预处理器有个功能:宏可以接受参数,并且可以"字符串化"参数。

// 字符串化操作符 #
#define STRINGIFY(x) #x
std::string name = STRINGIFY(trace);  // 变成 "trace"

// 连接操作符 ##
#define MAKE_FUNC(x) void log_##x() {}
MAKE_FUNC(info)  // 变成 void log_info() {}

如果把所有需要列举的名字放在一个宏里,然后把这个宏作为"模板",让其他宏来使用它:

// 先定义一个"名字列表"宏
#define LOG_LEVELS \
    trace \
    debug \
    info \
    critical \
    warning \
    error \
    fatal

但是这样写有问题:直接用空格分隔没法被其他宏正确处理。需要一种方式让每个名字都能被单独处理。需要记住的重点是:c/cpp的宏就是简单的文本替换

X Macro模式

X Macro的核心思想:让列表宏接受一个"操作"参数

#define FOREACH_LOG_LEVEL(OPERATION) \
    OPERATION(trace)                  \
    OPERATION(debug)                   \
    OPERATION(info)                    \
    OPERATION(critical)                 \
    OPERATION(warning)                   \
    OPERATION(error)                      \
    OPERATION(fatal)

// 现在可以这样用:
// 定义枚举:让OPERATION变成 "名字,"
// 转字符串:让OPERATION变成 case: return "名字"
enum class log_level {
    // 这里需要每个名字后面加逗号
#define ADD_AS_ENUM(name) name,
    FOREACH_LOG_LEVEL(ADD_AS_ENUM)  // 展开成: trace, debug, info, ...
#undef ADD_AS_ENUM  // 用完就清理
};

预处理器展开后变成:

enum class log_level {
    trace,
    debug,
    info,
    critical,
    warning,
    error,
    fatal
};

具体效果可以使用以下命令进行查看

g++ -E main.cpp -o tmp.txt
cat tmp.txt | less

实现枚举转字符串

std::string log_level_name(log_level lev) {
    switch(lev) {
#define CASE_RETURN_STRING(name) \
    case log_level::name: return #name;
    
    FOREACH_LOG_LEVEL(CASE_RETURN_STRING)
#undef CASE_RETURN_STRING
    
    default: return "unknown";
    }
}

展开之后:

std::string log_level_name(log_level lev) {
    switch(lev) {
    case log_level::trace: return "trace";
    case log_level::debug: return "debug";
    // ...
    case log_level::fatal: return "fatal";
    default: return "unknown";
    }
}

实现字符串转枚举

log_level log_level_from_name(const std::string& name) {
#define IF_NAME_RETURN_ENUM(lev) \
    if (name == #lev) return log_level::lev;
    
    FOREACH_LOG_LEVEL(IF_NAME_RETURN_ENUM)
#undef IF_NAME_RETURN_ENUM
    
    return log_level::info;  // 默认值
}

展开后:

log_level log_level_from_name(const std::string& name) {
    if (name == "trace") return log_level::trace;
    if (name == "debug") return log_level::debug;
    // ...
    if (name == "fatal") return log_level::fatal;
    return log_level::info;
}

现代字符串格式化

// 传统方式 - 问题多多
char buf[1024];
sprintf(buf, "Value: %d, Name: %s", 42, "Tom");  // 不安全,易出错

std::ostringstream oss;
oss << "Value: " << 42 << ", Name: " << "Tom";   // 啰嗦,性能差

// C++20 format - 现代方式
std::string msg = std::format("Value: {}, Name: {}", 42, "Tom");
// 简洁!类型安全!可扩展!

同时,format可以在编译期检查

// 看看代码中怎么用的
template <typename... Args>
void log(log_level lev, 
         details::with_source_location<std::format_string<Args...>> fmt,  // 注意这里
         Args&&... args)
{
    // std::format_string 是一个编译期包装器
    // 它会在编译时检查格式字符串是否匹配参数
    auto format = std::vformat(fmt.format().get(), std::make_format_args(args...));
    // ...
}

// 使用时
log_info("Hello {}", 42);     // OK
log_info("Hello {}", "world"); // OK
// log_info("Hello {}", 42, 100); // 编译错误!参数不匹配

with_source_location包装器

代码中我们使用了这个包装器,为什么需要 with_source_location 包装器呢?(std::source_location是cpp20新推出的一个类,用于获取当前代码的行号,列号,文件名等,类似C的__FILE__宏,了解即可)

考虑以下问题

直接传字符串的问题

// 直观但错误的想法
void log(log_level lev, const std::string& fmt, auto&&... args) {
    auto loc = std::source_location::current();  // 错误!
    auto msg = std::vformat(fmt, std::make_format_args(args...));
    std::cout << loc.file_name() << ":" << loc.line() << " " << msg;
}

// 使用时
void foo() {
    log(log_level::info, "Hello {}", 42);  // 期望打印 foo.cpp:10
}

问题出在哪里?

// source_location::current() 的行为
void log(...) {
    auto loc = std::source_location::current();  
    // 这个 loc 捕获的是 log 函数内部的位置!
    // 也就是 log.cpp:15,而不是调用点的 foo.cpp:10
}

// 相当于:
void log(...) {
    // current() 返回"它自己被调用时的位置"
    // 它在 log 函数体内被调用,所以返回 log 函数的位置
}

为什么不能像 __LINE__ 那样工作?

// 传统的宏方式可以工作是因为预处理器的文本替换
#define LOG(msg) \
    log(__FILE__, __LINE__, msg)  // 宏展开时直接插入调用点的位置

// 宏展开后:
log("main.cpp", 42, "Hello");  // 直接传入了字面量

// 但函数参数传递时,这个信息就丢失了
void log(const char* file, int line, const std::string& msg) {
    // file 和 line 是参数,可以通过参数传递
}

尝试用函数重载

// 手动传位置
void log(log_level lev, 
         const std::string& fmt, 
         std::source_location loc = std::source_location::current(),
         auto&&... args) {  //  参数包不能在默认参数后面
    // ...
}

// 即使能编译,使用也很丑陋
log(log_level::info, 
    "Hello {}", 
    std::source_location::current(),  // 用户必须手动传!
    42);

为什么参数包必须放在最后?C++ 规定:参数包必须是函数参数列表的最后一个

template<typename... Args>
void good(Args&&... args, int x) {}  // 错误:参数包后面不能有参数

template<typename... Args>
void good(int x, Args&&... args) {}  // 正确:参数包在最后

这就产生了矛盾:

  • 我们需要 source_location 作为默认参数(通常放最后)
  • 但可变参数包也必须放最后
  • 两者冲突了!

引入包装器

我们需要同时捕获三样东西:

  1. 格式字符串(编译期已知)
  2. 格式化参数(运行时才知道)
  3. 调用位置(编译期已知)
// 把格式字符串和调用位置"打包"在一起
// 这样它们就可以作为一个整体,放在参数列表前面
template<typename T>
struct with_source_location {
    T inner;                 // 存格式字符串
    std::source_location loc; // 存调用位置
    
    // 在构造时就捕获位置!
    template<typename U>
    with_source_location(U&& u, 
                         std::source_location l = std::source_location::current())
        : inner(std::forward<U>(u)), loc(l) {}
};

当时用的时候,构造是关键,注意用户传入参数构造之时:

// 用户调用 log_info
template<typename... Args>
void log_info(with_source_location<std::format_string<Args...>> fmt, 
              Args&&... args) {
    // 当函数被调用时,fmt 已经构造完成了!
    auto& loc = fmt.location();  // 拿到调用点的位置
    auto& format_str = fmt.format();  // 拿到格式字符串
    // ...
}

// 调用过程分解:
log_info("Hello {}", 42);
// 1. 编译器看到 "Hello {}" 和 42
// 2. 需要构造第一个参数:with_source_location<std::format_string<int>>
// 3. 调用构造函数:with_source_location("Hello {}", source_location::current())
//    注意:current() 在这里被调用,位置是调用点!
// 4. 构造好的对象传给 log_info

为什么用 format_string 而不是 string呢?- 编译器检查

// 如果直接用 std::string
void bad_log(with_source_location<std::string> fmt, auto&&... args) {
    // fmt 只是普通字符串,无法在编译时检查格式
    std::vformat(fmt.format(), ...);  // 运行时可能抛出格式异常
}

// 使用 format_string
void good_log(with_source_location<std::format_string<Args...>> fmt, 
              Args&&... args) {
    // fmt.format().get() 在编译期就验证过格式
    // 如果格式不匹配参数,根本编译不过!
}

// 编译期检查示例
good_log("Hello {}", 42);     // 编译通过
good_log("Hello {}", "world"); // 编译通过
// good_log("Hello {}", 42, 100); // 编译错误:参数太多
// good_log("Hello", 42);         // 编译错误:格式字符串需要参数

format_string的内部原理如下:

// 标准库中 format_string 的简化实现
template<typename... Args>
struct format_string {
    std::string_view str;
    
    // 编译期构造函数
    consteval format_string(const char* s) : str(s) {
        // 编译期检查格式字符串是否合法
        // 检查占位符 {} 数量是否等于 sizeof...(Args)
        // 如果检查失败,编译错误!
    }
    
    std::string_view get() const { return str; }
};

std::chrono时间处理

// C++20 的时区支持
std::chrono::zoned_time now {
    std::chrono::current_zone(),           // 系统当前时区
    std::chrono::system_clock::now()       // 当前时间点
};

// zoned_time 的格式化输出
std::format("{}", now);  // 2024-01-15 10:30:45.123456 CST

环境变量配置

我们可能会有使用环境变量的需求,类似LOG_LEVEL=debug ./cc的形式,因此我们需要正确读取环境变量。

namespace details {
    // 使用立即函数(IIFE)进行复杂初始化
    inline log_level g_max_level = []() -> log_level {
        if (auto lev = std::getenv("LOG_LEVEL")) {  // 读取环境变量
            return log_level_fron_name(lev);        // 字符串转枚举
        }
        return log_level::info;                      // 默认值
    }();  // 立即调用

    // 同样的模式初始化日志文件
    inline std::ofstream g_log_file = []() -> std::ofstream {
        if (auto path = std::getenv("LOG_FILE")) {
            return std::ofstream(path, std::ios::app);  // 追加模式
        }
        return std::ofstream();  // 空文件流,不输出到文件
    }();
}

至于这里为什么使用details是为了防止用户使用库的时候前端直接弹出一推对用户无用的透明函数或者类,我们将这些部分放入其中。

条件编译与跨平台

// 预定义宏检测平台
#if (__linux__) || defined(__APPLE__)
    // Linux 或 macOS
    // ANSI 转义序列支持
    inline constexpr char k_reset_ansi_color[4] = "\E[m";
#else
    // Windows 或其他
    // 不支持ANSI颜色
    inline constexpr char k_reset_ansi_color[1] = "";
#endif


// 定义辅助宏来简化条件编译
#if (__linux__) || defined(__APPLE__)
#define LOG_IF_HAS_ANSI_COLORS(x) x  // 保留颜色代码
#else
#define LOG_IF_HAS_ANSI_COLORS(x)    // 忽略颜色代码
#endif

// 使用
std::cout << LOG_IF_HAS_ANSI_COLORS(k_level_ansi_colors[lev] +) 
          << msg 
          << LOG_IF_HAS_ANSI_COLORS(+k_reset_ansi_color) 
          << '\n';

使用ANSI转义序列支持,简而言之就是在对应的字符串前面加入这些转义序列就可以改变输出后的颜色,大家可以自行查询,这里只是给出使用。

完整的日志流程

// 用户调用
tinylog::log_info("用户 {} 登录成功", username);

// 1. 参数捕获
//    with_source_location 捕获调用点位置
//    format_string 编译期检查格式

// 2. 格式化
//    std::vformat 执行实际格式化

// 3. 时间处理
//    zoned_time 获取本地时间

// 4. 组装消息
//    "{} {}:{} [{}] {}" - 时间 文件:行 [级别] 消息

// 5. 输出
//    - 写入文件(如果设置了LOG_FILE)
//    - 控制台输出(如果级别足够)
//    - ANSI颜色(如果平台支持)

完整代码

#include <chrono>
#include <cstdint>
#include <format>
#include <fstream>
#include <iostream>
#include <source_location>
#include <string>

namespace tinylog {

#define FOREACH_LOG_LEVEL(f)     \
    f(trace)                     \
    f(debug)                      \
    f(info)                       \
    f(critical)                   \
    f(warning)                    \
    f(error)                       \
    f(fatal)

enum class log_level : std::uint8_t {
#define _FUNCTION(x) x,
    FOREACH_LOG_LEVEL(_FUNCTION)
#undef _FUNCTION
};

namespace details {

    inline std::string log_level_name(log_level lev)
    {
        switch (lev) {
#define _FUNCTION(name)   \
    case log_level::name: \
        return #name;
            FOREACH_LOG_LEVEL(_FUNCTION)
#undef _FUNCTION
        }
        return "unknown";
    }

    inline log_level log_level_fron_name(const std::string& name)
    {
#define _FUNCTION(lev) \
    if (name == #lev)  \
        return log_level::lev;
        FOREACH_LOG_LEVEL(_FUNCTION)
#undef _FUNCTION
        return log_level::info;
    }

#if (__linux__) || defined(__APPLE__)
    inline constexpr char k_level_ansi_colors[(std::uint8_t)log_level::fatal + 1][8] = {
        "\E[37m",
        "\E[35m",
        "\E[32m",
        "\E[34m",
        "\E[33m",
        "\E[31m",
        "\E[31;1m",
    };

    inline constexpr char k_reset_ansi_color[4] = "\E[m";
#define LOG_IF_HAS_ANSI_COLORS(x) x
#else
#define LOG_IF_HAS_ANSI_COLORS(x) x
    inline constexpr char k_level_ansi_colors[(std::uint8_t)log_level::fatal + 1][1] = {
        "",
        "",
        "",
        "",
        "",
        "",
        "",
    };

    inline constexpr char k_reset_ansi_color[1] = "";
#endif

    inline log_level g_max_level = []() -> log_level {
        if (auto lev = std::getenv("LOG_LEVEL")) {
            return log_level_fron_name(lev);
        }
        return log_level::info;
    }();

    inline std::ofstream g_log_file = []() -> std::ofstream {
        if (auto path = std::getenv("LOG_FILE")) {
            return std::ofstream(path, std::ios::app);
        }
        return std::ofstream();
    }();

    inline void output_log(log_level lev, std::string msg, std::source_location const& loc)
    {
        std::chrono::zoned_time now { std::chrono::current_zone(), std::chrono::system_clock::now() };
        msg = std::format("{} {}:{} [{}] {}", now, loc.file_name(), loc.line(), log_level_name(lev), msg);
        if (g_log_file) {
            g_log_file << msg + '\n';
        }
        if (lev >= g_max_level) {
            std::cout << LOG_IF_HAS_ANSI_COLORS(k_level_ansi_colors[(std::uint8_t)lev] +) msg LOG_IF_HAS_ANSI_COLORS(+k_reset_ansi_color) + '\n';
        }
    }

    template <typename T>
    struct with_source_location {
    private:
        T inner;
        std::source_location loc;

    public:
        template <typename U>
            requires std::constructible_from<T, U>
        consteval with_source_location(U&& inner, std::source_location loc = std::source_location::current())
            : inner(std::forward<U>(inner))
            , loc(std::move(loc))
        {
        }
        constexpr T const& format() const { return inner; }
        constexpr std::source_location const& location() const { return loc; }
    };

}

template <typename... Args>
void log(log_level lev, details::with_source_location<std::format_string<Args...>> fmt, Args&&... args)
{

    auto const& loc = fmt.location();
    // TODO:
    auto format = std::vformat(fmt.format().get(), std::make_format_args(args...));
    details::output_log(lev, std::move(format), loc);
}

#define _FUNCTION(name)                                                                             \
    template <typename... Args>                                                                     \
    void log_##name(details::with_source_location<std::format_string<Args...>> fmt, Args&&... args) \
    {                                                                                               \
        return log(log_level::name, std::move(fmt), std::forward<Args>(args)...);                   \
    }
FOREACH_LOG_LEVEL(_FUNCTION)
#undef _FUNCTION

static void set_log_level(log_level lev)
{
    details::g_max_level = lev;
}

static void set_log_file(const std::string& path)
{
    details::g_log_file = std::ofstream(path, std::ios::app);
}

}
posted @ 2026-03-16 16:02  大胖熊哈  阅读(2)  评论(0)    收藏  举报