整理:从零实现一个 C++ 轻量级日志系统:原理与实践

前段时间遇到一个小问题,后来发现这是个挺常见的坑,顺手整理一篇笔记。

🔥个人主页:Cx330🌸

❄️个人专栏:《C语言》《LeetCode刷题集》《数据结构-初阶》《C++知识分享》

《优选算法指南-必刷经典100题》《Linux操作系统》:从入门到入魔

《Git深度解析》:版本管理实战全解 《Qt 极境架构》

🌟心向往之行必能


🎥Cx330🌸的简介:


目录

前言:

一. 日志系统的设计理念

1.1 日志的核心组成要素

1.2 日志系统的两大核心阶段

1.3 为什么选择策略模式?

二、代码设计:实现一个完整的日志库

2.1 RAII 风格互斥锁封装(线程安全基石)

2.2 格式化时间戳模块

2.3 类型安全的日志等级模块

三. 基于策略模式的日志刷新核心实现

3.1 抽象策略基类 LogStrategy

3.2 控制台日志策略 ConsoleLogStrategy

3.3 文件日志策略 FileLogStrategy

四. 日志主体类与流式输出设计

4.1 Logger 主类的整体架构

4.2 LogMessage 内部类:RAII 实现日志自动刷新

4.3 完整的 Logger 类实现

五. 日志系统的线程安全与可重入性深度解析

六、日志系统源码

6.1 完整Logger.hpp代码

6.2 完整测试代码

6.3 优化方向

写在最后


前言:

日志系统可以说是每个程序员都绕不开的话题。在大型C++工程中,日志打印和记录几乎是日常开发中最常用的功能之一 —— 排查Bug、追踪调用链、监控线上服务状态,都离不开一套好用的日志工具。

提到C++日志库,有一些人会想:日志系统到底是怎么实现的? 抛开spdlog这些现成的库,我们自己能不能从零手搓一个可用的日志组件出来?

其实手搓一个简易日志工具并不复杂,核心思路就是 将格式化后的信息输出到不同的目的地,比如控制台、文件等。在这个思路的指引下,我们可以一步步搭建自己的日志系统,并顺便搞懂:

日志级别是怎么分类和管理的; 日志消息如何格式化并分派到不同的输出端; 异步日志如何做到高性能; RAII机制在日志系统中的应用。

如果你曾对这些坑感到好奇,那这篇文章就是写给你的。下面,我们就从零着手,手搓一个实用且可扩展的C++日志工具。


一. 日志系统的设计理念

1.1 日志的核心组成要素

一条合格的工业级日志,一定要包含必选字段可选扩展字段,确保坑可追溯、状态可监控:

  • 必选核心字段
时间戳:可读性强的年月日时分秒格式,精准定位事件发生时间
  • 日志等级:区分事件严重程度,支持分级过滤与告警
  • 日志内容:用户自定义的业务 / 调试信息
可选扩展字段
  • 进程 PID / 线程 ID:多进程 / 多线程环境下定位执行流
  • 文件名与行号:精准定位日志打印的代码位置
  • 自定义扩展字段:如模块名、用户 ID 等业务信息
本文实现的日志格式如下,完全兼容主流日志库的规范:

[2026-04-16 21:33:18] [DEBUG] [1030871] [Main.cc] [10] - hello world hello Cx330
         日期          日志等级    pid     源文件   行号 -         内容       root

1.2 日志系统的两大核心阶段

日志的生命周期可拆分为两个完全解耦的阶段,这是我们设计的核心依据:

1. 日志形成阶段:将时间戳、等级、文件名、行号、用户内容等信息,拼接成一条完整的格式化字符串,与日志输出目的地无关
2. 日志刷新阶段:将格式化做好的日志字符串,写入到指定目的地(控制台、文件、数据库、网络等),仅关注写入逻辑

两个阶段解耦后,我们可以独立扩展刷新逻辑,而无需修改日志格式化的核心代码,这正是策略模式的最佳应用场景。

1.3 为什么选择策略模式?

1. 分离关注点:日志类(上下文)只负责构建格式化后的日志消息,具体的“写到哪里”交由独立的对象做好。
2. 运行时切换:程序运行时可随时切换日志策略,比如调试阶段用控制台输出,生产环境用文件持久化。
3. 扩展性极佳:新增一种输出方法(比如 UDP 网络发送),只需实现一个新的类,日志类完全不要改动。
4. 支持组合:日志类可以持有多个对象,一条日志同时送给控制台、文件、远程服务器,这正是策略模式在集合层面的灵活运用。


二、代码设计:实现一个完整的日志库

日志系统的核心前提是线程安全,同时要时间戳、日志等级等基础能力支撑,我们先实现这些底层模块。

2.1 RAII 风格互斥锁封装(线程安全基石)

多线程环境下,控制台、日志文件都是临界资源,多个线程同时写入会导致内容交错、乱序,一定要通过互斥量保证临界区的原子性。我们基于 Linux 原生的pthread_mutex封装互斥锁,并通过 RAII 机制管理锁的生命周期,避免手动解锁导致的死锁、内存泄漏坑,这也是 C++11 std::lock_guard的核心实现原理。

  • Mutex.hpp
#ifndef __MUTEX_HPP
#define __MUTEX_HPP

#include
#include

class Mutex
{
public:
    Mutex()
    {
        pthread_mutex_init(&_lock,nullptr);
    }
    void Lock()
    {
        pthread_mutex_lock(&_lock);
    }
    pthread_mutex_t *Orgin()
    {
        return &_lock;
    }
    void Unlock()
    {
        pthread_mutex_unlock(&_lock);
    }
    ~Mutex()
    {
        pthread_mutex_destroy(&_lock);
    }
private:
    pthread_mutex_t _lock;
};

// 锁的开关
class LockGuard
{
public:
    LockGuard(Mutex *lockp):_lockp(lockp)
    {
        _lockp->Lock();
    }
    ~LockGuard()
    {
        _lockp->Unlock();
    }
private:
    Mutex *_lockp;
};

#endif

核心设计解析

  • 禁用拷贝:互斥量是系统资源,不允许拷贝和赋值,避免重复释放、死锁等问题
  • RAII 机制LockGuard在对象构造时加锁,析构时自动解锁,即使代码中途抛出异常,也能保证锁被释放,彻底避免手动解锁的遗漏
  • 接口封装:屏蔽原生pthread库的接口细节,给出更符合 C++ 面向对象的使用方法

2.2 格式化时间戳模块

时间戳是日志的核心字段,我们要实现秒级、可重入、格式化的时间戳获取功能。

重点要注意:C 标准库的localtime函数是不可重入的,多线程环境下会出现数据错乱,因此一定要使用可重入版本localtime_r,它由调用者给出结构体缓冲区,避免了全局静态变量的竞态问题。

  • 时间戳实现代码(在命名空间里面)
// 获取当前时间的字符串表示(格式:YYYY-MM-DD HH:MM:SS)
std::string GetTimeStamp()
{
    // 获取从1970-01-01 UTC到当前时刻的秒数(Unix时间戳)
    time_t timestamp = time(nullptr);

    // 定义tm结构体用于存储分解后的本地时间
    struct tm data_time;
    // 将时间戳转换为本地时间(线程安全版本,localtime_r是POSIX标准)
    localtime_r(&timestamp, &data_time);

    // 缓冲区,用于存放格式化后的时间字符串
    char data_time_str[128];
    // 使用snprintf进行格式化,限制最大写入长度,防止溢出
    // 格式:年-月-日 时:分:秒,各部分不足两位时补零
    snprintf(data_time_str, sizeof(data_time_str), "%4d-%02d-%02d %02d:%02d:%02d",
             // tm_year 从1900年开始计数,需要加1900得到实际年份
             data_time.tm_year + 1900,
             // tm_mon 范围0~11,需要加1转换为实际月份
             data_time.tm_mon + 1,
             data_time.tm_mday,   // 日(1~31)
             data_time.tm_hour,   // 小时(0~23)
             data_time.tm_min,    // 分钟(0~59)
             data_time.tm_sec);   // 秒(0~60,闰秒时可达60)

    // 返回std::string对象,自动拷贝缓冲区内容
    return data_time_str;
}

细节解析

  • 可重入性保障:使用localtime_r替代localtime,确保多线程环境下时间转换不会出现数据竞争
  • 格式化补零:通过%02d确保月、日、时、分、秒始终是两位数字,保证日志格式的一致性
  • 时间偏移修正tm_year需要 + 1900 得到真实年份,tm_mon需要 + 1 得到真实月份,这是tm结构体的标准规范
  • 测试代码
```

include

include

include

include "Logger.hpp"

using namespace LogModule;

// 测试时间戳模块
void testTime()
{
for(int i = 0; i < 5; i++)
{
std::cout


今天的内容大概就这些,实际开发中大家还会遇到更多细节,欢迎留言分享自己的经验。

评论 (0)

暂无评论