17.添加异步日志:日志消息的存储与输出机制

本节所讲的日志是诊断日志,即是文本的,供人阅读的日志。将代码运行时的重要信息进行保存,通常用来故障诊断和追踪,也可以用于性能分析。

举一个简单的例子:假如我们使用socket()函数的时候出了问题,那就会在日志中保存这个错误,这样就方便我们进行故障诊断和追踪。在服务器端,日志是很必要的。

日志又可以简单的分成两种,一种是同步日志,另一种是异步日志

同步日志即是要写一条日志的时候,需要把消息完全写出后(即是把消息写入到磁盘文件)才能执行后续的程序代码。而由于操控磁盘时间 会比操控cpu时间会慢很多很多,所以可见,这种方式的日志的问题就在于程序可能会阻塞在磁盘写入操作上

而异步日志的思路是需要写日志消息的时候只是将日志消息进行存储,当积累到一定量或者到达一定时间间隔时,由后台线程自动将存储的所有日志进行输出(写入到磁盘文件)

可见,对于异步日志来说,每次有日志消息产生的时候,只需要一个存储的行为即可,存储结束就可以继续执行后面的业务代码了,而真正写入到磁盘的操作,是由后台线程进行的,这样做的好处就是:前台线程不会阻塞在写日志上,后台线程真正写出日志时,日志消息往往已经积累了很多,此时只需要调用一次IO函数(如fwrite()),而不需要每条消息都调用一个IO函数,如此也提高了效率。

1.消息的存储

那按照我们对异步日志的分析,我们就需要先把日志存储在一个缓冲区buffer中,等积累到一定量或一定时间间隔后就把在缓冲区的消息一次性写入到磁盘文件中。

1.1 FixedBuffer类

这个不是第8节写的Buffer类,是不一样的。这个缓冲区不难,要说的也在代码注释中了。

// 缓冲区大小定义  
const int KSmallBuffer = 1024;          // 小缓冲区大小为1024字节  
const int KLargeBuffer = 1024 * 4000;   // 大缓冲区大小为4096000字节(即4000KB)  

// 缓冲区模板类,专用于存放日志数据  
// 模板参数 SIZE 指定了缓冲区的大小,大小为 SIZE 个 char 类型的数组  
template  
class FixedBuffer  
{  
public:  
    // 构造函数,初始化当前指针 cur_ 指向缓冲区的起始位置  
    FixedBuffer() : cur_(data_) {}  

    // 往缓冲区中添加数据  
    void Append(const char* buf, size_t len) {  
        // 检查可用空间是否足够存放新的数据  
        if (Available() > static_cast(len)) {  
            // 复制数据到缓冲区  
            memcpy(cur_, buf, len);  
            // 更新当前指针的位置  
            AppendComplete(len);  
        }  
    }  

    // 添加完数据后,更新 cur_ 指针的位置  
    void AppendComplete(size_t len) {   
        cur_ += len; // 将当前指针后移 len 个字节  
    }  

    // 清空数据,将缓冲区所有内容置为0  
    void Menset() {   
        memset(data_, 0, sizeof(data_)); // 对缓冲区进行清零  
    }  

    // 重置数据,将当前指针重置到缓冲区的起始位置  
    void Reset() {   
        cur_ = data_; // 将 cur_ 指针重置到缓冲区开头  
    }  

    // 获取当前缓冲区中已存放数据的长度  
    int Length() const {   
        return static_cast(cur_ - data_); // 计算当前指针与缓冲区起始位置的距离  
    }  

    // 获取缓冲区中的数据  
    const char* Data() const {   
        return data_; // 返回缓冲区数据的起始地址  
    }  

    // 获取当前数据尾的指针,即当前指针 cur_  
    char* Current() {   
        return cur_; // 返回当前指针  
    }  

    // 获取剩余的可用空间大小  
    int Available() const {   
        return static_cast(End() - cur_); // 计算缓冲区末尾与当前指针之间的距离  
    }  

private:  
    // 获取缓冲区的末尾指针,即指向缓冲区的末尾  
    const char* End() const {   
        return data_ + sizeof(data_); // 返回指向缓冲区末尾的指针  
    }  

    // 实际存放数据的缓冲区  
    char data_[SIZE]; // 数据缓冲区,大小为 SIZE 个 char  

    // 当前数据尾的指针,用于指示缓冲区当前已写入数据的末尾  
    char* cur_; // 指向当前已写入数据的尾部  
};

那说完了缓冲区buffer,那接着来看看存储的最终地点——磁盘文件

1.2 AppendFile类

其是真正操控磁盘文件的类,内部有个成员变量FILE* fp_,这个打开文件后的文件描述符。

// 底层控制磁盘文件输出的类  
class AppendFile  
{  
public:  
    // 构造函数,接受文件名并打开文件  
    explicit AppendFile(const std::string& file_name)   
        : fp_(fopen(file_name.c_str(), "ae")), // 以追加模式打开文件  
          writtenBytes_(0) // 初始化已写入字节数为0  
    {  
        if (fp_ == nullptr) {  
            // 如果打开文件失败,打印错误信息  
            printf("log file open failed: errno = %d reason = %s \n", errno, strerror(errno));  
        } else {  
            // 为文件描述符 fp_ 设置缓冲区  
            setbuffer(fp_, buffer_, sizeof(buffer_));  
        }  
    }  

    // 析构函数,关闭文件  
    ~AppendFile() {  
        fclose(fp_); // 关闭文件描述符 fp_  
    }  

    // 真正的添加数据到磁盘文件,调用 fwrite()  
    void Append(const char* str, size_t len) {  
        // 写入数据到磁盘文件  
        size_t n = fwrite(str, 1, len, fp_);  
        if (n != len) {  
            // 如果写入的数据长度与预期不符,处理错误(可以添加错误处理逻辑)  
        }  
        // 更新已写入的字节数  
        writtenBytes_ += n;  
    }  

    // 冲刷缓冲区的内容写入到磁盘文件  
    void Flush() {  
        // 强制将缓冲区内的数据写回到文件中  
        fflush(fp_);  
    }  

    // 获取已写入字节的大小  
    off_t writtenBytes() const {   
        return writtenBytes_; // 返回已写入的字节数  
    }  

private:  
    // 文件描述符,用于文件操作  
    FILE* fp_; // 文件指针  

    // 用于缓冲写入的内存缓冲区  
    char buffer_[1024 * 64]; // 64KB 的缓冲区  

    // 记录已写入磁盘文件的字节大小  
    off_t writtenBytes_; // 已写入字节数  
};  

来看看其一些函数的实现。

构造函数中调用fopen()打开文件,并获得文件描述符fp_。

构造函数内使用的setbuffer()函数将缓冲区设置为本地的buffer_,即调用AppendFile::Append()的时候不是直接就往磁盘中写数据,是先把数据存储到buffer_中去等buffer_内数据满了,再把buffer_内的数据一次性存到磁盘文件中,这样可以提高效率。

// AppendFile 类构造函数:接受一个文件名并打开相应的磁盘文件  
AppendFile::AppendFile(const std::string& file_name)  
    : fp_(fopen(file_name.c_str(), "ae")) // 以追加模式(“ae”)打开文件并初始化文件指针 fp_  
{  
    // 检查文件指针是否为 nullptr,表示文件打开失败  
    if (fp_ == nullptr) {  
        // 打印错误信息,显示 errno 和失败原因  
        printf("log file open failed: errno = %d reason = %s \n", errno, strerror(errno));  
    } else {  
        // 设置文件的缓冲区,使用提供的本地缓冲区  
        // 通过 setbuffer 函数将 fp_ 的缓冲区设为 buffer_,以提高写入效率  
        setbuffer(fp_, buffer_, sizeof(buffer_));  
    }  
}  

// 冲刷缓冲区的内容写入到磁盘文件  
void AppendFile::Flush()  
{  
    // fflush 函数用于强制将缓冲区内的数据写入指定的文件流(fp_)  
    // 也就是说,它会清空缓冲区,将流中所有待写入的数据写入磁盘  
    fflush(fp_);  
}  

2.日志消息的输出

说如何输出之前要先说下日志的格式。

日志消息格式为:时间  日志级别 日志正文 源文件名及行号

消息格式目前没有添加线程id,以后可能会加上的。

时间的使用就使用之前写的Timestamp类。

日志设置了6种级别:TRACE、DEBUG、INFO、WARN、ERROR和FATAL。

日志的输出形式为流形式:日志级别< 日志的输出使用流形式是为了使用起来更自然,不需要如printf("%d\n",10)这样要费心保持格式字符串和参数类型的一致性。

muduo中的没有使用标准库的iosrtream,而是用自己写的LogStream,也是为了性能原因。

这里也简单说说,iostream在线程安全方面没有保证,例如cout<<1<<2;这是两次函数调用,相当于cout.operator<<(1)<在多线程中,std::cout可能会造成输入内容不连续的。而自写的LogStream类是可以用其他方法实现线程安全的,这个后面会说。

有兴趣的可以去深入了解下iostream。

2.1 LogStream类

这个类主要是重载流输出操作符。函数调用了<<后,就会往缓冲区buffer_(前面的模板类FixedBuffer)中存入日志消息的(调用Buffer::Append())。

还有另一种,<<1,需要存入数字类型的,就需要把数字类型转为字符串再输出到缓冲区(其具体实现可以去看完整代码,这里没有显示出来)。

// 日志流类,用于格式化和管理日志输出  
class LogStream  
{  
public:  
    // 使用固定大小的缓冲区,缓冲区的类型为 FixedBuffer  
    using Buffer = FixedBuffer; // 定义缓冲区类型,缓冲区大小为 KSmallBuffer  

    // 重载流输出操作符,用于处理 bool 类型  
    LogStream& operator<<(bool v)  
    {  
        // 根据布尔值 v 将 "1" 或 "0" 添加到缓冲区  
        buffer_.Append(v ? "1" : "0", 1);  
        return *this; // 返回当前 LogStream 对象,以支持链式调用  
    }  

    // 重载流输出操作符用于其他类型,这里声明但未实现  
    LogStream& operator<<(short);  
    LogStream& operator<<(int);  
    LogStream& operator<<(long);  
    LogStream& operator<<(long long);  
    LogStream& operator<<(float);  
    LogStream& operator<<(double);  
    LogStream& operator<<(char);  
    LogStream& operator<<(const char*);  
    LogStream& operator<<(const std::string&);  

    // 将数据输出到缓冲区  
    void Append(const char* data, int len) {   
        buffer_.Append(data, len); // 调用指定的缓冲区的 Append 方法  
    }  

    // 获取缓冲区的对象,常量函数  
    const FixedBuffer& Buffer() const {   
        return buffer_; // 返回当前 LogStream 使用的缓冲区对象  
    }  

private:  
    // 格式化数字类型为字符串并输出到缓冲区  
    template  
    void FormatInteger(T);  

    // 定义缓冲区对象  
    Buffer buffer_; // 使用自定义的 Buffer 类型作为缓冲区  

    // 数字转为字符串后可以占用的最大字节数  
    static const int KMaxNumbericSize = 48; // 定义最大数字字符长度  
};  

有了LogStream类之后,格式就好弄了,那么来看看是如何使用输出的。

使用方式如LOG_ERROR<<"file cannot open!"。

这个LOG_ERROR就类似于std::cout一样的。std::cout<<"file cannot open!"。

那我们使用的时候就像使用std::cout一样的。那LOG_ERROR又是怎样的呢。

2.2 Logger类

这个类对外是可见的,也是通过这个类来使用日志的。

// 日志记录类  
class Logger  
{  
public:  
    // 日志级别枚举  
    enum class LogLevel  
    {  
        DEBUG, // 调试使用  
        INFO,  // 信息  
        WARN,  // 警告  
        ERROR, // 错误  
        FATAL, // 致命  
        NUM_LOG_LEVELS // 日志级别总数  
    };  

    // 构造函数,初始化 Logger 对象  
    Logger(const char* FileName, int line, LogLevel level, const char* funcName);  
    
    // 析构函数  
    ~Logger();  

    // 获取日志流对象,支持使用 "<<" 操作符输出日志  
    LogStream& Stream() { return stream_; }  

    // 获取全局日志等级,并非当前对象的等级  
    static LogLevel GlobalLogLevel();  

    // 默认输出函数,将数据输出到标准输出  
    void DefaultOutput(const char* msg, int len) {  
        fwrite(msg, 1, len, stdout); // 将消息写入标准输出  
    }  

private:  
    // 格式化时间函数,将格式化的时间写入日志流对象的缓冲区  
    void formatTime();  

    // 日志流对象,负责管理和格式化日志消息  
    LogStream stream_;  

    // 当前日志记录的级别  
    LogLevel level_;  

    // 使用该 Logger 的源文件名称(__FILE__)  
    const char* filename_;  

    // 当前行号,表明日志记录出现在源代码的第几行  
    int line_;  
};  

// 宏定义,方便日志记录的使用  
#define LOG_WARN Logger(__FILE__, __LINE__, Logger::LogLevel::WARN, __func__).Stream()  

首先就需要定义出一些日志等级。接着来看看其构造函数和析构函数,就可以知道打印日志的时候如何做到线程安全了。

结合上面的宏定义,使用LOG_WARN<<11的时候会在该线程构造一个临时的匿名 Logger对象,这是在栈上的,不是全局变量,只能是在该线程可见,可以做到线程安全。LOG_WARN<<11;代码结束的时候,临时的匿名 Logger对象也会进行析构,在析构函数中通过DefaultOutput()函数进行输出日志。

// Logger 类的构造函数  
Logger::Logger(const char* FileName, int line, Logger::LogLevel level, const char* funcName)  
    : stream_() // 初始化日志流对象  
    , level_(level) // 设置当前日志级别  
    , filename_(FileName) // 保存源文件名称  
    , line_(line) // 保存当前行号  
    , time_(std::chrono::time_point_cast(system_clock::now())) // 获取当前时间  
{  
    formatTime(); // 格式化并输出时间  

    // 输出当前日志级别和调用函数名  
    stream_ << g_loglevel_name[static_cast(level_)] << funcName << "():";   
}  

// Logger 类的析构函数  
Logger::~Logger()  
{  
    // 在析构时输出文件名和行号,以便于定位日志信息  
    stream_ << " - " << filename_ << " : " << line_ << '\n';   

    // 获取当前日志流的缓冲区内容  
    const LogStream::Buffer& buf(Stream().buffer());   

    // 调用 DefaultOutput 将缓冲区内容输出到标准输出  
    DefaultOutput(buf.Data(), buf.Length()); // 目前版本是通过 fwrite() 输出到 stdout  
}  

最后再来梳理下日志的使用流程(例子:LOG_WARN<<"23dsf")

1.使用LOG_WARN会先生成一个匿名的临时Logger对象,并返回一个LogStream对象的引用。

2.调用 << ,用于将内容("23dsf")输入到LogStream类中的buffer缓冲区(就是开头的FixedBuffer类)中;同一个临时对象多次调用<<(比如LOG_WARN<<"23dsf"<<454;),这就会多次将内容输入到buffer_中存储起来。

3.这句代码结束了,匿名的临时Logger对象就会进行析构,就会调用DefaultOutput()函数,目前是通过fwrite()写到stdout(即是屏幕中)。

17.添加异步日志:日志消息的存储与输出机制_第1张图片

 这节的日志是输出到stdout,也就是屏幕,目前这日志输出是同步的,即是一条日志消息代码结束,就要立刻fwrite()到屏幕的。我们先熟悉了这流程,下一节就容易实现异步的日志了。

最后的疑惑:

怎么要创建这么多类呀,感觉又是不爽。

那这里就要想清楚,这些类的作用是干什么的呢,一定要一一对应起来。

我们新建了这么多类是为了可以实现异步日志的。

1.要是同步日志,那就直接输出到磁盘文件就行啦,那就用不到我们写的日志消息缓冲区类FixedBuffer。而要实现异步日志,需要先把日志消息保存起来,等到一定量了再写到磁盘文件中,那FixedBuffer就充当了临时的存储地。

2.我们为了在使用日志时不用像printf("%d\n",10)这样要费心保持格式字符串和参数类型的一致性,而又因为使用std::cout是线程不安全的,具体的看上面对其的分析,所以才创建了LogStream类的。

3.那我们不使用printf,不使用std::cout,那我们怎么使用该日志呢,所以才有了Logger类的,可以把该类充当成std::cout的样子和附加其他的功能。

4.而AppendFile类是为了方便我们操作磁盘的文件的。而这一节只是还没用到该类而已。

日志是可以脱离服务器程序,是可以单独使用测试的。所以这节就只上传日志的程序,等到日志的章节结束了,就再和之前的服务器程序一起上传

你可能感兴趣的:(c++,tcp/ip,服务器,linux,网络)