《Linux系统编程》Linux 系统多线程(七): C++ 线程安全日志系统封装:基于策略模式解耦,兼容 glog 使用风格

🔥小叶-duck个人主页

❄️个人专栏《Data-Structure-Learning》《C++入门到进阶&自我学习过程记录》

《Linux操作系统从入门到实践》《Qt从入门到实践》

《算法题讲解指南》--优选算法

《算法题讲解指南》--递归、搜索与回溯算法

《算法题讲解指南》--动态规划算法

未择之路,不须回头
已择之路,纵是荆棘遍野,亦作花海遨游


目录

前言

一、日志系统的核心设计理念

[1.1 日志的核心组成要素](#1.1 日志的核心组成要素)

[1.2 日志系统的两大核心阶段](#1.2 日志系统的两大核心阶段)

[1.3 为什么选择策略模式?](#1.3 为什么选择策略模式?)

二、前置基础模块实现

[2.1 互斥锁封装(线程安全基石)](#2.1 互斥锁封装(线程安全基石))

[2.2 格式化时间戳模块](#2.2 格式化时间戳模块)

[2.3 类型安全的日志等级模块](#2.3 类型安全的日志等级模块)

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

[3.1 抽象策略基类 LogStrategy](#3.1 抽象策略基类 LogStrategy)

[3.2 控制台日志策略 ConsoleLogStrategy](#3.2 控制台日志策略 ConsoleLogStrategy)

[3.3 文件日志策略 FileLogStrategy](#3.3 文件日志策略 FileLogStrategy)

[3.4 测试刷新策略代码](#3.4 测试刷新策略代码)

[四. 日志主体类与流式输出设计](#四. 日志主体类与流式输出设计)

[4.1 Logger 主类的整体架构](#4.1 Logger 主类的整体架构)

[4.2 LogMessage 内部类:RAII 实现日志自动刷新](#4.2 LogMessage 内部类:RAII 实现日志自动刷新)

[4.3 完整的 Logger 类实现](#4.3 完整的 Logger 类实现)

五、实战:日志系统完整使用示例

[5.1 完整 Logger.hpp 代码](#5.1 完整 Logger.hpp 代码)

[5.2 完整测试代码](#5.2 完整测试代码)

结束语


前言

在 Linux 后端与多线程服务端开发场景中,日志模块是故障排查、服务运行状态监控不可或缺的基础组件。不少初学者习惯使用 std::cout 打印直接调试信息,但在多线程并发环境下,极易出现日志文本穿插、输出顺序错乱等问题,其实在我们前面几篇对多线程的学习在示例代码的输出结果中就能体现出来;同时这种硬编码输出方式无法灵活切换日志输出目标(控制台、本地文件、网络远端),也难以实现日志分级、问题溯源等工程化需求。

虽然业内现已存在 spdlog、glog、Boost.Log 等这些成熟日志库,但我们自主从零搭建一套线程安全的日志框架,能够帮我们更好吃透设计模式、线程同步互斥机制、RAII 资源管理、可重入函数等 Linux 系统编程核心知识点,还可以回顾复习之前C++中多态、智能指针等重要知识点。本文依托策略模式,从零搭建一套用法风格贴近 glog、具备多线程安全能力、易于拓展的 C++ 日志系统,整体代码与架构设计遵循工业项目开发标准。

一、日志系统的核心设计理念

计算机中的日志记录系统和软件运行中发生事件的文件,主要作用是监控运行状态、记录异常信息,帮助快速定位问题并支持程序员进行问题修复。它是系统维护、故障排查和安全管理的重要工具。

1.1 日志的核心组成要素

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

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

本文所实现的日志格式如下,完全兼容主流日志库的规范:

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

日志完整生命周期能够拆分为两个相互独立、互不耦合的阶段,这也是整套日志框架设计的核心依据:

  1. 日志形成阶段:整合时间戳、日志等级、源码文件名、代码行号、用户传入消息等信息,拼接成一条格式完整的日志字符串。该阶段不用关心日志最终输出到何处(如控制台、本地文件、数据库、远端网络等),只用于获取日志信息。
  2. 日志刷新阶段:接收已经格式化完毕的日志字符串,借助某些操作(如多态等)即可将数据指定写入目标位置(控制台、本地文件、数据库、远端网络等),仅负责执行写入操作。

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

1.3 为什么选择策略模式?

策略模式属于行为型设计模式,核心思路:定义多组可独立执行的算法(策略),把每组算法封装为独立单元,并且支持算法之间灵活替换。

放到日志系统中,各类日志输出方式就对应不同策略:控制台打印、文件持久存储、网络远程上报,各自属于独立的写入算法。在日志框架中引入策略模式,具备以下优势:

  • 符合开闭原则:想要新增日志输出目标(例如数据库),只需要新增对应的策略类,原有业务代码不用修改;
  • 模块解耦:日志格式化核心逻辑与刷新逻辑完全分离,代码职责单一
  • 运行时动态切换:程序运行过程中能够更换日志输出策略,典型场景:调试阶段使用控制台打印,上线生产环境切换为文件保存。

二、前置基础模块实现

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

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

多线程环境下,控制台、日志文件都是临界资源,当多个线程同时写入在没有保护的情况下必然会导致内容交错、乱序,所以我们必须通过互斥量保证临界区的原子性。

在前面讲解互斥锁的时候,最后我们基于 Linux 原生的 pthread_mutex 手动封装了一个互斥锁模块,并通过 RAII 机制管理锁的生命周期,避免手动解锁导致的死锁、内存泄漏等问题,这也是 C++11 std::lock_guard 的核心实现原理。当时封装互斥锁一方面是便于我们理解掌握这些接口的使用,另一方面就是为日志系统以及后面的线程池的设计做铺垫,我们直接使用前面封装好的互斥锁即可。

  • Mutex.hpp
cpp 复制代码
#ifndef MUTEX_HPP
#define MUTEX_HPP

#include <iostream>
#include <pthread.h>
#include <string>

namespace MutexModule
{
    class Mutex
    {
    public:
        Mutex()
        {
            pthread_mutex_init(&_mutex, nullptr);
            std::cout << "mutex init success" << std::endl;
        }

        void Lock()
        {
            int n = pthread_mutex_lock(&_mutex);
            if (n != 0)
            {
                std::cerr << "pthread_mutex_lock false" << std::endl;
            }
        }

        void Unlock()
        {
            int n = pthread_mutex_unlock(&_mutex);
            if (n != 0)
            {
                std::cerr << "pthread_mutex_unlock false" << std::endl;
            }
        }

        ~Mutex()
        {
            int n = pthread_mutex_destroy(&_mutex);
            if (n != 0)
            {
                std::cerr << "pthread_mutex_destroy false" << std::endl;
            }
            else
            {
                std::cout << "mutex destroy success" << std::endl;
            }
        }

        pthread_mutex_t *GetMutex()
        {
            return &_mutex;
        }

    private:
        pthread_mutex_t _mutex;
    };

    // RAII风格的互斥锁的封装(实现自动解锁)
    class LockGuard
    {
    public:
        LockGuard(Mutex &mutex) : _mutex(mutex)
        {
            _mutex.Lock();
        }

        ~LockGuard()
        {
            _mutex.Unlock();
        }

    private:
        Mutex &_mutex;
    };
}

#endif

核心设计解析

  1. 禁用拷贝:互斥量是系统资源,不允许拷贝和赋值,避免重复释放、死锁等问题(这个里面没有,大家可以自己加上)
  2. RAII 机制:LockGuard 在对象构造时加锁,析构时自动解锁,即使代码中途抛出异常,也能保证锁被释放,彻底避免手动解锁的遗漏
  3. 接口封装:屏蔽原生 pthread 库的接口细节,提供更符合 C++ 面向对象的使用方式

2.2 格式化时间戳模块

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

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

  • 时间戳实现代码
cpp 复制代码
//Log.hpp
#ifndef LOG_HPP
#define LOG_HPP

#include <iostream>
#include "Mutex.hpp"
#include <string>
#include <filesystem> //C++17:提供文件操作
#include <ctime>

namespace LogModule
{
    // 1. 获取时间
    std::string GetTimeStamp()
    {
        time_t currtime = time(nullptr); // 默认获取当前时区的时间(获取到的是秒级时间戳)
        struct tm curr_tm;
        // 使用带 _r (Reentrant) 后缀的版本 localtime_r 而不是普通的 localtime。
        // 因为普通版本内部使用静态全局变量保存结果,在多线程写日志时极易发生数据覆盖错乱;
        // _r 版本要求我们自己传入存放结果的地址(&dataTime),保证了多线程环境下的绝对安全。
        localtime_r(&currtime, &curr_tm);

        // 准备一个足够大的字符数组作为格式化字符串的缓冲区
        char timebuffer[128];
        // 使用 snprintf 将时间结构体安全地格式化为类似 [2026-04-16 19:21:32] 的排版
        // %4d: 4位数字对齐; %02d: 2位数字,不足两位的在高位自动补0(如4月显示为04)
        snprintf(timebuffer, sizeof(timebuffer), "%4d-%02d-%02d %02d:%02d:%02d",
                 curr_tm.tm_year + 1900, // 坑点修复:tm_year 表示的是自 1900 年起经过的年数,必须加上 1900
                 curr_tm.tm_mon + 1,     // 坑点修复:tm_mon 范围是 [0, 11](0代表1月),必须加上 1
                 curr_tm.tm_mday,        // 日([1, 31])
                 curr_tm.tm_hour,        // 时
                 curr_tm.tm_min,         // 分
                 curr_tm.tm_sec          // 秒
        );

        return timebuffer; //字符数组会自动隐式转换为 std::string 对象返回
    }
}

#endif

核心细节解析

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

using namespace LogModule;

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

int main()
{
    // 1. 测试时间
    testTime();
    return 0;
}

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

日志等级用于区分事件的严重程度,我们使用 C++11 的enum class 实现类型安全的日志等级,避免普通枚举的隐式类型转换问题 ,同时提供枚举到字符串的转换能力。

  • 日志等级实现代码
cpp 复制代码
#ifndef LOG_HPP
#define LOG_HPP

#include <iostream>
#include "Mutex.hpp"
#include <string>
#include <filesystem> //C++17:提供文件操作
#include <ctime>

using namespace MutexModule;

namespace LogModule
{
    // 2. 日志等级 -- 枚举类型(整数)转换成字符串类型
    // 使用 enum class (强类型枚举) 而不是普通 enum
    // 优势:1. 具有独立的作用域,避免命名冲突;2. 不允许隐式类型转换,更加类型安全
    enum class LogLevel
    {
        DEBUG,   // 调试信息:用于开发过程中输出详细状态,帮助定位问题。生产环境通常关闭
        INFO,    // 常规信息:记录程序的关键运行节点,用于了解系统正常运行的状态
        WARNING, // 警告信息:出现了预期之外的情况,但系统仍能继续运行,需要引起关注
        ERROR,   // 错误信息:发生了运行时错误,导致当前操作失败,但主程序依然存活
        FATAL    // 致命错误:最高严重级别。出现了无法恢复的问题,程序即将崩溃或被迫退出
    };

    // 枚举类型(整数)转换成字符串类型
    std::string LogLevel_to_String(LogLevel level)
    {
        switch (level)
        {
        case LogLevel::DEBUG:
            return "DEBUG";
        case LogLevel::INFO:
            return "INFO";
        case LogLevel::WARNING:
            return "WARNING";
        case LogLevel::ERROR:
            return "ERROR";
        case LogLevel::FATAL:
            return "FATAL";
        default:
            // 防御性编程:处理未知的枚举值,防止程序出现未定义行为
            return "UNKNOWN";
        }
    }
}
#endif

核心设计解析

  • 类型安全enum class不会隐式转换为整型,避免了错误的等级赋值,编译期即可发现类型问题。
  • 等级分层:遵循业界通用的 5 级日志规范,覆盖从调试到致命错误的全场景。
  • 字符串转换 :通过 switch 语句实现枚举到字符串的映射,确保日志中输出可读性强的等级名称,而非整型数字。

  • 测试代码
cpp 复制代码
#include <iostream>
#include <memory>
#include <unistd.h>
#include "Log.hpp"

using namespace LogModule;

// 测试日志类枚举类型转字符类型模块
void testEnum()
{
    std::cout << LogLevel_to_String(LogLevel::DEBUG) << std::endl;
    std::cout << LogLevel_to_String(LogLevel::INFO) << std::endl;
    std::cout << LogLevel_to_String(LogLevel::WARNING) << std::endl;
    std::cout << LogLevel_to_String(LogLevel::ERROR) << std::endl;
    std::cout << LogLevel_to_String(LogLevel::FATAL) << std::endl;
}

int main()
{
    // 2. 测试枚举类转字符串类型
    testEnum();
    return 0;
}

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

基于策略模式的设计,我们先定义抽象的刷新策略基类 ,再分别实现控制台和文件两种具体的刷新策略,通过基类指针指向对应的派生类对象即可实现指定策略的日志刷新,后续可无限扩展其他策略。

3.1 抽象策略基类 LogStrategy

抽象基类定义了所有刷新策略必须实现的纯虚接口,同时使用虚析构函数确保子类对象能正确析构。

cpp 复制代码
#ifndef LOG_HPP
#define LOG_HPP

namespace LogModule
{
    // 3. 刷新策略
    // 基类: 策略模式 (Strategy Pattern)
    // 核心思想:将"日志的产生"与"日志的刷新目的地"解耦。
    // 通过定义统一的接口,使得程序可以在运行时动态决定将日志输出到控制台、文件、数据库或网络。
    class LogStrategy
    {
    public:
        // 虚析构函数:在多态体系中,基类必须拥有虚析构函数。
        // 这样当我们通过基类指针删除派生类对象时,才能确保调用到子类的析构函数,防止内存泄漏。
        virtual ~LogStrategy() = default; // 不在这里析构

        // 核心刷新接口:这是一个纯虚函数。
        // 纯虚函数的核心作用是定义一种"契约",强制派生类(子类)必须实现具体的逻辑。
        // 不同的子类可以根据自己的策略(如 ConsoleStrategy 或 FileStrategy)来实现不同的刷新行为。
        virtual void SyncLog(const std::string &message) = 0; // 强制子类对其进行重写
    };
}
#endif

设计说明

  • 纯虚函数 SyncLog 定义了策略的统一接口 ,入参是格式化完成的日志字符串,子类只需关注具体的写入逻辑。
  • 虚析构函数C++ 多态 的基础规范,避免通过基类指针释放子类对象时出现由于子类部分引起的内存泄漏

3.2 控制台日志策略 ConsoleLogStrategy

控制台策略 负责将日志输出到标准输出流 (stdout),核心是保证多线程环境下的输出原子性避免日志交错

cpp 复制代码
#ifndef LOG_HPP
#define LOG_HPP

namespace LogModule
{
    // 策略1: 控制台日志策略
    // 子类:继承自策略基类,用于将日志直接刷新到标准输出(显示器),常用于本地开发与调试
    class ConsoleLogStrategy : public LogStrategy
    {
    public:
        // 构造函数与析构函数:当前策略不涉及复杂资源申请,故使用默认实现即可
        ConsoleLogStrategy()
        {
        }
        ~ConsoleLogStrategy()
        {
        }

        // 实现具体的日志同步逻辑------刷新到控制台
        void SyncLog(const std::string &message) override // override:检查重写的错误
        {
            // 【核心原理】显示器(stdout)在多线程环境下属于"临界资源"。
            // 如果不加保护,多个线程同时调用 std::cout 会导致各条日志的字符在屏幕上发生"交织"或乱码 。
            // 使用自定义的 LockGuard 配合互斥锁,确保这一系列操作的原子性。
            LockGuard lockguard(_mutex); // 加锁
            std::cout << message << std::endl;
        }

    private:
        // 互斥锁:专门用于保护当前控制台输出的原子性,防止并发打印时消息错乱
        Mutex _mutex;
    };
}
#endif

核心细节解析

  • 线程安全保障:控制台是全局临界资源,通过互斥锁保证同一时刻只有一个线程能执行输出操作,彻底避免多线程日志交错。
  • RAII 锁管理 :使用LockGuard 自动管理锁,无需手动解锁,代码更健壮。

3.3 文件日志策略 FileLogStrategy

文件策略负责将日志持久化到磁盘文件,核心功能包括:自动创建日志目录追加模式写入线程安全 保障,使用 C++17 。的filesystem 库处理目录和文件操作(记得带上对应头文件)

cpp 复制代码
#ifndef LOG_HPP
#define LOG_HPP

#include <fstream>
#include <filesystem>

namespace LogModule
{
    // 定义全局默认文件路径与文件名常量
    const static std::string defaultlogdir = "./Log";
    const static std::string defaultlogfilename = "log.txt";
    // 策略2:文件类日志策略
    // 子类:继承自策略基类,实现将日志持久化到磁盘文件的逻辑
    class FileLogStrategy : public LogStrategy
    {
    public:
        FileLogStrategy(const std::string &logdir = defaultlogdir, const std::string &logfilename = defaultlogfilename)
            : _logdir(logdir),
              _logfilename(logfilename)
        {
            // 【重点】构造阶段即进行加锁保护。
            // 理由:判断目录是否存在并创建目录属于"先检查再执行(Check-Then-Act)"模式,
            // 必须保证这一系列操作的原子性,防止多线程同时创建导致竞态冲突。
            LockGuard lockGuard(_mutex);

            // 使用 C++17 的 <filesystem> 库进行跨平台路径检查
            if (std::filesystem::exists(_logdir))
            {
                // 如果指定路径已存在则返回值为true,无需再创建直接返回
                return;
            }
            else
            {
                // 异常捕捉处理
                try
                {
                    // try:包裹可能抛出异常的代码,后续紧跟一个或多个 catch 块,用于匹配不同类型的异常

                    // 递归创建目录(类似于 Linux 命令 mkdir -p),如果路径中包含多级不存在的目录会一并创建
                    std::filesystem::create_directories(_logdir);
                }
                catch (std::filesystem::filesystem_error &e)
                {
                    // catch 语句捕获指定类型的异常,执行对应的处理逻辑;

                    //  若创建异常(如权限不足、磁盘空间不足等),则进行捕获并输出错误信息
                    std::cerr << e.what() << std::endl;
                }
            }
        }

        // 析构函数:由于不涉及手动管理的堆内存或特殊文件句柄(使用局部变量流管理),故使用默认实现
        ~FileLogStrategy()
        {
        }

        // 执行具体// 加锁保护:防止多线程同时写入同一文件导致内容交织(Interleaving)乱码 的日志落盘操作
        void SyncLog(const std::string &message) override
        {
            // 加锁保护:进行文件操作刷新文件毋庸置疑是需要原子操作的,
            // 防止多线程同时写入同一文件导致内容交织(Interleaving)乱码
            LockGuard lockguard(_mutex); // 加锁

            // 构造完整的目标文件路径
            std::string target = _logdir + (_logdir.back() != '/' ? "/" : "") + _logfilename; // "./Log/" + "log.txt"

            // 以"追加模式"(std::ios::app)打开文件流:
            // 核心逻辑:保证每条新日志都写在文件末尾,不会覆盖已有日志内容 。
            std::ofstream out(target, std::ios::app); // 追加

            if (!out.is_open()) // 打开文件检查
            {
                return; // 如果因权限或路径问题打开失败,则放弃本次写入,防止程序崩溃
            }

            // 将消息流式写入文件,并手动添加换行符以符合日志排版规范
            out << message << "\n"; // 流式写入

            // 文件流离开作用域或显式调用 close 会自动触发刷新并关闭文件
            out.close();
        }

    private:
        std::string _logdir;      // 存储目录路径
        std::string _logfilename; // 存储文件名称
        Mutex _mutex;             // 用于保障当前策略类实例在多线程环境下的线程安全
    };
}
#endif

核心设计解析

  • 自动目录创建 :构造函数中检查日志目录是否存在,不存在则通过 create_directories 递归创建,避免手动创建目录的繁琐。
  • 追加模式写入 :使用std::ios::app 打开文件,所有日志都会追加到文件末尾,不会覆盖历史日志,符合日志系统的通用规范。
  • 异常处理 :目录创建时使用try catch 捕获 filesystem 的异常,避免目录创建失败导致程序崩溃。
  • 线程安全:文件写入全程加锁,保证多线程环境下不会出现半行日志、内容交错的问题。

3.4 测试刷新策略代码

cpp 复制代码
#include <iostream>
#include <memory>
#include <unistd.h>
#include "Log.hpp"

using namespace LogModule;

// 测试刷新策略
void testStrategy()
{
    std::string message1 = "console: hello consoleLog, hello World!";
    std::string message2 = "file: hello fileLog, hello World!";

    // 基类LogStrategy指针指向派生类ConsoleLogStrategy对象
    std::unique_ptr<LogStrategy> strategy = std::make_unique<ConsoleLogStrategy>(); 
    strategy->SyncLog(message1);
    strategy->SyncLog(message1);
    strategy->SyncLog(message1);
    strategy->SyncLog(message1);
    strategy->SyncLog(message1);

    // 基类LogStrategy指针指向派生类FileLogStrategy对象
    strategy = std::make_unique<FileLogStrategy>();
    strategy->SyncLog(message2);
    strategy->SyncLog(message2);
    strategy->SyncLog(message2);
    strategy->SyncLog(message2);
    strategy->SyncLog(message2);
}

int main()
{
    // 3. 测试策略
    testStrategy();
    return 0;
}

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

完成基础模块和策略模式的实现后,我们来实现日志系统的主体类,核心目标是:兼容 glog 的流式调用风格、自动拼接日志信息、RAII 自动触发日志刷新

4.1 Logger 主类的整体架构

Logger类是日志系统的对外入口,核心职责包括:

  • 管理当前使用的日志刷新策略,支持动态切换
  • 提供仿函数接口,生成日志消息对象
  • 封装策略切换的便捷接口

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

LogMessage 是 Logger 的内部类,是整个日志系统最巧妙的设计:

  • 构造函数 中完成日志信息 (时间、等级、PID、文件名、行号)的拼接
  • 重载 << 运算符支持流式拼接任意类型的日志内容
  • 析构函数自动触发日志刷新,利用临时对象的生命周期实现 "写完即刷新"

4.3 完整的 Logger 类实现

cpp 复制代码
#ifndef LOG_HPP
#define LOG_HPP

#include <sstream> //流式格式化控制

namespace LogModule
{
    // 4、Logger 类:日志系统的核心统筹管理者
    // 负责维护日志刷新策略(显示器/文件)并作为产生日志消息的入口
    class Logger
    {
    public:
        Logger()
        {
            // 构造函数:初始化时默认开启控制台刷新策略,后续可对Logger对象调用对应函数进行策略切换
            UseConsoleLogStrategy();
        }

        // 切换策略:动态更换为控制台输出策略
        void UseConsoleLogStrategy()
        {
            _strategy = std::make_unique<ConsoleLogStrategy>();
        }

        // 切换策略:动态更换为文件输出策略
        void UseFileLogStrategy()
        {
            _strategy = std::make_unique<FileLogStrategy>();
        }

        ~Logger()
        {
        }

        // 内部类LogMessage:代表未来单条日志消息的生命周期管理
        // 核心设计思想:利用临时对象的生命周期(RAII)实现日志的自动组装与刷新
        class LogMessage
        {
        public:
            // 构造函数:构建日志的"左半部分"(前缀信息)
            // 包括时间、等级、PID、文件名和行号,并预置到流中
            LogMessage(const LogLevel &loglevel, const std::string &filename, const int &line, Logger &logger)
                : _currenttime(GetTimeStamp()),
                  _loglevel(loglevel),
                  _pid(getpid()),
                  _filename(filename),
                  _line(line),
                  _logger(logger)
            {
                std::stringstream ss;
                // 像拼积木一样组装固定格式的前缀
                ss << "[" << _currenttime << "] "
                   << "[" << LogLevel_to_String(_loglevel) << "] "
                   << "[" << _pid << "] "
                   << "[" << _filename << "] "
                   << "[" << _line << "] "
                   << "- ";

                _loginfo = ss.str(); // ss转成字符串由_loginfo接收
            }

            // LogMessage(传参) << "hello" << "XXXX" << 3 << 'a'
            // 支持 模板重载 <<:构建日志的"右半部分"(用户自定义内容)
            // 利用模板接纳任意类型,并通过 stringstream 实现安全的字符串转换与链式拼接
            template <class T>
            LogMessage &operator<<(const T &in)
            {
                std::stringstream ss;
                ss << in;       // stringstream 可自动处理 int, double, string 等类型转换
                _loginfo += ss.str(); // 将内容追加到完整日志字符串中
                return *this;         // 返回自身引用,支持像 cout 一样的连续 << 调用
                // 为什么不能返回值? ------> 不能实现连续 << 的调用
                // 原因:值返回 ------> 函数结束时会拷贝构造一个全新临时对象返回,而非return返回的原对象
                // 就会导致:obj << A << B; 等价于-> (tmp = obj<<A) << B;
                // obj<<A 会生成临时副本 tmp,而后续 << B 是针对副本的修改,而非原obj!
                // 原对象 obj 完全没收到 B 的内容,函数执行完临时 tmp 就会直接析构,
                // B的日志内容直接丢失,导致原对象 obj 只会接收第一个 << 的内容。
            }

            // 再看回来 LogMessage(传参) << "hello" << "XXXX" << 3 << 'aaa'
            // LogMessage(传参)是匿名对象,在代码执行完就会自动调用析构销毁,而我们又想自动刷新日志
            // 那么我们能不能在LogMessage(传参)结束也就是调用析构的时候顺带自动刷新日志呢?当然可以
            // 但是刷新日志的函数需要由外部类的成员变量std::unique_ptr<LogStrategy> _strategy;来调用
            // 内部类无法直接调用------>所以我们还需要一个类型为Logger的成员变量
            ~LogMessage()
            {
                if (_logger._strategy)
                {
                    // 多态的使用
                    _logger._strategy->SyncLog(_loginfo);
                }
            }

        private:
            std::string _currenttime; // 当前时间
            LogLevel _loglevel;       // 日志等级
            pid_t _pid;               // 当前进程ID
            std::string _filename;    // 文件名
            int _line;                // 代码行号
            std::string _loginfo;     // 存储整条待刷新的日志字符串,即上面参数的合并

            Logger &_logger;
            // 外部类引用:巧妙借助外面类Logger的成员变量_strategy调用刷新日志函数,当临时对象销毁时同时自动刷新日志
            // 这也就是为什么我们需要设计为内部类的主要原因,如果是设计到内外则无法使用Logger的成员变量_strategy
        };

        // 仿函数重载:作为"桥梁"连接宏调用与内部消息对象
        // 自此我们就可以完全屏蔽Logger类中内部类的接口,
        // 在仿函数中隐藏式的调用内部类的构造来创建临时对象,无需将内部类的接口暴露在外
        LogMessage operator()(LogLevel level, const std::string filename, int line)
        {
            // 创建并返回匿名对象,开启后续的流式 << 操作
            return LogMessage(level, filename, line, *this);
            // 而正好这个仿函数重载是外部类中,所以传入*this正好就可以被构造函数的参数接收
            // 注意:不要使用引用返回,因为返回的是匿名对象,函数结束栈帧销毁时匿名对象也会一起销毁
            // 如果是引用返回,外部拿到的引用指向的就是已经销毁的对象内存
        }
        // 巧妙设计为返回的是LogMessage也就是值返回,当代码执行完就会自动销毁
        // 就正好满足在内部类中析构借助外部类_strategy调用刷新日志函数,自动对日志进行刷新

    private:
        std::unique_ptr<LogStrategy> _strategy; // 多态策略基类指针:决定日志去向
    };

    // 定义一个全局模块的Logger对象, 方便后续的使用
    Logger logger;

    // LOG 宏:对外提供的极简调用接口
    // 自动捕获当前代码的 __FILE__ (文件名) 和 __LINE__ (行号)
#define LOG(level) logger(level, __FILE__, __LINE__)

    // 还是使用宏: 提供动态调整日志策略的便捷开关
#define ENABLE_CONSOLE_LOG_STRATEGY() logger.UseConsoleLogStrategy()
#define ENABLE_FILE_LOG_STRATEGY() logger.UseFileLogStrategy()
}
#endif

核心设计深度解析

  • 仿函数机制 :重载 **operator()**让 Logger 对象可以像函数一样调用,返回一个 LogMessage 临时对象,这是实现流式调用的核心。
  • RAII 自动刷新LogMessage 是临时对象 ,当整条 LOG(xxx) << "xxx" 语句执行完毕 后,临时对象会被析构 ,析构函数中自动调用策略的刷新接口无需用户手动触发刷新。
  • 模板化流式运算符 :通过模板重载 << 运算符 ,支持 int、double、string、char 等任意可流输出的类型,实现和 std::cout 一致的使用体验。
  • 预定义宏封装
    • FILE:编译期自动替换为当前源文件名
    • LINE:编译期自动替换为当前代码行号
    • LOG 宏将繁琐的参数传递封装为极简的调用方式,完全对齐 glog 的使用风格
  • 全局单例 :定义全局的 logger 对象 ,整个程序共用一个日志实例 ,避免重复创建,同时保证策略切换全局生效
  • 测试代码
cpp 复制代码
#include <iostream>
#include <memory>
#include <unistd.h>
#include "Log.hpp"

using namespace LogModule;

// 4. 整体测试
void test()
{
    // 手动开启控制台策略
    ENABLE_CONSOLE_LOG_STRATEGY();
    LOG(LogLevel::DEBUG) << "CONSOLE: hello world " << 3.14 << " " << 'a';
    LOG(LogLevel::ERROR) << "CONSOLE: hello world " << 3.14 << " " << 'a';
    LOG(LogLevel::INFO) << "CONSOLE: hello world " << 3.14 << " " << 'a';
    LOG(LogLevel::WARNING) << "CONSOLE: hello world " << 3.14 << " " << 'a';
    LOG(LogLevel::FATAL) << "CONSOLE: hello world " << 3.14 << " " << 'a';

    // 手动开启文件策略
    ENABLE_FILE_LOG_STRATEGY();
    LOG(LogLevel::DEBUG) << "FILE: hello world " << 3.14 << " " << 'a';
    LOG(LogLevel::ERROR) << "FILE: hello world " << 3.14 << " " << 'a';
    LOG(LogLevel::INFO) << "FILE: hello world " << 3.14 << " " << 'a';
    LOG(LogLevel::WARNING) << "FILE: hello world " << 3.14 << " " << 'a';
    LOG(LogLevel::FATAL) << "FILE: hello world " << 3.14 << " " << 'a';
}

int main()
{
    // 4. 整体测试
    test();
    return 0;
}

五、实战:日志系统完整使用示例

我们通过一个完整的示例,展示日志系统的基础使用、策略切换、多线程安全验证。

5.1 完整 Logger.hpp 代码

cpp 复制代码
#ifndef LOG_HPP
#define LOG_HPP

#include <iostream>
#include "Mutex.hpp"
#include <string>
#include <filesystem> //C++17:提供文件操作
#include <ctime>

#include <fstream>
#include <filesystem>

#include <sstream> //流式格式化控制

using namespace MutexModule;

namespace LogModule
{
    // 1. 获取时间
    std::string GetTimeStamp()
    {
        time_t currtime = time(nullptr); // 默认获取当前时区的时间(获取到的是秒级时间戳)
        struct tm curr_tm;
       
        char timebuffer[128];
        snprintf(timebuffer, sizeof(timebuffer), "%4d-%02d-%02d %02d:%02d:%02d",
                 curr_tm.tm_year + 1900, // 坑点修复:tm_year 表示的是自 1900 年起经过的年数,必须加上 1900
                 curr_tm.tm_mon + 1,     // 坑点修复:tm_mon 范围是 [0, 11](0代表1月),必须加上 1
                 curr_tm.tm_mday,        // 日([1, 31])
                 curr_tm.tm_hour,        // 时
                 curr_tm.tm_min,         // 分
                 curr_tm.tm_sec          // 秒
        );

        return timebuffer; // 字符数组会自动隐式转换为 std::string 对象返回
    }

    // 2. 日志等级 -- 枚举类型(整数)转换成字符串类型
    // 使用 enum class (强类型枚举) 而不是普通 enum
    // 优势:1. 具有独立的作用域,避免命名冲突;2. 不允许隐式类型转换,更加类型安全
    enum class LogLevel
    {
        DEBUG,   // 调试信息:用于开发过程中输出详细状态,帮助定位问题。生产环境通常关闭
        INFO,    // 常规信息:记录程序的关键运行节点,用于了解系统正常运行的状态
        WARNING, // 警告信息:出现了预期之外的情况,但系统仍能继续运行,需要引起关注
        ERROR,   // 错误信息:发生了运行时错误,导致当前操作失败,但主程序依然存活
        FATAL    // 致命错误:最高严重级别。出现了无法恢复的问题,程序即将崩溃或被迫退出
    };

    // 枚举类型(整数)转换成字符串类型
    std::string LogLevel_to_String(LogLevel level)
    {
        switch (level)
        {
        case LogLevel::DEBUG:
            return "DEBUG";
        case LogLevel::INFO:
            return "INFO";
        case LogLevel::WARNING:
            return "WARNING";
        case LogLevel::ERROR:
            return "ERROR";
        case LogLevel::FATAL:
            return "FATAL";
        default:
            // 防御性编程:处理未知的枚举值,防止程序出现未定义行为
            return "UNKNOWN";
        }
    }

    // 3. 刷新策略
    // 基类: 策略模式 (Strategy Pattern)
    // 核心思想:将"日志的产生"与"日志的刷新目的地"解耦。
    // 通过定义统一的接口,使得程序可以在运行时动态决定将日志输出到控制台、文件、数据库或网络。
    class LogStrategy
    {
    public:
        // 虚析构函数:在多态体系中,基类必须拥有虚析构函数。
        virtual ~LogStrategy() = default; // 不在这里析构

        // 核心刷新接口:这是一个纯虚函数。
        virtual void SyncLog(const std::string &message) = 0; // 强制子类对其进行重写
    };

    // 策略1: 控制台日志策略
    // 子类:继承自策略基类,用于将日志直接刷新到标准输出(显示器),常用于本地开发与调试
    class ConsoleLogStrategy : public LogStrategy
    {
    public:
        // 构造函数与析构函数:当前策略不涉及复杂资源申请,故使用默认实现即可
        ConsoleLogStrategy()
        {
        }
        ~ConsoleLogStrategy()
        {
        }

        // 实现具体的日志同步逻辑------刷新到控制台
        void SyncLog(const std::string &message) override // override:检查重写的错误
        {
            // 【核心原理】显示器(stdout)在多线程环境下属于"临界资源"。
            // 如果不加保护,多个线程同时调用 std::cout 会导致各条日志的字符在屏幕上发生"交织"或乱码 。
            // 使用自定义的 LockGuard 配合互斥锁,确保这一系列操作的原子性。
            LockGuard lockguard(_mutex); // 加锁
            std::cout << message << std::endl;
        }

    private:
        // 互斥锁:专门用于保护当前控制台输出的原子性,防止并发打印时消息错乱
        Mutex _mutex;
    };

    // 定义全局默认文件路径与文件名常量
    const static std::string defaultlogdir = "./Log";
    const static std::string defaultlogfilename = "log.txt";
    // 策略2:文件类日志策略
    // 子类:继承自策略基类,实现将日志持久化到磁盘文件的逻辑
    class FileLogStrategy : public LogStrategy
    {
    public:
        FileLogStrategy(const std::string &logdir = defaultlogdir, const std::string &logfilename = defaultlogfilename)
            : _logdir(logdir),
              _logfilename(logfilename)
        {
            // 【重点】构造阶段即进行加锁保护。
            // 理由:判断目录是否存在并创建目录属于"先检查再执行(Check-Then-Act)"模式,
            // 必须保证这一系列操作的原子性,防止多线程同时创建导致竞态冲突。
            LockGuard lockGuard(_mutex);

            // 使用 C++17 的 <filesystem> 库进行跨平台路径检查
            if (std::filesystem::exists(_logdir))
            {
                // 如果指定路径已存在则返回值为true,无需再创建直接返回
                return;
            }
            else
            {
                // 异常捕捉处理
                try
                {
                    // 递归创建目录(类似于 Linux 命令 mkdir -p),如果路径中包含多级不存在的目录会一并创建
                    std::filesystem::create_directories(_logdir);
                }
                catch (std::filesystem::filesystem_error &e)
                {
                    //  若创建异常(如权限不足、磁盘空间不足等),则进行捕获并输出错误信息
                    std::cerr << e.what() << std::endl;
                }
            }
        }

        // 析构函数:由于不涉及手动管理的堆内存或特殊文件句柄(使用局部变量流管理),故使用默认实现
        ~FileLogStrategy()
        {
        }

        // 执行具体// 加锁保护:防止多线程同时写入同一文件导致内容交织(Interleaving)乱码 的日志落盘操作
        void SyncLog(const std::string &message) override
        {
            // 加锁保护:进行文件操作刷新文件毋庸置疑是需要原子操作的,
            // 防止多线程同时写入同一文件导致内容交织(Interleaving)乱码
            LockGuard lockguard(_mutex); // 加锁

            // 构造完整的目标文件路径
            std::string target = _logdir + (_logdir.back() != '/' ? "/" : "") + _logfilename; // "./Log/" + "log.txt"

            // 以"追加模式"(std::ios::app)打开文件流:
            // 核心逻辑:保证每条新日志都写在文件末尾,不会覆盖已有日志内容 。
            std::ofstream out(target, std::ios::app); // 追加

            if (!out.is_open()) // 打开文件检查
            {
                return; // 如果因权限或路径问题打开失败,则放弃本次写入,防止程序崩溃
            }

            // 将消息流式写入文件,并手动添加换行符以符合日志排版规范
            out << message << "\n"; // 流式写入

            // 文件流离开作用域或显式调用 close 会自动触发刷新并关闭文件
            out.close();
        }

    private:
        std::string _logdir;      // 存储目录路径
        std::string _logfilename; // 存储文件名称
        Mutex _mutex;             // 用于保障当前策略类实例在多线程环境下的线程安全
    };

    // 4、Logger 类:日志系统的核心统筹管理者
    // 负责维护日志刷新策略(显示器/文件)并作为产生日志消息的入口
    class Logger
    {
    public:
        Logger()
        {
            // 构造函数:初始化时默认开启控制台刷新策略,后续可对Logger对象调用对应函数进行策略切换
            UseConsoleLogStrategy();
        }

        // 切换策略:动态更换为控制台输出策略
        void UseConsoleLogStrategy()
        {
            _strategy = std::make_unique<ConsoleLogStrategy>();
        }

        // 切换策略:动态更换为文件输出策略
        void UseFileLogStrategy()
        {
            _strategy = std::make_unique<FileLogStrategy>();
        }

        ~Logger()
        {
        }

        // 内部类LogMessage:代表未来单条日志消息的生命周期管理
        // 核心设计思想:利用临时对象的生命周期(RAII)实现日志的自动组装与刷新
        class LogMessage
        {
        public:
            // 构造函数:构建日志的"左半部分"(前缀信息)
            // 包括时间、等级、PID、文件名和行号,并预置到流中
            LogMessage(const LogLevel &loglevel, const std::string &filename, const int &line, Logger &logger)
                : _currenttime(GetTimeStamp()),
                  _loglevel(loglevel),
                  _pid(getpid()),
                  _filename(filename),
                  _line(line),
                  _logger(logger)
            {
                std::stringstream ss;
                // 像拼积木一样组装固定格式的前缀
                ss << "[" << _currenttime << "] "
                   << "[" << LogLevel_to_String(_loglevel) << "] "
                   << "[" << _pid << "] "
                   << "[" << _filename << "] "
                   << "[" << _line << "] "
                   << "- ";

                _loginfo = ss.str(); // ss转成字符串由_loginfo接收
            }

            // LogMessage(传参) << "hello" << "XXXX" << 3 << 'a'
            // 支持 模板重载 <<:构建日志的"右半部分"(用户自定义内容)
            // 利用模板接纳任意类型,并通过 stringstream 实现安全的字符串转换与链式拼接
            template <class T>
            LogMessage &operator<<(const T &in)
            {
                std::stringstream ss;
                ss << in;       // stringstream 可自动处理 int, double, string 等类型转换
                _loginfo += ss.str(); // 将内容追加到完整日志字符串中
                return *this;         // 返回自身引用,支持像 cout 一样的连续 << 调用
            }

            ~LogMessage()
            {
                if (_logger._strategy)
                {
                    // 多态的使用
                    _logger._strategy->SyncLog(_loginfo);
                }
            }

        private:
            std::string _currenttime; // 当前时间
            LogLevel _loglevel;       // 日志等级
            pid_t _pid;               // 当前进程ID
            std::string _filename;    // 文件名
            int _line;                // 代码行号
            std::string _loginfo;     // 存储整条待刷新的日志字符串,即上面参数的合并

            Logger &_logger;
        };

        // 仿函数重载:作为"桥梁"连接宏调用与内部消息对象
        LogMessage operator()(LogLevel level, const std::string filename, int line)
        {
            return LogMessage(level, filename, line, *this);
        }

    private:
        std::unique_ptr<LogStrategy> _strategy; // 多态策略基类指针:决定日志去向
    };

    // 定义一个全局模块的Logger对象, 方便后续的使用
    Logger logger;

    // LOG 宏:对外提供的极简调用接口
    // 自动捕获当前代码的 __FILE__ (文件名) 和 __LINE__ (行号)
#define LOG(level) logger(level, __FILE__, __LINE__)

    // 还是使用宏: 提供动态调整日志策略的便捷开关
#define ENABLE_CONSOLE_LOG_STRATEGY() logger.UseConsoleLogStrategy()
#define ENABLE_FILE_LOG_STRATEGY() logger.UseFileLogStrategy()
}
#endif

5.2 完整测试代码

cpp 复制代码
#include <iostream>
#include <memory>
#include <unistd.h>
#include "Log.hpp"

using namespace LogModule;

// 多线程测试函数:5个线程同时打印日志
void *thread_log_test(void *arg)
{
    char *thread_name = (char *)arg;
    for (int i = 0; i < 5; i++)
    {
        LOG(LogLevel::INFO) << thread_name << " 执行日志打印, 循环次数: " << i;
        usleep(1000);
    }
    return nullptr;
}

int main()
{
    // 1. 基础控制台日志输出
    std::cout << "===== 控制台日志测试 =====" << std::endl;
    ENABLE_CONSOLE_LOG_STRATEGY();
    LOG(LogLevel::DEBUG) << "这是DEBUG调试日志, 数值: " << 3.14159;
    LOG(LogLevel::INFO) << "这是INFO常规日志, 服务启动成功";
    LOG(LogLevel::WARNING) << "这是WARNING警告日志, 配置缺失, 使用默认值";
    LOG(LogLevel::ERROR) << "这是ERROR错误日志, 文件读取失败";
    LOG(LogLevel::FATAL) << "这是FATAL致命日志, 内存耗尽, 服务退出";

    // 2. 切换为文件日志策略
    std::cout << "\n===== 文件日志测试 =====" << std::endl;
    ENABLE_FILE_LOG_STRATEGY();
    LOG(LogLevel::INFO) << "切换为文件日志策略, 日志将持久化到./log/log.txt";
    LOG(LogLevel::DEBUG) << "文件日志测试, 支持链式拼接: " << "字符串 " << 1234 << " 浮点数 " << 2.71828;

    // 3. 多线程线程安全测试
    std::cout << "\n===== 多线程日志测试 =====" << std::endl;
    pthread_t t1, t2, t3, t4;
    pthread_create(&t1, nullptr, thread_log_test, (void *)"thread-1");
    pthread_create(&t2, nullptr, thread_log_test, (void *)"thread-2");
    pthread_create(&t3, nullptr, thread_log_test, (void *)"thread-3");
    pthread_create(&t4, nullptr, thread_log_test, (void *)"thread-4");

    // 等待所有线程执行完毕
    pthread_join(t1, nullptr);
    pthread_join(t2, nullptr);
    pthread_join(t3, nullptr);
    pthread_join(t4, nullptr);

    LOG(LogLevel::INFO) << "多线程日志测试完成, 无乱序、无交错";
    return 0;
}

结束语

从零搭建一套线程安全的日志系统,不只是一次重复造轮子的练习,更是综合运用 Linux 多线程开发、设计模式、C++ RAII 资源管理、可重入函数等关键技术的实战演练。本文依托策略模式搭建日志框架,实现日志格式化与落地写入逻辑解耦,同时保障线程安全、接口简洁易用,并且具备良好的拓展能力,API 风格贴近 glog 流式写法。

日常项目开发里,我们大多直接选用 spdlog 这类成熟开源日志组件。但吃透底层实现原理至关重要:当日志输出错乱、宕机引发日志丢失、多线程并发产生性能瓶颈等故障出现时,扎实的底层知识可以帮助我们快速排查、妥善处理问题,这也是深耕底层技术带来的核心竞争力。

相关推荐
重生的黑客14 小时前
Linux 进程程序替换与自定义 Shell:从 exec 函数族到命令行解释器
linux·运维·服务器·shell
北极糊的狐14 小时前
阿里云服务器-命令2-Linux 系统实时资源监视器 top 命令详解(进程级实时资源监控)
linux·运维·服务器
三言老师15 小时前
clear与history历史命令管理实操
linux·运维·服务器·网络·centos
味悲16 小时前
Linux 环境下 DNS 服务器搭建
linux·运维·服务器
dok1216 小时前
Johannes 《Linux内核模块与设备驱动开发:编写Linux驱动程序》 (7)open,release (8) write read
linux
皮卡狮16 小时前
进程间通信:认识匿名管道和进程池实现
linux
一只旭宝17 小时前
C++手写shared_ptr共享智能指针|原子引用计数、强弱引用控制块、赋值重载底层深度剖析
开发语言·c++·面试
feasibility.17 小时前
wsl安装Ubuntu方法(含网络不稳定处理)
linux·运维·windows·ubuntu
‎ദ്ദിᵔ.˛.ᵔ₎17 小时前
linux进程
linux
湿滑路面17 小时前
硅基聊天室——如何用supervisor优雅的管理服务进程
linux·前端·python