日志系统的功能也就是将一条消息格式化后写入到指定位置,这个指定位置一般是文件,显示器,支持拓展到数据库和服务器,后面我们就知道如何实现拓展的了,支持不同的写入方式(同步异步),同步:业务线程自己写到文件中,也就是open一个文件,如何调用write。异步:业务线程不负责打开文件和调用write写数据,而是用一个vector容器保存数据,业务线程往里面写,然后让另一个线程去写。
日志输出我们用日志器对象来完成,听不懂没关系,后面我们就知道项目有哪些模块了。
一 常用小功能模块
后面项目实现有些小功能时常用到,我们先实现了,方便后面使用。
模块实现
获取日期,静态接口是为了免去实例化对象。
namespace logs
{
namespace util
{
// 1 获取系统时间
class Date
{
public:
static time_t now()
{
return (time_t)time(nullptr);
}
};
};
};
具体实现
namespace logs
{
namespace util
{
// 2 判断文件是否存在
// 3 获取文件所在路径
// 4 创建目录
class file
{
public:
static bool exits(const std::string& filename)
{
return access(filename.c_str(), F_OK) == 0; 返回0表示找到了
}
static std::string pathname(const std::string& path)
{
int pos = path.find_last_of("/\\",-1);
std::string ret = path.substr(0, pos);
return ret;
}
static bool CreateDreactor(const std::string& path)
{
int pos = 0;
int front = 0;
while(pos != -1)
{
pos = path.find_first_of("/\\",pos);
// ./test/test.c
std::string pathname = path.substr(0,pos);
if(pos == -1)//假如path == test.c
{
mkdir(pathname.c_str(),0777);
}
else
pos = pos + 1; 继续往后找目录分隔符
if(exits(pathname)) 如果目录已经存在就不创建了
continue;
mkdir(pathname.c_str(),0777);
}
return true;
}
};
};
};
这个access函数可以根据选项判断文件是否存在,是否可以被读被写。不过这个是linux下的系统调用,windows无法识别,我们可以用一个通用接口来代替。
使用如下。不用考虑struct stat是是什么,我们看返回值判断文件是否存在。
比较复杂的就是创建目录函数,第一次截取./abc创建,第二次截取./abc/bcd再创建,这没问题吗?没有,mkdir就是一层一层创建的。所以我们要用mkdir创建./abc/bcd/test.c要先mkdir ./abc,然后mkdir ./abc/bcd,最后mkdrir ./abc/bcd/test.c。
由此得substr的第二个参数这里必须pos,不能是pos+1,当我们要创建 ./test/test2的时候,可是最后一次find返回-1,此时如果substr(0,pos+1)就会什么都截取不了。
功能测试
测试结果:
二 日志等级类模块
模块实现
1 定义各个日志等级的宏
namespace logs
{
class loglevel
{
public:
enum class level
{
UNKNOW = 0,//未知等级错误
DEBUG,//进行debug调试的日志信息
INFO,//用户提示信息
WARN,//警告信息
ERROR,//错误信息
FATAL,//致命错误信息
OFF,//关闭日志
};
};
};
enum class value?不是直接enum value吗?实际上enum class value是c++11新推出的,它的区别在于内部定义的宏是有作用域的,要使用必须:level::DEBUG。
也就是说像enum Date内部的成员是在类似全局域,任何地方都可以直接使用,所以像上图那样枚举成员重复就会报错,但是用enum class就没事,因为此时成员都被划分在各自的类域内了。
2 提供一个接口将宏转为字符串,为什么不一开始定义成字符串呢?不行,一开始必须是整型,因为我们将来需要等级之间用来比较,如果是字符串不好比较,为什么要比较呢,因为我们需要设置一个功能,那就是项目日志门槛功能,我们可以设置项目运行时日志只有高于某个等级才可以输出,就可以过滤很多不必要的消息。
namespace logs
{
class loglevel
{
public:
static std::string to_String(const level& lv)
必须是const的,不然外部无法传递,如果不是引用传递则无所谓
{
switch(lv)
{
case level::DEBUG:
{
return "DEBUG";
break;
}
case level::INFO:
{
return "INFO";
break;
}
case level::WARN:
{
return "WARN";
break;
}
case level::ERROR:
{
return "ERROR";
break;
}
case level::FATAL:
{
return "FATAL";
break;
}
case level::OFF:
{
return "FATAL";
break;
}
default:
{
return "UNKONW";
break;
}
}
};
};
};
功能测试
void test2()//测试levels.hpp内的小组件
{
std::cout<<logs::loglevel::to_String(logs::loglevel::level::DEBUG)<<std::endl;
std::cout<<logs::loglevel::to_String(logs::loglevel::level::ERROR)<<std::endl;
std::cout<<logs::loglevel::to_String(logs::loglevel::level::FATAL)<<std::endl;
std::cout<<logs::loglevel::to_String(logs::loglevel::level::INFO)<<std::endl;
std::cout<<logs::loglevel::to_String(logs::loglevel::level::UNKNOW)<<std::endl;
std::cout<<logs::loglevel::to_String(logs::loglevel::level::WARN)<<std::endl;
std::cout<<logs::loglevel::to_String(logs::loglevel::level::OFF)<<std::endl;
}
三 日志消息类设计
时间,等级和主体消息都是日志信息的重要部分,时间和等级还可以用来过滤,尽可能减少不必要信息的干扰。
下面这个类就是日志消息类
namespace logs
{
struct LogMsg
{
LogMsg(loglevel::level level,
const std::string file,
const std::string logger,
const std::string payload,
size_t line)
: _time(util::Date::now())//复用util.hpp中的now函数实现
,_level(level)
,_file(file)
,_line(line)
,_tid(std::this_thread::get_id())
,_payload(payload)
,_logger(logger)
{
;
}
public:
time_t _time;//日志时间
loglevel::level _level;//日志等级,要指定类域
std::string _file;//文件名
size_t _line;//行号
std::string _logger;//日志器名称
std::string _payload;//日志消息主体
std::thread::id _tid;//线程id
};
};
可是光有日志消息不行啊,我们还得将上述元素排列格式化好,也就是格式化上述准备的元素,接下来就来实现一个格式化类。
四 格式化模块
模块实现
从这里开始就有点不好理解了,实现这个模块只要大致了解提供的接口,实现完后再来理解就清晰多了。
我们要对一条消息进行格式化,也就是对一条消息的各个部分进行格式化,所以我们把对一整个日志消息类的格式化,变成对各个部分格式化,这部分的实现我称为格式化子项类的实现。即便下面各个类的函数成员是开放的,但是每个类之间的函数还是处于不同的类域,就不会出现命名冲突的问题,所以每当我们想写个函数,就顺便写个类封装起来。
成员如下,一个成员一个格式化子项类
// time_t _time;//日志时间
// loglevel::level _level;//日志等级,要指定类域
// std::string _file;//文件名
// size_t _line;//行号
// std::string _logger;//日志器名称
// std::string _payload;//日志消息主体
// std::thread _tid;//线程id
格式化子项类
namespace logs
{
class Format
{
public:
using ptr = std::shared_ptr<Format>; c++11支持的取别名
virtual void format(std::ostream& out,const logs::LogMsg& lsg) = 0;
};
等级处理函数
class LevelFormat:public Format
{
public:
void format(std::ostream& out,const logs::LogMsg& lsg)override
{
out<<logs::loglevel::to_String(lsg._level);
}
这里就复用了先前实现的将等级常量转字符串的方法。
};
时间处理函数
class TimeFormat:public Format
{
public:
TimeFormat(const std::string& pattern)
:_pattern(pattern)
{
;
}
void format(std::ostream& out,const logs::LogMsg& lsg)override
{
struct tm st;
localtime_r(&lsg._time, &st); 将时间戳转为tm结构体
char arr[32] = {0};
strftime(arr, sizeof(arr), _pattern.c_str(),&st);
将tm结构体内的时间按指定格式转到arr数组中,显然这个格式我们可以指定
out<<arr;
}
private:
std::string _pattern;
};
class LinelFormat:public Format
{
public:
void format(std::ostream& out,const logs::LogMsg& lsg)override
{
out<<(lsg._line);
}
};
class LoggerFormat:public Format
{
public:
void format(std::ostream& out,const logs::LogMsg& lsg)override
{
out<<lsg._logger;
}
};
消息主体处理类
class PayloadFormat:public Format
{
public:
void format(std::ostream& out,const logs::LogMsg& lsg)override
{
out<<lsg._payload;
}
};
文件名处理类
class FileFormat:public Format
{
public:
void format(std::ostream& out,const logs::LogMsg& lsg)override
{
out<<lsg._file;
}
};
线程id处理类
class TidFormat:public Format
{
public:
void format(std::ostream& out,const logs::LogMsg& lsg)override
{
out<<lsg._tid;
}
};
换行处理类
class NlineFormat:public Format
{
public:
void format(std::ostream& out,const logs::LogMsg& lsg)override
{
out<<"\n";
}
};
TAB缩进处理类
class TableFormat:public Format
{
public:
void format(std::ostream& out,const logs::LogMsg& lsg)override
{
out<<"\t";
}
};
其余字符处理类
class OtherFormat:public Format
{
public:
OtherFormat(const std::string& val)
:_val(val)
{
;
}
std::string _val;
void format(std::ostream& out,const logs::LogMsg& lsg)override
{
out<<_val;
}
};
};
当然看完上面代码,可能还有不少问题,1 out是什么?好像每个format函数都把消息类的成员输出到out中,这个out可以认为是一个缓冲区。当我们按顺序调用不同类内的format函数,就会在缓冲区内形成一个日志消息。
例如 下面这个就是缓冲区内的日志消息,就是我们先调用TimeFormat类内的函数输出时间,后输出名称,最后输出主体消息,才有的这个格式化好的消息,那我们怎么知道先调用哪个类内的format函数,显然这里需要一个格式,后面提。
而格式化子项类则负责将消息类中的成员取出,加以处理后放到缓冲区。显然有个调用者提供了缓冲区,然后再按顺序调用格式化子项类中的函数,这样一条日志消息就做好了,这个我们后面再细说。
2 为什么要抽象出一个Format父类,这个也得后提,如果还有其它问题,慢慢来,解决完这几个就能对这部分的实现有一个比较清晰的认识,应该也能自主解决了。
我们得再提及一个类,格式化类,前面的那个叫格式化子项类,诶,咋还有个类,来看看它提供了什么功能。
Formater类成员1:保存了一个日志格式,这个格式是我们传入的。
%t%d的含义如下
parttern函数负责解析这个格式,解析完后就调用CreateFormat创建格式化子项对象保存到数组中,也就是说上面的格式就转成对应格式化子项对象在数组中的排序,当我们遍历这个数组去调用format函数时就是在按我们给的格式顺序去构建日志消息,而由于每个格式化子项都是不同的类型,可是vector只能存一个类型,所以才要抽象出一个格式化子项父类,然后所有格式化子项类都继承这个父类,这样vector才能接受所有格式化子项对象。
//格式化类
class Formater
{
public:
Formater(const std::string& format = "[%d{%H:%M:%S}][%t][%c][%f:%l][%p]%T%m%n")
void format(std::ostream& out,const LogMsg& msg);
std::string format(const LogMsg& msg);//返回日志消息
private:
bool parttern();
Format::ptr CreateFormat(const std::string& key,const std::string& val);
private:
std::string _format;//日志格式
std::vector<Format::ptr> _vf;//保存格式处理函数,后续按顺序调用
};
};
也就是说我们首先接收格式,然后解析格式,当我们能够把下面的格式字符串拆成一个个%d,%t...,我们就可以直接判断应该调用哪个格式化子项函数了。 值得注意的是,由于我们经常使用printf,潜意识告诉我们%d表示输出整型,但那个是printf内部的规定,现在这里是我们自己实现的类,我们想%d对应什么就对应什么。
Formater(const std::string& format = "[%d{%H:%M:%S}][%t][%c][%f:%l][%p]%T%m%n")
:_format(format)
{
assert(parttern()); 开始解析格式字符串,必须成功,失败也就没必要继续下去了。
}
parttern函数实现。
private:
bool parttern()
{
std::vector<std::pair<std::string,std::string>> vss;
std::string key,val;//这两个变量不能放在for内部
key记录的格式字符,例如%d中的d,%p中的p
val记录的主要是普通字符
然后vss容器用来保存这两个变量的值
for(int i = 0; i < _format.size();)
{
if(_format[i] != '%')成立表示是普通字符 例如abc%d,
前面的abc都会被val保存起来,直接输出,普通字符就是不需要做格式化处理的字符
{
val.push_back(_format[i++]);
continue; 跳过下面,去判断下一个字符是不是%
}
遇到%,如果是%%,此时我们认为是将%%看成是一个%的普通字符
就像两个\\一样
if(_format[i] == '%' && _format[i+1] == '%')//是普通字符
{
val.push_back(_format[i]);
i+=2;//跳过%
continue;
}
else 解析%d
{
if(val.size())// 先push先前的普通字符到vss数组中
vss.push_back(std::make_pair(key,val));
val = ""; 然后清空val 例子:ab%d
if(i+1 == _format.size())//例如%
{
std::cout<<"%之后没有格式化字符"<<std::endl;
return false;
}
key = _format[++i];
i++;//例如:%d,我要跳过%d中的d,后面不然会被当成普通字符添加到日志消息中
%d{%H},判断后面是否有格式子串,也有可能到结尾,我们认为{}是前面一个格式字符%的子串
因为%d是表示时间,但是日期也可以有格式,{}内部保存的就是对时间的格式化。
if(i < _format.size() && _format[i] == '{')
{
int pos = _format.find_first_of("}",i);
if(pos == -1)
{
std::cout<<"无匹配的},输入错误"<<std::endl;
return false;
}
i = ++i;
val = _format.substr(i,pos-i);
此时val就截取了时间格式,后续传给格式化子项函数
i = pos+1;//跳过"%H:%S:%S"这个格式子串
}
vss.push_back(std::make_pair(key,val));
val = "";
key = "";
}
}
vss.push_back(std::make_pair(key,val));//例如%dabc,处理末尾的原始字符
看完上面代码,我们可以知道格式字符串中的%d, 都按顺序保存到了vss容器中
我们在这里统一调用CreateFormat()函数创建格式化子项对象保存到_vf中。
for(auto& e : vss)
{
_vf.push_back(CreateFormat(e.first,e.second));
}
return true;
}
我们在解析字符串的时候说过key是保存%d中的d,这样在下面这个函数内就可以做判断,如果不把下面这个判断封装成函数,那在parttern函数中,每遇到个%,难道都做一次下面这个判断吗?
我们还在parttern函数中将key和val统一保存到vector,而不是遇到个%就判断是否调用CreateFormat()函数,不然的话类内成员函数间的耦合度很高,如果觉得不高,那就把CreateFormat()参数改一改,看看是你的代码修改简便,还是我上面的代码修改起来简便。下面这个函数只创建两种格式化子项时传了val,要好好体会val是什么。
Format::ptr CreateFormat(const std::string& key,const std::string& val)
{
if(key == "d") return std::make_shared<TimeFormat>(val);
if(key == "t") return std::make_shared<TidFormat>();
if(key == "c") return std::make_shared<LoggerFormat>();
if(key == "f") return std::make_shared<FileFormat>();
if(key == "l") return std::make_shared<LinelFormat>();
if(key == "p") return std::make_shared<LevelFormat>();
if(key == "m") return std::make_shared<PayloadFormat>();
if(key == "n") return std::make_shared<NlineFormat>();
if(key == "T") return std::make_shared<TableFormat>();
if(key == "") return std::make_shared<OtherFormat>(val);
//例如遇到%g
std::cout<<"%之后的格式字符输入错误:% "<<key<<std::endl;
abort();
return Format::ptr();//返回什么都行,都不会被调用,因为程序都终止了
}
可是把格式化子项对象保存到数组有什么用呢? 我们还实现了两个format函数,这两个就是做最后的消息组装,我们来看看具体实现。
这两个函数怎么好像在调用format?调用自己?当然不是, _vf数组中的成员是Format类型的,不是Formatter类型的,调用的肯定是类内成员函数啦,所以Format类内函数的out是什么呢?就是我们在这里定义的,std::stringstream ss,或者是外部定义的stringstream变量,也就是说最后日志消息就被格式化到这个类型的变量里了。
namespace logs
{
class Formater
{
public:
using ptr = std::shared_ptr<Formater>;
void format(std::ostream& out , const LogMsg& msg)
{
for(auto& e : _vf)
{
e->format(out,msg);
}
}
std::string format(const LogMsg& msg) 返回日志消息
{
std::stringstream ss;
format(ss,msg);
return ss.str();
}
private:
std::string _format;//日志格式
std::vector<Format::ptr> _vf;//保存格式处理函数,后续按顺序调用
};
};
功能测试
void test3()//测试日志消息类和格式化类
{
定义了一条日志
logs::LogMsg msg(logs::loglevel::level::DEBUG,__FILE__,"日志器1","测试日志",__LINE__);
给日志定格式
logs::Formater ft;
std::cout<<ft.format(msg);
Formater类内的format函数会执行格式化子项函数,并且将结果返回,我们直接打印看一看
}
没有给Formater ft传入格式时,结果如下,用了缺省格式。
如果传了,结果如下,此时我们可以发现,只有我们输入了对应的格式字符,日志消息才会输出对应的部分,具体原因大家可以结合先前代码理解。
经过前面几步,一条日志已经被制作出来了,难道日志做出来就是直接cout吗?如何控制输出方向呢?就由接下来的落地类来实现了
五 日志落地类
模块实现
将日志消息输出到指定位置,支持拓展日志消息落地到不同位置,如何支持拓展的呢?后面我们就知道了。下面这几个落地位置是我们自己实现的。
1 标准输出 2 指定文件 3 滚动文件(也就是按大小和时间切换新文件保存)
如果我们是只实现一个类内函数的话,那显然在后续增加落地方向的时候就要修改类内函数,这显然是不符合开闭原则的,而下面这种抽象出基类,在派生实现落地逻辑的设计,后续增加落地方向可以不修改原来代码,而是直接添加一个新的子类,符合开闭原则。
namespace logs
{
class sink
{
public:
智能指针类型取别名,因为后续要使用智能指针来管理sink的子类对象
using ptr = std::shared_ptr<sink>;
virtual void log(const char* dst,size_t size) = 0;
};
//标准输入输出
class Stdoutsink:public sink
{
public:
void log(const char* dst,size_t size)override
{
std::cout.write(dst,size);
}不能直接cout,第一次用cout内部的函数,因为直接cout是无法指定大小的
};
};
下面这个就是文件落地类实现。
class Filesink:public sink
{
public:
Filesink(const std::string& pathname) 既然是输出到文件显然要别人先传个文件名啦
:_pathname(pathname)
{
util::file::CreateDreactor(util::file::pathname(_pathname));
创建文件,还要创建对应的目录,就像./test/test.log,要把test目录页创建出来,
可以复用一中实现的功能
}
void log(const char* dst,size_t size)override
{
_ofs.write(dst,size); 直接写入
assert(_ofs.good());
}
std::string _pathname;
std::ofstream _ofs;
};
我一开始没有将_ofs设为类内成员,而是在log内定义一个局部变量,后面发现这样每次都要open
所以就定义成成员,一直存在了
滚动文件落地类实现
可是要切换多个文件,首先就要有多个文件名吧,接下来看看多个文件名如何构建,就是用基础文件名+拓展文件名,拓展文件名一般是时间,用年月日时分秒结尾,会不会一秒内生成两个文件呢?实际上不太可能,但我们的代码比较简单,很容易出现一直打开同一个文件写入的情况,后面提。
这个滚动文件是根据文件大小来滚动的,也就是当文件超出一定大小后,就要切换新文件了,这个文件容量是我们规定的,可是如何知道文件已经使用的大小呢?显然也要有个成员记录写入的字节数,获取文件属性也可以,只是效率有点低。
class Rollsink:public sink
{
public:
Rollsink(const std::string& basename,int max_size)
:_basename(basename)
,_filename(basename)
,_max_size(max_size) 这个是文件最大容量,当写入字节数超过这个容量就要切换文件了
{
CreateNewfile(); 构建文件名
util::file::CreateDreactor(util::file::pathname(_filename));//创建文件
_ofs.open(_filename,std::ofstream::binary | std::ofstream::app);
assert(_ofs.is_open());
}
有了地址和长度,就可以获取数据并写入了
void log(const char* dst,size_t size)override
{
if(_cur_size >= _max_size) 判断是否要切换文件
{
_ofs.close(); 一定要关闭,不然就出现资源泄露了
CreateNewfile();
_cur_size = 0; 一定要清零,不然会反复进入if语句,就会打开同一个文件
_ofs.open(_filename,std::ofstream::binary | std::ofstream::app);
assert(_ofs.is_open());
}
//打开并写入
_ofs.write(dst,size);
_cur_size += size;
assert(_ofs.good());
}
void CreateNewfile()
{
std::stringstream sst;
sst<< _basename;
struct tm st;
const time_t t = time(nullptr);
localtime_r(&t,&st); 这个函数可以将时间戳转为struct tm类型的结构体
sst<< st.tm_year+1900;
sst<< st.tm_mon+1;
sst<< st.tm_mday;
如果写入操作时间很短,我们就再加个count做后缀。
sst<< "->";
_filename = sst.str(); 用类内成员记录新文件名
}
int _count = 0;
std::ofstream _ofs;
std::string _filename;
std::string _basename; 文件基础名
int _max_size; 文件容量
int _cur_size = 0; 当前文件使用字节数
};
这个是tm类内的成员,有年月日时分秒。
下面这个Factorysink封装了上述落地类的创建方式,免得一个类的构造函数发生更改,然后要所有代码都改动,所以就封装了一个静态接口来创建,可是上面几个落地类的构造函数的参数是不同的,这个时候我们就想起了用可变参数+模板函数,静态可变参数的模板我也是第一次用。
//创建上面的日志落地类
class Factorysink
{
public:
template<typename T,typename ...Args>
static std::shared_ptr<T> Create(Args&& ...args)
{
return std::make_shared<T>(std::forward<Args>(args)...);
}
};
接收参数用了万能引用,右值引用是:int && args,虽然都有&&,但是万能引用的类型是不确定的,而右值引用的类型确定的,传参的时候还用了完美转发,保持参数在传递时特性不改变,也就是让右值不会变成左值。这个实际上是个简单工厂模式,所有类的对象都在一个类内创建,显然由于模板的存在,后续有新的落地类这个工厂类都不会改动。
模块测试
void test4()//测试日志消息落地类
{
logs::LogMsg msg(logs::loglevel::level::DEBUG,__FILE__,"日志器1","测试日志",__LINE__);
logs::Formater ft;
std::string ret = ft.format(msg);//此时是把消息准备好了
下面是创建消息的落地类以及调用log函数开始落地写入。
auto sptr = logs::Factorysink::Create<logs::Stdoutsink>();
sptr->log( ret.c_str(),ret.size());
auto sptr2 = logs::Factorysink::Create<logs::Filesink>("./logs/test.log");
sptr2->log( ret.c_str(),ret.size());
auto sptr3 = logs::Factorysink::Create<logs::Rollsink>("./test/test.log",1024);
int i = 0;
while(i < 5*1024) 写够5*1024字节,显然应该创建5个文件
{
i += ret.size();
sptr3->log(ret.c_str(),ret.size());
}
}
滚动文件bug,并没有出现切换,第一次发现因为我每次写入日志,没有对_cur_size做++,还有就是要清零,不然会每次写入都if成立去打开文件,而且由于写入过快,然后就会进入同一个文件。
不过为什么清零了后还是只有一两个文件呢,因为执行得太快了,一两秒内可能写完了,然后每次Createfile就会打开同一个文件,所以我们用时间还是不太好切换,最好加个计数器。
结果如下。
六 落地模块拓展举例
用户编写的落地方式如何被使用? 例如用户想来个按时间切换的滚动文件落地类,当前时间为一百秒,十秒切换一个文件,所以在到110秒的时候要切换文件了。
拓展实现
我们又定义了一个枚举类型,当传不同的常量表示切换时间间隔分别是秒,分,时,天。
enum class TimeGap
{
GAP_SECOND,
GAP_MINUTE,
GAP_HOUR,
GAP_DAY,
};
class RollBytimesink : public logs::sink
{
public:
RollBytimesink(const std::string& basename,TimeGap gap_size)
:_basename(basename)
,_filename(basename)
{
switch (gap_size) 确认时间间隔大小
{
case TimeGap::GAP_SECOND: _gap_size = 1;break;
case TimeGap::GAP_MINUTE: _gap_size = 60;break;
case TimeGap::GAP_HOUR: _gap_size = 3600;break;
case TimeGap::GAP_DAY: _gap_size = 3600*24;break;
}
_old_time = logs::util::Date::now();
CreateNewfile();//构建文件名
logs::util::file::CreateDreactor(logs::util::file::pathname(_filename));
_ofs.open(_filename , std::ofstream::binary | std::ofstream::app);
assert(_ofs.is_open());
}
当当前时间大于_old_time+_gap_size时表明此时已经过了一个时间间隔,
是时候创建新文件了
void log(const char* dst,size_t size)override
{
if(logs::util::Date::now() >= _old_time + _gap_size)
{
_old_time += _gap_size;
_ofs.close();
CreateNewfile();
_ofs.open(_filename,std::ofstream::binary | std::ofstream::app);
assert(_ofs.is_open());
}
//打开并写入
_ofs.write(dst,size);
assert(_ofs.good());
}
void CreateNewfile()
{
std::stringstream sst;
sst<< _basename;
struct tm st;
const time_t t = time(nullptr);
localtime_r(&t,&st);
sst<< st.tm_year+1900;
sst<< st.tm_mon+1;
sst<< st.tm_mday;
sst<<st.tm_hour;
sst<<st.tm_min;
sst<<st.tm_sec;
_filename = sst.str();
}
std::ofstream _ofs;
std::string _filename;
std::string _basename;
int _old_time;
int _gap_size;//切换时间间隔
};
拓展测试
写入五秒,此时应该会有五个文件
void test5()
{
logs::LogMsg msg(logs::loglevel::level::DEBUG,__FILE__,"日志器1","测试日志",__LINE__);
logs::Formater ft;
std::string ret = ft.format(msg);//此时是把消息准备好了
//下面是解决消息的输出方向
auto sptr3 = logs::Factorysink::Create<RollBytimesink>("./test/test.log", TimeGap::GAP_SECOND);
auto i = logs::util::Date::now();
while(i + 5 > logs::util::Date::now())//往文件输出日志五秒,应该创建五个文件
{
sptr3->log(ret.c_str(),ret.size());
usleep(10000);
}
}
当然打印的日志时间是固定的,因为那是制作日志消息时的时间,还有就是注意文件名要更换,不然每次都会打开同一个文件。当我们终于可以输出日志消息到任意地方了,此时就有新问题,难道每次我都要创建一个LogMsg对象,然后自己创建格式化器去格式化日志消息对象,返回一个string保存的格式化好的日志消息,再创建落地类实现日志落地吗? 对于使用者来说有点麻烦。所以需要一个新模块对先前功能做整合。
七 日志器模块
我们希望使用者只需要创建logger对象,调用这个对象的debug,info,warn,error,fatal等函数就可以打印出对应等级的日志,支持解析可变参数列表和日志消息主体的格式。还支持同步和异步日志器。
同步日志器:直接对日志消息进行输出。
异步日志器:将日志消息放到缓冲区,由异步工作器线程进行输出。
好像支持很多功能啊,没事我们先实现最简单的同步日志器。
日志器模块成员介绍
这个模块成员是什么呢?先来个日志器名称,然后是格式化Formater类的对象,显然这个成员需要一个日志格式,还需要一个vector对象,这个对象内部放着多个日志落地对象,因为一条日志可能既需要打印到屏幕上,还要输出到文件中。
namespace logs
{
class Logger
{
protected:
Logger(std::string logger,std::vector<sink::ptr> sinks , loglevel::level lev,Formater::ptr& formater) 这个是外部传入的格式化器
:_logger(logger)
,_limit_level(lev)
,_sinks(sinks)
,_formater(formater)
从日志消息类成员,我们知道如果要构建一条日志消息,
外部需要传入文件名,行号(这两个绝对不能在函数中通过宏获取)
还有消息主体和格式,让我们内部构建对应级别的日志消息
但是消息主体也不需要用户自己输入,可以将参数和格式传入,我们内部自己组建消息主体
void Debug(const std::string& file,size_t line,const char* format,...);
void Info(const std::string& file,size_t line,const char* format,...);
void War(const std::string& file,size_t line,const char* format,...);
void Fatal(const std::string& file,size_t line,const char* format,...);
void Error(const std::string& file,size_t line,const char* format,...);
void OFF(const std::string& file,size_t line,const char* format,...);
virtual void log(const char* dst,size_t size) = 0;
std::mutex mutex; 锁的作用后提
Formater::ptr _formater;
std::string _logger; 日志器名称
std::atomic<loglevel::level> _limit_level;
std::vector<sink::ptr> _sinks;//保存了该日志器的落地方向
};
};
日志器的名称是唯一标识的,作用后提,后面我们是可以将日志器统一管理的,而日志器名就是日志器对象的唯一标识。这个限制等级是经常要被访问的,在目前的接口来看几乎不对其进行修改,应该不用担心线程安全的问题,如果后面担心后面会修改,就设为原子性,懒得加锁了,因为如果一个线程执行要申请很多锁,就比较容易冲突,执行比较慢。
所以日志器内部的Debug函数负责构建Debug日志,Info函数构建一条info级别的日志,然后子类实现log来决定这条日志的去向。为啥不直接在父类内实现呢?
为什么要子类来实现呢? 因为我们要实现同步日志器和异步日志器,这两个日志器的区别在于落地方式的不同,注意不是落地方向,同步日志器是直接输出,而异步日志器是交给缓冲区,由其它线程去输出,但是日志的构建大家都是一样的,所以抽象出基类共同使用。抽象基类还有个好处是?可以用基类指针对子类日志器管理和操作,这也得后面提了。
抽象类内部实现
看着多,但只要了解了一个Debug函数,其它几个都是几乎一模一样的。
namespace logs
{
class Logger
{
protected:
Logger(std::string logger,std::vector<sink::ptr> sinks,std::string format)
:_logger(logger)
,_sinks(sinks)
,_formater(new Formater(format))
{
;
}
void Debug(const std::string& file,size_t line,const char* format,...)
{
if(_limit_level > loglevel::level::DEBUG)
return;
//制作消息
va_list vp; 获取可变参数的开头
va_start(vp,format);
char* ret;
vasprintf(&ret,format,vp);
这个函数可以将可变参数按照format格式转到ret指向的空间中
serialize(loglevel::level::DEBUG,file,ret,line);
free(ret);释放资源
va_end(vp);
}
void Info(const std::string& file,size_t line,const char* format,...)
{
if(_limit_level > loglevel::level::INFO)
return;
//制作消息
va_list vp;
va_start(vp,format);
char* ret;
vasprintf(&ret,format,vp);
serialize(loglevel::level::INFO,file,ret,line);
free(ret);
va_end(vp);
}
void War(const std::string& file,size_t line,const char* format,...)
{
if(_limit_level > loglevel::level::WARN)
return;
//制作消息
va_list vp;
va_start(vp,format);
char* ret;
vasprintf(&ret,format,vp);
serialize(loglevel::level::WARN,file,ret,line);
free(ret);
va_end(vp);
}
void Fatal(const std::string& file,size_t line,const char* format,...)
{
if(_limit_level > loglevel::level::FATAL)
return;
//制作消息
va_list vp;
va_start(vp,format);
char* ret;
vasprintf(&ret,format,vp);
serialize(loglevel::level::FATAL,file,ret,line);
free(ret);
va_end(vp);
}
void Error(const std::string& file,size_t line,const char* format,...)
{
if(_limit_level > loglevel::level::ERROR)
return;
//制作消息
va_list vp;
va_start(vp,format);
char* ret;
vasprintf(&ret,format,vp);
serialize(loglevel::level::ERROR,file,ret,line);
free(ret);
va_end(vp);
}
void OFF(const std::string& file,size_t line,const char* format,...)
{
if(_limit_level > loglevel::level::OFF)
return;
//制作消息
va_list vp;
va_start(vp,format);
char* ret;
vasprintf(&ret,format,vp);
serialize(loglevel::level::OFF,file,ret,line);
free(ret);
va_end(vp);
}
这个函数是前面几个成员函数的公共部分,因为它们都要构建出一条日志消息
void serialize(loglevel::level level,const std::string& file,char* cmsg,size_t line)
{
LogMsg msg(level,file,_logger,cmsg,line);
开始格式化
std::string retstring = _formater->format(msg);
//进行落地
log(retstring.c_str(),retstring.size());
调用的是下面的log函数
}
virtual void log(const char* dst,size_t size) = 0;
std::mutex mutex;
Formater::ptr _formater;
std::string _logger;//日志器名称
std::atomic<loglevel::level> _limit_level;
std::vector<sink::ptr> _sinks;//保存了该日志器的落地方向
};
};
子类同步日志器实现
各个sink直接调用log函数。
class sync_Logger:public Logger
{
public:
sync_Logger(std::string& logger,std::vector<sink::ptr> sinks,loglevel::level lev,Formater::ptr& formater)
:Logger(logger,sinks,lev,formater) 这是在调用基类的构造函数
{
;
}
void log(const char* dst,size_t size)
{
std::unique_lock<std::mutex> lock(_mutex);
if(_sinks.size() == 0)
return;
for(auto e : _sinks) 如果是多线程同时调用这个log函数,在写入时要加锁保护,
虽然现在是单线程,提前保护一下呗。
{
e->log(dst,size);
}
}
};
同步日志器模块测试
封装后的使用:
封装前的使用
使用起来代码量差不多啊,为什么还要有个日志器封装呢?其实从上面可以看出使用者减少了接收日志消息的操作,也不用自己调用落地模块的log函数去落地了。
但是使用还不够简便,下面我们再做一些优化,简化使用难度。
八 日志器建造者模块
从下面的测试图中我们知道,我们使用日志器,要先创建很多对象,这些对象都是给日志器传的参数,然后我们才能创建一个日志器,有点麻烦,我们能不能封装一个类,传参给这个类,这个类去帮我们创建日志器成员,然后构建日志器并返回,这就要用到建造者模式了。
首先抽象一个建造者类,然后派生出子类建造者类。为什么这里要分出父类子类呢? 直接实现一个类不就好了吗?这个问题和后面的全局日志器实现有关,因为创建局部和全局的日志器有个处理不一样,但是零部件的构建是一样的,同理父类实现一份,两个子类共享多香。
//日志器建造者类
class loggerbuild
{
public:
loggerbuild()
:_limit_level(loglevel::level::DEBUG)
,_type(LoggerType::sync_Logger)
{
;
}
void buildlevel(loglevel::level lev)
{
_limit_level = lev;
}
void buildlogname(const std::string& name)
{
_logger = name;
}
void buildformater(const std::string& parttern)
{
_formater = std::make_shared<Formater>(parttern);
}
void buildloggertype(LoggerType& type)
{
_type = type;
}
template<typename T,typename ...Args>
void buildsink(Args&& ...args)
{
_sinks.push_back(std::make_shared<T>(std::forward<Args>(args)...));
}
virtual Logger::ptr build() = 0;
std::mutex _mutex;
LoggerType _type;
Formater::ptr _formater;
std::string _logger;//日志器名称
std::atomic<loglevel::level> _limit_level;
std::vector<sink::ptr> _sinks;//保存了该日志器的落地方向
};
根据类型返回同步和异步日志器,这些都是局部的日志器,都是只能定义的地方使用,其它作用域要使用得通过传参等方式,比较麻烦,全局的则是任意地方都可以获取日志器对象来输出日志。
enum class LoggerType
{
Asynclooper,
sync_Logger
};
根据枚举类型返回不同的日志器对象
class Localloggerbuild:public loggerbuild
{
Logger::ptr build()override
{
if(_type == LoggerType::Asynclooper) 异步,这个return可以在实现完异步日志器再补全
return std::make_shared<Async_Logger>(_logger,_sinks,_limit_level,_formater);
return std::make_shared<sync_Logger>(_logger,_sinks,_limit_level,_formater);
}
};
模块测试
九 异步日志器设计思想
我们前面说过异步日志器是把数据给一个缓冲区,然后由异步工作器线程去处理缓冲区中的数据,显然这里有两个线程,一个是业务线程将数据写给缓冲区,一个是工作线程处理日志,如果他们用的是一个缓冲区,那就要用我先前博客提到的环形队列和消息队列的两种生产消费者模型,本文用的是双缓冲区实现的生产消费者模型,这三种实现并无优劣之分。
不过如果是双缓冲区的话,工作线程处理完任务缓冲区内的数据,如何拿到任务写入缓冲区的数据呢,拷贝?不,我们是用swap函数,这个和拷贝有点区别。所以如果工作线程处理完自己缓冲区的数据了,外部缓冲区是空的,就不能交换,就得等待,这个等待的实现就是用信号量。业务线程也不能一直往任务写入缓冲区写,如果满了得等工作线程,这个等待也是用信号量。
这里面还要实现一个缓冲区模块,还要一个日志处理模块,内部包含一个线程对日志消息做落地操作,最后这两个模块都服务于异步日志器模块,有点不好理解,我打算先看看异步日志器需要什么?
我们从同步日志器的实现来分析,同步日志器是直接在自己的log函数内部调用sink类的log函数往文件或者屏幕输出了,但如果是异步日志器,应该输出到缓冲区,可是如何找到缓冲区呢?
显然此时需要一个缓冲区对象做异步日志器的成员,当然传参也不是不可以,但是什么都要外部创建,对使用者来说是比较麻烦的。
再来想想那缓冲区内部放什么呢,是直接放一条格式化好的字符串,而不是放一个logmsg对象。如果是放logmsg对象,工作线程处理函数获取数据的时候要多拷贝一次缓冲区内的数据下来做格式化,而不是直接获取处理。可是这个缓冲区要提供什么功能我们还有点模糊,不急慢慢来,这已经是最后一个关卡了,过了我们就可以将各个模块串联起来,我写博客提到的很多细节也是我实现完才想起来的,我是没办法在实现前就想得很清楚。
如果日志器内部就用一个char*指向一个区域,然后直接把数据丢到这里面,之后难道在日志器里面再定义一个线程,然后让这个线程去读取char*指向的空间,显然这里一个类负责的功能太过复杂,不符合高内聚低耦合,既负责log输出到缓冲区,还负责创建线程处理缓冲区数据,我们可以先实现一个Asynclooper(日志工作器)类,这个类对象成员有两个缓冲区,会创立线程会去读缓冲区数据进行落地操作,也会提供接口给外部对缓冲区进行写入,其实也就是给异步日志器类增加一个成员负责落地操作。
那缓冲区是直接弄一个char*,还是再封装一下呢? 如果不封装,那Asynclooper就要负责缓冲区的扩容,读写位置的维护,以免被覆盖,算了缓冲区也封装成类吧,这样以后别的类还能复用对应的接口,接下来就看看缓冲区的设计如下。
十 缓冲区实现
思想介绍
在单缓冲区中,读位置和写位置是不同的,我们需要通过对应的位置关系判断缓冲区是空还是满。
实现
类设计,内部用vector保存数据,而且要有两个下标,分别控制读写位置。
class buffer
{
public:
//往缓冲区push数据
void push(const char* dst,size_t len)
返回可读缓冲区的长度
size_t ReadAbleSize()
size_t WriteAbleSize()
返回可读位置的起始地址
const char* begin()
bool empty()
移动读写位置,不能让外部直接访问成员
void movewriter(size_t len)
void moveReader(size_t len)
重置归零,交换了缓冲区后,日志器写入的缓冲区就变空了,需要重新设置读写下标
void reset()
void swap(logs::buffer& buf) 交换缓冲区
private:
void EnSureEnoughsize(size_t len)//保证扩容后可以容纳len个字符
std::vector<char> _buffer;
int _writer_index = 0;
int _reader_index = 0;
};
具体实现
namespace logs
{
#define DEFAULT_BUFFER_SIZE (1*1024)//缓冲区大小默认为1kb
这两个宏的意义在EnSureEnoughsize() 扩容函数中可以体现
#define THRESHOLD_BUFFER_SIZE (4*1024) //阈值默认为4kb
#define INCREASE_BUFFER_SIZE (1*1024) //超过阈值后每次增长1kb
class buffer
{
public:
buffer()
:_buffer((DEFAULT_BUFFER_SIZE))
{
;
}
//往缓冲区push数据
void push(const char* dst,size_t len)
{
EnSureEnoughsize(len);
std::copy(dst,dst+len,&_buffer[_writer_index]);
movewriter(len);
}
size_t ReadAbleSize()
{
return _writer_index - _reader_index;
}
size_t WriteAbleSize()
{
return _buffer.size() - _writer_index;
}
//返回可读位置的起始地址
const char* begin()
{
return &_buffer[_reader_index];
}
bool empty()
{
return _reader_index == _writer_index;
}
void movewriter(size_t len)
{
_writer_index += len;
}
void moveReader(size_t len)
{
_reader_index += len;
}
void reset()
{
_reader_index = 0;
_writer_index = 0;
}
void swap(logs::buffer& buf)
{
std::swap(_buffer,buf._buffer);
这个swap只是交换了vector内部的指针,不会去拷贝指向空间内的数据。
std::swap(_writer_index,buf._writer_index);
std::swap(_reader_index,buf._reader_index);
}
private: 不对外提供的设为私有
void EnSureEnoughsize(size_t len)
{
if(_buffer.size() - _writer_index >= len) 可写入,无需扩容
return;
int newsize = 0;
while(_buffer.size() - _writer_index < len)
{
if(_buffer.size() < THRESHOLD_BUFFER_SIZE)
//缓冲区大小小于阈值,size增长为翻倍,直到足够写入
newsize = _buffer.size()*2;
else
newsize = _buffer.size() + INCREASE_BUFFER_SIZE;
大小大于阈值,size增长为线性增长
_buffer.resize(newsize); //扩容
}
}
std::vector<char> _buffer;
int _writer_index = 0;
int _reader_index = 0;
};
};
扩容情况主要用于测试,测试在大量写入时的效率如何,实际运行的时候的资源是有限的,不会运行我们无限扩容。缓冲区只负责写数据和扩容,是否要限制大小由上层决定,给上层足够的选择空间。空间不需要释放,频繁申请释放浪费时间,到日志器实现我们就知道缓冲区交换的作用。
测试缓冲区模块
void test8()//测试缓冲区模块
{
logs::buffer buf;
std::ifstream ifs;
ifs.open("./logs/test.log",std::ifstream::binary); 打开文件
ifs.seekg(0,std::ifstream::end);移动到文件末尾
size_t size = ifs.tellg(); 返回当前文件到文件起始位置的差,这就是文件的内容大小了
ifs.seekg(0,std::ifstream::beg);
再移回开头
std::string rec; 提前开辟好大小
rec.resize(size);
ifs.read(&rec[0],size); 将文件数据读取到rec字符串中
buf.push(rec.c_str(),rec.size()); 写入缓冲区
buf.movewriter(rec.size()); 更新下标
std::ofstream ofs("./logs/test2.log",std::ofstream::binary);
从缓冲区读数据到新文件
int readsize = buf.ReadAbleSize();
for(int i = 0; i < size;i++)
{
ofs.write(buf.begin(),1);
buf.moveReader(1);
}
}
最后我们在外部用命令比较两文件是否一致,一致说明缓冲区没问题,如何判断两个文件一不一样,如下。
十一 工作器实现
工作器设计
我们前面说了实现缓冲区是给工作器内部提供缓冲区对象,让工作器可以创建线程对缓冲区进行读写。所以管理成员如下。
1 双缓冲区对象。虽然工作器是对消费缓冲区的数据做处理,例如进行输出落地,但具体操作我们是不好直接写死的,所以我们让外部来指定,所以就有了个回调函数。那为什么是双缓冲区呢?因为异步日志器是把数据给任务缓冲区,工作器要将任务缓冲区和日志处理缓冲区进行交换,然后处理,总不能将任务缓冲区对象变成异步日志器的成员,那工作器怎么获取呢?我们看完工作器的实现就知道不把两个缓冲区都放在工作器内不好管理。
条件变量和锁的用处我们在实现中理解。
实现
namespace logs
{
enum class safe
{
ASYNC_SAFE,
ASYNC_UNSAFE,
};
class Asynclooper
{
public:
using Functor = std::function<void(buffer &)>;
Asynclooper(Functor factor, loopersafe safe = loopersafe::ASYNC_SAFE)
:_callback(factor)
,_stop(false)
,_safe(safe)
,_thread(std::thread(&Asynclooper::threadRun,this))
{
;
}
成员初始化,创建一个线程,第一个参数传的是执行函数,普通函数直接传函数名就可以了
传成员函数格式如上,参数是this指针。
~Asynclooper()
{
stop();
}
这个push接口是给外部的日志器使用的
void push(const char *dst, size_t len)
{
{
std::unique_lock<std::mutex>(_mutex); // 加锁访问缓冲区
if(_safe == safe::ASYNC_SAFE)
要用lambda表达式或者某个可调用的函数
_pro_condition.wait(_mutex,[&](){return _pro_buffer.WriteAbleSize() >= len;});
若len太大,则会一直在阻塞
_pro_buffer.push(dst, len);
_pro_buffer.movewriter(len);
}
_con_condition.notify_all();
}
可能工作线程在交换缓冲区时陷入了休眠,当我们添加了数据时,就可以唤醒工作线程
void stop()
{
_stop = true;
_con_condition.notify_all();
_thread.join();
}
private:
void threadRun()
当工作器定义好后就会一直在这里访问两个缓冲区,如果生产缓冲区不在
工作器的管理下,外部传参也传不进来。
{
while(!_stop)
{
{
std::unique_lock<std::mutex> lock(_mutex); 加锁访问缓冲区
看看生产缓冲区是否为空,不为空以及工作器被停止了都应该往下继续走
_con_condition.wait(lock,[&](){return _stop || !_pro_buffer.empty();});
_con_buffer.swap(_pro_buffer);//交换
}
if(_safe == safe::ASYNC_SAFE)
_pro_condition.notify_all();
_callback(_con_buffer);//处理任务缓冲区内的数据
_con_buffer.reset(); 处理完了,重置任务缓冲区
}
}
// 双缓冲区
bool _stop;
safe _safe;
Functor _callback;
logs::buffer _pro_buffer;
logs::buffer _con_buffer;
std::mutex _mutex;
std::condition_variable _pro_condition;
std::condition_variable _con_condition;
std::thread _thread;//异步工作器对应的线程
};
};
可以看到当我们定义了Asynclooper这个工作器对象时,初始化内部成员的时候就已经创建了一个线程,这个线程执行的就是下面这个函数,这个函数就负责处理消费缓冲区的数据,然后重置消费缓冲区,整个过程是在while循环内,那外部如何终止呢,就用stop()函数控制_stop成员,又或者整个对象释放的时候_stop也变成true,也就终止了。所以_stop也会被多线程访问,也要被保护,同样设为原子的性,而不是加锁。
这两个函数都有个{},看起来非常奇怪,实际上是非常巧妙的,巧妙的将锁的生命周期缩小在了{}内,如果没有这个{},外部主线程执行push函数和内部这个次线程执行的threadRun函数就是串行的了,影响效率。
好像只有一把锁?是的,免得外部push的时候,工作器线程突然去交换。
stop函数作用? 暂停工作器,并等待工作线程退出回收,这个暂停不是立刻停止,而是让工作线程下次while判断从循环中出来,可以让外部控制不要再进行落地了,所以也会出现工作线程和业务线程(业务线程就是我们定义日志器,输出日志消息到任务缓冲区的线程)
我们buffer实现的push接口是直接扩容,我们前面说了由外部控制,这个外部现在就是工作器。
我们先在工作器内定义了一个枚举类型,SAFE表示限制缓冲区的扩容,UNSAFE表示不限制。
然后根据_safe成员的类型决定要不要下面这句安全控制。
如果想让缓冲区无限扩容,我们就让_safe保存SAFE,push的时候就不会在条件变量下判断可写区域是否足够,而是直接调用缓冲区的push函数,内部无限扩容。反之,工作器对象在push的时候就会在条件变量下等待,即便被唤醒,也要判断可写区域是否足够,如果有一次len大于缓冲区的长度,而缓冲区又是有限的,此时就会一直阻塞住,这不是bug,而是我们使用不当,是我们自己设置缓冲区有限,不能扩容,还往缓冲区内部输入超限的数据。
一个工作器对象一个线程?
所以我们的构造函数就需要两个参数,一个是回调函数,还有一个是枚举类型表示该工作器是否安全。
十二 异步日志器
设计
同步日志器上面刚刚实现完,而且我们发现同步日志器只需要复用父类的成员即可,不需要自己添加成员,但是异步日志器需要,需要一个工作器,内部定义线程,对消费者缓冲区内的数据做处理。最后设计的接口成员如下。
class Async_Logger:public Logger
{
public:
Async_Logger(std::string& logger,std::vector<sink::ptr> sinks,loglevel::level lev,Formater::ptr& formater)
{
;
}
void log(const char* dst,size_t size);
void realog(buffer& buf);
private:
Asynclooper::ptr _looper;
};
实现
class Async_Logger:public Logger
{
public:
Async_Logger(std::string& logger,std::vector<sink::ptr> sinks,loglevel::level lev,Formater::ptr& formater,loopersafe loopsafe)
:Logger(logger,sinks,lev,formater) 调用基类的构造函数
,_looper(std::make_shared<Asynclooper>定义一个工作器(std::bind(&Async_Logger::realog,this,std::placeholders::_1)))
{
;
}
void log(const char* dst,size_t size)
{
_looper->push(dst,size);放入任务缓冲区
}
void realog(buffer& buf)
实际落地函数,是传给工作器执行的,会将缓冲区数据给sink落地类函数去落地
不用加锁,因为工作器内就一个线程,访问不会有线程安全的问题
{
if(_sinks.empty())
{
_sinks.push_back(Factorysink::Create<logs::Stdoutsink>());
}
for(auto e : _sinks)
{
e->log(buf.begin(),buf.ReadAbleSize());
}
}
private:
Asynclooper::ptr _looper;
};
比较有意思的还是下面这个bind语法,首先我们是在调用make_shared<Asynclooper>()返回一个智能指针,括号内部是在调用构造函数。
异步日志器测试
void test9()//测试异步日志器
{
std::shared_ptr<logs::Localloggerbuild> localbuild(new(logs::Localloggerbuild));
localbuild->buildlevel(logs::loglevel::level::DEBUG);
localbuild->buildformater("%d{%H:%S}%m%n");
localbuild->buildlogname("日志器1");
localbuild->buildloggertype(logs::LoggerType::Asynclooper);
localbuild->buildsink<logs::Filesink>("./logs/test.log");
localbuild->buildsink<logs::Rollsink>("./logs/test.log",1024);
logs::Logger::ptr lptr = localbuild->build();
int i = 0;
int count = 0;
while(i < 5*1024)
{
i += 21;
std::cout<<"count "<<count++<<std::endl;
lptr->Debug(__FILE__,__LINE__,"日志测试");
}
}
按理说应该是有5个文件,但是我这里测试后只有三个文件。
由于我们是先往文件输入日志后才判断是否会超过容量,所以就会出现此时文件里面已经放了1000字节了,结果工作线程获得了任务缓冲区的数据,这里面也有1000字节,直接写入,就超限了,为什么测试缓冲区的时候是五个文件呢?大家可以去看看上面的测试代码,当时是一个个字节写入的,所以写入文件时不会超出容量太多,就能开辟五个文件,我们这里一个文件放了2kb,就导致只开了三个文件。
完善建造者类
由于异步日志器内部多了个工作器,然后在传参时需要给工作器传回调函数和一个枚举类型,所以我们需要对建造者类进行修改。先给建造者父类增加一个成员,用来记录枚举类型,后面给日志器的构造函数传参。
可是为什么不是把成员定义在下面这个子类里面呢? 因为下面这个是局部的日志器创建,后面还有个全局的日志器创建,也要用到这个成员。
测试
异步日志器测试bug,发现写入数据变少,原因:还有部分数据在生产缓冲区,但是_stop已经被设为true,这部分数据就丢失了。代码修改如下,必须把生产缓冲数据处理完才能结束。
十三 日志器管理模块
实现原因
由于我们上面返回的日志器都是局部日志器,都只是在某个作用域有效,其它函数作用域都无法使用,传参太过麻烦,所以我们希望有个方法可以让我们创建一个日志器后可以在任意地方使用。所以我们设计出了一个日志管理类,这个类内部保存了创建好的日志器,可以让我们在任意地方获取。
显然这个管理类必须是个单例模式,不然的话我在一个函数内添加了日志器,这个日志器被管理类对象保存,结果这个管理类对象也是个局部的,别的作用域又如何访问这个成员内部的日志器呢。
设计
_loggers是管理所有日志器的数组,应该用map,LoggerManger应该是个单例模式,构造函数私有。
实现
class LoggerManager
{
LoggerManager& getinstance()//返回单例的管理者对象
{
static LoggerManager lm;
return lm;
}
void addlogger(Logger::ptr ptr)//添加一个日志器被管理
{
std::unique_lock<std::mutex>(_mutex);
if(haslogger(ptr->name()))//免得覆盖了
return;
_mp.insert(std::make_pair(ptr->name(),ptr));//添加被管理的日志器
}
Logger::ptr getlogger(const std::string& name)
{
std::unique_lock<std::mutex>(_mutex);//防止别人在别人添加的时候获取,导致获取错误数据
return _mp[name];
}
bool haslogger(const std::string& name)
{
std::unique_lock<std::mutex>(_mutex);
if(_mp.count(name))
return true;
return false;
}
private:
LoggerManager(){
std::unique_ptr<Localloggerbuild> build(new Localloggerbuild());
build->buildlogname("日志器2");
_root日志器只需要指定名称,其余的都有默认的初始化值
_root = build->build();
_mp[_root->name()] = _root; 默认日志器也要添加到容器中,这样也能通过get获取
}
std::mutex _mutex;
std::map<std::string,Logger::ptr> _mp;//根据日志器名字返回日志器对象
Logger::ptr _root;//默认的日志器
};
LoggerManager的构造函数内构建默认日志器,不能用全局日志器,必须用局部日志器。当外部获取LoggerManager的静态对象的时候,开始调用管理类的构造函数,内部创建了builder对象,build函数内部获取LoggerManager的静态对象来添加日志器,但是静态对象又没初始化完,
建造者完善
因为如果用户想将日志器添加到全局,让任何地方都能获取,那就得加入到单例管理类对象中被管理,而且要先获取单例对象,再调用add函数。为了简化,我们设计一个全局日志器建造者,使得对方调用这个建造者类的时候,我们就能顺便把日志器添加到单例对象的管理中。
//全局日志器创建
class Globalloggerbuild:public loggerbuild
{
public:
Logger::ptr build()override
{
assert(!_logger.empty());
if(_sinks.empty())
buildsink<Stdoutsink>();
if(_formater.get() == nullptr)
_formater = std::make_shared<Formater>();
Logger::ptr logger;
if(_type == LoggerType::Asynclooper)
logger = std::make_shared<Async_Logger>(_logger,_sinks,_limit_level,_formater,_looper_type);
else
logger = std::make_shared<sync_Logger>(_logger,_sinks,_limit_level,_formater);
LoggerManager::getinstance().addlogger(logger);
return logger;
}
};
测试
建造日志器并添加到单例类中并尝试在全局获取。
void test_log() 测试获取全局的日志器
{
logs::Logger::ptr manger = logs::LoggerManager::getinstance().getlogger("日志器1");
int i = 0;
int count = 0;
while(i < 5*1024)
{
i += 21;
manger->Debug(__FILE__,__LINE__,"日志测试");
}
}
void test11()测试日志管理模块
{
std::shared_ptr<logs::Globalloggerbuild> localbuild(new(logs::Globalloggerbuild));
localbuild->buildlevel(logs::loglevel::level::DEBUG);
localbuild->buildformater("%d{%H:%M:%S}[%m]%n");
localbuild->buildlogname("日志器1");
localbuild->buildloggertype(logs::LoggerType::Asynclooper);
localbuild->buildsink<logs::Filesink>("./logs/test2.log");
localbuild->buildsink<logs::Rollsink>("./logs/test2.log",1024);
logs::Logger::ptr lptr = localbuild->build();//建造日志器
test_log();
}
在其它函数内获取日志器来输出,可以获取并输出才表示日志器可以在全局获取。由于是异步的,最后也是生成了几个文件,不足五个,但是大小却是足够的。
十四 封装
思想
实现到了这一步,我们基本上的代码差不多写完了,接下来就是一些小封装实现。我在main.cc测试的时候,要使用功能就得包含不少头文件,
下面这里用户要先调用管理类的静态成员,再获取获取日志器,我们应该避免让用户去获取单例的管理者对象,而且输出日志每次都要传__FILE__,__LINE__这两个宏。
所以我们决定提供一些接口和宏函数,对日志系统接口进行使用便捷性优化。
1. 提供获取指定日志器的全局接口(避免用户自己操作单例对象)
2. 使用宏函数对日志器的接口进行代理(代理模式)
3.提供宏函数,可以直接进行日志的标准输出打印
实现
下面这个封装在log.h中。
下面两个全局接口就可以让用户直接获取日志器,而不用获取日志管理对象。
#include"logger.hpp"
#include<stdarg.h>
namespace logs
{
Logger::ptr getlogger(const std::string name)//提供全局接口获取日志器
{
return LoggerManager::getinstance().getlogger(name);
}
Logger::ptr getrootlogger()//提供全局接口获取默认日志器
{
return LoggerManager::getinstance().getlogger("root");
}
}
通过宏代理。
namespace logs
{
#define Debug(fmt,...) Debug(__FILE__,__LINE__,fmt,##__VA_ARGS__);
#define Info(fmt,...) Info(__FILE__,__LINE__,fmt,##__VA_ARGS__);
#define War(fmt,...) War(__FILE__,__LINE__,fmt,##__VA_ARGS__);
#define Error(fmt,...) Error(__FILE__,__LINE__,fmt,##__VA_ARGS__);
#define Fatal(fmt,...) Fatal(__FILE__,__LINE__,fmt,##__VA_ARGS__);
#define Off(fmt,...) Off(__FILE__,__LINE__,fmt,##__VA_ARGS__);
}
上面代码的意义在于,manger日志器获取接口变简易了。
以前
封装后:
而且调用Debug函数也不用传宏了。
void test2_log()//测试获取全局的日志器
{
logs::Logger::ptr manger = logs::getlogger("日志器1");
int i = 0;
int count = 0;
while(i < 5*1024)
{
i += 21;
count++;
manger->Debug("日志测试");
}
std::cout<<count<<std::endl;
}
void test12()
{
std::shared_ptr<logs::Globalloggerbuild> localbuild(new(logs::Globalloggerbuild));
localbuild->buildlevel(logs::loglevel::level::DEBUG);
localbuild->buildformater("%d{%H:%M:%S}[%f:%l][%m]%n");
localbuild->buildlogname("日志器1");
localbuild->buildloggertype(logs::LoggerType::Asynclooper);
localbuild->buildsink<logs::Filesink>("./logs/test2.log");
localbuild->buildsink<logs::Stdoutsink>();
logs::Logger::ptr lptr = localbuild->build();//建造日志器并添加到管理对象中
test2_log();
}
通过宏直接进行标准输出
namespace logs
{
后面的这个宏会被替换成上面的,所以这里要特别注意上面的宏名
别和下面搞混
#define DEBUG(fmt,...) logs::getrootlogger()->Debug(fmt,##__VA_ARGS__);
#define INFO(fmt,...) logs::getrootlogger()->Info(fmt,##__VA_ARGS__);
#define WAR(fmt,...) logs::getrootlogger()->War(fmt,##__VA_ARGS__);
#define ERROR(fmt,...) logs::getrootlogger()->Error(fmt,##__VA_ARGS__);
#define FATAL(fmt,...) logs::getrootlogger()->Fatal(fmt,##__VA_ARGS__);
#define OFF(fmt,...) logs::getrootlogger()->Off(fmt,##__VA_ARGS__);
}
有时候我们自己不想定义日志器,不想理会日志消息内部时间,行号文件名的排列,就可以调用上面的宏,会使用默认日志器进行输出,日志格式都是用的默认的。
void test2_log()//测试获取全局的日志器
{
int i = 0;
int count = 0;
while(i < 5*1024)
{
i += 21;
count++;
DEBUG("日志测试");
}
std::cout<<count<<std::endl;
}
而且只需要包含一个头文件。
十五 目录梳理
因为我们写代码还要上传到gitte上,我们不得写好看点吗,不得把代码整理一下。example里面是使用样例,我把测试代码和最终封装后的接口使用样例都放在了这里,logs内部是我们提供的组件源代码。
用户使用样例
十六 性能测试
测试环境:
在什么样的环境下,进行了什么测试,得出的结果。
#include"../logs/log.h"
#include<chrono>
日志器名称 线程数量 日志数量 日志长度
void bench(const std::string name,size_t thr_count,size_t msg_count,size_t msg_len)
{
获取指定日志器
logs::Logger::ptr logger = logs::getlogger(name);
if(logger.get() == nullptr)
return;
组织一条日志留了一个空位给\n,也就是说一条日志只放msg_len-1个有效字符,留一个放\n,因为\n也是一个字符
std::string msg(msg_len-1,'A');
创建线程
std::vector<std::thread> vt;
std::vector<int> vi; 记录每个线程耗时
size_t msg_per_count = msg_count / thr_count; 省略了日志余数
for(int i = 0; i < thr_count;i++)
{
vt.emplace_back([&,i]() 这里是在调用emplace创建线程,所以只需要传线程所需参数即可
{ 也就是线程执行函数,我们这里传了lambda表达式
//计时开始
auto begin = std::chrono::high_resolution_clock::now();
for(int j = 0 ; j < msg_per_count;j++)//写入日志
logger->Fatal("%s",msg.c_str());
auto end = std::chrono::high_resolution_clock::now();
std::chrono::duration<double> cost = end - begin;
//计时结束
vi.push_back(cost.count());
std::cout<<"线程: "<<i<<" 耗时: "<<cost.count()<<"s"<<std::endl;
});
}
//必须先回收线程,不然主线程先退出,下面访问vi数组报段错误
for(auto& e : vt)
{
e.join();
}
//计算总耗时
int max_cost = vi[0];
for(int i = 1; i < vi.size();i++)
{
if(vi[i] > max_cost)
{
max_cost = vi[i];
}
}
int size_per_sec = (msg_count * msg_len)/(max_cost*1024);//每秒输出日志大小,单位k
int msg_per_sec = msg_count/max_cost;//每秒输出日志条数
std::cout<<"总耗时: "<<max_cost<<"s"<<std::endl;
std::cout<<"每秒输出日志大小 "<<size_per_sec<<"k"<<std::endl;
std::cout<<"每秒输出日志条数 "<<size_per_sec<<"条"<<std::endl;
}
注意:计算时间是在线程执行函数内,不能将线程创建和回收的时间算入其中。
不能用auto。
总耗时考虑的是最大执行时间,因为在cpu资源充足的时候,多线程是并行的,所以总耗时是最大的时间。
同步异步测试代码,下面这份代码就是基础的测试代码了,后续的多线程和同步异步都是修改一些参数就可以测试了。
void syncbench()
{
// bench("sync",1,1000000,100);//同步单线程检测
bench("sync",3,1000000,100);//同步多线程检测
}
int main()
{
std::shared_ptr<logs::Globalloggerbuild> localbuild(new(logs::Globalloggerbuild));//创建一个全局建造者
//建造日志器对象成员
localbuild->buildlevel(logs::loglevel::level::DEBUG);
localbuild->buildformater("%d{%H:%M:%S}[%f:%l][%m]%n");
localbuild->buildlogname("sync");
localbuild->buildloggertype(logs::LoggerType::sync_Logger);
localbuild->buildsink<logs::Filesink>("./logs/test2.log");
//组装后返回日志器,全局建造者返回的是全局日志器
localbuild->build();
syncbench();
return 0;
}
同步单线程
同步多线程
好像是还快了一点。本来以为同步多线程会因为锁冲突而更慢,没想到比单线程还快了一点,首先可能是线程数量不多,冲突影响不大,然后就是我用的服务器是两核的,可以同时处理多个线程,例如一个线程在处理指令,另一个线程开始写,这样交替进行就比单线程快了。
例如15个线程,这个时候线程多反而是累赘了。
异步单线程
非安全模式业务线程会一直往缓冲区(也就是我们定义的vector)写,一直扩容,直到日志线程来交换,此时业务线程就不会等工作线程把数据丢到文件才继续写,所以我们下面计算耗时没有把落地的时间算入,真考虑的话肯定是不如同步日志器的,因为异步是先到内存,再到磁盘,同步日志器直接写磁盘反而省事了,这个耗时我认为应该是表示业务线程完成完写日志任务的时间,所以日志线程就不考虑日志落地到磁盘的时间。
void Asyncbench()
{
bench("Async",1,1000000,100);//异步单线程检测
}
int main()
{
std::shared_ptr<logs::Globalloggerbuild> localbuild(new(logs::Globalloggerbuild));//创建一个全局建造者
//建造日志器对象成员
localbuild->buildlevel(logs::loglevel::level::DEBUG);
localbuild->buildformater("%d{%H:%M:%S}[%f:%l][%m]%n");
localbuild->buildlogname("Async");
localbuild->buildUnsafeAsync();
localbuild->buildloggertype(logs::LoggerType::Asynclooper);
localbuild->buildsink<logs::Filesink>("./logs/test2.log");
//组装后返回日志器,全局建造者返回的是全局日志器
localbuild->build();
Asyncbench();
return 0;
}
和同步差别不大,因为我们都是往内存写,我们在操作系统文件章节曾提及,往文件写也不是真的写到磁盘,而是写到系统在内存的文件缓冲区,由os决定什么时候刷新。
异步多线程
6个线程,速度加快
14个线程,开始变慢。
好了,日志系统的项目实现就讲完了,对了如果不小心在vscode上删了自己的文件还是可以恢复的,百度一下就可以了。