日志系统的进化之路

从 C 到 C++17:手写日志系统的进化之路

在 C/C++ 开发中,日志是定位问题、监控程序运行的 “眼睛”。直接用printf打印信息虽然简单,但缺乏分级控制、上下文信息(时间、位置、线程),生产环境中还会产生大量冗余输出。而第三方日志库(如 spdlog、log4cpp)虽功能强大,但有时会引入不必要的依赖,对于轻量级项目或嵌入式场景并不友好。

这篇文章就从实际开发需求出发,一步步实现从 C 语言宏定义到 C++17 折叠表达式的日志系统,带你体会不同实现方案的设计思路、优缺点,最终得到一个轻量、高效、类型安全的日志工具。

一、核心需求:一个实用日志系统该具备什么?

在动手之前,先明确日志系统的核心需求 —— 毕竟好的设计都是从需求出发的:

  1. 日志分级:支持 INFO(信息)、DEBUG(调试)、ERROR(错误)三级,可按需开关(开发时输 DEBUG,生产时只输 ERROR);

  2. 上下文完备:自动携带时间戳、文件名、行号、线程 ID,调试时能快速定位问题;

  3. 类型安全:避免printf的格式符与参数不匹配(如%d对应string)导致的运行时崩溃;

  4. 轻量无依赖:不依赖第三方库,仅用标准库实现;

  5. 易用性强:调用方式接近printf,无需复杂配置。

带着这些需求,我们从最基础的 C 语言版本开始,逐步迭代优化。

二、V1.0:C 语言宏定义版(兼容 C/C++,轻量灵活)

这是最基础也最常用的实现方式,基于宏定义和stdarg.h,兼容 C 和 C++ 项目,适合对依赖有严格要求的场景。

2.1 实现代码

#include <stdio.h>
#include <stdarg.h>
#include <time.h>
#include <pthread.h>

// 日志分级
#define LOG_LEVEL DEBUG  // 全局输出阈值:DEBUG=输出所有,ERROR=只输出错误
#define INFO 0
#define DEBUG 1
#define ERROR 2

// 核心日志宏
#define LOG(level, format, ...) do { \
    if (level < LOG_LEVEL) break; \
    /* 时间戳处理 */ \
    time_t t = time(NULL); \
    struct tm ltm; \
    localtime_r(&t, &ltm);  // 线程安全版本(POSIX),Windows用localtime_s \
    char time_buf[32] = {0}; \
    strftime(time_buf, 31, "%Y-%m-%d %H:%M:%S", &ltm); \
    /* 输出日志:线程ID+时间+文件+行号+内容 */ \
    fprintf(stdout, "[%p] [%s] [%s:%d] " format "\n", \
            (void*)pthread_self(), time_buf, __FILE__, __LINE__, ##__VA_ARGS__); \
} while(0)

// 简化调用接口
#define INFO_LOG(format, ...) LOG(INFO, format, ##__VA_ARGS__)
#define DEBUG_LOG(format, ...) LOG(DEBUG, format, ##__VA_ARGS__)
#define ERROR_LOG(format, ...) LOG(ERROR, format, ##__VA_ARGS__)

// 测试代码
int main() {
    INFO_LOG("程序启动,版本号:%s", "v1.0.0");
    DEBUG_LOG("初始化缓冲区,大小:%d字节", 1024);
    int ret = -1;
    if (ret != 0) {
        ERROR_LOG("接口调用失败,错误码:%d", ret);
    }
    return 0;
}

2.2 核心设计思路

  1. 分级控制:通过LOG_LEVEL阈值过滤日志,低于阈值的日志直接跳过,无需修改业务代码即可切换环境;

  2. 上下文封装:利用__FILE__(文件名)、__LINE__(行号)预定义宏,自动获取代码位置;pthread_self()获取线程 ID,适配多线程场景;

  3. 可变参数处理stdarg.h__VA_ARGS__接收可变参数,##用于消除参数为空时的多余逗号,避免语法错误;

  4. 线程安全:用localtime_r替代localtime(后者非线程安全),避免多线程环境下时间戳错乱。

2.3 优缺点

  • 优点:轻量无依赖、兼容 C/C++、调用简单、无运行时开销;

  • 缺点:类型不安全(格式符与参数类型不匹配编译不报错)、不支持自定义类型(如自定义类)、参数中含逗号需特殊处理。

三、V2.0:C++11 递归模板版(类型安全,兼容旧标准)

C++11 引入的可变参数模板解决了类型安全问题,通过编译期类型推导,避免格式符不匹配的错误。这个版本兼容 C++11 及以上标准,适合无法升级到 C++17 的项目。

3.1 实现代码

#include <iostream>
#include <typeinfo>
#include <time.h>
#include <thread>
#include <cassert>

// 日志分级
enum class LogLevel { INFO, DEBUG, ERROR };
const LogLevel G_LOG_LEVEL = LogLevel::DEBUG;

// 递归终止函数:参数包为空时调用
void log_impl() {
    std::cout << std::endl;
}

// 递归展开参数包:每次处理一个参数
template<typename T, typename... Args>
void log_impl(const T& first, const Args&... rest) {
    // 打印参数类型和值(typeid需开启RTTI)
    std::cout << "[" << typeid(T).name() << ":" << first << "] ";
    log_impl(rest...);  // 递归处理剩余参数
}

// 日志前缀:时间戳+线程ID+文件+行号
void log_prefix(const char* file, int line) {
    // 时间戳(线程安全)
    time_t t = time(NULL);
    struct tm ltm;
    localtime_r(&t, &ltm);
    char time_buf[32] = {0};
    strftime(time_buf, 31, "%Y-%m-%d %H:%M:%S", &ltm);
    
    // 线程ID(C++11跨平台)
    auto tid = std::this_thread::get_id();
    
    // 输出前缀
    std::cout << "[" << tid << "] [" << time_buf << "] [" << file << ":" << line << "] ";
}

// 对外接口:支持分级控制
template<typename... Args>
void LOG(LogLevel level, const char* file, int line, const Args&... args) {
    if (level < G_LOG_LEVEL) return;
    log_prefix(file, line);
    log_impl(args...);
}

// 简化调用(自动传入文件和行号)
#define INFO_LOG(...) LOG(LogLevel::INFO, __FILE__, __LINE__, ##__VA_ARGS__)
#define DEBUG_LOG(...) LOG(LogLevel::DEBUG, __FILE__, __LINE__, ##__VA_ARGS__)
#define ERROR_LOG(...) LOG(LogLevel::ERROR, __FILE__, __LINE__, ##__VA_ARGS__)

// 测试自定义类(需重载operator<<)
class Person {
public:
    std::string name;
    int age;
    friend std::ostream& operator<<(std::ostream& os, const Person& p) {
        os << p.name << "(" << p.age << ")";
        return os;
    }
};

// 测试代码
int main() {
    INFO_LOG("程序启动", "v2.0.0");
    DEBUG_LOG("用户信息", Person{"张三", 23}, "分数", 95.5f);
    int ret = -2;
    if (ret != 0) {
        ERROR_LOG("数据库连接失败", "错误码", ret);
    }
    return 0;
}

3.2 核心设计思路

  1. 类型安全:通过可变参数模板typename... Args推导参数类型,无需手动指定格式符,编译期就能检查类型错误;

  2. 递归展开参数包:用log_impl递归处理每个参数,直到参数包为空(触发无参log_impl终止);

  3. 跨平台兼容:用std::this_thread::get_id()替代pthread_self(),支持 Windows、Linux 跨平台;

  4. 支持自定义类:只要重载operator<<,就能直接输出自定义对象,灵活性更强。

3.3 优缺点

  • 优点:类型安全、支持任意类型参数、跨平台、可扩展;

  • 缺点:需要手动实现递归终止函数、代码略显冗余、依赖 C++11 及以上标准。

四、V3.0:C++17 折叠表达式版(极致简洁,高效优雅)

C++17 引入的折叠表达式(Fold Expression)彻底解决了递归模板的冗余问题,一行代码就能展开参数包,让日志系统的实现变得极致简洁。

4.1 实现代码

#include <iostream>
#include <typeinfo>
#include <time.h>
#include <thread>
#include <string>

// 日志分级与全局阈值
enum class LogLevel { INFO, DEBUG, ERROR };
constexpr LogLevel G_LOG_LEVEL = LogLevel::DEBUG;

// 友好的类型名转换(解决typeid.name()可读性差的问题)
template<typename T>
std::string get_type_name() {
    std::string name = typeid(T).name();
    if (name == "PKc") return "string";    // GCC: const char*
    if (name == "i") return "int";         // GCC: int
    if (name == "f") return "float";       // GCC: float
    if (name == "d") return "double";      // GCC: double
    if (name == "b") return "bool";        // GCC: bool
    return name;
}

// 日志前缀:时间戳+线程ID+文件+行号
void log_prefix(const char* file, int line) {
    // 时间戳(线程安全)
    time_t t = time(nullptr);
    struct tm ltm;
    localtime_r(&t, &ltm);
    char time_buf[32] = {0};
    strftime(time_buf, 31, "%Y-%m-%d %H:%M:%S", &ltm);
    
    // 线程ID转换为字符串(便于输出)
    std::string tid_str = std::to_string(std::hash<std::thread::id>{}(std::this_thread::get_id()));
    
    // 输出前缀
    std::cout << "[" << tid_str.substr(0, 8) << "] [" << time_buf << "] [" << file << ":" << line << "] ";
}

// 核心日志函数:C++17折叠表达式展开参数包
template<typename... Args>
void LOG(LogLevel level, const char* file, int line, const Args&... args) {
    if (level < G_LOG_LEVEL) return;
    log_prefix(file, line);
    
    // 折叠表达式:一行展开所有参数(逗号运算符)
    (std::cout << "[" << get_type_name<Args>() << ":" << args << "] ", ...) << std::endl;
}

// 简化调用宏
#define INFO_LOG(...) LOG(LogLevel::INFO, __FILE__, __LINE__, ##__VA_ARGS__)
#define DEBUG_LOG(...) LOG(LogLevel::DEBUG, __FILE__, __LINE__, ##__VA_ARGS__)
#define ERROR_LOG(...) LOG(LogLevel::ERROR, __FILE__, __LINE__, ##__VA_ARGS__)

// 测试自定义类
class Student {
public:
    std::string name;
    int id;
    friend std::ostream& operator<<(std::ostream& os, const Student& s) {
        os << "Student{id:" << s.id << ", name:" << s.name << "}";
        return os;
    }
};

// 测试代码
int main() {
    INFO_LOG("服务启动成功", "端口", 8080);
    DEBUG_LOG("请求处理", "用户", Student{"李四", 1001}, "耗时", 3.2f, "ms");
    bool is_connect = false;
    if (!is_connect) {
        ERROR_LOG("网络连接失败", "状态", is_connect);
    }
    return 0;
}

4.2 核心设计思路

  1. 折叠表达式简化参数展开:用(std::cout << ... , args)一行代码替代递归,编译期直接展开为多个输出语句,效率更高、可读性更强;

  2. 友好的类型名:自定义get_type_name函数,将编译器晦涩的类型名(如PKc)转换为直观的名称(如string),日志更易读;

  3. 线程 ID 处理:用std::hashthread::id转换为字符串,避免不同平台线程 ID 输出格式不一致的问题;

  4. ** constexpr 优化 **:G_LOG_LEVELconstexpr定义,编译期就能确定是否输出日志,无运行时开销。

4.3 优缺点

  • 优点:极致简洁、类型安全、编译效率高、支持任意类型、日志可读性强;

  • 缺点:依赖 C++17 及以上标准,需要编译器开启-std=c++17(GCC)或对应选项(VS2017+)。

五、三个版本对比与选型建议

特性C 语言宏定义版(V1.0)C++11 递归模板版(V2.0)C++17 折叠表达式版(V3.0)
语言支持C/C++C++11+C++17+
类型安全
支持自定义类✅(需重载 <<)✅(需重载 <<)
代码简洁度
线程安全✅(需手动处理)✅(跨平台)✅(跨平台)
编译器依赖支持 C++11 即可需支持 C++17
适用场景C 项目、嵌入式、轻量场景无法升级 C++17 的 C++ 项目现代 C++ 项目、追求简洁高效

选型建议:

  • 若开发 C 项目或嵌入式场景:选 V1.0,无依赖、兼容性强;

  • 若 C++ 项目但无法升级到 C++17:选 V2.0,兼顾类型安全和兼容性;

  • 若使用 C++17 及以上标准:选 V3.0,简洁高效,是最优解。

六、实战扩展:让日志系统更实用

无论选择哪个版本,都可以根据实际需求添加以下扩展功能,让日志系统更贴合项目场景:

1. 输出到文件(而非控制台)

将日志输出到文件,避免程序退出后日志丢失:

#include <fstream>
std::ofstream g_log_file("app.log", std::ios::app); // 追加模式

// 替换cout为g_log_file
g_log_file << "[" << time_buf << "] " << ...;

2. 日志轮转(防止文件过大)

当日志文件达到指定大小(如 100MB)或每天凌晨,自动创建新文件:

void check_log_rotate() {
    std::ifstream file("app.log");
    file.seekg(0, std::ios::end);
    if (file.tellg() > 1024 * 1024 * 100) { // 100MB
        g_log_file.close();
        rename("app.log", "app.log.1"); // 重命名旧日志
        g_log_file.open("app.log", std::ios::app); // 新建日志文件
    }
}

3. 关闭 RTTI 依赖

若项目关闭了 RTTI(-fno-rtti),可移除typeid相关代码,或用模板特化实现类型判断:

// 模板特化获取类型名(无需RTTI)
template<typename T> struct TypeName { static const char* name; };
template<> struct TypeName<int> { static const char* name; };
const char* TypeName<int>::name = "int";
// 其他类型同理...

// 调用:TypeName<Args>::name

4. 支持格式化字符串

C++20 引入std::format,可替代cout实现更灵活的格式化输出:

#include <format>
// 折叠表达式结合format
(std::cout << std::format("[{}:{}]", TypeName<Args>::name, args) << " ", ...) << std::endl;

七、总结

从 C 语言的宏定义到 C++17 的折叠表达式,日志系统的进化本质上是 “兼顾灵活性、安全性和简洁性” 的过程。每个版本都有其适用场景,没有绝对的 “最优解”,只有最适合项目的选择。

核心收获:

  • 宏定义版胜在兼容和轻量,适合 C 项目或嵌入式场景;

  • C++11 模板版解决了类型安全问题,兼容旧标准;

  • C++17 折叠表达式版将简洁性和效率推向极致,是现代 C++ 的首选。

实际开发中,可根据项目的语言版本、依赖要求、功能需求,选择合适的实现方案,或基于本文代码进行二次扩展。如果你的项目有特殊场景(如嵌入式、高并发),欢迎在评论区分享,我们可以一起探讨优化方案~

评论
成就一亿技术人!
拼手气红包6.0元
还能输入1000个字符
 
红包 添加红包
表情包 插入表情
 条评论被折叠 查看
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值