本节所讲的日志是诊断日志,即是文本的,供人阅读的日志。将代码运行时的重要信息进行保存,通常用来故障诊断和追踪,也可以用于性能分析。
举一个简单的例子:假如我们使用socket()函数的时候出了问题,那就会在日志中保存这个错误,这样就方便我们进行故障诊断和追踪。在服务器端,日志是很必要的。
日志又可以简单的分成两种,一种是同步日志,另一种是异步日志。
同步日志即是要写一条日志的时候,需要把消息完全写出后(即是把消息写入到磁盘文件)才能执行后续的程序代码。而由于操控磁盘时间 会比操控cpu时间会慢很多很多,所以可见,这种方式的日志的问题就在于程序可能会阻塞在磁盘写入操作上。
而异步日志的思路是需要写日志消息的时候只是将日志消息进行存储,当积累到一定量或者到达一定时间间隔时,由后台线程自动将存储的所有日志进行输出(写入到磁盘文件)。
可见,对于异步日志来说,每次有日志消息产生的时候,只需要一个存储的行为即可,存储结束就可以继续执行后面的业务代码了,而真正写入到磁盘的操作,是由后台线程进行的,这样做的好处就是:前台线程不会阻塞在写日志上,后台线程真正写出日志时,日志消息往往已经积累了很多,此时只需要调用一次IO函数(如fwrite()),而不需要每条消息都调用一个IO函数,如此也提高了效率。
1.消息的存储
那按照我们对异步日志的分析,我们就需要先把日志存储在一个缓冲区buffer中,等积累到一定量或一定时间间隔后就把在缓冲区的消息一次性写入到磁盘文件中。
1.1 FixedBuffer类
这个不是第8节写的Buffer类,是不一样的。这个缓冲区不难,要说的也在代码注释中了。
//缓冲区大小
const int KSmallBuffer = 1024;
const int KLargeBuffer = 1024 * 4000;
//缓冲区模板类,迎来存放日志数据
//大小为存放SIZE个char类型的数组
template<int SIZE>
class FixedBuffer
{
public:
FixedBuffer() :cur_(data_) {}
//往缓冲区中添加数据
void Append(const char* buf, size_t len)
{
if (Available() > static_cast<int>(len)) {
memcpy(cur, buf, len);
AppendComplete(len);
}
}
//添加完数据后,更新cur指针的位置
void AppendComplete(size_t len) { cur_ += len; }
//清空数据
void Menset() { memset(data_, 0, sizeof(data_)); }
//重置数据
void Reset() { cur_ = data_; }
//获取数据的长度
int Length()const { return static_cast<int>(cur_ - data_); }
//获取数据
const char* Data()const { return data_; }
//获取当前数据尾的指针,即是获取cur_
char* Current() { return cur_; }
//获取剩余的可用空间大小
int Available()const { return static_cast<int>(End() - cur_); }
private:
//获取末尾指针
const char* End()const { return data_ + sizeof(data_); }
//模型
// data_ cur_
// | (已存放的数据) | 剩余的还可存放的大小
// ——————————————————————————————————————————
//实际存放的数据
char data_[SIZE];
//当前数据尾的指针
char* cur_;
};
那说完了缓冲区buffer,那接着来看看存储的最终地点——磁盘文件。
1.2 AppendFile类
其是真正操控磁盘文件的类,内部有个成员变量FILE* fp_,这个打开文件后的文件描述符。
//底层控制磁盘文件输出的类
class AppendFile
{
public:
explicit AppendFile(const std::string& file_name);
~AppendFile(); //析构函数调用fclose(fp_)
//真正的添加数据到磁盘文件,调用fwrite()
void Append(const char* str, size_t len);
//冲刷缓冲区的内容写入到磁盘文件
void Flush();
off_t writtenBytes() const { return writtenBytes_; }
private:
//文件描述符
FIEL* fp_;
//给fp_使用的缓冲区
char buffer_[1024 * 64];
//往磁盘文件中已写入的字节大小
off_t writtenBytes_;
};
来看看其一些函数的实现。
构造函数中调用fopen()打开文件,并获得文件描述符fp_。
构造函数内使用的setbuffer()函数将缓冲区设置为本地的buffer_,即调用AppendFile::Append()的时候不是直接就往磁盘中写数据,是先把数据存储到buffer_中去,等buffer_内数据满了,再把buffer_内的数据一次性存到磁盘文件中,这样可以提高效率。
AppendFile::AppendFile(const std::string& file_name)
(fp_(fopen(file_name.c_str(),"ae")))
{
if (fp_ == nullptr) {
printf("log file open failed: errno = %d reason = %s \n", errno, strerror(errno));
}
else {
setbuffer(fp_, buffer_, sizeof(buffer_));
}
}
//冲刷缓冲区的内容写入到磁盘文件
void AppendFile::Flush()
{
//功能是冲洗流中的信息,该函数通常用于处理磁盘文件。fflush()会强迫将缓冲区内的数据写回参数stream 指定的文件中。
fflush(fp_);
}
2.日志消息的输出
说如何输出之前要先说下日志的格式。
日志消息格式为:时间 日志级别 日志正文 源文件名及行号
消息格式目前没有添加线程id,以后可能会加上的。
时间的使用就使用之前写的Timestamp类。
日志设置了6种级别:TRACE、DEBUG、INFO、WARN、ERROR和FATAL。
日志的输出形式为流形式:日志级别<<message; 如 LOG_ERROR<<"file cannot open!";
日志的输出使用流形式是为了使用起来更自然,不需要如printf("%d\n",10)这样要费心保持格式字符串和参数类型的一致性。
muduo中的没有使用标准库的iosrtream,而是用自己写的LogStream,也是为了性能原因。
这里也简单说说,iostream在线程安全方面没有保证,例如cout<<1<<2;这是两次函数调用,相当于cout.operator<<(1)<<operator<<(2);在多线程中,std::cout可能会造成输入内容不连续的。而自写的LogStream类是可以用其他方法实现线程安全的,这个后面会说。
有兴趣的可以去深入了解下iostream。
2.1 LogStream类
这个类主要是重载流输出操作符。函数调用了<<后,就会往缓冲区buffer_(前面的模板类FixedBuffer)中存入日志消息的(调用Buffer::Append())。
还有另一种,<<1,需要存入数字类型的,就需要把数字类型转为字符串再输出到缓冲区(其具体实现可以去看完整代码,这里没有显示出来)。
//日志流类
class LogStream
{
public:
//固定大小的缓冲区
using Buffer= FixedBuffer<KSmallBuffer> ;
// 重载流输出操作符
LogStream& operator<<(bool v)
{
buffer_.Append(v ? "1" : "0", 1);
return *this;
}
LogStream& operator<<(short);
LogStream& operator<<(int)
{
// 格式化数字类型为字符串并输出到缓冲区
FormatInteger(v);
return *this;
}
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); }
//获取缓冲区的对象
const FixedBuffer& Buffer()const { return buffer_; }
private:
// 格式化数字类型为字符串并输出到缓冲区
template<class T>
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(cosnt 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:
//格式化时间 "%4d%2d%2d %2d:%2d:%2d",并把时间通过strea_<<写入到日志流对象内的缓冲区
void formatTime();
// 日志流 ,就可以使用 "<<" 输出日志
LogStream stream_;
// 日志等级
LogLevel level_;
// 使用该函数的文件名(即是 __FILE__ )
const char* filename_;
// 行数, 表明在第几行代码出记录的消息
int line_;
};
// 宏定义: LOG_INFO << "输出数据"; 这里只展示了一部分
#define LOG_WARN Logger(__FILE__, __LINE__, Logger::LogLevel::WARN, __func__).Stream()
首先就需要定义出一些日志等级。接着来看看其构造函数和析构函数,就可以知道打印日志的时候如何做到线程安全了。
结合上面的宏定义,使用LOG_WARN<<11的时候会在该线程构造一个临时的匿名 Logger对象,这是在栈上的,不是全局变量,只能是在该线程可见,可以做到线程安全。LOG_WARN<<11;代码结束的时候,临时的匿名 Logger对象也会进行析构,在析构函数中通过DefaultOutput()函数进行输出日志。
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<MicroSecond>(system_clock::now()))
{
formatTime(); //时间输出
//输出日志等级
stream_ << g_loglevel_name[static_cast<int>(level_)] << funcName << "():";
}
Logger::~Logger()
{
stream_ << " - " << filename_ << " : " << line_ << '\n';
const LogStream::Buffer& buf(Stream().buffer());
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(即是屏幕中)。
这节的日志是输出到stdout,也就是屏幕,目前这日志输出是同步的,即是一条日志消息代码结束,就要立刻fwrite()到屏幕的。我们先熟悉了这流程,下一节就容易实现异步的日志了。
最后的疑惑:
怎么要创建这么多类呀,感觉又是不爽。
那这里就要想清楚,这些类的作用是干什么的呢,一定要一一对应起来。
我们新建了这么多类是为了可以实现异步日志的。
1.要是同步日志,那就直接输出到磁盘文件就行啦,那就用不到我们写的日志消息缓冲区类FixedBuffer。而要实现异步日志,需要先把日志消息保存起来,等到一定量了再写到磁盘文件中,那FixedBuffer就充当了临时的存储地。
2.我们为了在使用日志时不用像printf("%d\n",10)这样要费心保持格式字符串和参数类型的一致性,,而又因为使用std::cout是线程不安全的,具体的看上面对其的分析,所以才创建了LogStream类的。
3.那我们不使用printf,不使用std::cout,那我们怎么使用该日志呢,所以才有了Logger类的,可以把该类充当成std::cout的样子和附加其他的功能。
4.而AppendFile类是为了方便我们操作磁盘的文件的。而这一节只是还没用到该类而已。
日志是可以脱离服务器程序,是可以单独使用测试的。所以这节就只上传日志的程序,等到日志的章节结束了,就再和之前的服务器程序一起上传。