19. 添加异步日志——3.继续完善

文章介绍了如何优化日志系统,包括在日志文件达到特定大小或时间时滚动日志,减少不必要的时间戳格式化,使用`thread_local`确保线程安全的日期和时间缓存,以及根据日志等级控制输出字段。同时,文章提到了获取和缓存线程ID的方法,以提高效率。

1.添加日志滚动功能。

为了防止日志文件过大,我们在日志文件大小到达预定的大小后,就要新创建一个日志文件来使用,或者通过时间(每天零点)去新建一个文件。

一个典型的日志文件的文件名如下:

logfile_test.20221122_140210.hostname.3605.log

一共5部分:1.logfile_test是服务器的进程名字;2.时间;3.主机名;4.进程id;5.后缀 .log。

看看LogFile中需要新添加的成员

{
public:
	//LogFile(const std::string& file_name, int flushEveryN=1024);
	LogFile(const std::string& basename,off_t rollSize,int flushInterval=3, int flushEveryN = 1024);

	bool rollFile();	//滚动日志
private:
	string getLogFileName(const string& basename, time_t* now);

	//日志文件名
	//const std::string filename_;
	const std::stirng basename_;    //文件的基础名字,后面的滚动日志的名字都是以这个为基础的
	const off_t rollSize_;		//滚动日志的大小
	const int flush_interval_seonds_;	//需要冲刷的时间间隔

    //这些时间的变量在构造函数中都是初始化为0
	time_t start_of_period_;//日志文件开始写的时间
	time_t last_roll_;		//最新一次日志滚动的时间
	time_t last_flush_;		//最新一次冲刷日志的时间
	const static int kRollPerSeconds_ = 60 * 60 * 24;	//一天中的秒数
};

其构造函数的参数有了变化,把文件名字改成了基础文件名字,添加了滚动日志的大小和需要冲刷的时间间隔,所以其成员也会添加这几个变量的。

之前的Append()函数只是判断是否需要冲刷文件而已,现在添加是否需要滚动日志的功能。

//新的append函数
void LogFile::Append(const char* logline, int len)
{
	file_->Append(logline, len);
	
	if (file_->writtenBytes() > rollSize_) {	//若文件写入的大小已超过了给定的size
		rollFile();
	}
	else {
		++count_;
		if (count_ > flush_everyN_) {
			time_t now = ::time(nullptr);
			//转换成当天凌晨0点对应的秒数
			time_t this_period = now / kRollPerSeconds_ * kRollPerSeconds_;
			if (this_period != start_of_period_) {
				rollFile();
			}
			else if (now - last_flush_ > flush_interval_seonds_) {
				last_flush_ = now;
				file_->Flush();
			}
		}
	}
}

//旧的 添加日志消息到file_的缓冲区
//void LogFile::Append(const char* logline, int len)
//{
//	file_->Append(logline,len);
//	if (++count_ >= flushEveryN_) {
//		count_ = 0;
//
//		file_->Flush();
//	}
//}

 这里需要说说转换当天零点的时间是怎弄的。举个例子:假设一天一共24秒,从第1天开始计算,那第1天0秒对应的秒数就是0,第2天0秒对应的秒数就是24。

假设当前时间对应的秒数是50,那50就是第2天2秒,那么如何求出50秒对应那天的零点对应的秒数呢。即是50/24*24=2*24=48,48秒就是当天的零点时刻的秒数。这样就可以转成当天0零点对应的秒数了。

 其主要流程就如上图所示。

这里可能有疑惑了:为什么要先判断是否已超过给定的冲刷条数,再进行判断是否超过给定的冲刷时间的呢

这个想想,我们给定的默认冲刷条数是1024。要是没超过,那说明日志消息还很少,就没很必要去滚动日志;还有日志消息更新的很慢,也没有必要在给定的时间去冲刷日志。

你也可以按照自己的想法和需求去修改冲刷和滚动日志的条件,这都是在你的实际需求之下才有用的哈。这里只是用了比较普遍的想法去判别条件去滚动和冲刷日志。

接着来看看rollFile()函数。

bool LogFile::rollFile()
{
	time_t now = time(nullptr);
	if (now > last_roll_)
	{
		std::string filename = getLogFileName(basename_, &now);
        //更新滚动日志的时间,冲刷时间,开启新日志的时间
		last_roll_ = now;
		last_flush_ = now;
		start_of_period_ = now / kRollPerSeconds_ * kRollPerSeconds_;;
		file_.reset(new AppendFile(filename));//新开一个日志文件
		return true;
	}
	return false;
}

getLogFileName函数是在basename_基础上,再去获取主机名等信息去合成一个新文件名字。

rollFile函数内部也会进行判断的,要是在同一秒的话,就不滚动日志。

那么其构造函数就需要有点变化了

LogFile::LogFile(const std::string& basename, off_t rollSize, int flushInterval_seconds, int flushEveryN)
{
	//file_ = std::make_unique<AppendFile>(filename_);   不使用这个了
	rollSize();    //使用滚动函数,其函数内部使用reset的
}

2.优化时间戳字符串的性能和一些其他的优化

之前的日志消息中的时间戳的字符串都是每一条日志消息都是重新格式化的。而日志消息很有可能是在同一秒内会有很多条,那就可以不用格式化同一秒的时间,只格式化其微妙部分就行。

我们就可以先把时间戳字符串中的日期和时间这两部分进行缓存,只格式化微妙的部分。例如"20221209 08:12:44"这个是缓存复用的,而微妙部分(".123345")这个是需要格式化的。

那么问题就来了,因为这是多线程去写日志消息的,而时间戳字符串中的日期和时间要缓存复用。

假如使用全局变量,那怎么保证A线程的字符串中的日期和时间不会被B线程拿去使用呢。

这时就需要c++11中的thread_local存储类型。有这个关键字的变量,在c++11中,本线程这个生命周期里面修改和读取thread_local类型的变量,不会与别的线程相互影响。

就是说A线程的时间戳字符串就只有A线程能修改和读取,而B线程不能对读取和修改,这样不用加锁就实现了。

Logger类中添加成员变量Timestamp time_

在Logger.cpp文件中添加如下变量。因为是需要改变下时间的格式化方式,那自然是要修改Logger::formatTime()函数的。

//logger.cpp

thread_local char t_time[64];	//当前线程的时间戳字符串的日期和时间
thread_local time_t t_lastSecond;	//当前线程的最新的日志消息的秒数

void Logger::formatTime()
{
	int64_t microSecondsSinceEpoch = time_.time_since_epoch().count();
	//得到秒数
	time_t seconds = static_cast<time_t>(microSecondsSinceEpoch / kMicroSecondsPerSecond);
	//得到其微妙
	int microseconds = static_cast<time_t>(microSecondsSinceEpoch % kMicroSecondsPerSecond);
	if (seconds != t_lastSecond) {	//秒数不相等,说明也要格式化秒数
		t_lastSecond = seconds;
		struct tm tm_time;
		gmtime_r(&seconds, &tm_time);
		int len = snprintf(t_time, sizeof(t_time), "%4d%02d%02d %02d:%02d:%02d",
			tm_time.tm_year + 1900, tm_time.tm_mon + 1, tm_time.tm_mday,
			tm_time.tm_hour, tm_time.tm_min, tm_time.tm_sec);
		assert(len == 17);
	}
	char buf[12] = { 0 };
	int lenMicro =sprintf(buf, ".%06d ", microseconds);
	stream_ << T(t_time, 17) << T(buf, lenMicro);
}

// helper class for known string length at compile time(编译时间)
class T
{
public:
	T(const char* str, unsigned len)
		:str_(str),
		len_(len)
	{
	}
	const char* str_;
	const unsigned len_;
};

inline LogStream& operator<<(LogStream& s, T v)
{
	s.Append(v.str_, v.len_);
	return s;
}

 日志消息的前4个字段是定长的(日期和时间是合为同一字段的,线程的id后面会再说),因此可以避免在运行期球字符串的长度(不会反复调用strlen()),这也是T类的由来。

3.添加线程id,并预先格式化为字符串

之前的c++11的std::thread::get_id()所获得的线程id不是一个整数,而是一个类,我们比较麻烦从中得到其整数id。而且其id也不是Linux内核的真正的线程id。所以使用另一种方法去获取Linux内核真正的线程ID。系统调用::syscall(SYS_gettid)。而因为是系统调用,消耗会比较大,所以我们在新建线程的时候,就调用其去获得线程id,并保存起来,之后该线程想使用线程id,就无需再使用系统调用了。那么在程序其他地方需要使用到线程id的时候也是这样,那整个服务器的有关线程的部分需要小改一下的哈。为了可以在创建线程的时候保存整数型的线程id,这里再新创一个类Thread,该类主要封装了c++11的std::thread,也是为了可以保存整数型的线程id。之后要用线程就用这个类Thead。

namespace CurrentThread
{
	// internal
	thread_local int t_cachedTid = 0;
	thread_local char t_tidString[32];
	thread_local int t_tidStringLength = 6;
	thread_local const char* t_threadName = "unknown";

	inline int tid()
	{
		if (__builtin_expect(t_cachedTid == 0, 0)){
			cacheTid();
		}
		return t_cachedTid;
	}

	void cacheTid()
	{
		if (t_cachedTid == 0)
		{
			t_cachedTid = static_cast<pid_t>(::syscall(SYS_gettid));
			t_tidStringLength = snprintf(t_tidString, sizeof(t_tidString), "%5d ", t_cachedTid);
		}
	}

	inline const char* tidString() // for logging
	{
		return t_tidString;
	}

	inline int tidStringLength() // for logging
	{
		return t_tidStringLength;
	}
}  // namespace CurrentThread

现在可以取得整数型线程i的了,在日志中使用线程id,只需要在Logger的构造函数中添加即可。

Logger::Logger(const char* basename, int line, Logger::LogLevel level, const char* funcName)
{
	formatTime();	//时间输出
	CurrentThread::tid();	//更新线程
	stream_ << T(CurrentThread::tidString(), CurrentThread::tidStringLength());
	stream_ << T(g_loglevel_name[static_cast<int>(level_)], 6);	//日志等级的字符串是定长的

	stream_ << funcName << "():";
}

4.按照不同的日志等级输出对应多少的字段

默认是INFO等级,所以是INFO等级的话,日志消息就不输出函数名等等,如果等级是DEBUG,就输出更多。这样就可以在需要追求性能时候,日志消息能输出少些。

那么就需要重载Logger的构造函数,和重新修改LOG_*这些宏定义。

//有多中级别,输出就有对应的输出。
Logger(const char* FileName,int line,LogLevel level,const char* funcName);
Logger(const char* file, int line);
Logger(const char* file, int line, LogLevel level);
Logger(const char* file, int line, bool toAbort);

//这里是不同的日志等级就会有对应的输出,有些等级会输出多些字段,有些等级的输出字段会少些
#define LOG_DEBUG if (Logger::GlobalLogLevel() <= Logger:LogLevel:::DEBUG) \
  Logger(__FILE__, __LINE__, Logger::LogLevel::DEBUG, __func__).Stream()
#define LOG_INFO if (Logger::LogLevel::logLevel() <= Logger::LogLevel::INFO) \
  Logger(__FILE__, __LINE__).Stream()
#define LOG_WARN Logger(__FILE__, __LINE__, Logger::LogLevel::WARN).Stream()

//构造函数
//Logger::Logger(const char* basename, int line, Logger::LogLevel level, const char* funcName)
//	:stream_()
//	,level_(level)
//	, basename_(basename)
//	,line_(line)
//	,time_(Timestamp::now())
//{
//	formatTime();	//时间输出
//	CurrentThread::tid();	//更新线程
//	stream_ << T(CurrentThread::tidString(), CurrentThread::tidStringLength());
//	stream_ << T(g_loglevel_name[static_cast<int>(level_)], 6);	//日志等级的字符串是定长的
//
//	stream_ << funcName << "():";
//}

那么这时候问题来了哈。现在构造函数是多个了,那每个构造函数都要写一遍构造函数内的初始化工作(格式化时间,线程等等),这些是在是太繁琐了。

所以又升级了啦。我们可以在Logger类内部新建一个类,叫做ImplLogger类内部拥有该类对象变量。让这个类的构造函数去做这些工作,那Logger类的构造函数就去初始化Impl类对象就行啦。

来看看Logger类内部的变化。

class Logger
{
private:
    class Impl
    {
    public:
        using  LogLevel = Logger::LogLevel;
        Impl(LogLevel level, const std::string& file, int line);
        void formatTime();
        void finish();

        Timestamp time_;
        LogStream stream_;
        LogLevel level_;
        int line_;          //行数 ,即是__LINE__
        std::string filename_;   //写日志消息说在的源文件,使用该函数的文件名(即是 __FILE__ )
    };

    Impl impl_;    //新添加这个类,并拥有这个类对象作为成员
};

那么之前的格式化时间的函数就放到Impl类内部了。就让Impl类的构造函数去完成之前Logger的构造函数的工作了。

那来看看Logger的构造函数简化后的样子。

//多中级别,输出就有对应的输出。
Logger::Logger(const char* FileName, int line, LogLevel level, const char* funcName)
	:impl_(level,file,line)
{
	impl_.stream_ << funcName << ' ';
}
Logger::Logger(const char* file, int line)
	:impl_(LogLevel::INFO,file,line)
{
}
Logger::Logger(const char* file, int line, LogLevel level)
	:impl_(level,file,line)
{
}

这样就简便舒服很多啦。

这样基本就可以了。那么我们就可以使用所写的日志去完善我们的服务器了,接着就把可能出错的位置用该日志保存下来,如创建socket时候(socket())等等地方就会用到日志啦。

完整源代码:https://github.com/liwook/CPPServer/tree/main/code/server_v19

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值