前段时间遇到一个小问题,后来发现这是个挺常见的坑,顺手整理一篇笔记。
🔥个人主页:Cx330🌸
❄️个人专栏:《C语言》《LeetCode刷题集》《数据结构-初阶》《C++知识分享》
《优选算法指南-必刷经典100题》《Linux操作系统》:从入门到入魔
🌟心向往之行必能
🎥Cx330🌸的简介:
目录
3.2 控制台日志策略 ConsoleLogStrategy
4.2 LogMessage 内部类:RAII 实现日志自动刷新
前言:
日志系统可以说是每个程序员都绕不开的话题。在大型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(×tamp, &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)
暂无评论