目录
前言:
一、策略模式
二、日志认识
三、设计思路
四、Log的实现
1、日志格式
2、日志构成
2.1、日志路径与文件名
2.2、日志等级
3、刷新策略
4、控制台策略
5、文件级(磁盘)策略
6、日志类的实现
7、嵌套类LogMessage实现
8、宏的巧妙使用与运算符重载
总结:
前言:
在学习我们的线程池之前,我们今天先对日志进行封装。
在今天的封装过程中,我会给大家提出一个新的概念策略模式。
一、策略模式
策略模式是设计模式的一种。
在IT行业中,涌入的人越来越多。而林子大了什么鸟都有,大佬和菜鸡们两极分化越来越严重。为了让菜鸡们不太拖大佬的后腿,于是大佬们针对一些经典的常见的场景,给定了一些对应的解决方案。由此引出了设计模式的概念。
而什么是策略模式呢?
策略模式 是一种行为型设计模式,它定义了一系列算法(策略),并将每个算法封装起来,使它们可以互相替换,且算法的变化不影响客户端。
其核心思想就是将算法与使用它的代码解耦,以及运行时动态切换算法(而非硬编码在类中)。
说起来可能会觉得有点抽象,我们可以根据策略模式实现一下我们的日志类,帮助大家对策略模式进行理解。
二、日志认识
计算机中的日志是记录系统和软件运行中发生的事件的文件,具有十足的重要性。其主要作用是监控运行状态,记录异常信息,以此来帮助快速定位问题并支持程序员进行问题修复。
它是系统维护,故障排查和安全管理的重要工具。
良好的日志应包含:
-
时间戳
-
日志级别
-
线程ID(多线程系统)
-
源代码位置(文件、行号)
-
模块/组件标识
-
实际日志消息
其中,时间信息,日志等级与日志内容是必须要有的。
日志有现成的解决方案,比如spdlog,glog,Boost.log、log4cxx等等,但我们这里依旧采用自定义日志的方式帮助大家学习。
我们这里采用设计模式中的策略模式来进行日志的设计,以便帮助大家理解。
三、设计思路
策略模式的核心是"定义算法族,封装每个算法,并使它们可以互相替换"。
在日志系统中,"算法"就是不同的日志处理方式(如输出到控制台、文件、网络等),目标是让这些处理方式可以灵活切换。
我们这里主要就只实现一下控制台策略与磁盘级策略,其他策略大家学完之后可以照着这些思路自己实现。
四、Log的实现
1、日志格式
我们说过,一个好的日志应该具备以下内容:
时间,日志等级,我们应该提前想好我们记录的日志的格式信息,比如:
可读性很好的时间\]\[日志等级\]\[进程pid\]\[打印对应日志的文件名\]\[行号\]-消息内容,支持可变参数
\[ 2024-08-04 12 : 27 : 03 \] \[DEBUG\] \[ 202938 \] \[main.cc\] \[ 16 \] - hello world
### 2、日志构成
#### 2.1、日志路径与文件名
想要构成一个日志类,我们就需要实现这两个构成: 1.先 构建日志字符串 2. 后刷新落盘(screen, file)
所以我们可以先定义两个字符串,负责记录日志文件的路径与文件名(以下操作都在命名空间内实现)
```cpp
//记录日志文件名字与路径
const std::string defaultlogname = "log.txt";
const std::string defaultlogpath = "./log/";
```
#### 2.2、日志等级
日志等级是日志系统中至关重要的组成部分,它决定了哪些日志消息应该被记录、存储或显示。良好的日志等级设计能帮助开发者高效地筛选和定位问题。
大多数日志系统采用以下标准等级(从低到高):
1. **TRACE** - 最详细的跟踪信息
* 用于记录程序执行的每一步细节
* 示例:方法进入/退出、循环迭代、变量值变化
2. **DEBUG** - 调试信息
* 开发环境中用于问题诊断
* 示例:关键变量值、条件分支选择
3. **INFO** - 常规运行信息
* 记录应用程序正常运行时的关键事件
* 示例:服务启动、配置加载、用户登录
4. **WARNING** - 警告信息
* 表明潜在问题,但系统仍能继续运行
* 示例:低磁盘空间、非关键API调用失败
5. **ERROR** - 错误信息
* 系统遇到严重问题,但可能恢复
* 示例:数据库连接失败、文件读取错误
6. **FATAL/CRITICAL** - 致命错误
* 导致系统无法继续运行的严重错误
* 示例:关键资源耗尽、无法恢复的系统崩溃
我们这里就采取后面五个日志等级。
为了记录这种等级变量,我们通常使用枚举体:
```cpp
//定义枚举体日志等级
enum class LogLevel
{
DEBUG=1,
INFO,
WARN,
ERROR,
FATAL
};
```
*** ** * ** ***
### 3、刷新策略
我们刚刚说过策略模式,这个策略模式的实现主要是依赖于C++的继承与多态特性。
我们需要有一个基类,随后让我们的不同的策略继承我们的基类,这样我们就可以通过同一个接口来调用不同的策略(就是使得基类的调用接口为虚,虽然让子类(调用的各种策略)重定义该同名函数实现多态)
所以我们需要先写一个策略基类,这个基类应该足够简洁,为了保证我们的派生类不会调用父类的默认生成的析构函数,正确释放资源,所以我们需要进行virtual它并把它置为default,意思是让编译器生成默认实现的一种更优方式。并且,在给他定义一个调用策略的统一接口置为虚函数,这样后面的子类就可以重新定义这个接口,从而实现多态。
在使用多态的基类时,其析构函数必须使用virtual进行虚析构。
原因我们之前在C++的多态那里讲了,这里防止大家忘记,我这里再重新简单说一下:
```cpp
class Base
{
public:
~Base() { cout << "~Base()" << endl; } // 非虚析构
};
class Derived : public Base
{
public:
~Derived() { cout << "~Derived()" << endl; }
int* data = new int[100]; // 派生类独占资源
};
Base* obj = new Derived();
delete obj; // 仅调用~Base(),内存泄漏!
```
在这个代码中,我们定义了一个基类Base,随后让Derived继承它。
如果我们不使用虚析构函数,当我们delete obj的时候,只会调用\~Base,而不会调用子类的析构函数,从而造成内存泄漏等问题。
当基类析构函数声明为`virtual`时:
1. 编译器为类生成虚函数表
2. 析构函数指针被放入vTable
3. 对象内存布局包含`__vptr`指向vTable
这样,在我们delete obj的时候,会通过obj-\>_vptr找到虚表,随后获取子类的析构函数地址并调用,最后调用基类的析构函数
*** ** * ** ***
回到正题 ,我们在实现虚接口函数时还应该给该函数后面置为=0,以表示声明函数为**抽象接口**,强制派生类必须实现。
所以我们的基类实现就应该是:
```cpp
class LogStrategy
{
public:
virtual ~LogStrategy() = default; // 进行虚析构
virtual void Synclog(const std::string &message) = 0; // 定义一个统一的调用接口以便后续子类重新定义实现多态
};
class ConsoleLogStrategy : public LogStrategy//控制台策略
{
};
class FileLogStrategy : public LogStrategy//文件(硬盘级)策略
{
};
```
### 4、控制台策略
控制台策略就是当我们使用这个策略时,会把信息打印到控制台而并非存储到文件中。
所以这个策略我们不需要多么复杂。
```cpp
class ConsoleLogStrategy : public LogStrategy // 控制台策略
{
public:
ConsoleLogStrategy()
{
}
~ConsoleLogStrategy()
{
}
void Synclog(const std::string &message) // 打印我们的信息到控制台
{
std::cout << message << std::endl;
}
};
```
这里我们需要注意,我们说过打印控制台是一个非原子性的操作,所以为了在多线程下正确运行,我们应该在打印中加入我们的互斥锁(所以我这里加入我们之前实现的mutex.hpp)
```cpp
using namespace MutexModule;
class ConsoleLogStrategy : public LogStrategy // 控制台策略
{
ConsoleLogStrategy()
{
}
~ConsoleLogStrategy()
{
}
void Synclog(const std::string &message) // 打印我们的信息到控制台
{
LockGuard lockguard(_lock); // 防止多个进程打印乱码
std::cout << message << std::endl;
}
private:
Mutex _lock; // 互斥量锁
};
```
### 5、文件级(磁盘)策略
接下里我们实现的这个策略就比较复杂了,涉及了多个内容。
首先明确一下这个策略的目的就是把日志内容记录到我们的日志文件中。
在这个策略中,我们就需要用到日志文件的名字与路径,所以我们需要这两个变量。同样的,由于不是原子性操作,所以我们肯定会用到锁,所以也可以定义一把锁。
另外,我们在写入日志文件的时候,日志文件是自动创建的,所以我们可以在调用这个策略时自动先建立一个目录结构与文件结构。
如何自动调用呢?我们可以把这个功能是现在构造函数中,另外,给大家介绍一个接口create_directories,这个接口定义在C++17中的#include \头文件中。作用是尝试递归创建目录。
为了防止多次调用这个接口,我们在调用它之前需要先进行判断该路径是否真实存在,这个就需要用到另外一个接口:exists,这个同样是在#include \头文件中。
另外,由于目录创建可能因权限不足/磁盘满/路径非法等失败,所以为了处理这个错误,我们需要捕捉异常,这个错误通常是在create_directories中出现,其对应的异常类型为:std::filesystem::filesystem_error。
那我们的文件级策略的构造函数就可以这样写:
```cpp
FileLogStrategy(const std::string &logpath=defaultlogpath, const std::string &logname=defaultlogname)
:_logpath(logpath),
_logname(logname)
{
if(std::filesystem::exists(_logpath))
{
return;//存在我们就直接返回防止重复创建路径
}
else
{
LockGuard LockGuard(&_mutex);
try
{
std::filesystem::create_directory(_logpath);
}
catch(const std::filesystem::filesystem_error& e)
{
std::cerr << "创建日志目录失败: " << e.what() << std::endl;
}
}
}
```
随后就是我们的统一接口SyncLog的重新定义:
我们需要把我们的日志消息输出到日志文件中,这里需要注意,我们此时还没用完全整得一个路径,所以我们需要把path与name合并,这样我们才能打开这个文件。
打开文件流我们这里使用C++的方式而不再使用File\*,也就是使用文件输入输出流,这里我们是输出流,所以使用ofstream,并且日志文件要以追加的方式进行写入。
并且要记得判断文件流打开成功与否:
```cpp
class FileLogStrategy : public LogStrategy // 文件(硬盘级)策略
{
public:
FileLogStrategy(const std::string &logpath=defaultlogpath, const std::string &logname=defaultlogname)
:_logpath(logpath),
_logname(logname)
{
if(std::filesystem::exists(_logpath))
{
return;//存在我们就直接返回防止重复创建路径
}
else
{
LockGuard LockGuard(_mutex);
try
{
std::filesystem::create_directory(_logpath);
}
catch(const std::filesystem::filesystem_error& e)
{
std::cerr << "创建日志目录失败: " << e.what() << std::endl;
}
}
}
~FileLogStrategy()
{}
void Synclog(const std::string &message)
{
LockGuard LockGuard(_mutex);
std::string log = _logpath+_logname;//合并文件路径与文件名
std::ofstream out(log,std::ios::app);//追加写入使用app
if(!out.is_open())
{
return;//如果打开失败
}
out< _strategy;
在构造函数中,我们默认策略方式为控制台策略,我们可以写两个接口来替换智能指针的对象来实现策略方式的切换:
```cpp
class Log
{
public:
Log()
{
// 默认采用ConsoleLogStrategy策略
_strategy = std::make_shared();
}
void EnableConsoleLog()
{
_strategy = std::make_shared();
}
void EnableFileLog()
{
_strategy = std::make_shared();
}
~Log() {}
private:
std::shared_ptr _strategy;
};
```
### 7、嵌套类LogMessage实现
大家有没有发现我们是不是缺少了什么关键的东西,诶你这日志类怎么调用策略呢?我打印的message从哪里来呢?
这里就要引出一个关键的东西了,这也是我们想带给大家看见的一种设计思路------我们需要在log类中设计出一个新的嵌套类!!
为什么要嵌套呢?嵌套类天生具有访问父类`private`成员的权限,也就是说他可以访问到我们的智能指针,随后调用其策略。
我们可以在嵌套类的构造中完成对完整的打印信息字符串string message的创建。
嵌套类中会包含着,时间,等级这些信息,以及会有一个完整的打印的字符串讲这些细碎的信息汇总。
也就是说此时我们要有一个logmessage日志类,其的成员函数至少包含:
```cpp
class logmessage
{
public:
Log();
~Log();
private:
std::string _time;//时间
LogLevel _level;//日志等级
std::string _filename; // 源文件名称
pid_t pid;//进程pid
int _line;//行号
std::string _loginfo;//一条完整的日志记录
};
```
此时我们就需要对其的成员变量进行初始化,获取当前时间,获取pid,获取日志等级,这个日志等级一定是从外界传出来的等级。
我们如何获取一个时间?
我们以前知道可以通过time函数获取时间戳,那么这个时间戳能不能变成一个比较直观的时间信息呢?
是可以的,我们可以通过localtime_r函数(这里不使用localtime是因为该函数不支持多线程的调用,可能会崩溃,即不可重入函数,我们这里获取时间应该是在构造函数里获取随后赋给_time,所以每一个线程都会调用,需要注意线程安全),传入时间戳从而获得一个结构体,这个结构体如下:
```cpp
struct tm {
int tm_sec; // 秒 [0, 60](考虑闰秒)
int tm_min; // 分钟 [0, 59]
int tm_hour; // 小时 [0, 23]
int tm_mday; // 月中的天数 [1, 31]
int tm_mon; // 月份 [0, 11](0=一月)
int tm_year; // 年份(实际年份 - 1900)
int tm_wday; // 星期 [0, 6](0=周日)
int tm_yday; // 年中的天数 [0, 365]
int tm_isdst; // 夏令时标志(>0:启用, 0:禁用, <0:未知)
};
```
所以我们可以在外部(命名空间内)定义一个函数来实现获取当前时间的功能:
```cpp
std::string GetCurrentTime()
{
time_t t = ::time(nullptr);
struct tm ttm;
::localtime_r(&t, &ttm); // ttm是一个输出型参数
// 定义缓冲区将数据格式化
char buffer[1024];
snprintf(buffer, sizeof(buffer), "%4d-%02d-%02d %02d:%02d:%02d",
ttm.tm_year + 1900,//这里注意给的年份是减去了1900的所以真实年要加回来,月份也是如此
ttm.tm_mon + 1,
ttm.tm_mday,
ttm.tm_hour,
ttm.tm_min,
ttm.tm_sec);
return buffer;
}
```
跟之前一样的原因,我们需要在构造函数内部合并完整的info,这里我们使用C++的stringstream来快速合并,但是在合并的时候要注意,我们的日志等级如果直接使用stringstream的\<\<功能合并,结果会是1,2,3而不是我们想要的日志名字debug,所以我们需要写一个接口实现其对于string类型的转化:
```cpp
std::string GetCurrentTime()
{
time_t t = ::time(nullptr);
struct tm ttm;
::localtime_r(&t, &ttm); // ttm是一个输出型参数
// 定义缓冲区将数据格式化
char buffer[1024];
snprintf(buffer, sizeof(buffer), "%4d-%02d-%02d %02d:%02d:%02d",
ttm.tm_year + 1900, // 这里注意给的年份是减去了1900的所以真实年要加回来,月份也是如此
ttm.tm_mon + 1,
ttm.tm_mday,
ttm.tm_hour,
ttm.tm_min,
ttm.tm_sec);
return buffer;
}
std::string Level_to_string(LogLevel level)
{
switch (level)
{
case LogLevel::DEBUG:
return "DEBUG";
case LogLevel::INFO:
return "INFO";
case LogLevel::WARN:
return "WARN";
case LogLevel::ERROR:
return "ERROR";
case LogLevel::FATAL:
return "FATAL";
default:
return "NONE";
}
}
class Log
{
public:
Log()
{
// 默认采用ConsoleLogStrategy策略
_strategy = std::make_shared();
}
void EnableConsoleLog()
{
LockGuard lockguard(_mutex);
_strategy = std::make_shared();
}
void EnableFileLog()
{
LockGuard lockguard(_mutex);
_strategy = std::make_shared();
}
~Log() {}
class logmessage
{
public:
logmessage(LogLevel level, const std::string &filename, const int &line)
: _level(level),
_line(line),
_time(GetCurrentTime()),
pid(getpid()),
_filename(filename)
{
std::stringstream ssbuffer;
ssbuffer << "[" << _time << "] "
<< "[" << Level_to_string(_level) << "] "
<< "[" << pid << "] "
<< "[" << _filename << "] "
<< "[" << _line << "] - ";
_loginfo = ssbuffer.str();
}
private:
std::string _time; // 时间
LogLevel _level; // 日志等级
pid_t pid; // 进程pid
std::string _filename; // 源文件名称
int _line; // 行号
std::string _loginfo; // 一条完整的日志记录
};
private:
std::shared_ptr _strategy;
Mutex _mutex;
};
```
那我们这个嵌套内设计出来有什么用呢?
或者说该怎么使用?
大家不要慌,先看我完整的写一下这个内容
### 8、宏的巧妙使用与运算符重载
我们先对log类进行()的运算符重载:
将他的返回值设定为嵌套类logmessage的变量:
```cpp
logmessage operator()(LogLevel level, const std::string &filename, int line)
{
return logmessage(level, filename, line);
}
```
这样我们可以通过log的()创建一个logmessage类型的临时变量,
此时我们再定义一个宏与Log类的全局变量(在命名空间中):
```cpp
Log log;
#define LOG(Level) log(Level, __FILE__, __LINE__)
```
这个 __FILE__, __LINE__也是一个宏,会在编译时替换为当前运行的文件名字与行号
也就是说我们在使用时可以使用LOG(Debug),只需要手动传递一个日志等级,我们就可以通过Log类型所重载的()函数来创建一个logmessage的临时变量。
有人还看不懂,这很正常,但接下来请大家注意,最神奇的来了。
我们在logmessage中再次重载\<\<运算符!!
```cpp
template
logmessage &operator<<(const T &message)
{
std::stringstream ss;
ss << message;
_loginfo += ss.str();
return *this;
}
```
这里为什么使用模板等会解释,我们还需要完成接下来这一步:
在logmessage的变量中新增一个Log类的引用的变量(因为你是嵌套类!!!你可以访问主类Log,那么此时你的构造函数所需传入的参数中必须新增一个主类的对象,并由此给你的Log类的引用的变量进行初始化,构造函数变量。并且我们在外面的重载的()函数构造时,也需要多传递\*this指针)(详情看后面代码)
随后特别写出logmessage的析构函数(注意我们之前LOG宏会创建出一个临时变量,而临时变量是有生命周期的!!!):
```cpp
class Log
{
public:
....省略
class logmessage
{
public:
logmessage(LogLevel level, const std::string &filename, const int &line, Log &log)//这里新增了一个参数
: _level(level),
_line(line),
_time(GetCurrentTime()),
pid(getpid()),
_filename(filename),
_log(log)//负责给你的log进行初始化,方便析构根据这个调用
{
.....省略
}
template
logmessage &operator<<(const T &message)
{
std::stringstream ss;
ss << message;
_loginfo += ss.str();
return *this;
}
~logmessage()
{
if (_log._strategy) // 如果该智能指针存在
{
_log._strategy->Synclog(_loginfo); // 调用该函数指针所指向策略的Synclog函数,实现多态
}
}
private:
std::string _time; // 时间
LogLevel _level; // 日志等级
pid_t pid; // 进程pid
std::string _filename; // 源文件名称
int _line; // 行号
Log &_log; // 引用外部的Log类,负责根据不同的策略进行刷新
std::string _loginfo; // 一条完整的日志记录
};
logmessage operator()(LogLevel level, const std::string &filename, int line)
{
return logmessage(level, filename, line, *this);//新增加入*this指针,因为该Log的*this就代表该类型
}
private:
std::shared_ptr _strategy;
Mutex _mutex;
};
```
此时同学们你们想到了什么?
结合我之前提醒的LOG宏会调用重载的()创建一个临时的logmessage对象?
我这个临时的logmessage对象的内部可是重载的\<\<的,而且为了接受任意类型参数,还特地使用了模板。
大家请看,此时我们在主函数中只需要:

记住此时的行号与文件名

如何呢?
让我为大家详细解释一下这个原因:
首先我们调用的是LOG(LogLevel::DEBUG),此时这个宏会在编译时替换为
```cpp
log(Level, __FILE__, __LINE__)
```
log是我们定义在命名空间内的全局变量,他的类型是Log,我们为Log重载了()运算符,那么log(Level, __FILE__, __LINE__)就会调用该重载函数,执行
```cpp
return logmessage(level, filename, line, *this);
```
返回一个logmessage的临时变量,此时相当于就变成了:
```cpp
logmessage(level, filename, line, *this) << "hello world";
```
我们在类logmessage中又重载了\<\<运算法,此时就会不断的把我们后面的\<\<加到loginfo中
最后执行完这个代码了。临时变量应该销毁了,此时我们的logmessage的析构函数是什么内容?
```cpp
~logmessage()
{
if (_log._strategy) // 如果该智能指针存在
{
_log._strategy->Synclog(_loginfo); // 调用该函数指针所指向策略的Synclog函数,实现多态
}
}
```
没错,析构时会自动调用对应策略,执行写入数据的操作!!!!
*** ** * ** ***
由于我们此时是默认的控制台策略,我们同样可以定义宏来改变策略
```cpp
Log log;
#define LOG(Level) log(Level, __FILE__, __LINE__)
#define ENABLE_CONSOLE_LOG() log.EnableConsoleLog()
#define ENABLE_FILE_LOG() log.EnableFileLog()
```
随后主函数改成:
```cpp
#include "log.hpp"
using namespace LogModule;
int main()
{
ENABLE_FILE_LOG();
LOG(LogLevel::DEBUG) << "hello file";
LOG(LogLevel::DEBUG) << "hello file";
LOG(LogLevel::DEBUG) << "hello file";
LOG(LogLevel::DEBUG) << "hello file";
ENABLE_CONSOLE_LOG();
LOG(LogLevel::DEBUG) << "hello world";
LOG(LogLevel::DEBUG) << "hello world";
LOG(LogLevel::DEBUG) << "hello world";
LOG(LogLevel::DEBUG) << "hello world";
return 0;
}
```
执行:

两种策略都成功执行!!
我们的日志就大功告成了。
在使用时根据不同的项目,功能,情况,传递不同的日志情况就可以了!!
*** ** * ** ***
## 总结:
日志的掌握十分有必要,本文为大家带来了日志实现的新思路与策略模式的讲解,希望对大家有所帮助!