Linux线程——基于封装组件实现策略模式日志

文章目录

基于封装组件实现策略模式日志

在之前的学习中,我们做了若干工作:

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(&current_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内。

MessageFormatLog的友元!可以直接访问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;
}