先来说一下这个日志系统的主要功能:
- 日志等级分类: 支持多种日志等级(DEBUG、INFO、WARNING、ERROR、FATAL),开发者可以根据日志的重要性进行分区和管理。
- 日志格式: 每条日志都包含时间戳、日志等级、进程ID、源代码文件名、行号以及具体的日志内容,有助于问题的定位和分析。
- 多目标输出: 支持将日志输出到控制台或文件。通过配置(
g_IsSave
变量),开发者可以轻松地在两者之间进行切换。 - 线程安全问题: 通过互斥锁(
pthread_mutex_t
)和LockGuard
实现了RAII锁管理,确保在多线程环境下日志系统的安全写入,避免数据竞争和损坏。 - 宏简化使用: 通过宏
LOG
提供了一种简洁的方式来自动生成日志记录。
日志等级
通过enum Level
定义了五个日志等级:DEBUG
、INFO
、WARNING
、ERROR
、FATAL
。这些等级允许开发者根据日志的中要求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);
}
其中该函数内部使用的函数有:
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
变量中。
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 指针指向的位置。
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_list
和vsnprintf
格式化。
-
va_list arg;
va_list
是一个用于存储可变参数信息的类型,它通常是一个指向参数列表的指针。被定义在<cstdarg>
或<stdarg.h>
头文件中。
-
va_start(arg, format)
va_start
用于初始化va_list
类型变量arg
,使其指向函数的可变参数列表的第一个元素。format
是紧跟在va_list
变量之后的最后一个固定参数,用于告诉编译器从哪里开始查找可变参数列表(就在这个固定参数后面)。- 你必须在使用任何可变参数之前调用
va_start
。
-
vsnprintf(char *str, size_t size, const char *format, va_list arg)
- 作用: 将格式化的数据写入到字符串
buffer
中。这个函数类似于snprintf
,但它接受一个va_list
类型的参数arg
来代替可变数量的参数。 - 参数:
buffer
:指向字符数组的指针,用于存储格式化后的字符串。size
:buffer 的大小,即可以存储的最大字符数(包含结尾的空字符\0
)。format
:格式字符串,指定了后续参数如何被格式化为字符串。arg
:va_list
类型的变量,包含了要格式化的可变参数的信息。
- 返回值: 成功时返回写入的字符串(不包括结尾的空字符
\0
),如果发生错误则返回负值。
- 作用: 将格式化的数据写入到字符串
-
va_end(arg);
- 作用: 清理
va_list
变量arg
。va_end
会将该变量置为无效状态,如nullptr.
- 作用: 清理
可变参数原理
为了方便使用 可变参数模板va_list
和vsnprintf
格式化 这两个接口,我们先来了解一下 可变参数 的原理。
// 默认传递进来的都是整数
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 指定的格式被插入到日志消息中。
-
do { … } while (0) 结构是一种常见的宏定义技巧,它确保了宏在使用时能够像一个单独的语句一样工作,即使它被用作更大的表达式的一部分。
-
LOG
宏是一个方便的封装,它允许在代码中以更简洁的方式调用LogMessage
函数。这个宏使用了C99引入的__VA_ARGS__
特性,它允许宏定义中包含可变数量的参数。 -
__VA_ARGS__
在宏展开时会被替换为传递给宏的实际参数(如果有的话)。在上面的例子中,__VA_ARGS__
代表传递给LogMessage
的所有可变参数...
。 -
##
: 在这个宏中,##
运算符的作用是连接两个操作数。当宏被展开时,##
会把两边的操作数合并成一个操作数。这里的关键在于,##__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)
今天的分享就到这里了,如果,你感觉这篇博客对你有帮助的话,就点个赞吧!感谢感谢……