文章目录
基于封装组件实现策略模式日志
在之前的学习中,我们做了若干工作:
1.学习线程相关概念
2.学习线程的控制,封装线程库
3.学习线程同步互斥、封装互斥量、信号量、条件变量
4.验证相关结论
但是,有一个问题我们是自始至终都没有解决的:
即多个线程向显示器打印的时候,打印的信息会混乱!而且看着非常没有头绪!
本质的原因是:
显示器也是一个文件,多个线程访问的时候,显示器文件也是共享资源!如果不加锁就会出现线程数据不一致的问题!所以,我们需要对信息输出做一个处理!
日志的基本概念
日志认识
计算机中的日志记录系统和软件运行中发生事件的文件,主要作用是监控运行状态、记录异常信息,帮助快速定位问题并支持程序员进行问题修复。它是系统维护、故障排查和安全管理的重要工具。
就好比我们之前在封装一些接口的时候,或者创建线程进行调用的时候,我们针对于一些可能会出错的地方、又或是一些地方会输出一些结果,我们都会做信息的输出,打印在stdout上。
有了这些信息,当发生一些错误时,我们就可以先查看一下输出的日志,看看是不是这些地方出问题了。有时候,这可以提高效率。特别是在项目的开发中!
日志有现成的解决方案,如:spdlog、glog、Boost.Log、Log4cxx
等等,我们依旧采用自定义日志的方式。主要是为了理解日志解决方案的原理!
策略模式设计
日志的需求
首先,我们需要明确,我们输出的日志,需要满足哪些条件:
bash
[可读性很好的时间] [日志等级] [进程pid] [打印对应日志的文件名][行号] - 消息内容,支持可
变参数(即可以连续追加多条信息)
[2024-08-04 12:27:03] [DEBUG] [202938] [main.cc] [16] - hello world
[2024-08-04 12:27:03] [DEBUG] [202938] [main.cc] [17] - hello world
[2024-08-04 12:27:03] [DEBUG] [202938] [main.cc] [18] - hello world
[2024-08-04 12:27:03] [DEBUG] [202938] [main.cc] [20] - hello world
[2024-08-04 12:27:03] [DEBUG] [202938] [main.cc] [21] - hello world
[2024-08-04 12:27:03] [WARNING] [202938] [main.cc] [23] - hello world
往后,我们需要打印信息的时候,都需要满足这样子的形式。
实现前的分析
首先我们来看看日志相关的信息获得方式:
bash
//时间通过时间戳可以转化
//日志等级:
/* DEBUG
INFO
WARNING
ERROR
FATAL */
//这里的日志等级看个人的实现,但是这里是最通用的:
//DEBUG代表这就是为了调试用的;INFO代表正常信息输出;WARNING代表告警
//ERROR代表会结果出错;FATAL代表致命错误,即可能导致线程/进程崩溃
//进程pid可以直接系统调用getpid()
//文件名和行号可以用预处理 : __FILE__ __LINE__就可以获得
然后,我们需要明白此次我们需要做的事情:
日志功能:
1.形成指定形式的日志消息
2.通过策略模式,选择输出到的地方
针对于第二点,有时候日志并不是要输出到显示屏上的!也可能需要输出到指定文件。那输出到哪里有谁来决定?当然是用户层!所以,我们这里基于用户选择策略,使用策略模式,让用户可以自主选择日志输出的位置!
日志解决方案实现------myLog
源码
我们先来直接看源码,这样子方便后续的讲解。
Log.hpp
cpp
#pragma once
// 实现日志!!!
#include <iostream>
#include <string>
#include "Mutex.hpp"
#include <filesystem> //c++17才能用
#include <fstream>
#include <memory>
#include <sstream>
#include <unordered_map>
#include <ctime>
#include <cstdio>
namespace myLog{
using namespace myMutex;
// 日志的默认换行符
const std::string sep = "\r\n";
// 日志等级
enum class LogLevel{
DEBUG,
INFO,
WARNING,
ERROR,
FATAL
//使用的时候需要自带前缀 LogLevel::
};
// 日志类型转字符串
std::unordered_map<LogLevel, std::string> LogLevel2Str = {
{LogLevel::DEBUG, "DEBUG"},
{LogLevel::INFO, "INFO"},
{LogLevel::WARNING, "WARNING"},
{LogLevel::ERROR, "ERROR"},
{LogLevel::FATAL, "FATAL"}
};
// 根据时间戳获得时间
std::string GetCurrTime(){
//使用localtime_r函数即可 这个函数是可以被重入的,即线程安全
//struct tm *localtime_r(const time_t *timep, struct tm *result);
//struct tm {
// int tm_sec; /* Seconds (0-60) */
// int tm_min; /* Minutes (0-59) */
// int tm_hour; /* Hours (0-23) */
// int tm_mday; /* Day of the month (1-31) */
// int tm_mon; /* Month (0-11) */
// int tm_year; /* Year - 1900 */
// int tm_wday; /* Day of the week (0-6, Sunday = 0) */
// int tm_yday; /* Day in the year (0-365, 1 Jan = 0) */
// int tm_isdst; /* Daylight saving time */
//};
//先获得时间戳
time_t current_time = time(nullptr);
struct tm curr;
localtime_r(¤t_time, &curr);
//这里的格式化想要达到效果使用c++是很困难的,直接用snprintf即可
char time_buffer[128] = {0};
// 在占位符前面加数字代表需要多少个位置标识,加0代表用0替代空位(如果有)
snprintf(time_buffer, sizeof(time_buffer), "%04d_%02d_%02d %02d:%02d:%02d",
curr.tm_year + 1900, curr.tm_mon + 1, curr.tm_mday,
curr.tm_hour, curr.tm_min, curr.tm_sec);
return time_buffer;
}
// 先来实现策略模式 -> 即实现不同的策略来进行指定位置的输出
// 采用的方法是 : 继承 + 多态
// 日志输出策略的类,这个让它变成抽象类(有纯虚函数),只定义方法
class LogStrategy{
public:
LogStrategy() = default;
~LogStrategy() = default;
virtual void SyncLog(const std::string& message) = 0;
// 不同模式核⼼是刷新方式的不同
};
// 输出到显示器上
class DisplayStrategy : public LogStrategy{
public:
DisplayStrategy() = default;
~DisplayStrategy() = default;
virtual void SyncLog(const std::string& message){
Lock_Guard guard(_display_lock);
std::cout << message << sep;
}
private:
Mutex _display_lock;
};
// 输出到对应的文件上
// 这里会有一个问题,就是文件需要[路径 + 文件名]
// 所以,这里搞个默认的文件路径和文件名
const std::string _file_path = "./LOG";
const std::string _file_name = "my.log";
class FileStrategy : public LogStrategy{
public:
FileStrategy(const std::string& path = _file_path, const std::string name = _file_name)
:_path(path), _name(name)
{
// 这里有一个问题,就是文件的目录 文件不存在
// 文件不存在不用怕,可以通过选项控制,不存在会自动创建
// 但是,目录需要我们检查一下是否有打开
// 防止同时创建
Lock_Guard guard(_file_lock);
if(std::filesystem::exists(_path)){
//为真 -> 目录即存在
return;
}
try{
std::filesystem::create_directories(_path);
}
catch(std::filesystem::filesystem_error& error){
std::cout << error.what() << std::endl; //这个就不打进日志里面去了
}
}
virtual void SyncLog(const std::string& message){
Lock_Guard guard(_file_lock);
// 这里需要注意的是,文件路径 + "/" + 文件名
// 但不确定中间的"/"是否存在,所以需要处理一下
std::string file_allname = _path
+ ((_path.back() == '/' ) ? "" : "/")
+ _name;
// 相当于打开file_allname文件,app选项是追加打开
std::ofstream out(file_allname, std::ios::app);
if(!out.is_open()) return;
// 这里就可以把内容输出到指定的文件流
// 可以使用out.write -> 但是比较麻烦
// 直接使用 << 流插入运算符的重载即可!
out << message << sep;
out.close();
}
~FileStrategy(){}
private:
std::string _path;
std::string _name;
Mutex _file_lock;
};
// 现在开始创建一个类Log,这个是用来选择日志的输出策略的
class Log{
public:
Log(){
// 默认打印到显示器
DisplayEnable();
}
~Log(){}
// 选择策略 -> 提供给用户层选择
void DisplayEnable(){
_fflush = std::make_unique<DisplayStrategy>();
}
// 支持用户自行传递参数,当然,也可以不传,使用默认参数
void FileEnable(const std::string& path = _file_path, const std::string name = _file_name){
_fflush = std::make_unique<FileStrategy>(path, name);
}
// 定义一个内部类,用于处理信息 (为什么要用内部类下面说)
class MessageFormat{
public:
// 把只能从外界传的内容写在构造内
MessageFormat(LogLevel log_level, std::string filename, int line, Log& Logger)
:_current_time(GetCurrTime()), //把时间戳转化为字符串显示的时间,等下完善
_Log_level(log_level),
_pid(getpid()),
_filename(filename),
_line(line),
_logger(Logger)
{
//这里先把日志的左半部分给完成 使用string stream来进行格式化
std::stringstream ss;
// 作用就是把对应的内容以string的形式格式化
ss << "[" << _current_time << "] "
<< "[" << LogLevel2Str[_Log_level] << "] "
<< "[" << _pid << "] "
<< "[" << _filename << "] "
<< "[" << _line << "] "
<< "- " ;
_message = ss.str();
// 但是,这里的loglevel本质上就是一个自定义类型,stringstream的流插入运算符肯定是没有重载的
// 本质是整数,但是整数不够直观,所以需要有一个日志类型 -> 转化到对应字符串的方式
// 这里直接用哈希表了
}
// 现在还需要处理日志的左半部分 如果是用c语言来写,就可使用可变参数
// 但是,我们更希望的是,使用c++的流插入运算符
// 这里需要使用一下模板参数,因为不知道传进来的是啥类型,如果是自定义类型(库内没重载的),需要手动改一下
template<class T>
MessageFormat& operator<<(const T& message){
std::stringstream ss;
ss << message;
_message += ss.str();
// 返回这个值是为了能够连续地使用后续的 << ,就像连续赋值一样,要不然没办法追加使用 <<
return *this;
}
~MessageFormat(){
if(_logger._fflush)
_logger._fflush->SyncLog(_message);
}
private:
std::string _current_time;
LogLevel _Log_level;
pid_t _pid;
std::string _filename;
int _line;
std::string _message; //要输出的信息
Log& _logger;
};
// Log类()的重载,返回值应该是什么呢?
// 这里需要知道,真正处理信息的是MessageFormat,但是做输出的是Log类
// 应该怎么样让这两个类进行联动呢?
//可以这样子:
//让MessageFormat析构的时候,自动地就把内容给刷新出去即可!
//刷新的就是使用那个全局的Log变量!
//所以,这里的逻辑是:
//外界选择策略,使用Log(LogLevel log_level, std::string filename, int line) << ...; 来进行输出日志
//Log()调用的返回值是一个MessageFormat临时对象!生命周期只有一行
//相当于是不断地向一个MessageFormat临时对象插入信息!
//直到 << 插入信息结束后,该对象就会被析构! -> 析构函数内直接刷新日志到指定位置
//这是RAII思想!
MessageFormat operator()(LogLevel log_level, std::string filename, int line){
// 故意返回临时对象 ,就是为了达到上面的效果
return MessageFormat(log_level, filename, line, *this);
}
private:
// 底层是一个基类指针,上层控制一下指向哪一个基类,就可以输出到哪个地方
std::unique_ptr<LogStrategy> _fflush;
};
//现在有一个问题,日志应该怎么使用?
//真正给外界使用的是Log类,但是信息是在MessageFormat类内被格式化
//应该怎么办呢?
//还需要注意的一个问题是,由于所有的线程在输出日志的时候,肯定要用的是同一把锁!
//直接定义一个全局的日志类 log logger!
//然后通过logger(LogLevel log_level, std::string filename, int line) << ...... << .. ; 使用
//所以,需要先给Log类实现()的重载
//Log全局对象 -> 给用户层使用的
Log logger;
// 为了方便用户层的使用,这里做一些宏替换
//使用宏,可以进行代码插⼊,方便随时获取文件名和⾏号
#define LOG(type) logger(LogLevel::DEBUG, __FILE__, __LINE__)
// 提供选择使用何种日志策略的方法
#define ENABLE_Display_LOG_STRATEGY() logger.DisplayEnable()
#define ENABLE_FILE_LOG_STRATEGY() logger.FileEnable()
}
// 实现的日志形式 : [时间] [日志等级] [发出日志进程的pid] [文件名] [行号] - 消息内容 支持可变参数
//时间通过时间戳可以转化
//日志等级:
/* DEBUG
INFO
WARNING
ERROR
FATAL */
//进程pid可以直接系统调用getpid
//文件名和行号可以用预处理 : __FILE__ __LINE__
//消息内容等到内部设计的时候再说
// 还有就是需要可以选择输出到哪个地方 : 文件 / 显示器
//日志功能:
//1.形成指定形式的日志消息
//2.通过策略模式,选择输出到的地方
myLog实现的讲解
这里,我们将根据上面展示的源码和相关的注释、原理进行讲解!
实现策略模式------继承多态实现
首先,我们需要实现一点,即如何让用户能够选择策略呢?
在用户能够选择之前,我们先需要提供这些策略。
这里我们明确一下本次实现的需求,我们本次只需要实现两个策略------显示器和文件。
当然,在未来,还有很多的策略可能需要选择,如输出到网络!
所以,对此我们采用继承多态的方式来实现是最合理的:
1.定义一个抽象基类,该基类只提供相关的接口定义。
2.其余的策略(如文件、显示器...)通过继承抽象基类,对响应方法进行重写
同时,我们引入自己封装的myMutex::Mutex
对输出的资源的保护!
这样子,就可以在多线程并发输出的情况下,保证了临界资源的安全!
具体的实现模块就是:
同时,为了实现的方便性,引入了c++17的filesystem库,还有使用c++文件库!
用户层使用的日志类------Log
但是,这只是策略。我们希望达到的效果是:
用户层自行选择,然后某个类就能自动的以该策略进行输出!所以,最好的办法是,拿着一个基类指针。选择哪个策略,就让基类指针指向哪个类!
所以,我们选择创建一个类class Log,到时候用户层使用的就是这个!
class Log内只有一个成员变量,即std::unique_ptr< LogStrategy > _fflush
,该基类指针指向子类进行使用!(类似于责任链模式下的操作)。
然后,为了能够选择策略,在class Log内实现两个接口:
DisplayEnable 显示器策略和FileEnable 文件策略,本质就是让class Log内的std::unique_ptr< LogStrategy > _fflush指向对应策略的派生类!
当然,class Log默认选择的是使用显示器策略!
处理信息类------MessageFormat
这个类是用来专门处理日志信息的!我们把它定义为Log的内部类。具体原因需要根据后序的设计方案和结果来进行讲解。这里先这样做!
首先,处理日志信息,需要包含日志的基本组成部分:
最后,我们需要把输出的日志放在这个_message内!然后选择策略输出。
其中,上面的组成部分中,有一些是需要用户层传进来的:
如日志等级,文件名,行号!这些没有办法通过什么系统调用来进行获取:
但是,这里需要说的是,获得的时间是时间戳,我们需要把自主把时间戳转化为时间字符串!
日志等级、文件名、行号就用用户层传进来的。进程的pid系统调用即可获得!
时间戳转化时间字符串的函数GetCurrTime()
:
这个就直接使用系统调用即可:
这里需要说明:
struct tm中的相关数据,计算出的确实是年月日等时间标识整数数据!但是,我们需要看相关注释:
算出来的年份是减掉1900,月份是0~11,秒是正常的,所以我们需要处理一下。
还有就是,因为使用c++的流插入符号是不好控制2025_02_03 01:16:19这样的时间的,因为有时候数据可能希望使用相同的位数(1 -> 01 12 -> 12),所以,使用c++的方式是不好控制的。所以,我们需要使用c库中的snprintf进行控制!
然后先将日志的左半部分添加到要输出的信息std::string _message上(构造函数中 ):
其中,日志等级:
然后再使用stringstream类对象来把所有的日志属性格式化。这里我们直接可以认为是,把数据格式化为字符串的形式即可!
但是,直接ss << LogLevel::DEBUG
是不行的!因为LogLevel是自定义类,stringstream中没有对自定义类的重载!而且,自定义类内的枚举本质是一个整数,直接打印整数效果不佳!所以,我们需要一个从日志等级 -> 对应字符串的转化方式!
转化方式有很多种,可以使用函数,但是这里我就直接使用哈希表了:
最后,只需要通过unordered_map实现的operator[]重载即可!
日志输出------Log和MessageFormat的联动
前面已经完成了基本的实现了------选择策略 + 日志信息左半部分!
但是,现在有一个问题,应该如何把日志右半边的信息也给带进来?
首先,我们要明确,日志信息我们打算怎么给!
因为日志的信息很可能是比较多变的,可能是打印整数、字符串、浮点数、地址...
所以,如果使用c语言来写,就需要使用可变参数!就如同printf那样!但是这样感觉很麻烦,我们还需要手动的控制输出的格式(调整占位符)。
我们更希望的是,拿着一个class Log对象,使用<<运算符,就可以把对应的信息插入,就像std::cout那样使用!
所以,我们需要实现一个operator <<(),即对kbd><<运算符重载!
现在问题来了,应该在哪里进行实现呢?因为我们要打印的信息是放在MessageFormat内。
MessageFormat是Log的友元!可以直接访问Log内的成员。但是友元这个性质不是互相的!也就是说,反过来是不行的!
前面我们为了让每个类的功能尽可能简单且解耦,所以选择让一个MessageFormat来处理信息!所以,这里就需要我们想办法解决上面的问题!
解决方法:
1.首先,信息既然存储在MessageFormat,那就让它进行重载!
一些细节就不说了,图中注释有!
2.外部用户使用的是Log类!故需要提供一个Log类内的方法来执行流插入的重载方法!
所以,在Log内提供一个仿函数调用,即对()进行重载!
框起来的部分先别管,我们等下会说。
我们让Log内的operator()函数的返回值为一个MessageFormat对象,就可以通过Log() << …;这样的方式来进行调用了。然后通过MessageFormat类对象来处理信息!
3.但是,这里有一个问题:如何刷新到对应的位置呢?这里只是能够让我们把日志的完整信息全部处理到MessageFormat类对象内的std::string _message内!
这里就需要明白一个问题,即所有的用户/线程,输出日志的时候,肯定是需要用到同一把锁的!要不然还是会出现线程安全的问题!
而我们这里通过让基类指针指向对应的策略,只需要用独一份的指针,就可以使用同一把锁!
而基类指针是放在Log类内,让用户自主选择策略后,指向对应的策略类后再进行刷新。
所以,如果要让所有的线程使用一把锁,就只能所有的线程只用一个Log类!
所以,这里我们定义一个全局的Log logger:
用户层中,只要是要打印日志,所有的线程都是用的这个类对象!
然后,所有的用户都是这样用的:
logger(若干参数) << … << …;,本质就是通过logger()
返回的MessageFormat在处理信息罢了,但是还没有把消息刷新出去!
我们希望的是,logger(若干参数) << … << …;这样后,就直接将日志输出!
所以,为了解决这个问题,我们可以用一下RAII思想:
我们知道logger()
的返回值是MessageFormat对象,但是如果我们在使用的时候,不进行接收,就是一个临时变量!临时变量的生命周期只有一行!
所以,我们可以让MessageFormat临时对象变量析构的时候(一行结束 ),就自动地把日志信息std::string _message输出到指定位置!
但是,输出信息是Log对象做的工作!着怎么办呢?
这很简单,直接让MessageFormat类内引用全局的Log logger,析构的时候自动使用引用进行对应的刷新即可:

所以,这就是为什么我们还需要给Log的()重载多传一个参数的原因!
代码测试样例
cpp
int main(){
logger(LogLevel::DEBUG, "main.cpp", 17) << "hello " << "3.14 " << " 17";
logger(LogLevel::DEBUG, "main.cpp", 18) << "hello " << "3.14 " << " 18";
logger(LogLevel::DEBUG, "main.cpp", 19) << "hello " << "3.14 " << " 19";
//主动设置文件输出策略
logger.FileEnable();
logger(LogLevel::DEBUG, "main.cpp", 22) << "hello" << "3.14" << " 20";
logger(LogLevel::DEBUG, "main.cpp", 23) << "hello" << "3.14" << " 21";
logger(LogLevel::DEBUG, "main.cpp", 24) << "hello" << "3.14" << " 22";
return 0;
}
输出结果:
1.显示器上输出:
2.文件输出:
最终,我们成功地实现了日志的输出!这里没有验证多线程下的问题!但是不用怕,这里加了锁了就是安全的!这里就不再展示了!
使用宏替换------方便用户使用
我们会发现,上面那样使用还是很麻烦。一方面是还要告诉用户使用全局Log对象使用,一仿麦呢还需要我们自己传递文件名和行号,这太麻烦了!
基于此,我们使用一下宏替换,让用户仅通过宏使用就方便多了:

这样子,用户就和Log logger这个全局对象隔离了!而且,c/c++预处理中,是有办法能够直接拿到文件名和行号的!即__FILE__
和__LINE__
。
所以,我们只需要通过宏替换,使用LOG(日志等级)
,就可以达到相同的效果!
cpp
int main(){
LOG(LogLevel::DEBUG) << "hello " << "3.14 " << " 34";
LOG(LogLevel::DEBUG) << "hello " << "3.14 " << " 35";
LOG(LogLevel::DEBUG) << "hello " << "3.14 " << " 36";
ENABLE_FILE_LOG_STRATEGY();
LOG(LogLevel::DEBUG) << "hello " << "3.14 " << " 39";
LOG(LogLevel::DEBUG) << "hello " << "3.14 " << " 40";
LOG(LogLevel::DEBUG) << "hello " << "3.14 " << " 41";
return 0;
}
