基础同步日志库:C++实现------学习笔记
摘要 :日志库核心作用便是留底 与查错,以方便的记录程序都做了什么,并且也方便程序员查找程序错误点。本篇学习笔记主要记录作者构建基础同步日志库的个人感悟与心得,各位后来者也可以以此借鉴。(本篇文章为纯新手向,希望学习spdlog库或更高级的日志库的请移步其他文章)
1:核心特性
双sink输出 :即我们希望实现用户调用特定函数或接口实现CLI与文件文件同步输出,因为我们要实现日志库的核心特性可视化 与持久化 。
该两项核心特性是区别平时我们打印字符串调试,保证程序崩溃日志可溯源。
我们先看函数实现代码(我先完成朴素实现,再慢慢引入RAII,时间戳,日志等级等等功能)。
c++
void Write(std::string msg){
std::ofstream logFile;//创建文件流
logFile.open("log.txt" , std::ios::out | std::ios::app);//以写入与追加模式打开文件
if(! logFile.is_open()){//判断文件是否成功打开,若打开不成功,返回错误提示并终止函数
std::cout << "File open failed!" << std::endl;
return;
}
//输出用户自定义的message
std::cout << msg <<std::endl;
logFile << msg << std::endl;
logFile.close();
return;
}
知识点一下,sink即数据流的目的地,CLI窗口,文件流,网络都可以是sink,我们这里所讨论的sink暂时只有CLI窗口与文件流。
该阶段我并没有给出类的构造,我们暂时只实现与理解双sink输出。
该函数便是我们后续一切附加功能的原型,当然为了符合OOP特性,我们一切还是需要将该函数进行封装成类,仅对外暴露接口。
所以,我们创建了两个文件
Logger.h
c++
class Logger{
private:
std::ofstream logFile_;//文件流成员
bool is_openLog_ = true;//类是否成功创建成员变量
//构造函数打开文件流,若文件打开不成功,将类打开状态设为假,同时返回消息,让用户自行处理
Logger(){
logFile_.open("log.txt",std::ios::out | std::ios::app);
if(! logFile_.is_open()){
std::cout << "File open failed!" << std::endl;
is_openLog_ = false;
}
}
~Logger(){
logFile_.close();
}
public:
//类单例化,保证安全
static Logger& getInstance(){
static Logger instance;
return instance;
}
//删除所有拷贝构造与移动构造函数,严格保证单例需求
Logger(const Logger&) = delete;
Logger& operator=(const Logger&) = delete;
Logger(Logger&&) = delete;
Logger& operator=(const Logger&&) = delete;
bool is_open();//判断类是否创建成功
void Write(const std::string&);//日志输出函数
};
以下是Logger.cpp的两个函数
c++
//模仿fstream库中的is_open函数
bool Logger::is_open(){
return is_openLog_;
}
void Logger::Write(const std::string& msg){
//判断类是否启动成功
if(! is_openLog_){
std::cout << "Object construction failed!" << std::endl;
return;
}
std::cout << msg <<std::endl;
logFile_ << msg << std::endl;
return;
}
在该阶段,并无什么高深的原理,我们只是将两个输出合并为了一项。
当然,对于基本的日志库来说,我们还有两个东西没解决,那就是时间戳与日志等级,这就是我接下来要讲的。
2:日志等级与拼接字符串
只有设立日志等级,我们才能更加清楚程序运行的情况,也能更好的排查问题。
我们先看代码,我这里使用的是枚举方案,根据自身喜好,也可以使用宏常量或别名定义
c++
//日志等级枚举
enum class LogLevel{
TRACE = 10,
DEBUG = 20,
INFO = 30,
WARN = 40,
ERROR = 50
};
这里的枚举赋值在我们这种基础版中并非必须,只是我个人倾向
在枚举值设定完成,我们还需要再在Logger类中新增一个私有成员函数,这个成员函数很简单,只是负责接收对于等级,随后返回等级字符串,供我们在Write函数中来拼接字符串
c++
//根据日志等级返回字符串
std::string logger::Level_to_String(LogLevel level){
switch(level){
case LogLevel::TRACE: {
return "[TRACE]";
}
case LogLevel::DEBUG: {
return "[DEBUG]";
}
case LogLevel::INFO: {
return "[INFO]";
}
case LogLevel::WARN: {
return "[WARN]";
}
case LogLevel::ERROR: {
return "[ERROR]";
}
default: {
return "[UNKNOWN]";
}
}
}
原理很简单,那么进入下一个阶段,我们将改造Write,但放心,只是些小小的改动,我们只需要将Level_to_String函数返回的字符串,拼接在message之前就好了。
c++
void logger::Write(const std::string& msg,const LogLevel& level){
if(! is_openLog_){
std::cout << "Object construction failed!" << std::endl;
return;
}
std::string level_str = Level_to_String(level);
std::cout << level_str << msg <<std::endl;
logFile_ << level_str << msg << std::endl;
return;
}
到此,我们的基础同步日志走到了第二阶段,那么就可以进行第三步了,再拼接一个时间戳。
3:构造时间戳与拼接
时间戳是保证我们能区分该日志是什么时候产生的,而不至于陷入靠猜的底部,同样也是解决排查日志不方便的痛点来的。
作者用的是C++17标准的chrono时间库,C++11有同库,但作者并不确定能否运行,请自行排查库的兼容问题,C++11以下请用ctime或time.h替换,但底层构造逻辑都一样。
在构造时间戳字符串之前,我们先明确,我们最终的时间戳是要精确到微秒级别,因为对于工业级产品,每秒日志可达上百万,秒与毫秒是不够用的
c++
//返回当前时间戳字符串
std::string Logger::Time_to_String(){
auto now = std::chrono::system_clock::now();
std::ostringstream oss;
//日历获取
auto sec_since_epoch = std::chrono::duration_cast<std::chrono::seconds>(now.time_since_epoch());
std::time_t now_time_t = sec_since_epoch.count();
std::tm* now_tm = std::localtime(&now_time_t);
oss << std::put_time(now_tm, "%Y-%m-%d %H:%M:%S");
std::string calstr = oss.str();
oss.str("");
oss.clear();
//获取微秒数
auto us_since_epoch = std::chrono::duration_cast<std::chrono::microseconds>(now.time_since_epoch());
auto us_part = us_since_epoch - std::chrono::duration_cast<std::chrono::microseconds>(sec_since_epoch);
oss << std::setw(6) << std::setfill('0') << us_part.count();
std::string usstr = oss.str();
std::string timestr = "[" + calstr + "." + usstr + "]";
return timestr;
}
这个部分我会一条条拆解,因为chrono确实复杂了一些,我当时也折腾了一会。
3.1:定义变量
c++
auto now = std::chrono::system_clock::now();
std::ostringstream oss;
我们先获取当前系统时间值,存储在auto变量中,方便我们后续进行运算
而oss的作用是方便我们后续进行数据与字符串的转换操作。
3.2:获取当前日历
c++
auto sec_since_epoch = std::chrono::duration_cast<std::chrono::seconds>(now.time_since_epoch());
std::time_t now_time_t = sec_since_epoch.count();
std::tm* now_tm = std::localtime(&now_time_t);
oss << std::put_time(now_tm, "%Y-%m-%d %H:%M:%S");
std::string calstr = oss.str();
oss.str("");
oss.clear();
sec_since_epoch先获获取向下取整后的当前的整秒数
再将整秒数转换为time_t类型,以方便跟C风格日历兼容,随后立刻将转换好的类型通过localtime来转换为日历结构体。(注意,localtime是非线程安全的,如果需要请自行更换线程安全的函数。)
随后oss接收格式化后的日历类型,按照固定格式格式化,calstr接收oss转换后的字符串
最后,清空oss,因为我们后面还要用他。
3.3:获取当前微秒数
c++
auto us_since_epoch = std::chrono::duration_cast<std::chrono::microseconds>(now.time_since_epoch());
auto us_part = us_since_epoch - std::chrono::duration_cast<std::chrono::microseconds>(sec_since_epoch);
oss << std::setw(6) << std::setfill('0') << us_part.count();
std::string usstr = oss.str();
us_since_epoch同样向下取整获取微秒数。
随后,将我们前面获取的纪元总秒数sec_since_epoch强制转换为微秒类型,通过us减去sec,我们就可以得到当前不满一秒的总微秒偏移量。
随后使用setw强制格式6位,不足填充0,然后输出我们得到的结果到oss
最后依然由oss转换为字符串,这步结束oss是否清空取决于自己,我是觉得后续用不到了,系统会自动回收,没必要了
3.4:拼接字符串,随后返回并处理
c++
std::string timestr = "[" + calstr + "." + usstr + "]";
return timestr;
这里就不用展开讲了,只是拼接字符串而已
随后我们在Write中如何使用该函数?
很简单,如下:
c++
void Logger::Write(const std::string& msg,const LogLevel& level){
if(! is_openLog_){
std::cout << "Object construction failed!" << std::endl;
return;
}
std::string level_str = Level_to_String(level);
std::string time_str = Time_to_String();
std::cout << time_str << level_str << msg <<std::endl;
logFile_ << time_str << level_str << msg << std::endl;
return;
}
到这里其实我们整个基础同步函数的主要工作就做完了,但我们还有一个最重要的收尾工作。那就是线程安全。
4:线程安全
为何要保证线程安全?因为如果出现多线程情况,日志的输出就会变得异常诡异。最常见的就是格式错乱,所以为了保证安全,我们需要给这个线程套上线程锁。
但这里的工作其实蛮简单的,接触过线程的肯定都知道,如果有连线程锁都不知道的,给我老老实实去补线程相关知识啊喂
我这里直接给代码
首先给Logger类中新增成员变量
c++
std::mutex mtx_;
随后只需要将Write输出字符串的地方套一层锁
c++
{
std::lock_guard<std::mutex> lock(mtx_);
std::cout << time_str << level_str << msg <<std::endl;
logFile_ << time_str << level_str << msg << std::endl;
}
请注意,大括号的作用是保证作用域范围。
直到这里,我们整个基础同步输出日志库的整个代码其实就写完了。但我其实后续还加了两个东西
一个是流式输出,例如TRACE << "hello"这种更自然的语法,我引入了LogBuilder辅助类,利用 RAII 特性在析构时自动调用Write。由于涉及临时对象和模板重载,代码量稍大,在这一版中我觉得没必要展开否则会导致篇幅过长,若有兴趣,各位可以用我的思路去试试
另一个是双缓冲输出,这是一个工业日志库的必要构建,但同样实现及其复杂,同样会导致篇幅过长。我简单概括一下这东西的作用,在我们这一版中,我在我自身的环境下测试大约为6500EPS,也就是每秒6500条日志,但加了双缓冲后,达到了3.5万EPS,但这不是双缓冲的极限,这是作者本身拼接字符串的性能开销拖慢了双缓冲,若各位有兴趣可以自行去查询搜索。