日志模块实现

先来说一下这个日志系统的主要功能:

  1. 日志等级分类: 支持多种日志等级(DEBUG、INFO、WARNING、ERROR、FATAL),开发者可以根据日志的重要性进行分区和管理。
  2. 日志格式: 每条日志都包含时间戳、日志等级、进程ID、源代码文件名、行号以及具体的日志内容,有助于问题的定位和分析。
  3. 多目标输出: 支持将日志输出到控制台或文件。通过配置(g_IsSave 变量),开发者可以轻松地在两者之间进行切换。
  4. 线程安全问题: 通过互斥锁(pthread_mutex_t)和LockGuard实现了RAII锁管理,确保在多线程环境下日志系统的安全写入,避免数据竞争和损坏。
  5. 宏简化使用: 通过宏LOG提供了一种简洁的方式来自动生成日志记录。

日志等级

通过enum Level定义了五个日志等级:DEBUGINFOWARNINGERRORFATAL。这些等级允许开发者根据日志的中要求i来过滤和记录日志。

enum Level
{
    DEBUG = 0,
    INFO,
    WARNING,
    ERROR,
    FATAL
};

日志格式

时间

通过GetTimeString函数获取。

std::string GetTimeString()
{
    time_t curr_time = time(nullptr);
    struct tm *format_time = localtime(&curr_time);
    if(format_time == nullptr)
        return "None";
    
    char time_buffer[1024];
    snprintf(time_buffer, sizeof(time_buffer), "%d-%d-%d %d:%d:%d", 
             format_time->tm_year + 1900,
             format_time->tm_mon + 1,
             format_time->tm_mday,
             format_time->tm_hour,
             format_time->tm_min,
             format_time->tm_sec);
    return std::string(time_buffer);
}

其中该函数内部使用的函数有:

  1. time_t time(time_t *timer)
  • 功能: 获取当前时间(自1970年1月1日00:00:00 UTC以来的秒数)
  • 参数: timer 是指向 time_t 的指针,用于存储获取的时间。如果传递 nullptr,则不存储时间。这里传入 nullptr
  • 返回值: 返回当前时间(如果 timer 不是 nullptr,则同时存储在 timer 指向的位置)。
  • 在这个函数中,time(nullptr) 用于获取当前时间,并将这个时间值存储在 curr_time 变量中。
  1. struct tm *localtime(const time_t *timer);
  • 功能:time_t 表示的时间(自1970年1月1日00:00:00 UTC以来的秒数)转化为本地时间(考虑时区)。

  • 参数: timer 是指向time_t 的指针,包含了需要转换的时间。

  • 返回值: 返回一个指向 struct tm 的指针,该结构体包含了转换后的本地时间信息(年、月、日、时、分、秒等)。如果转换失败,返回 nullptr
    在这里插入图片描述
    注: 这里年份返回的年份是 当前年份 - 1900,所以如果我们获取当前年份 需要在返回值+1900;月份返回的范围为 0 -11,所以实际当前月份还需要在返回值上 + 1。

  • 在这个函数中,localtime(&curr_time) 用于将 curr_time 转换为本地时间,并将结果存储在 format_time 指针指向的位置。

  1. int snprintf(char *str, size_t size, const char *format, ...);
  • 功能: 将格式化的数据写入字符串。它允许你指定目标缓冲区的大小,从而避免缓冲区溢出。
  • 参数:
    • str:目标字符串的指针。
    • size:目标缓冲区的大小(包括终止的空字符)。
    • format:格式字符串,指定了后续参数如何被格式化和插入到目标字符串中。
    • ...:可变数量的参数,这些参数的值将根据 format 字符串中的格式说明符被格式化并插入到 str 中。
  • 返回值: 如果成功,返回写入的字符串(不包括终止的空字符);如果发生错误,返回负值。
  • 在这个函数中,snprintf 用于将format_time指向的struct tm中的时间信息格式化为一个字符串,并存储在time_buffer中。

日志等级字符串

为了方便整体日志的子字符串拼接,需要将等级转换为字符串。通过LevelToString函数转化为字符串:

std::string LevelToString(int level)
{
    switch (level)
    {
    case DEBUG:
        return "Debug";
    case INFO:
        return "Info";
    case WARNING:
        return "Warning";
    case ERROR:
        return "Error";
    case FATAL:
        return "Fatal";
    default:
        return "Unknown";
    }
}

进程ID

通过getpid()获取,也需要将其转换为字符串。

源代码文件名和行号

通过宏__FILE____LINE__自动获取。

__FILE____LINE__ 是 C、C++、Objective-C 以及一些其他编程语言中预定义的宏,它们用于在编译时提供有关源代码的额外信息。这些宏通常用于调试目的,帮助开发者快速定位代码中的特定位置。

FILE

  • __FILE__ 宏被替换为当前源代码文件名的一个字符串字面量。这个文件名是编译器在编译时看到的文件名,可能是源文件本身的名称,也可能是经过预处理器(如包含头文件)处理后的等效名。
  • 使用__FILE__ 可以帮助在日志或错误消息中记录代码发生问题的具体文件。

LINE

  • __LINE__ 宏被替换为当前源代码行号的十进制整数。这个行号是从文件开始处计数的,包括所有行,即使它们是空行或只包含注释。
  • 使用 __LINE__ 可以帮助精确指出代码发生问题的具体行。

注意事项

  • 这些宏是由编译器提供的,因此在非编译环境中(如脚本语言或某些解释器环境中)不可用。
  • 使用这些宏时,要确保它们不会暴露敏感信息,特别是在生产环境中记录日志时。
  • 虽然这些宏在调试时非常有用,但在发布版本中保留大量的调试信息可能会增加最终程序的大小。因此,一些项目在构建发布版本时会去除最小化调试信息的输出。

用户自定义日志内容

对于用户来说,他可能想要输出特定的内容,因此就需要向日志系统提供多个参数,所以该日志系统的参数需要设计为 不定参数

要实现 不定参数,需要使用可变参数模板va_listvsnprintf格式化。

  1. va_list arg;

    • va_list 是一个用于存储可变参数信息的类型,它通常是一个指向参数列表的指针。被定义在<cstdarg><stdarg.h>头文件中。
  2. va_start(arg, format)

    • va_start 用于初始化va_list类型变量arg,使其指向函数的可变参数列表的第一个元素。
    • format是紧跟在 va_list 变量之后的最后一个固定参数,用于告诉编译器从哪里开始查找可变参数列表(就在这个固定参数后面)。
    • 你必须在使用任何可变参数之前调用 va_start
  3. vsnprintf(char *str, size_t size, const char *format, va_list arg)

    • 作用: 将格式化的数据写入到字符串buffer中。这个函数类似于snprintf,但它接受一个 va_list 类型的参数arg来代替可变数量的参数。
    • 参数:
      • buffer:指向字符数组的指针,用于存储格式化后的字符串。
      • size:buffer 的大小,即可以存储的最大字符数(包含结尾的空字符\0)。
      • format:格式字符串,指定了后续参数如何被格式化为字符串。
      • argva_list类型的变量,包含了要格式化的可变参数的信息。
    • 返回值: 成功时返回写入的字符串(不包括结尾的空字符\0),如果发生错误则返回负值。
  4. va_end(arg);

    • 作用: 清理 va_list变量argva_end 会将该变量置为无效状态,如nullptr.

可变参数原理

为了方便使用 可变参数模板va_listvsnprintf格式化 这两个接口,我们先来了解一下 可变参数 的原理。

// 默认传递进来的都是整数
void Test(int num, ...)
{
	va_list arg;
	va_start(arg, num);
	while(num)
	{
		int data = va_arg(arg, int);
		num--;
	}
	va_end(arg); // arg = NULL
}

Test(3, 11, 22, 33);

在C语言当传参的时候,都是从右向左进行实例化的,同时在实例化时,会向内存空间写入,也就是该函数的栈结构形成时,它的参数会从右向左入栈:
在这里插入图片描述
我们要使用这个可变参数部分,就需要把每个参数都提取出来:前面我们说过 va_list 类型,它其实是一个 void* 类型的指针,我们定义一个该类型的变量 arg 后,使用 va_start(arg, num)函数,使arg指针指向可变参数部分的第一个元素(就是通过最后一个固定参数的地址 &num,将其减去该固定参数的字节数 sizeof(num)实现的)。
在这里插入图片描述
现在arg指针已经指向可变参数部分的第一个元素了,可以使用va_arg函数提取参数:首先根据传入arg指针(第一个参数),定位到参数,再根据 va_arg 函数传入的可变参数类型(第二个参数),对 arg 指针做强制类型转换,然后再解引用就可以获得该元素了。
在这里插入图片描述
可变参数的数量可以根据 num 来确定,要想取得剩余参数,就需要移动arg指针,继续解引用即可:arg += sizeof(int)
在这里插入图片描述
现在来回看一下,我们使用的 printf()函数:

int printf(const char *format, ...);

它的函数参数也使用了可变参数。其通常的用法为:

int a = 1;
float b = 3.14;
char c = 'c';
printf("%d, %f, %c", a, b, c);

其第一个参数是一个字符串常量(固定参数,打印格式),第二个参数就是可变参数部分。
首先,会根据字符串常量(固定参数的最后一个)确定可变参数第一个元素的地址;然后根据参数类型解引用,如%d,就是整形类型;根据%占位符的个数确定可变参数的个数,以便取到所有的可变参数。


如果一个函数中使用了可变参数都要向上述Test函数中,自主解析获取参数,就会比较麻烦。这里 vsprintf()vsnprintf()两个函数可以帮我们组织可变参数,并按照我们想要的格式写入缓冲区中。

拼接日志&&输出

将上述日志各种信息拼接起来,实现LogMessage。将信息拼接起来后,我们可以选择将信息输出哪里:显示器或文件,所以这里要给一个参数is_save供开发者选择。

因为显示器或文件会称为共享资源,所以在输出之前,还要加锁进行互斥。通过互斥锁(pthread_mutex_t)和LockGuard实现了RAII锁管理,确保在多线程环境下日志系统的安全写入,避免数据竞争和损坏。

其中LockGuard的实现为:

// LockGuard.hpp
#ifndef __LOCK_GURAD_HPP__
#define __LOCK_GURAD_HPP__

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

class LockGruad
{
public:
    LockGruad(pthread_mutex_t *mutex):_mutex(mutex)
    {
        pthread_mutex_lock(_mutex); // 构造加锁
    }
    ~LockGruad()
    {
        pthread_mutex_unlock(_mutex); // 析构解锁
    }
private:
    pthread_mutex_t *_mutex;
};

#endif

函数最终实现:

pthread_mutex_t lock = PTHREAD_MUTEX_INITIALIZER;
bool g_is_save = false;
const char* log_name = "log.txt";

void SaveFile(const std::string& log_name, const std::string& message)
{
    std::ofstream out(log_name, std::ios::app);
    if (!out.is_open())
    {  
        std::cerr << "Failed to open log file: " << log_name << std::endl;  
    } 
    out << message;
    out.close();
}

void LogMessage(std::string filename, int lineNo, bool is_save, int level, const char *format, ...)
{
    std::string level_string = LevelToString(level);
    std::string time_string = GetTimeString();
    pid_t self_id = getpid();
    
    // 获取格式化信息
    char buffer[1024];
    va_list arg;
    va_start(arg, format);
    vsnprintf(buffer, sizeof(buffer), format, arg);
    va_end(arg);

    std::string message = "[" + time_string + "]" + "[" + level_string + "]" +
                          "[" + std::to_string(self_id) + "]" + "[" + filename + "]" +
                          "[" + std::to_string(lineNo) + "]" + buffer + '\n';
    // 输出之前加锁
    LockGruad lockGuard(&lock);
    if(!is_save) // 不保存,则输出到屏幕上
    {
        std::cout << message;
    }
    else // 保存,则输出到指定文件中
    {
        SaveFile(log_name, message);
    }
}

宏简化使用

LOG

#define LOG(level, format, ...)                                                  \
    do                                                                           \
    {                                                                            \
        LogMessage(__FILE__, __LINE__, g_is_save, level, format, ##__VA_ARGS__); \
    } while (0);

它接受三个参数(实际上是可变数量的参数,但在这里我们主要考虑前三个):

  • level:日志的级别,比如 DEBUG、INFO、WARNING、ERROR 等。这个参数用于控制哪些级别的日志应该被记录,具体取决于日志系统的配置或当前的运行环境。
  • format:一个格式化字符串,类似于 printf 函数的第一个参数,用于指定日志消息的格式。
  • ...:可变参数列表,这些参数将按照 format 指定的格式被插入到日志消息中。
  1. do { … } while (0) 结构是一种常见的宏定义技巧,它确保了宏在使用时能够像一个单独的语句一样工作,即使它被用作更大的表达式的一部分。

  2. LOG宏是一个方便的封装,它允许在代码中以更简洁的方式调用LogMessage函数。这个宏使用了C99引入的__VA_ARGS__特性,它允许宏定义中包含可变数量的参数。

  3. __VA_ARGS__在宏展开时会被替换为传递给宏的实际参数(如果有的话)。在上面的例子中,__VA_ARGS__ 代表传递给 LogMessage 的所有可变参数...

  4. ##: 在这个宏中,## 运算符的作用是连接两个操作数。当宏被展开时,## 会把两边的操作数合并成一个操作数。这里的关键在于,##__VA_ARGS__ 确保即使没有传递可变参数,宏也能正确地展开。

## 运算符

如果没有 ## 运算符,并且在调用 LOG 宏时没有传递可变参数,宏的展开将会出现问题。

LogMessage(__FILE__, __LINE__, gIsSave, level, format, __VA_ARGS__);

由于 __VA_ARGS__ 在这种情况下为空,宏展开的结果将是:

LogMessage(__FILE__, __LINE__, gIsSave, level, format, );

这里有一个多余的逗号,这会导致编译错误,因为 LogMessage 函数期望一个特定数量的参数,而在这种情况下,它会接收到一个额外的逗号,这不符合 C/C++ 语言规范。

例如,如果我们只调用 LOG(INFO, "Hello world!"),那么宏展开后的情况会是这样的:

LogMessage(__FILE__, __LINE__, gIsSave, INFO, "Hello world!", );

## 运算符的作用

有了 ## 运算符,情况就不同了。无论是否传递可变参数,宏展开都会正确进行。

如果没有任何可变参数,宏会被展开为:

LogMessage(__FILE__, __LINE__, gIsSave, level, format);

这正是 LogMessage 函数所期望的形式。


如果有可变参数,比如:

LOG(INFO, "User %s logged in.", "alice");

宏会被展开为:

LogMessage(__FILE__, __LINE__, gIsSave, INFO, "User %s logged in.", "alice");

这也符合 LogMessage 函数的期望。

示例
假设我们有如下的宏调用:

LOG(INFO, "User logged in.");

这里的宏展开会是:

LogMessage(__FILE__, __LINE__, gIsSave, INFO, "User logged in.", ##__VA_ARGS__);

由于 __VA_ARGS__ 在这种情况下为空,## 运算符将不会添加任何额外的操作数,因此最终的展开结果将是:

LogMessage(__FILE__, __LINE__, gIsSave, INFO, "User logged in.");

这与没有传递可变参数时的情况是一致的,并且不会导致编译错误。

总结

  • ## 运算符确保即使没有可变参数,宏也能正确展开。
  • 没有 ## 运算符,宏可能会在没有可变参数时产生编译错误。
  • ##__VA_ARGS__ 确保宏在调用时能正确地处理可变参数列表,无论是否提供了参数。

EnableFile 和 EnableScreen

#define EnableFile()      \
    do                    \
    {                     \
        g_is_save = true; \
    } while (0);

#define EnableFile()      \
    do                    \
    {                     \
        g_is_save = false; \
    } while (0);

这两个宏用于控制日志消息是保存到文件还是输出到屏幕。它们通过修改全局变量 g_is_save 的值来实现这一点。g_is_save 是一个布尔值,用于在 LogMessage 函数中决定将消息保存到文件还是输出到标准输出。

在宏定义的结尾处有一个不必要的反斜杠 () 跟着一个换行符。这种情况下,预处理器会认为宏定义还没有结束,而实际上宏定义已经完成了。这通常发生在宏定义跨越多行的时候,但最后一行不应该以反斜杠结尾。

错误示例

#define EnableScreen()     \
    do                     \
    {                      \
        g_is_save = false; \
    } while (0)            \  // 不需要的反斜杠

正确的修正
您应该移除最后一个反斜杠和换行符,让宏定义的最后一行直接结束。正确的宏定义应该是这样的:

#define EnableScreen()     \
    do                     \
    {                      \
        g_is_save = false; \
    } while (0)

解释
当宏定义跨越多行时,通常每一行的结尾会有一个反斜杠 () 和换行符,表示宏定义将在下一行继续。

但是,当宏定义的最后一行不再需要继续时,就不应该有反斜杠和换行符了。

如果还是出现警告,可以使用这种写法:

#define EnableFile() do { g_is_save = true; } while (0)  
#define EnableScreen() do { g_is_save = false; } while (0)

总体代码

// LockGuard.hpp
#ifndef __LOCK_GURAD_HPP__
#define __LOCK_GURAD_HPP__

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

class LockGruad
{
public:
    LockGruad(pthread_mutex_t *mutex) : _mutex(mutex)
    {
        pthread_mutex_lock(_mutex); // 构造加锁
    }
    ~LockGruad()
    {
        pthread_mutex_unlock(_mutex); // 析构解锁
    }

private:
    pthread_mutex_t *_mutex;
};

#endif
// LOG.hpp

#pragma once

#include <iostream>
#include <fstream>
#include <string>
#include <ctime>
#include <unistd.h>
#include <cstdarg>
#include <sys/types.h>
#include <pthread.h>
#include "LockGuard.hpp"

pthread_mutex_t lock = PTHREAD_MUTEX_INITIALIZER;
bool g_is_save = false;
const char *log_name = "log.txt";
// 日志等级
enum Level
{
    DEBUG = 0,
    INFO,
    WARNING,
    ERROR,
    FATAL
};

std::string LevelToString(int level)
{
    switch (level)
    {
    case DEBUG:
        return "Debug";
    case INFO:
        return "Info";
    case WARNING:
        return "Warning";
    case ERROR:
        return "Error";
    case FATAL:
        return "Fatal";
    default:
        return "Unknown";
    }
}

std::string GetTimeString()
{
    time_t curr_time = time(nullptr);
    struct tm *format_time = localtime(&curr_time);
    if (format_time == nullptr)
        return "None";

    char time_buffer[1024];
    snprintf(time_buffer, sizeof(time_buffer), "%d-%d-%d %d:%d:%d",
             format_time->tm_year + 1900,
             format_time->tm_mon + 1,
             format_time->tm_mday,
             format_time->tm_hour,
             format_time->tm_min,
             format_time->tm_sec);
    return std::string(time_buffer);
}

void SaveFile(const std::string &log_name, const std::string &message)
{
    std::ofstream out(log_name, std::ios::app);
    if (!out.is_open())
    {
        std::cerr << "Failed to open log file: " << log_name << std::endl;
    }
    out << message;
    out.close();
}

void LogMessage(std::string filename, int lineNo, bool is_save, int level, const char *format, ...)
{
    std::string level_string = LevelToString(level);
    std::string time_string = GetTimeString();
    // pthread_t self_id = pthread_self();
    pid_t self_id = getpid();


    // 获取格式化信息
    char buffer[1024];
    va_list arg;
    va_start(arg, format);
    vsnprintf(buffer, sizeof(buffer), format, arg);
    va_end(arg);

    std::string message = "[" + time_string + "]" + "[" + level_string + "]" +
                          "[pid:" + std::to_string(self_id) + "]" + "[" + filename + "]" +
                          "[" + std::to_string(lineNo) + "] " + buffer + "\n";
    // 输出之前加锁
    LockGruad lockGuard(&lock);
    if (!is_save) // 不保存,则输出到屏幕上
    {
        std::cout << message;
    }
    else // 保存,则输出到指定文件中
    {
        SaveFile(log_name, message);
    }
}

#define LOG(level, format, ...)                                                  \
    do                                                                           \
    {                                                                            \
        LogMessage(__FILE__, __LINE__, g_is_save, level, format, ##__VA_ARGS__); \
    } while (0)

#define EnableFile()  do  {  g_is_save = true; } while (0)


#define EnableScreen()  do  {  g_is_save = false; } while (0)

今天的分享就到这里了,如果,你感觉这篇博客对你有帮助的话,就点个赞吧!感谢感谢……

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值