目录
前言
一般而言,业务的服务都是周而复始的运行,若程序出现问题而未进行记录,则在后期修复时会出现无从下手的情况。
因此,本次我们将实现一个日志系统用于记录程序运行状态的信息,以便程序员随时根据信息进行分析。
该系统拥有以下功能:
- 支持多级别日志消息
- 支持同步日志和异步日志
- 支持可靠写入日志到控制台、文件和滚动文件中
- 支持多线程程序并发写日志
- 支持拓展不同的日志落地方向
整体架构
🍧一个项目的实现,离不开各个模块的共同合作,我们将根据功能划分几个模块。
- 日志信息模块:记录日志输出所需的相关信息
- 消息格式化模块:设置日志输出格式,并提供对日志信息进行格式化
- 日志落地模块:将格式化文成后的日志消息字符串输出到指定的位置
- 日志器模块:由以上模块组建,进行各个等级日志的输出操作
- 日志器管理模块:对所有创建的日志器进行管理
🍧接下来就一起来看看代码实现吧。
工具类的实现
🍧在落实具体模块前,我们先对常用的工具类进行一个实现。
日期类
🍧在之后的日志输出中,我们便经常需要使用到时间这个信息,所以将其封装起来,之后直接调用即可。
cpp
namespace Alpaca
{
namespace util
{
class Date
{
public:
static time_t now()
{
return time(nullptr);
}
};
}
}
文件类
判断文件存在
🍧有一个结构体叫做 stat 用于记录文件的状态,我们可以试图通过获取文件状态来验证该文件是否存在。
🍧或是使用 Linux 下的 acces s 函数同样也能够达到同样的效果。
cpp
static bool exists(const std::string &pathname)
{
// 多操作系统共用
struct stat st;
if (stat(pathname.c_str(), &st) < 0)
return false;
return true;
// Linux专用
// return access(pathname.c_str(), F_OK) == 0;
}
获取文件路径
🍧对于一个整体文件名而言,从末尾开始第一个 **/(Linux)**或 **\(Windows)**前的字符串都是文件的路径。
🍧所以我们只要检索对应的字符,之前的字符串便是该文件的路径。若查找不到对应字符则说明该文件是存在根目录下。
cpp
static std::string path(const std::string &pathname)
{
size_t pos = pathname.find_last_of("/\\"); //匹配其中的任意字符
if (pos == std::string::npos)
return "."; //返回根目录
return pathname.substr(0, pos + 1); //截取对应的字符串
}
创建目录
🍧这个接口我们需要根据传进来的路径进行目录的创建,而创建一个文件需要保证前路径的目录存在 。
🍧因此,我们从头开始遍历路径名,若找到 / 或 \ 便能确定前路径的目录,我们需要判断该目录是否存在,若不存在则创建。
🍧当再找不到前路径便可以直接创建目标目录。
cpp
static void create_directory(const std::string& pathname)
{
if (pathname.empty()) //路径为空
return;
if (exists(pathname)) //路径已存在
return;
size_t pos = 0, idx = 0;
while (idx < pathname.size())
{
pos = pathname.find_first_of("/\\", idx);
if (pos == std::string::npos) //已无前路径,直接创建目录
{
mkdir(pathname.c_str(), 0755); //记得设置文件权限
return;
}
if (pos == idx) //避免符号连续的情况
{
idx = pos + 1;
continue;
}
std::string parent_dir = pathname.substr(0, pos); //截取前路径
if (parent_dir == "." || parent_dir == "..") // . 或 .. 必定存在不用考虑
{
idx = pos + 1;
continue;
}
if (exists(parent_dir)) //判断前路径是否存在
{
idx = pos + 1;
continue;
}
mkdir(pathname.c_str(), 0755); //创建前路径
idx = pos + 1; //迭代
}
}
日志等级的规划
🍧我们创建一个日志等级类,在其中使用枚举设定出不同的日志等级。
🍧由于我们都是以字符串的形式在外部使用,因而还需要实现一个函数用于枚举类型与字符串间的转换。
cpp
namespace Alpaca
{
class LogLevel
{
public:
enum value
{
UNKNOW = 0,
DEBUG,
INFO,
WARN,
ERROR,
FATAL,
OFF
};
static const char *Tostring(LogLevel::value level)
{
switch (level)
{
case LogLevel::value::DEBUG:
return "DEBUG";
case LogLevel::value::INFO:
return "INFO";
case LogLevel::value::WARN:
return "WARN";
case LogLevel::value::ERROR:
return "ERROR";
case LogLevel::value::FATAL:
return "FATAL";
case LogLevel::value::OFF:
return "OFF";
}
return "UNKNOW";
}
};
}
日志信息模块
🍧该模块用于存储记录日志输出所需的相关信息,根据使用的需要可以列举出以下信息:
- 输出时间:****_time
- 日志等级:****_level
- 源文件名称:****_file
- 源文件行号:****_line
- 线程id:****_tid
- 日志的主体信息:****_logger
- 日志器名称:****_payload
🍧而对应的构造函数只需要将对应的信息依次填入成员变量中即可。
cpp
namespace Alpaca
{
struct LogMsg
{
time_t _time;
LogLevel::value _level;
std::string _file;
size_t _line;
std::thread::id _tid;
std::string _logger;
std::string _payload;
LogMsg(LogLevel::value level, std::string file, size_t line,
std::string logger, std::string msg)
: _time(util::Date::now()), _level(level), _file(file)\
, _line(line), _tid(std::this_thread::get_id())\
, _logger(logger), _payload(msg){}
};
}
消息格式化模块
🍧平时我们在使用 printf 时也常常进行格式化操作,这里同样借鉴了该方式。
🍧在该模块中,首先需要由外部传入输出的格式,接着根据格式化字符串进行解析,最终将信息模块的数据填充进需要返回的字符串之中。
🍧同样,我们也同样规定了对应格式化字符所对应的意义:
- %d表示日期,包含子格式 {%H:%M:%S}
- %t表示线程ID
- %p表示日志等级
- %c表示日志器名称
- %f表示文件名
- %l表示行号
- %T表示制表符缩进
- %m表示有效载荷
- %n表示换行
格式化组件
抽象基类
🍧对于数据填充操作,我们想用统一的眼光看待,通过同一函数调用,但最终的结果根据对象的不同而不同。
🍧经这么一说,很自然就能联想到多态,因此格式化类的业务就很明了了。
🍧经由解析格式化字符串,我们获得了一个父类指针数组,之后遍历这个数组时对虚函数进行调用即可。
🍧因此这个基类必须拥有一个虚函数以便子类进行重写,而这个虚函数的参数为流插入和信息类的对象,方便我们直接添加数据。
cpp
namespace Alpaca
{
class FormatItem
{
public:
using ptr = std::shared_ptr<FormatItem>; //外部类中可能使用到该对象,声明一个智能指针类型方便使用
virtual void format(std::ostream& out, LogMsg& msg) = 0; //为了严谨这里定义成纯虚函数最佳
};
}
派生子类
🍧对于派生子类而言,就只需要重写上面的 format 函数,后根据对应的类将对应的数据插入进流中即可。
🍧不同的类的数据类型不同,一定要保证插入进流的为字符串即可。
cpp
class LineFormatItem : public FormatItem
{
public:
virtual void format(std::ostream& out, LogMsg& msg) override
{
out << msg._line;
}
};
日期格式化子类
🍧而凡是总有例外,日期的格式化还有包含子串。
🍧因此需要在构造函数中保存对应的字符串(不能加到虚函数中,这样将不构成重写)。
🍧之后我们从信息类中获取对应日志的时间,在此基础上使用strftime将其转换成字符串,由于strftime的参数为 struct tm* 。
🍧因此需要先time_t类型的数据转换成struct tm,而localtime_r函数便能帮助我们实现。
🍧获取到格式化日期字符串后,直接将其插入流中即可。
cpp
class TimeFormatItem : public FormatItem
{
public:
TimeFormatItem(const std::string& fmt = "%H:%M:%S") : _time_fmt(fmt) {}
void format(std::ostream& out, LogMsg& msg) override
{
struct tm t;
localtime_r(&msg._time, &t);
char tmp[32] = { 0 };
strftime(tmp, sizeof(tmp) - 1, _time_fmt.c_str(), &t);
out << tmp;
}
private:
std::string _time_fmt;
};
其他内容格式化子类
🍧在格式化字串中也常有其他字符用作分隔或其他用途,但这些字符并不在信息类中,而是需要外部传入,因此做法与上面日期子类相同。
cpp
class OtherFormatItem : public FormatItem
{
public:
OtherFormatItem(const std::string& str) : _str(str) {}
virtual void format(std::ostream& out, LogMsg& msg) override //检查重写
{
out << _str;
}
private:
std::string _str;
};
🍧该部分代码参考如下。
cpp
namespace Alpaca
{
class FormatItem
{
public:
using ptr = std::shared_ptr<FormatItem>;
virtual void format(std::ostream &out, LogMsg &msg) = 0;
};
class LevelFormatItem : public FormatItem
{
public:
virtual void format(std::ostream &out, LogMsg &msg) override
{
out << LogLevel::Tostring(msg._level);
}
};
class LineFormatItem : public FormatItem
{
public:
virtual void format(std::ostream &out, LogMsg &msg) override
{
out << msg._line;
}
};
class FileFormatItem : public FormatItem
{
public:
virtual void format(std::ostream &out, LogMsg &msg) override
{
out << msg._file;
}
};
class ThreadFormatItem : public FormatItem
{
public:
virtual void format(std::ostream &out, LogMsg &msg) override
{
out << msg._tid;
}
};
class LoggerFormatItem : public FormatItem
{
public:
virtual void format(std::ostream &out, LogMsg &msg) override
{
out << msg._logger;
}
};
class MsgFormatItem : public FormatItem
{
public:
virtual void format(std::ostream &out, LogMsg &msg) override
{
out << msg._payload;
}
};
class TimeFormatItem : public FormatItem
{
public:
TimeFormatItem(const std::string &fmt = "%H:%M:%S") : _time_fmt(fmt) {}
void format(std::ostream &out, LogMsg &msg) override
{
struct tm t;
localtime_r(&msg._time, &t);
char tmp[32] = {0};
strftime(tmp, sizeof(tmp) - 1, _time_fmt.c_str(), &t);
out << tmp;
}
private:
std::string _time_fmt;
};
class TableFormatItem : public FormatItem
{
public:
virtual void format(std::ostream &out, LogMsg &msg) override
{
out << "\t";
}
};
class NLineFormatItem : public FormatItem
{
public:
virtual void format(std::ostream &out, LogMsg &msg) override
{
out << "\n";
}
};
class OtherFormatItem : public FormatItem
{
public:
OtherFormatItem(const std::string &str) : _str(str) {}
virtual void format(std::ostream &out, LogMsg &msg) override
{
out << _str;
}
private:
std::string _str;
};
}
格式化类
🍧日志输出的格式一旦确定后便不再改变了,如此操作便是为了避免每次都进行格式的解析 ,只需要变换传入的信息对象即可。
🍧因此格式化字符串在构造函数中接收,而在成员函数中每次接收不同的信息对象。
🍧从而可以编写出该结构的大概框架。
cpp
class Formatter
{
public:
using ptr = std::shared_ptr<Formatter>;
Formatter(const std::string& pattern = "[%d{%H:%M:%S}][%t][%c][%f:%l][%p]%T%m%n")
: _patter(pattern)
{}
std::string format(LogMsg& msg); //外部调用
void format(std::ostream& out, LogMsg& msg); //内部嵌套
// 解析格式化规则字符串
bool ParsePattern();
private:
// 根据格式化字符创建不同的格式化对象
FormatItem::ptr createItem(const std::string& key, const std::string& val);
private:
std::string _patter; // 格式化字符串
std::vector<FormatItem::ptr> _items; //父类指针数组
};
根据字符创建不同对象
🍧在解析格式化字符串前,我们需要先实现这个接口,用于根据我们传入的格式化字符创建不同的格式化对象 。
🍧早在父类组件实现时,便声明了父类智能指针这个类型,而成员中的数组中存的便是这个智能指针。
🍧因此,该函数只需要返回对应派生类对象的智能指针即可,且使用智能指针还可以帮助我们完成空间的释放,无需我们手动管理。
cpp
FormatItem::ptr createItem(const std::string& key, const std::string& val)
{
if (key == "d")
return std::make_shared<TimeFormatItem>(val);
if (key == "T")
return std::make_shared<TableFormatItem>();
if (key == "t")
return std::make_shared<ThreadFormatItem>();
if (key == "p")
return std::make_shared<LevelFormatItem>();
if (key == "c")
return std::make_shared<LoggerFormatItem>();
if (key == "f")
return std::make_shared<FileFormatItem>();
if (key == "l")
return std::make_shared<LineFormatItem>();
if (key == "m")
return std::make_shared<MsgFormatItem>();
if (key == "n")
return std::make_shared<NLineFormatItem>();
if (key.empty())
return std::make_shared<OtherFormatItem>(val);
std::cout << "使用非法格式化字符: %" << key << std::endl;
abort();
return FormatItem::ptr();
}
格式化字符串的解析
🍧接下来我们就可以进行格式化字符串的解析了。
🍧在创建个别子项中需要参数的传入,因而我们需要将格式化字符串中相应的内容保存下来(例如日期的子串)。
🍧我们不妨使用一个pair为成员的数组进行保存,若是有参数就将参数保存,若无对应参数则第二个位置留空。
🍧其中的每组对象都对应着函数调用时的两个参数。
cpp
std::vector<std::pair<std::string, std::string>> fmt_order;
🍧接着便开始循环解析,只需要在格式化字符串中查找**%,遇到** % **前的所有字符都是原始字符****,我们暂时将他们保留起来**
cpp
if (_patter[pos] != '%')
{
val.push_back(_patter[pos++]);
continue;
}
🍧遇到两个 % 的情况我们将其视为转义字符,表示一个 % 的原始字符,同样存于原始字符串中。
cpp
if (pos + 1 < _patter.size() && _patter[pos + 1] == '%')
{
val.push_back('%');
pos += 2;
continue;
}
🍧若都未能满足上面两个判断的条件,那么当下 pos 位置一定为% ,因此我们需要先将之前保存起来的原始字符串转移至解析数组之中。
cpp
// 走到这说明原始字符串结束,先推送
if (!val.empty())
{
fmt_order.push_back(std::make_pair("", val));
val.clear();
}
🍧接下来我们让pos向下移动一位,若此时越界则说明**%匹配失败,直接返回false****即可。**
🍧匹配成功,那么当前位置的字符就是我们在查找的格式化字符,我们将其存到 key****中。
cpp
pos++;
if (pos >= _patter.size()) //检查越界
{
std::cout << "%后未有对应的格式化字符" << std::endl;
return false;
}
key = _patter[pos];
🍧接下来,还需要考虑字符后面可能还带着的子串 ,往下一位判断是否为**{,接着进行对}**的查找。
🍧若查找不到**}进行匹配,便直接返回false****,否则就不断往** val****中存入数据。
cpp
// 考虑子规则的情况
pos++;
if (pos < _patter.size() && _patter[pos] == '{')
{
pos++;
while (pos < _patter.size() && _patter[pos] != '}')
{
val.push_back(_patter[pos++]);
}
if (pos >= _patter.size())
{
std::cout << "子规则{}匹配出错" << std::endl;
return false;
}
pos++;
}
🍧能平安走到这里,就代表以获取一个格式化字符 的相关内容,接下来我们便可以将对应的 key val 的值推送到数组之中了。
cpp
// 走出子规则,推送上方解析的数据
fmt_order.push_back(std::make_pair(key, val));
key.clear();
val.clear();
🍧最后循环结束,我们便可以根据解析出来的数组,获取对应对象的指针并填充成员数组。
cpp
// 根据解析到的数据,初始化成员
for (auto& it : fmt_order)
{
_items.push_back(createItem(it.first, it.second));
}
return true;
函数整体
cpp
// 解析格式化规则字符串
bool ParsePattern()
{
std::vector<std::pair<std::string, std::string>> fmt_order;
int pos = 0;
std::string key, val;
while (pos < _patter.size())
{
if (_patter[pos] != '%')
{
val.push_back(_patter[pos++]);
continue;
}
// 走到当前pos当前便指向一个%
if (pos + 1 < _patter.size() && _patter[pos + 1] == '%')
{
val.push_back('%');
pos += 2;
continue;
}
// 走到这说明原始字符串结束,先推送
if (!val.empty())
{
fmt_order.push_back(std::make_pair("", val));
val.clear();
}
// 现在进行格式化字符的处理,此时pos指向%
pos++;
if (pos >= _patter.size()) //检查越界
{
std::cout << "%后未有对应的格式化字符" << std::endl;
return false;
}
key = _patter[pos];
// 考虑子规则的情况
pos++;
if (pos < _patter.size() && _patter[pos] == '{')
{
pos++;
while (pos < _patter.size() && _patter[pos] != '}')
{
val.push_back(_patter[pos++]);
}
if (pos >= _patter.size())
{
std::cout << "子规则{}匹配出错" << std::endl;
return false;
}
pos++;
}
// 走出子规则,推送上方解析的数据
fmt_order.push_back(std::make_pair(key, val));
key.clear();
val.clear();
}
// 根据解析到的数据,初始化成员
for (auto& it : fmt_order)
{
_items.push_back(createItem(it.first, it.second));
}
return true;
}
格式化函数
🍧实现了对格式化字符串的解析,我们只需要在构造函数调用一次即可 ,之后都使用格式化子项数组即可,而这次解析务必成功否则直接出错。
cpp
Formatter(const std::string& pattern = "[%d{%H:%M:%S}][%t][%c][%f:%l][%p]%T%m%n")
: _patter(pattern)
{
assert(ParsePattern());
}
🍧还记得上方定义时写的两个 format 函数吗?外部就是通过这个函数进行格式化后的字符串的获取。
🍧而我们上面写的格式化子项都是将对应的数据插入到流之中, 且因我们最后要获取的是一个字符串,那么不妨使用stringstream进行信息的获取。
🍧同时,stringstream为 ostream的子类可以进行赋值转换**,因此可以作为参数。**
🍧因此,通过对子项数组的遍历,同样调用 log 函数便可完成字符串的组合,遍历结束将流中的数据返回即可。
cpp
std::string format(LogMsg& msg) //外部调用
{
std::stringstream ss;
format(ss, msg);
return ss.str();
}
void format(std::ostream& out, LogMsg& msg) //遍历操作
{
for (auto it : _items)
{
it->format(out, msg);
}
}
日志落地类模块
🍧该模块用于将格式化后的日志消息字符串输出到指定的位置,因为有多种不同的落地方式,我们不妨再次使用多态的方式进行模块的搭建。
🍧同时,外部拓展新落地方向时只需要继承基类并重写虚函数即可。
基类实现
🍧这里需要注意,析构函数也要定义成虚函数,这样即使在外部用父类指针进行管理但最后调用析构函数时会触发多态调用派生类的析构函数。
🍧而这个 log 函数则是我们接下来要进行重写的主要落地函数,而参数使用了字符串和长度的形式则是借鉴了文件操作 。
cpp
namespace Alpaca
{
class LogSink
{
public:
using ptr = std::shared_ptr<LogSink>;
LogSink() {}
virtual ~LogSink() {}
virtual void log(const char* data, size_t len) = 0; //定义成纯虚函数
};
}
标准落地子类
🍧标准落地自然不用多说,以防万一我们根据指定长度写入 ,因此要使用write这个成员函数。
cpp
class StdoutSink : public LogSink
{
public:
void log(const char* data, size_t len)
{
std::cout.write(data, len);
}
};
文件落地子类
🍧这个落地子类,我们需要将日志消息写入到一个文件中,那么需要知道文件的路径,而因为一个子类只对应一个文件,所以文件路径这一信息在构造函数中传入即可。
🍧而打开文件前还需要确保文件前路径存在,因此我们可以调用之前在工具类中实现的 create_directory 函数,对传入文件的路径进行创建。若前段目录未存在则创建,而若存在便会直接返回。
🍧这些前置操作完成后,使用ofstream成员打开对应的文件(记得加追加选项,不然就是覆盖写入),如此我们的构造函数便算完成。
🍧而重写的虚函数也是直接往文件中写入数据即可,写入后判断一下是否写入成功。
cpp
class FileSink : public LogSink
{
public:
// 构造时需要文件名打开文件,并保留句柄
FileSink(const std::string& pathname) : _pathname(pathname)
{
// 创建文件所在路径,存在即返回
util::File::create_directory(util::File::path(pathname));
// 打开文件
_ofs.open(pathname, std::ios::binary | std::ios::app);
assert(_ofs.is_open()); //检查打开成功
}
// 将日志信息写入文件
void log(const char* data, size_t len)
{
_ofs.write(data, len);
assert(_ofs.good());
}
private:
std::string _pathname;
std::ofstream _ofs;
};
🍧再补充一点,当 ofstream 对象被销毁时,任何打开的文件都会自动关闭,因此无需我们手动关闭。
滚动文件落地子类
🍧日志输出的频率极快,若是只在一个文件中输出,查看起来可能会造成不便,因此我们可以使用滚动文件 的方式。
🍧例如,我们可以设置成当一个文件写入数据量已达1Mb,便打开新文件往新文件中写入。
构造函数
🍧这里我们使用的便是实现文件大小达到一定程度便打开新文件的策略即 rollbysize 。
🍧因此,我们需要有成员分别记录当前文件的大小和文件的最大值,除此以外我们还为文件增加一个唯一标识,_name_count表示落地类直到现在打开了多少个滚动文件,最后便是基础文件名和ofsream句柄。
🍧而构造函数的内部与上一个文件落地类似,都是先保证路径上的目录存在,接着打开完整拓展名的文件即可。
cpp
class RollBySizeSink : public LogSink
{
public:
RollBySizeSink(const std::string& basename, size_t max_size)
: _basename(basename), _max_size(max_size), _cur_size(0), _name_count(1)
{
std::string pathname = createNewFile();
// 创建文件所在路径
util::File::create_directory(util::File::path(pathname));
// 打开文件
_ofs.open(pathname, std::ios::binary | std::ios::app);
assert(_ofs.is_open());
}
private:
size_t _name_count; //文件标识
std::string _basename; //基础文件名
std::ofstream _ofs;
size_t _max_size; //最大文件大小
size_t _cur_size; //当前文件大小
};
获取文件的拓展名
🍧因为我们使用的是滚动文件 落地,因此会涉及到打开不同名的文件,因此在构造时只需传入基础文件名即可,而拓展名由我们根据唯一变量进行添加。
cpp
//拓展名 = 基础文件名 + 时间信息 + 滚动文件的计数器
std::string createNewFile()
{
time_t t = util::Date::now();
struct tm lt;
localtime_r(&t, <);
std::stringstream filename;
filename << _basename; //基础文件名
filename << lt.tm_year + 1900 << lt.tm_mon + 1 //时间信息
<< lt.tm_mday << lt.tm_hour
<< lt.tm_min << lt.tm_sec;
filename << "-";
filename << _name_count++; //滚动文件的计数器
filename << ".log";
return filename.str();
}
虚函数重写
🍧在每次落地前,我们都需要计算一下当前文件大小是否超过了文件的限制 ,若超过了则获取一个新文件名,接着关闭原文件的流并打开文件的流。(一定要先关闭原文件,否则可能导致文件描述符被占用完了,进而导致程序崩溃)
cpp
void log(const char* data, size_t len)
{
// 文件过大时创建一个新文件
if (_cur_size > _max_size)
{
std::string pathname = createNewFile();
_ofs.close(); //先关闭再打开
_ofs.open(pathname, std::ios::binary | std::ios::app);
assert(_ofs.is_open());
_cur_size = 0;
}
_ofs.write(data, len);
assert(_ofs.good());
_cur_size += len;
}
落地类工厂
🍧最后我们需要一个 工厂 专门负责落地对象的获取。
🍧但我们不能写死,因为我们还要支持新落地方式的拓展,因此我们使用模板的方式进行对应对象的获取。
🍧同时,又由于各个对象初始化所需的参数不同,便还需要可变参数列表接收不同数量的参数。
cpp
class SinkFactory
{
public:
template <class Sinktype, class... Args>
static LogSink::ptr create(Args &&...args)
{
return std::make_shared<Sinktype>(std::forward<Args>(args)...); //将可变参数包展开
}
};
🍧之后只要在外部指定落地类类型调用该函数并传入对应参数即可获取为对应的落地对象。
日志器模块
🍧我们实现的日志器还分作同步日志器 和异步日志器两种。
🍧同步写日志时,是以串行的模式进行运行,日志完成前不可以进行接下来的业务处理。
🍧而异步日志器中则有专门的线程负责日志的输出操作。
日志器基类
🍧同样,我们先抽象出一个日志器基类 ,之后的各个日志器类型都是在此基础上建立的。
🍧我们先梳理一下该类需要的相关成员,首先便是日志的限制等级,对于一个日志器设定有对应的等级限制,写日志时若输出等级小于限制等级将不会输出。
🍧接着,在下一个模块我们会将日志器管理起来,作为标识符,我们需要对每个日志器进行命名。这样一个个日志器组合起来,便成为了一个庞大的日志系统。
🍧不仅如此,前几个模块都是这个模块的一部分,在日志器中我们还要有专属的格式化模块和落地模块数组(一个日志器可能有多种落地方式)。
🍧最后,为了保证该日志系统能够被线程并发访问,因而在成员中还需要一个互斥锁来保证不会出现冲突。
cpp
namespace Alpaca
{
class Logger
{
public:
using ptr = std::shared_ptr<Logger>;
Logger(const std::string& logger_name, LogLevel::value limit_level,
Formatter::ptr& formatter, std::vector<LogSink::ptr>& sinks)
: _logger_name(logger_name), _limit_level(limit_level),
_formatter(formatter), _sinks(sinks.begin(), sinks.end()) {}
// 通过传入的参数构建一个msg对象,且调用目标方法所生成的日志等级一定与该方法一致
void debug(const std::string& file, size_t line, const std::string& fmt, ...);
void info(const std::string& file, size_t line, const std::string& fmt, ...);
void warn(const std::string& file, size_t line, const std::string& fmt, ...);
void error(const std::string& file, size_t line, const std::string& fmt, ...);
void fatal(const std::string& file, size_t line, const std::string& fmt, ...);
std::string GetLoggerName(); // 不返回引用防止外界修改
protected:
virtual void log(const char* data, size_t len) = 0;
void serialize(LogLevel::value level, const std::string& file, size_t line, char* str);
protected:
std::mutex _mutex;
std::string _logger_name;
std::atomic<LogLevel::value> _limit_level;
Formatter::ptr _formatter;
std::vector<LogSink::ptr> _sinks;
};
}
🍧这里将日志等级定义成了原子性的了,因此之后对其访问就不用加锁了。
🍧其中,各个日志等级 的成员函数是给到外部调用的,而在该函数内部,我们需要完成对限制等级的判断,获取传入参数并进行格式化,最后基于落地类数组进行实际落地。
日志器外部接口实现
🍧需要注意的一点是,参数需要传入对应的文件名 和日志输出时的行号(如果在类内获取,就失去了对应定位的效果)。
🍧函数刚进入时,我们需要对限制等级进行一次判断,同时因为我们使用枚举定义的日志等级,因此可以直接比较(下面以 debug 等级进行演示)。
cpp
if (LogLevel::value::DEBUG < _limit_level)
return;
🍧接下来我们便需要解析不定参数 ,需要va_list这个类型协助我们进行解析操作。
🍧值得注意的一点是,这个传入的不定参数,外部用于对日志正文的格式化。因此这里我们根据对应的格式化字符串将其转化成对应的字符串,作为日志内容用于接下来日志信息类的构建,而这个操作刚好由 vasprintf****完成。
🍧关于这个 va_list 可以将其看作一个指针,我们通过转换指向从而获得不定参数中的各个成员。
cpp
// 解析不定参数
va_list ap; //定义类型
va_start(ap, fmt); //让ap指向不定参数开始
char* res; //定义一个指针用于接收数据
int ret = vasprintf(&res, fmt.c_str(), ap); //转化成字符串
if (ret == -1) //失败则输出相关信息
{
std::cout << "vasprintf failed" << std::endl;
return;
}
va_end(ap); //让ap指向不定参数的结尾
🍧而格式化并落地部分,我们将其封装到了 serialize 函数中,下面一起看看如何实现吧。
🍧根据传入的参数,我们创建出对应的信息对象,紧接着传入格式化模块中转换成字符串,最后交由log函数处理。
cpp
void serialize(LogLevel::value level, const std::string& file, size_t line, char* str)
{
// 构建Logmsg对象
LogMsg msg(level, file, line, _logger_name, str);
// 获取格式化后的字符串
std::stringstream ss;
_formatter->format(ss, msg);
// 日志落地
log(ss.str().c_str(), ss.str().size());
}
🍧正因两种日志器的落地方式,因此这个log 函数则是接下来子类需要重写的虚函数。
🍧走完 serialize 函数后,回到原输出函数,因为vsprintf内部会动态开辟空间给res,在函数结束前还需要释放对应的空间。
cpp
free(res); // vsprintf内部会动态开辟空间给res
🍧由此,debug 部分功能便已实现,而其他等级的函数只需要在该函数的基础上更改其中的等级即可。
基类代码
cpp
namespace Alpaca
{
class Logger
{
public:
using ptr = std::shared_ptr<Logger>;
Logger(const std::string& logger_name, LogLevel::value limit_level,
Formatter::ptr& formatter, std::vector<LogSink::ptr>& sinks)
: _logger_name(logger_name), _limit_level(limit_level),
_formatter(formatter), _sinks(sinks.begin(), sinks.end()) {}
// 通过传入的参数构建一个msg对象,且调用目标方法所生成的日志等级一定与该方法一致
void debug(const std::string& file, size_t line, const std::string& fmt, ...)
{
// 判断输出等级
if (LogLevel::value::DEBUG < _limit_level)
return;
// 解析不定参数
va_list ap;
va_start(ap, fmt);
char* res;
int ret = vasprintf(&res, fmt.c_str(), ap);
if (ret == -1)
{
std::cout << "vasprintf failed" << std::endl;
return;
}
va_end(ap);
serialize(LogLevel::value::DEBUG, file, line, res);
free(res); // vsprintf内部会动态开辟空间给res
}
void info(const std::string& file, size_t line, const std::string& fmt, ...)
{
// 判断输出等级
if (LogLevel::value::INFO < _limit_level)
return;
// 解析不定参数
va_list ap;
va_start(ap, fmt);
char* res;
int ret = vasprintf(&res, fmt.c_str(), ap);
if (ret == -1)
{
std::cout << "vasprintf failed" << std::endl;
return;
}
va_end(ap);
serialize(LogLevel::value::INFO, file, line, res);
free(res); // vsprintf内部会动态开辟空间给res
}
void warn(const std::string& file, size_t line, const std::string& fmt, ...)
{
// 判断输出等级
if (LogLevel::value::WARN < _limit_level)
return;
// 解析不定参数
va_list ap;
va_start(ap, fmt);
char* res;
int ret = vasprintf(&res, fmt.c_str(), ap);
if (ret == -1)
{
std::cout << "vasprintf failed" << std::endl;
return;
}
va_end(ap);
serialize(LogLevel::value::WARN, file, line, res);
free(res); // vsprintf内部会动态开辟空间给res
}
void error(const std::string& file, size_t line, const std::string& fmt, ...)
{
// 判断输出等级
if (LogLevel::value::ERROR < _limit_level)
return;
// 解析不定参数
va_list ap;
va_start(ap, fmt);
char* res;
int ret = vasprintf(&res, fmt.c_str(), ap);
if (ret == -1)
{
std::cout << "vasprintf failed" << std::endl;
return;
}
va_end(ap);
serialize(LogLevel::value::ERROR, file, line, res);
free(res); // vsprintf内部会动态开辟空间给res
}
void fatal(const std::string& file, size_t line, const std::string& fmt, ...)
{
// 判断输出等级
if (LogLevel::value::FATAL < _limit_level)
return;
// 解析不定参数
va_list ap;
va_start(ap, fmt);
char* res;
int ret = vasprintf(&res, fmt.c_str(), ap);
if (ret == -1)
{
std::cout << "vsprintf failed" << std::endl;
return;
}
va_end(ap);
serialize(LogLevel::value::FATAL, file, line, res);
free(res); // vsprintf内部会动态开辟空间给res
}
std::string GetLoggerName() // 不返回引用防止外界修改
{
return _logger_name;
}
protected:
virtual void log(const char* data, size_t len) = 0;
void serialize(LogLevel::value level, const std::string& file, size_t line, char* str)
{
// 构建Logmsg对象
LogMsg msg(level, file, line, _logger_name, str);
// 获取格式化后的字符串
std::stringstream ss;
_formatter->format(ss, msg);
// 日志落地
log(ss.str().c_str(), ss.str().size());
}
protected:
std::mutex _mutex;
std::string _logger_name;
std::atomic<LogLevel::value> _limit_level;
Formatter::ptr _formatter;
std::vector<LogSink::ptr> _sinks;
};
}
同步日志器
🍧同步日志器不需要增加新的成员,对于 log 函数的重写只需要遍历落地类的数组将对应的数据进行落地即可。
cpp
class SyncLogger : public Logger
{
public:
SyncLogger(const std::string& logger_name, LogLevel::value limit_level,
Formatter::ptr& formatter, std::vector<LogSink::ptr>& sinks)
: Logger(logger_name, limit_level, formatter, sinks) {}
protected:
// 直接通过落地模块的句柄进行日志落地
void log(const char* data, size_t len)
{
std::unique_lock<std::mutex> lock(_mutex); //加锁
if (_sinks.empty())
return;
for (auto sink : _sinks) //实际落地
{
sink->log(data, len);
}
}
};
异步日志器
🍧在异步日志器实现前,还有一个重要的拼图还未凑齐,那就是负责异步写日志的线程 。
异步工作线程
🍧我们将该线程封装进一个类中,为类中的一个成员在构造函数中创建线程 ,在析构函数中进行线程关闭的工作。
🍧该类的成员负责数据的输入,而成员中的工作线程负责日志的实际落地。
cpp
AsyncLooper(func_t func, AsyncType looper_type = AsyncType::ASYNC_SAFE)
: _stop(false),
_looper_type(looper_type),
_thread(std::thread(&AsyncLooper::threadEntry, this)), //设置一个工作线程的入口函数
_func(func) {}
~AsyncLooper()
{
stop();
}
void stop()
{
_stop = true;
_cond_con.notify_all();
_thread.join(); // 等待工作线程退出
}
🍧在实际开发中,写日志操作并不会分配太多的资源,因此工作线程只需要一个 就够了。
🍧那么我们传入进来的每条信息都是立刻输出的吗?在文件系统时我们学过,系统调用是十分低效的,若是每有一条就直接输出到文件中,便会严重影响整体线程的效率。因此,我们需要实现一个缓冲区模块,临时存放传入进来的日志消息。
🍧现在我们一起来分析一下,在运行过程中可能涉及的冲突问题,生产者和生产者之间的冲突(写入线程间),生产者和消费者之间的冲突(写入线程和异步工作线程间)。当前的问题便是,锁冲突较为严重,所有线程间都存在互斥关系。
🍧不如,我们的缓冲区模块就使用两个缓冲区组成,如此便有效地减少了锁冲突。
🍧经由改变结构,优化了生产者和消费者之间的冲突,只有在交换缓冲区的过程中才需要进行锁的申请。
缓冲区
🍧现在一起来看看单个缓冲区类是如何实现的吧。
🍧首先便是如何存放一条条日志信息了,因为此时写入已经是格式化后的字符串,我们直接使用 **vector<char>**进行存储即可。
🍧接着,我们这个缓冲区是一个单向的缓冲区,只有缓冲区的数据被清空后才会从头开始写入,当缓冲区被写满后会根据异步线程的设定决定是阻塞还是扩容,因此需要两个指针,分别告诉我们从哪里开始读、哪里开始写,以便进一步操作。
cpp
namespace Alpaca
{
#define DEFAULT_BUFFER_SIZE (1 * 1024 * 1024) //默认大小
#define THRESHOLD_BUFFER_SIZE (8 * 1024 * 1024) //小于这个大小每次扩容 *2
#define INCREASE_BUFFER_SIZE (1 * 1024 * 1024) //大于上方大小每次扩容增加这个数
class Buffer
{
public:
Buffer()
: _buffer(DEFAULT_BUFFER_SIZE),
_read_idx(0), _write_idx(0) {}
private:
std::vector<char> _buffer;
size_t _read_idx;
size_t _write_idx;
};
}
缓冲区的基础操作
🍧对于这个缓冲区而言,我们需要获取一些基础信息或偏移读写指针 ,不妨将其作为接口封装起来。
cpp
const char* begin() //返回可读数据的起始地址
{
return &_buffer[_read_idx];
}
size_t ReadAbleSize() //能读取的数据量
{
return (_write_idx - _read_idx);
}
size_t WriteAbleSize() //还能写的空间
{
return (_buffer.size() - _write_idx);
}
void MoveReader(size_t len) //移动读端
{
assert(len <= ReadAbleSize());
_read_idx += len;
}
void MoveWriter(size_t len) //移动写端
{
assert(len + _write_idx <= _buffer.size());
_write_idx += len;
}
void reset() //将偏移量初始化
{
_write_idx = 0;
_read_idx = 0;
}
void swap(Buffer& buffer) //交换缓冲区
{
_buffer.swap(buffer._buffer);
std::swap(buffer._read_idx, _read_idx);
std::swap(buffer._write_idx, _write_idx);
}
bool empty() //缓冲区判空
{
return (_read_idx == _write_idx);
}
保证足够空间写入
🍧该函数用于确保缓冲区有足够的空间进行写入,本质上为一种扩容函数。
🍧缓冲区的数据若经过几次扩容则可能变得相当庞大,因此不能每次都以两倍进行增长,我们可以设置当空间大于某个数值后,缓冲区每次扩容成线性增长。
cpp
void ensureEnoughSize(size_t len)
{
if (len < WriteAbleSize()) //空间足够直接返回
return;
size_t newsize = 0;
while (newsize < len)
{
if (_buffer.size() < THRESHOLD_BUFFER_SIZE) //小于指定数值每次扩容两倍
newsize = _buffer.size() * 2;
else
newsize = _buffer.size() + INCREASE_BUFFER_SIZE; //大于指定数值每次线性扩容
}
_buffer.resize(newsize);
}
向缓冲区插入数据
🍧在外部我们会进行根据异步日志的模式对线程进行限制,因此我们这里直接确保空间足够 即可。
🍧往缓冲区写入数据后还要记得把可写指针向后偏移。
cpp
void push(const char* data, size_t len)
{
//将是否扩容的决定权交给用户,因此这里只扩容
ensureEnoughSize(len);
std::copy(data, data + len, &_buffer[_write_idx]);
// 将可写位置向后偏移
MoveWriter(len);
}
开始搭建
🍧现在,我们已经完成了缓冲区的实现,接下来便可以进行异步线程部分的搭建了。
🍧于日志器而言,成员需要一个异步类型的标志,一个运行标识符,两个缓冲区,与之对应的条件变量,为了使用条件变量还需要一个互斥锁,以及异步线程和其中的回调函数。
cpp
namespace Alpaca
{
enum class AsyncType
{
ASYNC_SAFE, // 缓冲区满了则阻塞
ASYNC_UNSAFE // 无限扩容
};
class AsyncLooper
{
private:
AsyncType _looper_type;
std::atomic<bool> _stop; //运行标识符
Buffer _pro_buf; // 生产者缓冲区
Buffer _con_buf; // 消费者缓冲区
std::mutex _mutex;
std::condition_variable _cond_pro;
std::condition_variable _cond_con;
std::thread _thread; // 异步工作器对应的工作线程
func_t _func; // 回调函数
};
}
🍧接下来将分成两部分进行实现,分别是生产者写入数据 ,与消费者处理数据。
生产者的数据写入
🍧对生产者而言,数据的写入即将对应数据拷贝到缓冲区中即可,首先缓冲区可能处于一个并发访问的状态,需要先加上锁。
🍧接着判断日志器的写入方式为无限扩容还是阻塞,若是阻塞模式下则进行判断缓冲区中的数据是否足够写入,为否则阻塞。
🍧我们实现的 push 函数内部会保证缓冲区中的空间足够写入,因此直接拷贝数据即可,最后唤醒消费者处理数据即可。
cpp
void push(const char* data, size_t len)
{
// 1.无限扩容 --非安全 2.固定大小
std::unique_lock<std::mutex> lock(_mutex);
if (_looper_type == AsyncType::ASYNC_SAFE)
_cond_pro.wait(lock, [&]()
{ return _pro_buf.WriteAbleSize() >= len; }) //使用lambda表达式;
// 走到这说明缓冲区中有足够空间够我们写入 或是处于无限扩容状态下
_pro_buf.push(data, len);
_cond_con.notify_one(); // 唤醒消费者对缓冲区进行处理
}
消费者的数据处理
🍧消费者即异步线程 ,一开始异步线程所属的缓冲区便是空的,因此每次都需要先获取新的数据。
🍧由此需要先对生产者的缓冲区状态进行判断,若该缓冲区为空,交换过来也没有意义。
🍧进入临界区,我们先对运行状态判断一下,但别着急break万一生产者中还有数据,还是要进行处理的。
🍧接着根据条件变量进行阻塞,若成功走到下面说明生产者缓冲区满足条件,可以交换,交换后唤醒生产者继续填充数据。
🍧走出临界区,说明数据已经准备好了,接着调用回调函数进行数据落地即可,最后清空缓冲区的偏移量,等待获取新数据。
cpp
void threadEntry() // 线程入口函数
{
while (1)
{
//判断生产者的缓冲区是否满足交换要求
{
std::unique_lock<std::mutex> lock(_mutex); //加上{}使锁的临界区就在{}内部分内容
// 避免生产缓冲区中有数据但没有被完全处理的情况
if (_stop && _pro_buf.empty())
break;
//运行状态下,生产者缓冲区为空阻塞,非运行状态下不阻塞
_cond_con.wait(lock, [&]()
{ return _stop || !_pro_buf.empty(); });
_con_buf.swap(_pro_buf);
_cond_pro.notify_all();
}
_func(_con_buf);
_con_buf.reset();
}
}
外部函数的实现
🍧异步线程实现后,剩下的最后一步便是外部函数的实现,在这里我们需要实现一个用于插入日志 的接口,以及异步线程中的回调函数。
🍧首先为插入日志的接口,因为内部帮我们加锁了,所以直接调用异步线程对象的成员函数即可。
cpp
void log(const char* data, size_t len)
{
_looper->push(data, len);
}
🍧而回调函数也很简单,像同步日志器那样,遍历落地对象数据进行落地操作即可 。
cpp
void realLog(Buffer& buf) //实际落地函数
{
if (_sinks.empty())
return;
for (auto sink : _sinks)
{
sink->log(buf.begin(), buf.ReadAbleSize());
}
}
🍧最后需要注意一点,我们在类内写的函数都是默认带有 this 指针的,因此在传入前需要先绑定参数。
cpp
_looper(std::make_shared<AsyncLooper>(std::bind(&AsyncLogger::realLog, this, std::placeholders::_1), logger_type)
日志器建造者
🍧经过上面模块的实现,我们希望通过一个统一的方式进行日志器的建造 ,接下来我们实现一个日志器建造者负责各个类型日志器的建造。
抽象基类
🍧为了方便同步与异步日志器的创建,我们创建了 LoggerType 字段用于二者的区分。
cpp
enum LoggerType
{
LOGGER_SYNC,
LOGGER_ASYNC
};
🍧在构造函数完成部分成员的初始化,而其他成员则在成员函数中传入即可,而 build 为纯虚函数,需要子类进行重写。
cpp
class LoggerBuilder
{
public:
LoggerBuilder()
: _logger_type(LoggerType::LOGGER_SYNC),
_limit_level(LogLevel::value::DEBUG),
_looper_type(AsyncType::ASYNC_SAFE) {}
void BuildLoggerType(LoggerType type)
{
_logger_type = type;
}
void BuildLoggerName(std::string name)
{
_logger_name = name;
}
void BuildLoggerLevel(LogLevel::value level)
{
_limit_level = level;
}
void BuildEnableUsafeAsync()
{
_looper_type = AsyncType::ASYNC_UNSAFE;
}
void BuildFormatter(const std::string& pattern)
{
_formatter = std::make_shared<Formatter>(pattern);
}
template <class SinkType, class... Args>
void BuildSinks(Args &&...args)
{
LogSink::ptr psink = SinkFactory::create<SinkType>(std::forward<Args>(args)...);
_sinks.push_back(psink);
}
virtual Logger::ptr build() = 0;
protected:
AsyncType _looper_type; //异步线程模式
LoggerType _logger_type; //日志器模式
std::string _logger_name; //日志器名称
std::atomic<LogLevel::value> _limit_level; //日志限制等级
Formatter::ptr _formatter; //格式化模块
std::vector<LogSink::ptr> _sinks; //落地模块
};
🍧局部日志器和全局日志器的区别就是在 build 函数中全局日志器会被保存起来,而局部日志器不会。
局部日志器建造
🍧首先我们需要对部分重要对象先进行检测,若不存在则使用默认的设置,但日志器的名字不能没有 ,若检测到未传入名字则直接报错。
🍧之后根据日志器的模式返回对应的日志器指针即可。
cpp
class LocalLoggerBuilder : public LoggerBuilder
{
public:
Logger::ptr build() override
{
assert(_logger_name.empty() == false);
if (_formatter.get() == nullptr)
{
_formatter = std::make_shared<Formatter>();
}
if (_sinks.empty())
{
BuildSinks<StdoutSink>();
}
if (_logger_type == LOGGER_ASYNC)
{
return std::make_shared<AsyncLogger>(_logger_name, _limit_level, _formatter, _sinks, _looper_type);
}
return std::make_shared<SyncLogger>(_logger_name, _limit_level, _formatter, _sinks);
}
};
全局日志器建造者
🍧全局相比局部只需要多加一步,那么就是将获取的日志器指针添加到管理模块的单例对象 中,而管理模块接下来我们会进行实现。
cpp
class GlobalLoggerBuilder : public LoggerBuilder
{
public:
Logger::ptr build() override
{
assert(_logger_name.empty() == false);
if (_formatter.get() == nullptr)
{
_formatter = std::make_shared<Formatter>();
}
if (_sinks.empty())
{
BuildSinks<StdoutSink>();
}
Logger::ptr logger;
if (_logger_type == LOGGER_ASYNC)
logger = std::make_shared<AsyncLogger>(_logger_name, _limit_level, _formatter, _sinks, _looper_type);
else
logger = std::make_shared<SyncLogger>(_logger_name, _limit_level, _formatter, _sinks);
LoggerManager::getInstance().AddLogger(logger); //添加到单例对象中
return logger;
}
};
日志器管理模块
🍧对日志器的管理我们采取 KV 模型,以日志器名称作为定义一个日志器的唯一标识。 为了处理并发访问的问题,成员中还需要增加一个互斥锁。
🍧同时,我们还要将这个管理模块设计成单例模式,使其在全局函数中只有一份。而另一个成员则是自动生成的默认日志器。
cpp
class LoggerManager
{
public:
private:
LoggerManager() //构造函数私有化
{}
private:
std::mutex _mutex;
Logger::ptr _root_logger;
std::unordered_map<std::string, Logger::ptr> _loggers;
};
单例对象的获取
🍧在C++11 中直接获取 static 变量是线程安全的,我们可以直接用这种方式进行单例对象的获取。
cpp
static LoggerManager& getInstance() //返回引用
{
static LoggerManager eton;
return eton;
}
日志器的管理
🍧对于日志器的管理,我们需要实现日志器的添加 、判断存在和获取。
🍧判空非常简单,加锁后直接对名字进行查找最后判断结果即可,同时哈希表的查找效率极高,因此消耗并不大。
cpp
bool IsLoggerExist(const std::string& name)
{
std::unique_lock<std::mutex> lock(_mutex);
auto pos = _loggers.find(name);
if (pos == _loggers.end())
return false;
return true;
}
🍧增加时,我们先判断对应名称的日志器是否存在,若存在直接返回即可,之后加锁往哈希表中插入数据即可。
cpp
void AddLogger(Logger::ptr& logger)
{
if (IsLoggerExist(logger->GetLoggerName()))
{
std::cout << "该名称的日志器以存在" << std::endl;
return;
}
std::unique_lock<std::mutex> lock(_mutex);
_loggers[logger->GetLoggerName()] = logger;
}
🍧获取日志器时,若查找不到对应日志器的话,可以选择直接报错 也可以返回默认的日志器。
cpp
Logger::ptr GetLogger(const std::string& name)
{
std::unique_lock<std::mutex> lock(_mutex);
auto pos = _loggers.find(name);
if (pos == _loggers.end())
{
std::cout << "no such name logger" << std::endl;
assert(false);
}
return pos->second;
}
构造函数
🍧之前成员中就定义了一个默认的日志器,而构造函数主要便是进行该默认日志器的初始化。
cpp
LoggerManager()
{
std::unique_ptr<LoggerBuilder> builder(new LocalLoggerBuilder()); //获取建造者
builder->BuildLoggerName("root"); //填充默认日志器名称
_root_logger = builder->build(); //日志器建造,并保存在成员变量中
_loggers["root"] = _root_logger; //全局化
}
🍧而默认日志器的获取,直接返回成员变量 即可。
cpp
Logger::ptr rootLogger()
{
return _root_logger;
}
代理日志器的接口
提供指定日志器的全局接口
🍧若是不进行一层封装,那么用户在使用时会感到十分的不便 ,需要先获取单例对象再调用函数。
🍧这里直接提供两个全局接口,在函数中帮我们完成了上述操作,使用户可以直接获得指定的日志器。
cpp
// 1.提供指定日志器的全局接口
Logger::ptr Getlogger(const std::string& name)
{
return LoggerManager::getInstance().GetLogger(name);
}
Logger::ptr GetRoot()
{
return LoggerManager::getInstance().rootLogger();
}
使用宏函数对日志器的接口进行代理
🍧接着我们可以使用宏函数 对原本日志器的接口进行代理,接下来调用对应的函数就不用手动传入文件名和行号了。
其中,##VA_ARGS 代表的是可变参数列表。
cpp
// 2.使用宏函数对日志器的接口进行代理
#define debug(fmt, ...) debug(__FILE__, __LINE__, fmt, ##__VA_ARGS__)
#define info(fmt, ...) info(__FILE__, __LINE__, fmt, ##__VA_ARGS__)
#define warn(fmt, ...) warn(__FILE__, __LINE__, fmt, ##__VA_ARGS__)
#define error(fmt, ...) error(__FILE__, __LINE__, fmt, ##__VA_ARGS__)
#define fatal(fmt, ...) fatal(__FILE__, __LINE__, fmt, ##__VA_ARGS__)
提供宏函数直接进行日志的标准输出
🍧同样提供宏函数直接调用默认日志器进行日志输出。
cpp
// 3.提供宏函数直接进行日志的标准输出
#define DEBUG(fmt, ...) GetRoot()->debug(fmt, ##__VA_ARGS__)
#define INFO(fmt, ...) GetRoot()->info(fmt, ##__VA_ARGS__)
#define WARN(fmt, ...) GetRoot()->warn(fmt, ##__VA_ARGS__)
#define ERROR(fmt, ...) GetRoot()->error(fmt, ##__VA_ARGS__)
#define FATAL(fmt, ...) GetRoot()->fatal(fmt, ##__VA_ARGS__)
拓展
🍧我们实现的日志系统支持落地类的拓展 ,因此我们在外部自己实现一个落地类,该落地类是根据时间进行滚动文件的。
cpp
enum TimeGap
{
GAP_SECOND,
GAP_MIN,
GAP_HOUR,
GAP_DAY
};
class RollByTimeSink : public LogSink
{
public:
RollByTimeSink(const std::string &basename, TimeGap gap_type)
: _basename(basename)
{
switch (gap_type)
{
case GAP_SECOND:
_gap_size = 1;
break;
case GAP_MIN:
_gap_size = 60;
break;
case GAP_HOUR:
_gap_size = 3600;
break;
case GAP_DAY:
_gap_size = 3600 * 24;
break;
}
std::string filename = createNewFile();
// 创建文件所在路径
util::File::create_directory(util::File::path(filename));
// 打开文件
_ofs.open(filename, std::ios::binary | std::ios::app);
assert(_ofs.is_open());
}
void log(const char *data, size_t len)
{
time_t t = util::Date::now();
// 判断当前文件是否在该时间段中,否则创建新文件
if (t / _gap_size != _cur_size)
{
std::string pathname = createNewFile();
_ofs.close();
_ofs.open(pathname, std::ios::binary | std::ios::app);
assert(_ofs.is_open());
_cur_size = t / _gap_size;
}
_ofs.write(data, len);
assert(_ofs.good());
}
private:
std::string createNewFile()
{
time_t t = util::Date::now();
struct tm lt;
localtime_r(&t, <);
std::stringstream filename;
filename << _basename;
filename << lt.tm_year + 1900 << lt.tm_mon + 1
<< lt.tm_mday << lt.tm_hour
<< lt.tm_min << lt.tm_sec;
filename << "-";
filename << _name_count++;
filename << ".log";
return filename.str();
}
private:
size_t _name_count;
std::string _basename;
std::ofstream _ofs;
size_t _cur_size;
size_t _gap_size;
};
🍧在主函数中,我们搭建出对应的日志器,接着进行五秒的日志输出。
cpp
int main()
{
std::unique_ptr<LoggerBuilder> builder(new GlobalLoggerBuilder());
builder->BuildLoggerLevel(LogLevel::value::WARN);
builder->BuildLoggerName("Async_logger");
builder->BuildLoggerType(LoggerType::LOGGER_ASYNC);
builder->BuildFormatter("[%c][%f:%l]%m%n");
builder->BuildSinks<RollByTimeSink>("./logfile/roll-async-by-time", GAP_SECOND);
Logger::ptr logger = builder->build();
size_t cur = util::Date::now();
while (util::Date::now() < cur + 5)
{
logger->fatal("%s", "this is a test log");
usleep(1000);
}
return 0;
}
🍧可以看到生成了 0~6 的文件,而 0 和 6 号文件因为位于计时的开始和结束并没有写入数据 , 实际的数据主要写入在1~5号文件中。
测试
🍧在项目总体完成后,我们接下来我们将对该日志系统的性能进行测试。
🍧测试环境: 2核2G 云服务器 CentOS 7.6.1810 。
测试函数的实现
🍧为了测试该日志系统在多线程环境 下的运行状态,因此该测试函数内部需要创建多个线程同时进行写日志的操作。
🍧而线程的数量交由外部用户决定,首先计算每个线程需要输出几条日志,接着创建线程输出日志并开始计时,任务完成后进行时间的计算。
cpp
void bench(const std::string& logger_name, size_t thread_num, size_t msg_num, size_t msg_len)
{
// 获取日志器
Logger::ptr logger = Getlogger(logger_name);
if (logger.get() == nullptr)
{
return;
}
std::cout << "测试日志: " << msg_num << " 条,总大小: " << msg_num * msg_len / 1024 << "KB" << std::endl;
// 组织指定长度的日志消息
std::string msg(msg_len - 1, 'A'); //-1是为了添加\n
// 创建指定数量的线程
std::vector<std::thread> threads;
std::vector<double> cost_array(thread_num); //记录各个线程的消耗时间
size_t per_num = msg_num / thread_num;
for (int i = 0; i < thread_num; i++)
{
threads.emplace_back([&, i]()
{
// 开始测试
int num = per_num;
if (i + 1 == thread_num)
num += (msg_num % thread_num);
auto start = std::chrono::high_resolution_clock::now();
for (int i = 0; i < num; i++)
{
logger->fatal("%s", msg.c_str());
}
auto end = std::chrono::high_resolution_clock::now();
std::chrono::duration<double> cost = end - start;
cost_array[i] = cost.count(); });
}
for (int i = 0; i < thread_num; i++)
{
threads[i].join();
}
// 计算总耗时,求并发最大值
double max_cost = 0;
for (int i = 0; i < cost_array.size(); i++)
{
if (cost_array[i] > max_cost)
max_cost = cost_array[i];
if (i + 1 == thread_num)
per_num += (msg_num % thread_num);
std::cout << "线程" << i + 1 << ": "
<< "\t输出日志数量: " << per_num << ",耗时: " << cost_array[i] << "s" << std::endl;
}
size_t per_sec_msg = msg_num / max_cost;
size_t per_sec_size = per_sec_msg * msg_len / 1024;
std::cout << "\t总耗时: " << max_cost << "s" << std::endl;
std::cout << "\t每秒输出日志数量: " << per_sec_msg << "条" << std::endl;
std::cout << "\t每秒输出日志大小: " << per_sec_size << "KB" << std::endl;
}
🍧我们还可以对日志器的创建进行封装,分成同步日志测试函数 和异步日志测试函数。
cpp
void sync_bench()
{
std::unique_ptr<LoggerBuilder> builder(new GlobalLoggerBuilder());
builder->BuildLoggerName("sync_logger");
builder->BuildLoggerType(LoggerType::LOGGER_SYNC);
builder->BuildFormatter("%m%n");
builder->BuildSinks<FileSink>("./logfile/sync.log");
builder->build();
bench("sync_logger", 5, 1000000, 100);
}
void async_bench()
{
std::unique_ptr<LoggerBuilder> builder(new GlobalLoggerBuilder());
builder->BuildLoggerName("Async_logger");
builder->BuildLoggerType(LoggerType::LOGGER_ASYNC);
builder->BuildEnableUsafeAsync(); // 排除实际落地时间造成的影响
builder->BuildFormatter("%m%n");
builder->BuildSinks<FileSink>("./logfile/async.log");
builder->build();
bench("Async_logger", 5, 1000000, 100);
}
测试结果
🍧图一为同步日志器,图二为异步日志器。
总结
🍧 在该项目中我们频繁地使用继承和多态的相关操作,因此需要熟练掌握对应的操作方法,理解底层原理。
🍧同时,我们还用到了多种设计模式,例如单例模式、工厂模式、建造者模式、代理模式,同样需要学习对应模式的思想。
🍧在函数实现时还使用到了不定参数列表,一样需要熟悉它的使用方法,例如可变参数包的展开方式,及可变模板参数列表的使用。
🍧而对于双缓冲区的运行逻辑也要十分的清晰,它是如何实现数据写入,如何保证线程安全的。
🍧最后就是可以对整体项目进行结构的梳理,可以先尝试画出项目的各个模块,再在此基础上进行消息的拓展。
源码
🍧好了,今天的项目分享到这里就结束了,下面是对应的源码可以进行参考。