C++实现分布式网络通信框架RPC(4)——日志模块

发布于:2025-06-11 ⋅ 阅读:(35) ⋅ 点赞:(0)

目录

一、前言

二、为什么使用日志系统

三、日志系统的实现


一、前言

前面的文章都将mprpc框架的基本功能完成了,接下来该给框架添加日志模块了。

二、为什么使用日志系统

        在框架或者说软件运行的过程中会出现很多的正常的输出信息和错误信息,我们不可能将它全部打印在显示器上,运行时间长了,屏幕上输出的信息特别多,万一有什么问题我们也不好定位,出了问题一般看日志。当rpc请求过来的时候,在运行的时候就会产生信息,这时候需要写日志信息,但是写日志信息本质上是一个磁盘I/O,它是很慢的,如果要把写日志信息这一部分的开销也算在rpc业务请求执行的这个过程中,这样rpc执行请求业务的处理效率实在是太慢了。如图所示

所以我们考虑引入一个缓冲区队列,先将日志写入一个队列里,就相当于是一个异步的写入机制,如下

三、日志系统的实现

这样前边的写日志信息就是一个内存操作是非常快的,而后面的磁盘 I/O操作就专门交给一个线程去做,这样这个磁盘的I/O操作就不会把它的消耗花费在我们的rpc请求的业务处理过程中。

但是还是要注意的是我们的rpcProvider端是采用muduo库设计的,使用的是 epoll+多线程的模式,这样就会存在很多的rpc请求同时到达的情况,就会有多个线程同时向队列中写日志的情况出现,所以我们还需要注意线程安全。

而且考虑到队列为空的话,磁盘 I/O的线程就不用抢锁了,抢了也没法写入,且如果拿到了的话,前边往队列里写日志信息的线程就拿不到了,这就拖慢了日志信息的写入,就会导致rpc业务处理变慢。所以这里我们需要使用到生产者消费者模型。

且我们规定将日志信息写在当前目录下的bin文件,日志文件的名字格式为 如2025-4-5-log.txt

下面就来实现,对于日志我们也是使用单例模式(虽然影响不是很大)

//logger.h
enum LogLevel
{
    INFO,//普通信息
    ERROR,//错误信息
};


class Logger
{
public:
    static Logger& GetInstance();
    void SetLogLevel(LogLevel);//设置日志的级别
    void Log(std::string msg);//设置日志的信息
private:
   
   int m_loglevel;//日志的级别
   LockQueue<std::string> m_lockQueue;//日志缓冲队列

   Logger();
   Logger(const Logger&)=delete;
   Logger(Logger&&)=delete;

};

//lockqueue.h
// 异步写日志的日志队列
// 需要涉及到线程安全
#pragma once
#include <queue>
#include <thread>
#include <mutex>
#include <condition_variable>
//模板代码的实现只能写在头文件当中
template <typename T>
class LockQueue
{
public:
    //对于push,多个工作线程都会调用写日志
    void push(const T &data)
    {
        std::lock_guard<std::mutex> lock(m_mutex);//使用类似只能指针的锁,自动的加锁和释放锁,加完东西后出函数后就自动释放锁
        m_queue.push(data);
        m_condvariable.notify_one();//写入数据后唤醒磁盘i\o线程
    }
    //对于pop只有一个线程从队列读日志文件,并且向磁盘中写入日志文件
    T pop()
    {
        std::unique_lock<std::mutex> lock(m_mutex);//条件变量需要的
        while(m_queue.empty())//使用while防止线程的虚假唤醒
        {
            //如果日志队列为空,那么就不让它去抢锁的资源了,因为抢到了也没用,直接让它等待就好了,并且释放锁
            m_condvariable.wait(lock);
        }
        T data=m_queue.front();
        m_queue.pop();
        return data;
    }

private:
    std::queue<T> m_queue;
    std::mutex m_mutex;
    std::condition_variable m_condvariable;
};

实现

//logger.cc
Logger& Logger::GetInstance()
 {
    static Logger m_logger;
    return m_logger;
 }
 //静态函数在类外定义完需要初始化
 Logger::Logger()
 {
    //启动专门的写日志线程
    std::thread writeLogTask([&](){
        for(;;)
        {
            //获取当前的日期,接着取日志信息,写入相应的日志文件中,对于文件不存在则创建,有的话则追加 a+
            time_t now =time(nullptr);//获取的是从1970年到现在的秒数
            tm* nowTm=localtime(&now);//获取当前的时间

            char file_name[128];
            sprintf(file_name,"%d-%d-%d-log.txt",nowTm->tm_year+1900,nowTm->tm_mon+1,nowTm->tm_mday);//将格式化的字符串写入缓冲区file_name中
            FILE* pf=fopen(file_name,"a+");
            if(nullptr==pf)
            {
                std::cout<<"logger file::"<<file_name<<" open error"<<std::endl;
                exit(EXIT_FAILURE);
            }

            //此时打开文件就往里面写入就可以
            std::string msg=m_lockQueue.pop();

            char time_buf[128]={0};
            sprintf(time_buf,"%d:%d:%d => [%s]",
                    nowTm->tm_hour,
                    nowTm->tm_min,
                    nowTm->tm_sec,
                    (m_loglevel ==  INFO ? "info":"error"));
            msg.insert(0,time_buf);
            msg.append("\n");

            fputs(msg.c_str(),pf);
            fclose(pf);
        }

    });
    //设置分离线程,相当于守护线程
    writeLogTask.detach();
 }

void Logger::SetLogLevel(LogLevel level) // 设置日志的级别
{
    m_loglevel=level;
}
void Logger::Log(std::string msg) // 设置日志的信息
{
    m_lockQueue.push(msg);
}

std::thread 的构造函数就是这么设计的,它接受一个可调用对象(Callable Object) 作为参数,并在线程启动后调用这个对象。而 Lambda 表达式本质上就是一个匿名的可调用对象,构造函数大致如下

template <class Function, class... Args>
explicit thread(Function&& f, Args&&... args);

Lambda 表达式在 C++ 中会被编译器自动转换为一个匿名类的对象,这个类重载了 operator(),因此它是一个 Functor(仿函数)

auto func = []() { std::cout << "Hello from lambda\n"; }; 
func(); // 实际上调用了 operator()

在 C++ 中,当在一个类成员函数中使用 Lambda 表达式并用 [&] 捕获变量时,这意味着我们希望以引用的方式捕获所有外部作用域中的变量。具体到类成员函数中,这包括但不限于:

  1. 局部变量:函数内部定义的变量。
  2. 类成员变量:通过 this 指针间接访问的类的数据成员。
  3. 外部作用域中的变量:如果该成员函数被调用时所在的上下文中有其他外部变量(例如全局变量或上层作用域中的局部变量),它们也会被引用捕获。

在上面的Lambda 使用了 [&] 来捕获变量。那么它实际上捕获了哪些变量呢?

  1. m_lockQueuem_loglevel:这两个是类的成员变量。由于 Lambda 是在成员函数内定义的,所以默认情况下可以通过 this 指针访问这些成员变量。使用 [&] 就意味着 this 指针本身也被按引用捕获了,因此可以访问和修改这些成员变量。

  2. 局部变量:在上面的例子中没有使用任何局部变量。如果有定义局部变量,并且在 Lambda 内部使用了它们,那么这些局部变量也会被按引用捕获。

  3. 外部作用域中的变量:如果在定义 Lambda 的函数之外还存在其他变量,并且在 Lambda 内部使用了这些变量,那么这些变量也会被按引用捕获。但是,在上述例子中并没有这种情况。

 最后,我们的日志模块大致也完成了,但是考虑在用户使用日志的时候,不需要让用户自己去获取Logger的实例,自己用实例去写日志,这样太过于麻烦,所以我们可以定义宏,以可变参数的方式为用户提供更方便更快捷的日志写入方式

且支持可变参数的话,用户在写日志的时候可以使用自己的风格 logmsgformat,比如我们接下来就使用如下风格的日志:   LOG_INFO("xxx %d %s",2000,"xxx")

#define LOG_INFO(logmsgformat, ...) \
do \
{  \
    Logger& logger=Logger::GetInstance(); \
    logger.SetLogLevel(INFO); \
    char c[1024]={0}; \
    snprintf(c,1024,logmsgformat, ##__VA_ARGS__); \
    logger.Log(c);\
}while(0);

#define LOG_ERR(logmsgformat, ...) \
do \
{  \
    Logger& logger=Logger::GetInstance(); \
    logger.SetLogLevel(ERROR); \
    char c[1024]={0}; \
    snprintf(c,1024,logmsgformat, ##__VA_ARGS__); \
    logger.Log(c);\
}while(0);
  • 使用do while结构来封装多语句宏定义,避免宏展开时可能引起的语法错误或逻辑错误。
  • ##__VA_ARGS__ :可变参的参数列表
  • 在 C/C++ 中,宏定义默认是单行的。也就是说,预处理器会把 #define 后面的内容当作一行处理。但为了代码可读性,我们通常希望将复杂的宏分成多行写。这时就需要使用 \ 来告诉预处理器:

这两个宏的作用是:

  1. 获取全局唯一的 Logger 单例;
  2. 设置当前日志级别(INFO / ERROR);
  3. 格式化传入的日志内容(支持变参);
  4. 调用 Log() 方法将日志写入线程安全队列,供后台线程异步落盘; 

这样,我们就能使用日志将我们之前使用标准输出打印的错误全都用日志来实现,且最后也会将日志文件保存在我们当前目录下。如图


这就是框架的日志模块的全部内容了,感谢阅读! 


网站公告

今日签到

点亮在社区的每一天
去签到