日志模块设计

本篇文章主要参考如下文章,主要是对代码做一个较为详尽的解释

参考链接:https://blog.csdn.net/qq_46495964/article/details/122952567

前言: 日志系统在程序运行中有着非常大的作用,用于记录程序的运行情况,在程序出错后查看日志,方便地定位出错的大概范围。在设计日志系统之前,先考虑一下日志需要输出什么信息呢?什么信息才是有用的信息,都知道写日志是一种对文件的io操作,所以尽可能避免输出没用的信息。 有用的信息:关键变量的值、运行的位置(哪个文件、哪个函数、哪一行)、时间、线程号、进程号等等。

日志系统的设计

  1. 日志的级别

    在测试、调试、交付等场景需要输出不同的级别日志。

    1
    2
    3
    4
    5
    6
    7
    8
    9
    //常见的日志级别
    enum LOGLEVEL
    {
    LOG_LEVEL_NONE,
    LOG_LEVEL_ERROR, // error
    LOG_LEVEL_WARNING, // warning
    LOG_LEVEL_DEBUG, // debug
    LOG_LEVEL_INFO, // info
    };
  2. 日志的输出地

    日志输出的地方可能不同,终端、控制台、UI界面、文件等等都有。

    1
    2
    3
    4
    5
    6
    enum LOGTARGET
    {
    LOG_TERM = 0x00,
    LOG_FILE = 0x01,
    LOG_UI = 0x10
    };
  3. 日志的作用域

    日志做到什么时候都可以输出,可作用于全程序文件,考虑到多线程情况下,必须保证日志的输出需要得到线程安全的保障,所以需要一个全局且唯一的日志器。使用设计模式中的单例模式—–日志器

C++版本的日志系统的实现

Logger.h

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
这是一个基本的线程安全日志系统的头文件,其目的是为应用程序提供日志功能。以下是对这个头文件的详细解释:

1. **预处理指令和宏**:
- `#ifndef`, `#define`, 和 `#endif` 用于保证头文件只被包含一次,避免重复定义。
- `#define` 宏定义了三个日志级别:`LogInfo`, `LogWarning`, 和 `LogError`。这些宏都使用了变参(`...`),允许用户为日志提供自定义的格式和参数。这些宏捕获了日志消息的来源(文件、行号和函数)并将其添加到日志队列中。

2. **类定义:Logger**
- 是一个单例类,这意味着整个程序中只能有一个实例。这通过私有的默认构造函数、删除的复制构造函数和赋值运算符来实现。
- `GetInstance()` 方法提供了对单例实例的访问。
- `SetFileName()` 允许设置日志文件的名称。
- `Start()` 和 `Stop()` 用于启动和停止日志线程。
- `AddToQueue()` 是用于将新的日志消息添加到日志队列的方法。
- `threadfunc()` 是日志线程的工作函数,它持续从日志队列中读取消息并写入文件。

3. **类的私有成员**:
- `filename_`: 存储日志文件的名称。
- `fp_`: 用于文件操作的文件指针。请注意,在所给代码中,`FILE` 的声明被注释掉了,你需要包含 `<cstdio>` 或者直接使用 C++ 的文件流类(如 `std::ofstream`)。
- `spthread_`: 一个智能指针,指向日志线程。
- `mutex_`: 用于保护日志队列和其他共享资源,确保多线程访问时的线程安全。
- `cv_`: 条件变量,用于通知日志线程有新的消息到来。
- `exit_`: 一个标志,指示日志线程何时退出。
- `queue_`: 存储待处理日志消息的队列。

4. **注释**:
- 提供了关于该文件的基本信息,如文件名称和日期。

总之,这个日志系统设计的思路是:
1. 用户调用预定义的宏添加日志消息。
2. 日志消息被添加到一个线程安全的队列。
3. 一个独立的日志线程从队列中取出消息并写入文件。

这种设计允许应用程序非阻塞地添加日志消息,而将I/O操作留给专门的线程,从而提高应用程序的性能。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
/** 
* 日志类头文件, Logger.h
* 2022.02.1
**/

#ifndef __LOGGER_H__
#define __LOGGER_H__

#include <string>
#include <memory>
#include <thread>
#include <mutex>
#include <condition_variable>
#include <list>

//struct FILE;

#define LogInfo(...) Logger::GetInstance().AddToQueue("INFO", __FILE__, __LINE__, __PRETTY_FUNCTION__, __VA_ARGS__)
#define LogWarning(...) Logger::GetInstance().AddToQueue("WARNING", __FILE__, __LINE__, __PRETTY_FUNCTION__, __VA_ARGS__)
#define LogError(...) Logger::GetInstance().AddToQueue("ERROR", __FILE__, __LINE__, __PRETTY_FUNCTION__, __VA_ARGS__)

class Logger
{
public:
static Logger& GetInstance();

void SetFileName(const char* filename);
bool Start();
void Stop();

void AddToQueue(const char* pszLevel, const char* pszFile, int lineNo, const char* pszFuncSig, char* pszFmt, ...);

private:
Logger() = default;
Logger(const Logger& rhs) = delete;
Logger& operator =(Logger& rhs) = delete;

void threadfunc();

private:
std::string filename_;
FILE* fp_{};
std::shared_ptr<std::thread> spthread_;
std::mutex mutex_;
std::condition_variable cv_; //有新的日志到来的标识
bool exit_{false};
std::list<std::string> queue_;
};

#endif //!__LOGGER_H__

Logger.cpp

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
这是日志类的实现文件`Logger.cpp`。它对`Logger.h`中定义的函数进行了具体的实现。我将分步解释这个文件的内容:

1. **#include语句**: 引入了所需的头文件,其中`Logger.h`是之前你展示的日志类的头文件,其他的是C和C++标准库中的头文件。

2. **GetInstance方法**: 实现了单例模式的核心功能。通过使用局部静态对象`logger`,它确保只创建一次`Logger`类的实例。

3. **SetFileName方法**: 允许用户设置日志文件的名称。

4. **Start方法**:
- 如果用户没有提供日志文件名,它会生成一个默认的日志文件名,该文件名包含当前的日期和时间。
- 尝试打开日志文件以写入和读取。
- 创建一个新线程来处理日志消息。

5. **Stop方法**:
- 设置`exit_`标志为`true`。
- 通过条件变量通知日志线程。
- 等待日志线程结束。

6. **AddToQueue方法**:
- 使用变长参数列表格式化日志消息。
- 生成一个包含日期、时间、日志级别、线程ID、源文件名、行号、函数签名和日志消息的完整日志字符串。
- 使用互斥锁将完整的日志字符串添加到队列。
- 通过条件变量通知日志线程有新的消息到来。

7. **threadfunc方法**:
- 日志线程的核心功能。
- 检查文件指针是否为空。
- 在`exit_`为`false`的情况下,循环处理日志消息。
- 使用互斥锁和条件变量等待新的消息到来。
- 当有新的消息时,从队列中取出并写入日志文件。
- 如果设置了退出标志并且队列为空,线程将退出。

总体上,这是一个简单但功能完整的日志系统实现。它使用了多线程,互斥锁和条件变量来确保线程安全,并使日志操作与应用程序的其他部分异步进行,从而提高了性能。

然而,也有一些可以进一步优化或改进的地方:
1. 在`Start()`中,文件是以"wt+"模式打开的,这意味着如果文件已经存在,它会被覆盖。如果希望在现有文件中追加日志,则应使用"at+"
2. 代码中没有明确的错误处理或异常处理策略。例如,如果`fopen`失败或`std::thread`创建失败时。
3. 在`threadfunc`中,当队列不为空时,每处理一个消息就会刷新文件,这可能影响性能。可以考虑在队列中有多个消息时,一次性写入多个消息,然后再刷新。
4. 可能会考虑添加一个`Rotate`功能,当日志文件大小超过某个限制时,自动将当前日志文件重命名,并创建一个新的日志文件。
5. 为了提高性能,可以考虑使用双缓冲技术,这样可以减少互斥锁的争用。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
/**
* 日志类实现文件, Logger.cpp
* 2022.02.1
**/

#include "Logger.h"
#include <time.h>
#include <stdio.h>
#include <memory>
#include <stdarg.h>

Logger& Logger::GetInstance()
{
static Logger logger;
return logger;
}

void Logger::SetFileName(const char* filename)
{
filename_ = filename;
}

bool Logger::Start()
{
if (filename_.empty())
{
time_t now = time(NULL);
struct tm* t = localtime(&now);
char timestr[64] = { 0 };
sprintf(timestr, "%04d%02d%02d%02d%02d%02d.imserver.log", t->tm_year + 1900, t->tm_mon + 1, t->tm_mday, t->tm_hour, t->tm_min, t->tm_sec);
filename_ = timestr;
}

fp_ = fopen(filename_.c_str(), "wt+");
if (fp_ == NULL)
return false;

spthread_.reset(new std::thread(std::bind(&Logger::threadfunc, this)));

return true;
}

void Logger::Stop()
{
exit_ = true;
cv_.notify_one();

//等待时间线程结束
spthread_->join();
}

void Logger::AddToQueue(const char* pszLevel, const char* pszFile, int lineNo, const char* pszFuncSig, char* pszFmt, ...)
{
char msg[256] = { 0 };

va_list vArgList;
va_start(vArgList, pszFmt);
vsnprintf(msg, 256, pszFmt, vArgList);
va_end(vArgList);

time_t now = time(NULL);
struct tm* tmstr = localtime(&now);
char content[512] = { 0 };
sprintf(content, "[%04d-%02d-%02d %02d:%02d:%02d][%s][0x%04x][%s:%d %s]%s\n",
tmstr->tm_year + 1900,
tmstr->tm_mon + 1,
tmstr->tm_mday,
tmstr->tm_hour,
tmstr->tm_min,
tmstr->tm_sec,
pszLevel,
std::this_thread::get_id(),
pszFile,
lineNo,
pszFuncSig,
msg);

{
std::lock_guard<std::mutex> guard(mutex_);
queue_.emplace_back(content);
}

cv_.notify_one();
}

void Logger::threadfunc()
{
if (fp_ == NULL)
return;

while (!exit_)
{
//写日志
std::unique_lock<std::mutex> guard(mutex_);
while (queue_.empty())
{
if (exit_)
return;

cv_.wait(guard);
}

//写日志
const std::string& str = queue_.front();

fwrite((void*)str.c_str(), str.length(), 1, fp_);
fflush(fp_);
queue_.pop_front();
}
}

问题描述

  1. Stop方法: 设置exit_标志为true。 通过条件变量通知日志线程。 等待日志线程结束

    请问这里为什么需要通知日志线程

    解答:在Logger类的实现中,日志线程的工作是从队列中获取日志消息并将其写入文件。为了实现这个功能,它在队列为空时通过cv_.wait(guard)进入休眠状态,等待新的日志消息的到来。

    当你调用Stop方法时,你希望日志线程能够尽快结束。但有一种情况需要考虑:当Stop被调用时,队列可能仍然是空的,这意味着日志线程可能处于cv_.wait(guard)的休眠状态。

    在这种情况下,即使你设置了exit_标志为true,日志线程仍然不会醒来检查这个标志,因为它正在等待新的日志消息到来。

    为了确保日志线程能够醒来、检查exit_标志并正确地退出,你需要在Stop方法中通过cv_.notify_one()通知日志线程。这样,日志线程将被唤醒,然后它可以检查exit_标志,并根据该标志的值决定是否退出。

    总之,通知日志线程是为了确保它可以从cv_.wait(guard)中醒来,然后检查退出条件并安全地退出。

  2. 这个程序没有设计线程来将新的日志信息加入队列吗

    从提供的代码来看,日志系统中只设计了一个专门的日志线程(在Logger::Start()方法中启动),其任务是从队列中提取日志消息并将其写入文件。此外,并没有为添加新日志消息到队列专门设计线程。

    当你希望写入日志时,只需在你的主程序或任何其他线程中调用LogInfo(), LogWarning(), 或 LogError()这样的宏。这些宏内部会调用Logger::AddToQueue(),该方法将日志消息格式化并加入到队列中。这种设计的优点是简单且高效,因为它允许多个线程在运行时无缝地向日志系统添加消息,而无需额外的线程。

    但值得注意的是,Logger::AddToQueue()使用了互斥锁mutex_来保证多个线程同时向队列添加消息时的线程安全。这意味着,即使多个线程同时调用日志宏,Logger::AddToQueue()中的代码仍然是线程安全的。

    总的来说,这个日志系统的设计考虑到了多线程环境,允许多个线程同时添加日志消息,但它并没有专门设置一个线程来完成这项任务,而是允许主程序或其他任何线程来直接调用添加日志的宏。

    自己的话:你自己程序在运行的时候是不是有多个线程,这些线程就是负责啥时候要调用LogInfo(), LogWarning(), 或 LogError()这样的宏。所以当然不需要额外的线程来操作加入队列操作,只需要一个带锁函数即可。