【项目】基于多设计模式下的同步异步日志系统 - 项目实现
项目设计
核心目标:将一条消息,进行格式化成为指定格式的字符串后,写入到指定位置。
1. 日志系统是支持多落地方向的,也就是支持将日志消息落地到不同的位置,如标准输出、指定文件、滚动文件,同时也支持由用户自己扩展,如写入到数据库。
2. 日志系统支持不同的写入方式(同步、异步):
- 同步:由业务线程自己将日志写入到指定位置。流程简单,但是有可能会因为阻塞导致效率降低。
- 异步:业务线程将日志放入内存缓冲区中,让异步线程负责将日志写入指定位置
3. 日志系统支持多日志器,日志输出以日志器为单位。项目是需要对日志器进行管理的。
模块划分
日志等级模块:对日志进行等级划分,每一条日志都有自己的等级,以便于控制输出。
日志消息模块:封装一条日志所需的各种要素(时间、线程ID、文件名、行号、日志等级、消息主题、...)
消息格式化模块:按照指定的格式,对于日志消息的关键要素进行组织,最终得到一个指定格式的字符串。这个字符串就是真正要写入到指定位置的。
日志落地模块:负责对日志消息进行指定方向的写入。
日志器模块:日志器模块是对上面几个模块的整合。是一个抽象,基于这个抽象去具象出同步日志器和异步日志器。
- 同步日志器模块:完成日志的同步输出功能
- 异步日志器模块:完成日志的异步输出功能
异步线程模块:负责异步日志的实际落地输出功能。
单例的日志器管理模块:对日志进行全局的管理,以便于能够在项目的任何位置获取指定的日志器进行日志输出。
实用工具类的设计与实现
提前完成一些零碎的功能接口,以便于项目中会用到:
- 获取系统时间
- 判断文件/目录是否存在
- 获取文件所在的目录路径
- 创建目录。向指定的文件中写入日志时,若这个文件所在的目录根本不存在,此时肯定是无法写的,所以要实现一个创建目录的功能。
class Date
{
public:// 获取当前时间static time_t now(){// 返回的是时间戳return time(nullptr);}
};class File
{
public:// 判断一个文件是否存在static bool exists(const std::string& pathname);// 获取文件所在的目录路径static std::string path(const std::string& pathname);// 创建目录static void createDirectory(const std::string& pathname);
};
判断一个文件/目录是否存在
#include <unistd.h>int access(const char *pathname, int mode);
实用这个系统调用即可判断一个文件是否存在,第一个参数是文件的路径名称,第二个参数标识的是要对这个文件进行的操作,传入F_OK,即可判断这个文件是否存在。文件存在返回0,不存在返回-1。
// 判断一个文件/目录是否存在
static bool exists(const std::string& pathname)
{return (access(pathname.c_str(), F_OK) == 0);
}
因为access是Linux的系统调用,所以如果实用这个接口,项目的平台移植性并不好。
#include <sys/stat.h>int stat(const char *pathname, struct stat *statbuf);
判断文件是否存在,也可以实用stat,stat是获取文件属性的,如果获取成功了,表示文件存在,获取失败了则表示文件不存在。返回值小于0就表示获取失败。
// 判断一个文件/目录是否存在
static bool exists(const std::string& pathname)
{struct stat st;if (stat(pathname.c_str(), &st) < 0){return false;}return true;
}
获取文件所在的目录路径
假设一个文件所在路径是./abc/test.txt,其实就是获取最后一个 '/' 之前的内容。
// 获取文件所在的目录路径
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);
}
创建目录
假设文件所在路径是./abc/bcd/a.txt,我们想往a.txt中写入日志,此时必须是abc、bcd都存在才行。
#include <sys/stat.h>
#include <sys/types.h> // 部分系统需要,用于定义 mode_t 等类型int mkdir(const char *pathname, mode_t mode);
创建目录使用这个系统调用,第一个参数是目录所在的路径,第二个参数是目录的权限。
// 创建目录
static void createDirectory(const std::string& pathname)
{// ./abc/bcd/cde// 这里传入的是目录,cde就是目录,要想cde存在,必须保证其父目录存在,以此类推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) == true){idx = pos + 1;continue;}mkdir(parent_dir.c_str(), 0777);idx = pos + 1;}
}
测试一下:
int main()
{std::cout << cxflog::util::Date::now() << std::endl;std::string pathname = "./abc/bca/a.txt";cxflog::util::File::createDirectory(cxflog::util::File::path(pathname));return 0;
}
是可以成功创建的。
日志等级模块的设计与实现
在这个模块中,我们需要先定义出所有的日志等级,并提供一个接口,将日志等级转化为一个描述字符串。一共有7个日志等级:
- UNKNOW:未知等级日志
- DEBUG:调试等级日志
- INFO:提示等级日志
- WARN:警告等级日志
- ERROR:错误等级日志
- FATAL:致命错误等级日志
- OFF:关闭等级日志
每一个项目中都会设置一个默认的日志输出等级,只有输出的日志等级大于等于默认限制等级才会被输出。
class LogLevel
{
public:enum class 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";}
};
日志消息模块的设计与实现
意义:存储一条日志消息所需的各项元素。用户在写日志时,只会写入自己想要的输出的,但是日志在真正输出时,是需要在用户输出的基础上加上一些内容的,例如日志信息中需要有文件名、行号等。所以,日志消息类就是存储一条日志消息所需的各项元素。未来用户每写一条日志,都会被封装成一个日志消息对象。
一条日志中,需要有以下的内容:
- 日志的输出时间
- 日志器名称
- 线程ID
- 源文件名称
- 源代码行号
- 日志等级
- 日志主体消息
[2022-09-09 20:16:07][root][1234567][main.cpp:77][FATAL]: 创建套接字失败
struct LogMsg
{time_t _ctime; // 日志产生的时间戳LogLevel::value _level; // 日志等级size_t _line; // 源代码行号std::thread::id _tid; // 线程IDstd::string _file; // 源文件名称std::string _logger; // 日志器名称std::string _payload; // 日志主体消息LogMsg(LogLevel::value level, size_t line,const std::string file, const std::string logger, const std::string msg) :_ctime(util::Date::now()), _level(level), _line(line),_tid(std::this_thread::get_id()), _file(file), _logger(logger), _payload(msg){}
};
日志格式化模块
前面我们说了,用户每写一条日志,都会被封装成一个日志消息对象。但是这个日志消息对象是一个结构体,是没办法直接输出的。日志格式化模块就是将用户输入的内容格式化成一个可以直接输出的、遵循指定格式的日志字符串。也就是将日志消息对象中的内容,按照一定的格式组织成一个可以直接输出的日志字符串。
日志格式化模块的设计思想
日志格式化模块的核心是一个格式化字符串和格式化子项数组。
格式化字符串是一个包含固定文本和格式化字符的字符串,用于规定日志的整体结构和固定内容。因此,我们需要定义一些格式化字符:
- %d:日期
- %T:缩进
- %t:线程ID
- %p:日志等级
- %c:日志器名称
- %f:文件名
- %l:行号
- %m:日志主体消息
- %n:换行
有了这些格式化字符后,就可以决定日志的输出样式了。
格式化子项数组是是一个存储 “日志元数据” 的数组,每个元素对应格式化字符串中的一个占位符,用于提供动态的日志内容。格式化子项数组是通过解析格式化字符串得到的。未来输出日志时,会通过遍历格式化子项数组来进行输出。遍历到什么,就到日志消息对象中获取对应的内容进行输出。
假设现在格式化字符串是:
[%d{%H:%M:%S}][%f:%l] %m%n
解析得到的格式化子项数组是:
- 其他信息(非格式化字符)子项:[
- 时间子项:%H:%M:%S
- 其他信息子项:][
- 文件名子项
- 其他信息子项::
- 行号子项
- 其他信息子项:]
- 日志主体消息子项
- 换行子项
每一个子项就是一个类,为了将不同类型的对象放到一个数组当中,可以使用一个基类去让这个子项类继承。
假设现在有一条日志消息,要按照上面的格式进行输出:
LogMsg
{size_t _ctime = 7777777;LogLevel::value _level = DEBUG;size_t _line = 53;std::thread::id _tid = 1234567;std::string _file = main.cpp;std::string _logger = root;std::string _payload = "套接字创建失败";
}
进行日志输出时,首先会开辟一块内存空间,然后遍历格式化子项数组。到1发现是其他信息子项,直接输出;到2发现是日期子项,就会到LogMsg中取出时间戳,然后将时间戳转化成日期子项的格式,保存到内存空间中;3仍然是其他信息子项;4是文件名子项,就会到LogMsg中取出文件名,保存到内存空间中,后面都是同理。最终可以得到:
[18:50:39][main.cpp:53]套接字创建失败\n
日志格式化子项类的设计思想
作用:从日志消息中取出指定的元素,追加到一块内存空间中
设计思想:
- 抽象一个格式化子项基类
- 基于基类,派生出不同的格式化子项子类:主体消息、日志等级、时间子项、文件名、行号、日志器名称、线程ID、制表符、换行、其他
这样就可以在父类中定义父类指针的数组,指向不同的格式化子项子类对象。
日志格式化子项类的实现
// 抽象格式化子项基类
class FormatItem
{
public:using ptr = std::shared_ptr<FormatItem>;virtual ~FormatItem() {}// 将日志信息msg中的特定信息格式化后输出到流os中virtual void format(std::ostream& os, const LogMsg& msg) = 0;
};
// 主体消息子项
class MsgFormatItem : public FormatItem
{
public:void format(std::ostream& os, const LogMsg& msg) override{os << msg._payload;}
};
// 日志等级子项
class LevelFormatItem : public FormatItem
{
public:void format(std::ostream& os, const LogMsg& msg) override{os << LogLevel::toString(msg._level);}
};
// 线程ID子项
class ThreadFormatItem : public FormatItem
{
public:void format(std::ostream& os, const LogMsg& msg) override{os << msg._tid;}
};
// 时间子项
class TimeFormatItem : public FormatItem
{
public:TimeFormatItem(const std::string& format = "%H:%M:%S") :_format(format){if (format.empty()) _format = "%H:%M:%S";}void format(std::ostream& os, const LogMsg& msg) override{time_t t = msg._ctime;struct tm lt;// localtime可以将时间戳转换成一个时间结构体localtime_r(&t, <);char tmp[128];// strftime可以将struct tm转换成一个指定格式的字符串strftime(tmp, 127, _format.c_str(), <);os << tmp;}
private:std::string _format; // 保存时间的输出格式,默认是时分秒
};
// 文件名子项
class FileFormatItem : public FormatItem
{
public:void format(std::ostream& os, const LogMsg& msg) override{os << msg._file;}
};
// 行号子项
class LineFormatItem : public FormatItem
{
public:void format(std::ostream& os, const LogMsg& msg) override{os << msg._line;}
};
// 日志器子项
class LoggerFormatItem : public FormatItem
{
public:void format(std::ostream& out, LogMsg& msg) override{out << msg._logger;}
}
// 制表符子项
class TabFormatItem : public FormatItem
{
public:void format(std::ostream& os, const LogMsg& msg) override{os << "\t";}
};
// 换行子项
class NLineFormatItem : public FormatItem
{
public:void format(std::ostream& os, const LogMsg& msg) override{os << "\n";}
};
// 其他信息子项
class OtherFormatItem : public FormatItem
{
public:OtherFormatItem(const std::string& str = "") :_str(str) {}void format(std::ostream& os, const LogMsg& msg) override{// 其他信息子项直接输出原始字符串os << _str;}
private:std::string _str;
};
日志格式化类的实现
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") :_pattern(pattern){// 必须成功,否则没有格式化字符串就没办法向下进行了assert(parsePattern());}// 对日志消息对象进行格式化void format(std::ostream& out, LogMsg& msg){for (auto& item : _items){item->format(out, msg);}}std::string format(LogMsg& msg){std::stringstream ss;format(ss, msg);return ss.str();}
private:// 对格式化字符串进行解析, 得到格式化子项数组bool parsePattern();// 根据不同的格式化字符创建不同的格式化子项对象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<ThreadFormatItem>();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 == "p") return std::make_shared<LevelFormatItem>();if (key == "T") return std::make_shared<TabFormatItem>();if (key == "m") return std::make_shared<MsgFormatItem>();if (key == "n") return std::make_shared<NLineFormatItem>();if (key == "") return std::make_shared<OtherFormatItem>(val);std::cout << "没有对应的格式化字符: %" << key << std::endl;abort();return FormatItem::ptr();}
private:std::string _pattern; // 格式化字符串std::vector<FormatItem::ptr> _items; // 格式化子项数组
};
日志格式化字符串解析思想
我们要如何将一个格式化字符串解析成为一个格式化子项数组呢?思想就是遍历格式化字符串,不是%就一直向后,直到遇到%,表示原始字符串结束。遇到%时,看看后面是不是%,若是,表示就是一个%,若不是,表示后面是一个格式化字符。
假设有以下格式化字符串:
abcde[%d{%H:%M:%S}][%p]%T%m%n
我们对其进行解析可以得到:
- key = nullptr,val = abcde[
- key = d, val = %H:%M:%S
- key = nullptr,val = ][
- key = p, val = nullptr
- key = nullptr,val = ]
- key = T, val = nullptr
- key = m, val = nullptr
- key = n, val = nullptr
解析完格式化字符串后,得到上面的数组,根据这个数组创建格式化子项,然后将格式化子项添加到成员数组当中。
// 对格式化字符串进行解析, 得到格式化子项数组
bool parsePattern()
{// 1. 对格式化字符串进行解析std::vector<std::pair<std::string, std::string>> fmt_order;size_t pos = 0;std::string key, val;while (pos < _pattern.size()){// 处理格式化字符串,判断是否是%,不是则是原始字符if (_pattern[pos] != '%'){val.push_back(_pattern[pos++]);continue;}// 当pos位置是%,判断一下是否是%%,若是,将%%处理为一个%,是原始字符if (pos + 1 < _pattern.size() &&_pattern[pos + 1] == '%'){val.push_back('%');pos += 2;continue;}// 到这,说明%后面是一个格式化字符,原始字符串处理完毕fmt_order.push_back(std::make_pair("", val));val.clear();// 现在pos指向%,我们要处理的是后面的格式化字符pos += 1;if (pos == _pattern.size()){std::cout << "%之后,没有对应的格式化字符!" << std::endl;return false;}key = _pattern[pos];pos += 1;if (_pattern[pos] == '{'){pos += 1;while (pos < _pattern.size() && _pattern[pos] != '}'){val.push_back(_pattern[pos++]);}// 走到末尾还没有遇到},说明格式错误if (pos == _pattern.size()){std::cout << "子规则{}匹配出错" << std::endl;return false;}// 没出错,则现在pos指向},让其向后走一步pos += 1;}fmt_order.push_back(std::make_pair(key, val));key.clear();val.clear();}// 2. 根据解析得到的数据,初始化格式化子项数组for (auto& it : fmt_order){_items.push_back(createItem(it.first, it.second));}return true;
}
测试一下:
int main()
{cxflog::LogMsg msg(cxflog::LogLevel::value::INFO, 53, "main.cpp", "root", "格式化功能测试...");cxflog::Formatter fmt;std::string str = fmt.format(msg);std::cout << str << std::endl;return 0;
}
int main()
{cxflog::LogMsg msg(cxflog::LogLevel::value::INFO, 53, "main.cpp", "root", "格式化功能测试...");cxflog::Formatter fmt("abcde[%d{%H:%M:%S}][%t][%c][%f:%l][%p]%T%m%n")std::string str = fmt.format(msg);std::cout << str << std::endl;return 0;
}
int main()
{cxflog::LogMsg msg(cxflog::LogLevel::value::INFO, 53, "main.cpp", "root", "格式化功能测试...");cxflog::Formatter fmt("abcde[%d{%H:%M:%S}][%t][%c][%f:%l][%p]%T%m%n");std::string str = fmt.format(msg);std::cout << str << std::endl;return 0;
}
int main()
{cxflog::LogMsg msg(cxflog::LogLevel::value::INFO, 53, "main.cpp", "root", "格式化功能测试...");cxflog::Formatter fmt("abcde[%d{%H:%M:%S][%t][%c][%f:%l][%p]%T%m%n");std::string str = fmt.format(msg);std::cout << str << std::endl;return 0;
}
功能是正常的。
日志落地模块 - 简单工厂模式
日志落地模块的设计思想
功能:将格式化完成后的日志消息字符串,输出到指定的位置。
扩展:支持同时将日志落地到不同的位置。
位置分类:
- 标准输出
- 指定文件
- 滚动文件(文件按照时间/大小进行滚动切换)
- 扩展:支持用户自己扩展落地方向,如落地到数据库等。
实现思想:
- 抽象出落地模块类
- 不同落地方向从基类进行派生。这样只需要通过父类指针指向子类对象即可在不修改源代码的情况下完成落地方向扩展
- 使用简单工厂模式进行创建于表示的分离
日志落地类的实现
// 日志落地模块基类
class LogSink
{
public:using ptr = std::shared_ptr<LogSink>;LogSink() {}virtual ~LogSink() {}// 将日志消息进行落地virtual void log(const char* data, size_t len) = 0;
};// 落地方向: 标准输出
class StdoutSink : public LogSink
{
public:// 将日志消息写入到标准输出void log(const char* data, size_t len) override{// 不能使用std::cout<<,因为不能指定输出长度std::cout.write(data, len);}
};// 落地方向: 指定文件
class FileSink : public LogSink
{
public:// 构造时传入文件名,并打开文件,将文件句柄管理起来FileSink(const std::string& pathname) :_pathname(pathname){// 1. 创建日志文件所在的目录util::File::createDirectory(util::File::path(_pathname));// 2. 创建并打开日志文件_ofs.open(_pathname, std::ios::binary | std::ios::app);assert(_ofs.is_open());}// 将日志消息写入到文件当中void log(const char* data, size_t len) override{_ofs.write(data, len);assert(_ofs.good());}
private:std::string _pathname; // 文件路径std::ofstream _ofs; // 文件句柄,将句柄管理起来就可以不用频繁打开文件了
};// 落地方向: 滚动文件(以文件大小进行滚动)
class RollBySizeSink : public LogSink
{
public:// 构造时传入基础文件名和文件最大大小,并打开文件,将操作句柄管理起来RollBySizeSink(const std::string& basename, size_t max_size) :_basename(basename), _max_fsize(max_size), _cur_fsize(0){std::string pathname = createNewFile();// 1. 创建日志文件所在的目录util::File::createDirectory(util::File::path(pathname));// 2. 创建并打开日志文件_ofs.open(pathname, std::ios::binary | std::ios::app);assert(_ofs.is_open());}// 将日志消息写入到滚动文件当中,写入前判断文件大小,超过了最大大小就切换文件void log(const char* data, size_t len) override{// 若当前文件写入的数据已经大于等于最大写入数据,关闭当前文件,切换文件if (_cur_fsize >= _max_fsize){_ofs.close();std::string pathname = createNewFile();_ofs.open(pathname, std::ios::binary | std::ios::app);assert(_ofs.is_open());_cur_fsize = 0;}_ofs.write(data, len);assert(_ofs.good());_cur_fsize += len;}
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;filename << lt.tm_mon + 1;filename << lt.tm_mday;filename << lt.tm_hour;filename << lt.tm_min;filename << lt.tm_sec;filename << ".log";return filename.str();}
private:// 通过基础文件名 + 扩展文件名(以时间生成)组成一个实际的当前输出文件名std::string _basename; // 基础文件名std::ofstream _ofs;size_t _max_fsize; // 文件的最大大小,单位是字节size_t _cur_fsize; // 文件当前已经写入的数据大小
};
此时在滚动文件中是会有一些问题的,当文件大小设置的比较小,可能会在1秒钟之内就写满了,然后根据时间创建一个与原先名字相同的文件,导致又向同一个文件内写入了,为了避免这种情况,就需要增加一个计数器,创建新文件名时,末尾加上这个计数器。
// 落地方向: 滚动文件(以文件大小进行滚动)
class RollBySizeSink : public LogSink
{
public:// 构造时传入基础文件名和文件最大大小,并打开文件,将操作句柄管理起来RollBySizeSink(const std::string& basename, size_t max_size) :_basename(basename), _max_fsize(max_size), _cur_fsize(0), _name_count(0){std::string pathname = createNewFile();// 1. 创建日志文件所在的目录util::File::createDirectory(util::File::path(pathname));// 2. 创建并打开日志文件_ofs.open(pathname, std::ios::binary | std::ios::app);assert(_ofs.is_open());}// 将日志消息写入到滚动文件当中,写入前判断文件大小,超过了最大大小就切换文件void log(const char* data, size_t len) override{// 若当前文件写入的数据已经大于等于最大写入数据,关闭当前文件,切换文件if (_cur_fsize >= _max_fsize){_ofs.close();std::string pathname = createNewFile();_ofs.open(pathname, std::ios::binary | std::ios::app);assert(_ofs.is_open());_cur_fsize = 0;}_ofs.write(data, len);assert(_ofs.good());_cur_fsize += len;}
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;filename << lt.tm_mon + 1;filename << lt.tm_mday;filename << lt.tm_hour;filename << lt.tm_min;filename << lt.tm_sec;filename << "-";filename << _name_count++;filename << ".log";return filename.str();}
private:// 通过基础文件名 + 扩展文件名(以时间生成)组成一个实际的当前输出文件名std::string _basename; // 基础文件名std::ofstream _ofs;size_t _max_fsize; // 文件的最大大小,单位是字节size_t _cur_fsize; // 文件当前已经写入的数据大小size_t _name_count; // 计数器
};
日志落地工厂类的实现
如果我们直接使用普通的简单工厂,未来用户想要自己扩展落地方向时,就需要修改源代码,这样肯定是不好的。所以,需要使用模板。
template<typename SinkType>
class SinkFactory
{
public:static LogSink::ptr create(){return std::make_shared<SinkType>();}
};
这样也是有问题的,因为不同的落地类的构造函数的参数是不同的,所以此时可以使用不定参。
class SinkFactory
{
public:template<typename SinkType, typename ...Args>static LogSink::ptr create(Args &&...args){return std::make_shared<SinkType>(std::forward<Args>(args)...);}
};
我们来测试一下:
int main()
{cxflog::LogMsg msg(cxflog::LogLevel::value::INFO, 53, "main.cpp", "root", "格式化功能测试...");cxflog::Formatter fmt;std::string str = fmt.format(msg);cxflog::LogSink::ptr stdout_lsp = cxflog::SinkFactory::create<cxflog::StdoutSink>();cxflog::LogSink::ptr file_lsp = cxflog::SinkFactory::create<cxflog::FileSink>("./logfile/test.log");// 一个文件的最大大小是1MBcxflog::LogSink::ptr roll_lsp = cxflog::SinkFactory::create<cxflog::RollBySizeSink>("./logfile/roll-", 1024 * 1024);stdout_lsp->log(str.c_str(), str.size());file_lsp->log(str.c_str(), str.size());size_t cursize = 0;size_t count = 0;// 向滚动文件中写入10MB的数据while(cursize < 1024 * 1024 * 10){std::string tmp = str + std::to_string(count++);roll_lsp->log(tmp.c_str(), tmp.size());cursize += tmp.size();}return 0;
}
可以看到,3个落地方向都是没有问题的。
日志落地模块的扩展
前面我们说过,日志落地模块是允许用户自己扩展的,我们现在测试一下。用户自己扩展以时间作为滚动文件切换的标准。
// 枚举时间类型,表示以什么时间作为标准进行滚动
enum class TimeGap
{GAP_SECOND,GAP_MINUTE,GAP_HOUP,GAP_DAY
};// 落地方向: 滚动文件(以时间进行滚动)
class RollByTimeSink : public cxflog::LogSink
{
public:RollByTimeSink(const std::string& basename, TimeGap gap_type):_basename(basename){switch(gap_type){case TimeGap::GAP_SECOND: _gap_size = 1; break;case TimeGap::GAP_MINUTE: _gap_size = 60; break;case TimeGap::GAP_HOUP: _gap_size = 3600; break;case TimeGap::GAP_DAY: _gap_size = 3600 * 24; break;}// 获取当前是第几个时间段,未来只要时间段不同就进行切换// 这里一定要对_gap_size等于1进行特殊判断,因为任何数取模1都等于0_cur_gap = _gap_size == 1 ? cxflog::util::Date::now() : cxflog::util::Date::now() % _gap_size;std::string filename = createNewFile();cxflog::util::File::createDirectory(cxflog::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 cur = cxflog::util::Date::now();if((cur % _gap_size) != _cur_gap){_ofs.close();std::string filename = createNewFile();_ofs.open(filename, std::ios::binary | std::ios::app);assert(_ofs.is_open());}_ofs.write(data, len);assert(_ofs.good());}
private:// 根据时间构造扩展文件名,并与基础文件名组合std::string createNewFile(){// 获取系统时间,以时间来构造扩展文件名time_t t = cxflog::util::Date::now();struct tm lt;localtime_r(&t, <);std::stringstream filename;filename << _basename;filename << lt.tm_year + 1900;filename << lt.tm_mon + 1;filename << lt.tm_mday;filename << lt.tm_hour;filename << lt.tm_min;filename << lt.tm_sec;filename << ".log";return filename.str();}
private:std::string _basename; // 基础文件名std::ofstream _ofs;size_t _cur_gap; // 当前是第几个时间段size_t _gap_size; // 时间段大小
};int main()
{cxflog::LogMsg msg(cxflog::LogLevel::value::INFO, 53, "main.cpp", "root", "格式化功能测试...");cxflog::Formatter fmt;std::string str = fmt.format(msg);cxflog::LogSink::ptr time_lsp = cxflog::SinkFactory::create<RollByTimeSink>("./logfile/roll-", TimeGap::GAP_SECOND);time_t old = cxflog::util::Date::now();while(cxflog::util::Date::now() < old + 5){time_lsp->log(str.c_str(), str.size());usleep(1000);}return 0;
}
日志器模块 - 建造者模式
日志器模块的设计思想
功能:对前面所有模块的整合,向外提供接口完成不同等级日志的输出。
管理的成员:
- 格式化模块对象。
- 落地模块对象数组。一个日志器可能会向多个位置进行日志输出。
- 默认的日志输出限制等级。大于等于限制等级的日志才能输出。
- 互斥锁。保证日志输出过程是线程安全的。一个日志器可能在多个线程中使用,就可能出现多个线程向同一个文件当作输出的情况,所以需要有一个互斥锁。
- 日志器名称。日志器的唯一标识,以便于查找。
提供的操作:
- debug等级日志的输出接口
- info等级日志的输出接口
- warn等级日志的输出接口
- error等级日志的输出接口
- fatal等级日志的输出接口
实现:日志器分为同步日志器和异步日志器,我们是定义出日志器基类,再派生出同步日志器类和异步日志器类。这样便于后序我们对日志器进行统一管理。在两种不同的日志器中,只有落地方式不同,所以需要将落地操作抽象出来。不同的日志器调用各自的落地操作进行日志落地。模块关联中使用基类指针对子类日志器对象进行日志管理和操作。
日志器模块基类的实现
// 日志器基类
class Logger
{
public:using ptr = std::shared_ptr<Logger>;Logger(const std::string& logger_name, LogLevel::value level, Formatter::ptr& formatter, std::vector<LogSink::ptr>& sinks) :_logger_name(logger_name), _limit_level(level), _formatter(formatter), _sinks(sinks.begin(), sinks.end()){}// 输出时,传入文件名、行号、格式化字符串、要输出的内容(不定参),就可以根据格式化字符串,获取所有要输出的内容了// 这些接口完成构造日志消息对象的过程并进行格式化,得到格式化后的日志消息字符串,然后进行落地输出void debug(const std::string& file, size_t line, const std::string& fmt, ...){// 1. 判断当前的日志是否达到了输出等级if (LogLevel::value::DEBUG < _limit_level) return;// 2. 对fmt格式化字符串和不定参进行字符串组织,得到日志消息字符串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);}void info(const std::string& file, size_t line, const std::string& fmt, ...){// 1. 判断当前的日志是否达到了输出等级if (LogLevel::value::INFO < _limit_level) return;// 2. 对fmt格式化字符串和不定参进行字符串组织,得到日志消息字符串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);}void warn(const std::string& file, size_t line, const std::string& fmt, ...){// 1. 判断当前的日志是否达到了输出等级if (LogLevel::value::WARN < _limit_level) return;// 2. 对fmt格式化字符串和不定参进行字符串组织,得到日志消息字符串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);}void error(const std::string& file, size_t line, const std::string& fmt, ...){// 1. 判断当前的日志是否达到了输出等级if (LogLevel::value::ERROR < _limit_level) return;// 2. 对fmt格式化字符串和不定参进行字符串组织,得到日志消息字符串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);}void fatal(const std::string& file, size_t line, const std::string& fmt, ...){// 1. 判断当前的日志是否达到了输出等级if (LogLevel::value::FATAL < _limit_level) return;// 2. 对fmt格式化字符串和不定参进行字符串组织,得到日志消息字符串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::FATAL, file, line, res);free(res);}
protected:// 构造日志消息对象、格式化成日志字符串、日志落地void serialize(LogLevel::value level, const std::string& file, size_t line, char* str){// 3. 构造LogMsg对象LogMsg msg(level, line, file, _logger_name, str);// 4. 通过格式化工具对LogMsg进行格式化,得到格式化后的日志字符串std::stringstream ss;_formatter->format(ss, msg);// 5. 进行日志落地log(ss.str().c_str(), ss.str().size());}// 抽象接口完成实际的落地输出 - 不同的日志器会有不同的实际落地方式virtual void log(const char* data, size_t len) = 0;
protected:std::mutex _mutex;std::string _logger_name; // 日志器名称// 这里输出限制等级需要定义为原子类型,保证多线程环境下等级修改和读取的线程安全性// 可以加锁,但是这样会导致效率较低std::atomic<LogLevel::value> _limit_level; // 输出限制等级Formatter::ptr _formatter; // 格式化模块对象std::vector<LogSink::ptr> _sinks; // 落地模块对象数组
};
同步日志器实现
// 同步日志器
class SyncLogger : public Logger
{
public:SyncLogger(const std::string& logger_name, LogLevel::value level, Formatter::ptr& formatter, std::vector<LogSink::ptr>& sinks) :Logger(logger_name, level, formatter, sinks){}
protected:// 将日志直接通过落地模块句柄进行日志落地void log(const char* data, size_t len) override{std::unique_lock<std::mutex> lock(_mutex);if (_sinks.empty()) return;for (auto& sink : _sinks){sink->log(data, len);}}
};
测试一下:
int main()
{std::string logger_name = "sync_logger";cxflog::LogLevel::value limit = cxflog::LogLevel::value::WARN; // DEBUG和INFO是无法输出的cxflog::Formatter::ptr fmt(new cxflog::Formatter("[%d{%H:%M:%S}][%c][%f:%l][%p]%T%m%n"));cxflog::LogSink::ptr stdout_lsp = cxflog::SinkFactory::create<cxflog::StdoutSink>();cxflog::LogSink::ptr file_lsp = cxflog::SinkFactory::create<cxflog::FileSink>("./logfile/test.log");cxflog::LogSink::ptr roll_lsp = cxflog::SinkFactory::create<cxflog::RollBySizeSink>("./logfile/roll-", 1024 * 1024);std::vector<cxflog::LogSink::ptr> sinks = {stdout_lsp, file_lsp, roll_lsp};cxflog::Logger::ptr logger(new cxflog::SyncLogger(logger_name, limit, fmt, sinks));size_t cursize = 0, count = 0;while(cursize < 1024 * 1024 * 10){logger->debug(__FILE__, __LINE__, "%s", "测试日志");logger->info(__FILE__, __LINE__, "%s", "测试日志");logger->warn(__FILE__, __LINE__, "%s", "测试日志");logger->error(__FILE__, __LINE__, "%s", "测试日志");logger->fatal(__FILE__, __LINE__, "%s", "测试日志");// 实际上会生产更多,因为一条日志在组织后,大小会大于20cursize += 80;}return 0;
}
此时是会向3个方向同时落地的。
成功输出了日志,并且还限制了日志输出的等级。
日志器模块扩展
我们会发现,上面在使用日志器输出日志时,需要先创建很多的零部件,然后才能通过日志器输出日志,这样是非常麻烦的。所以,可以使用建造者模式对日志器进行建造。
// 日志器类型
enum class LoggerType
{LOGGER_SYNC, // 同步LOGGER_ASYNC // 异步
};// 使用建造者模式来建造日志器,而不要让用户直接去构造日志器,简化用户的使用复杂度
// 1. 抽象一个日志器建造者类(完成日志器对象所需零部件的构建和日志器的构建)
// a. 设置日志器类型
// b. 将不同类型日志器的创建放到同一个日志器建造者类中完成
class LoggerBuilder
{
public:LoggerBuilder() :_logger_type(LoggerType::LOGGER_SYNC),_limit_level(LogLevel::value::DEBUG){}void buildLoggerType(LoggerType type) { _logger_type = type; }void buildLoggerName(const std::string& name) { _logger_name = name; }void buildLoggerLevel(LogLevel::value level) { _limit_level = level; }void buildFormatter(const std::string& pattern){_formatter = std::make_shared<Formatter>(pattern);}template<typename SinkType, typename ...Args>void buildSink(Args &&...args){LogSink::ptr psink = SinkFactory::create<SinkType>(std::forward<Args>(args)...);_sinks.push_back(psink);}virtual Logger::ptr build() = 0;
protected:LoggerType _logger_type; // 日志器类型std::string _logger_name; // 日志器名称// 这里不需要原子类型,因为这里不涉及多线程操作LogLevel::value _limit_level; // 输出限制等级Formatter::ptr _formatter; // 格式化模块对象std::vector<LogSink::ptr> _sinks; // 落地模块对象数组
};// 2. 派生出具体的建造者类 - 局部日志器的建造者 和 全局的日志器建造者(后面添加了全局单例管理器之后,将日志器添加全局管理)
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()){buildSink<StdoutSink>();}if (_logger_type == LoggerType::LOGGER_ASYNC) {}return std::make_shared<SyncLogger>(_logger_name, _limit_level, _formatter, _sinks);}
};
int main()
{std::unique_ptr<cxflog::LoggerBuilder> builder(new cxflog::LocalLoggerBuilder());builder->buildLoggerName("sync_logger");builder->buildLoggerLevel(cxflog::LogLevel::value::WARN);builder->buildFormatter("%m%n");builder->buildLoggerType(cxflog::LoggerType::LOGGER_SYNC);builder->buildSink<cxflog::FileSink>("./logfile/test.log");builder->buildSink<cxflog::StdoutSink>();cxflog::Logger::ptr logger = builder->build();size_t cursize = 0, count = 0;while(cursize < 1024 * 1024 * 5){logger->debug(__FILE__, __LINE__, "%s", "测试日志");logger->info(__FILE__, __LINE__, "%s", "测试日志");logger->warn(__FILE__, __LINE__, "%s", "测试日志");logger->error(__FILE__, __LINE__, "%s", "测试日志");logger->fatal(__FILE__, __LINE__, "%s", "测试日志");// 实际上会生产更多,因为一条日志在组织后,大小会大于20cursize += 80;}return 0;
}
异步缓冲区设计
前面我们完成的是同步日志器,直接将日志消息进行格式化后写入文件,接下来需要完成异步日志器。之所以要有异步日志器,就是为了避免因为写日志的过程阻塞,导致业务线程在写日志的时候影响效率。因此异步的思想就是不让业务线程进行日志的实际落地操作,而是将日志消息放到缓冲区中,接下来有一个专门的异步线程去针对缓冲区中的数据进行处理,也就是实际的落地操作。
实现:
- 实现一个线程安全的缓冲区
- 创建一个异步工作线程,专门负责缓冲区中日志消息的落地操作
缓冲区详细设计:
- 缓冲区需要使用队列,利用队列先进先出的特点,先放入缓冲区的日志消息先落地。这里不能使用STL中的queue,queue底层会涉及到空间频繁的申请和释放,降低效率。所以我们使用vector。
- 这个缓冲区的操作会涉及到多线程,因此缓冲区的操作必须保证线程安全。我们这里使用互斥锁保证缓冲区的线程安全。
- 在实际开发中,并不会给写日志操作分配太多的资源,因此一个异步日志器只需要有一个异步工作线程即可。也就是说,只需要有一个线程完成缓冲区内日志消息的落地即可。
当前涉及到的锁竞争:生产者与生产者的竞争、生产者与消费者的竞争。锁竞争是较为严重的,因为所有的线程之间都存在竞争关系。此时可以涉及为双缓冲区。
设计一个生产者缓冲区和一个消费者缓冲区。业务线程往生产者缓冲区写入日志,异步工作线程从消费者缓冲区中获取日志。此时是会涉及到缓冲区交换的:
- 当生产者缓冲区被写满了,就进行交换
- 当消费者缓冲区内部没有数据了,并且生产者缓冲区中有数据,就进行交换
有了双缓冲区后,大大减少了生产者与消费者间的锁竞争。此时只存在生产者与生产者的竞争,生产者与消费者的竞争只有在交换缓冲区时才存在。
异步缓冲区类的实现
单个缓冲区的进一步设计:我们让缓冲区存放格式化后的日志消息字符串。这样设计的好处:
- 减少了LogMsg对象频繁的构造的消耗
- 可以针对缓冲区中的日志消息,一次性进行IO操作,减少IO次数,提高效率
缓冲区类的设计:
- 管理一个存放字符串数据的缓冲区,我们使用vector<char>
- 当前写入数据位置的指针
- 当前读取数据位置的指针
提供的操作:
- 向缓冲区写入数据
- 获取可读数据起始地址的接口。这里不能提供读取缓冲区数据的接口,因为这样读取出来时需要保存,就会涉及到拷贝。
- 获取可读数据长度的接口
- 移动读写位置的接口
- 初始化缓冲区的操作。就是将读写位置初始化,在异步工作线程交换缓冲区之前进行。
- 交换缓冲区的接口。交换空间地址,不是交换空间数据。
#define DEFAULT_BUFFER_SIZE (1 * 1024 * 1024) // 缓冲区默认大小
#define THRESHOLD_BUFFER_SIZE (8 * 1024 * 1024) // 指数增长阈值
#define INCREMENT_BUFFER_SIZE (1 * 1024 * 1024) // 线性增长的大小
class Buffer
{
public:Buffer();// 向缓冲区写入数据void push(const char* data, size_t len);// 返回可读数据的起始地址const char* begin();// 返回可读数据的长度size_t readAbleSize();// 返回可写空间的长度size_t writeAbleSize();// 将读指针向后偏移void moveReader(size_t len);// 重置读写位置,初始化缓冲区void reset();// 对Buffer进行交换void swap(Buffer& buffer);// 判断缓冲区是否为空bool empty();
private:// 对空间进行扩容void ensureEnoughSize(size_t len);// 将写指针向后偏移void moveWriter(size_t len);
private:std::vector<char> _buffer;size_t _reader_idx; // 可读位置指针size_t _writer_idx; // 可写位置指针
};
可以看到是有空间扩容接口的。当我们要向缓冲区写入数据时,写入数据的大小加上缓冲区原有数据的大小大于缓冲区的大小,则进行扩容。我们前面说过,在实际开发中,对于写日志的操作,不应该分配太多的资源,如果缓冲区可以无限扩容显然是不合适的。正确的做法应该是缓冲区大小固定,当业务线程因为空间不足而向缓冲区写入失败时,就阻塞,等待异步工作线程处理完了进行唤醒。在测试时,是可以让缓冲区进行扩容的。
我们这里设计是缓冲区直接调用扩容接口,在未来的异步日志器类中进行控制。
这里的扩容思路是小于指数增长阈值时就指数增长,大于等于后就线性增长。
#define DEFAULT_BUFFER_SIZE (1 * 1024 * 1024) // 缓冲区默认大小
#define THRESHOLD_BUFFER_SIZE (8 * 1024 * 1024) // 指数增长阈值
#define INCREMENT_BUFFER_SIZE (1 * 1024 * 1024) // 线性增长的大小
class Buffer
{
public:Buffer():_buffer(DEFAULT_BUFFER_SIZE), _writer_idx(0), _reader_idx(0) {}// 向缓冲区写入数据void push(const char* data, size_t len){// 确保缓冲区空间足够ensureEnoughSize(len);// 将数据拷贝进缓冲区std::copy(data, data + len, &_buffer[_writer_idx]);// 将写位置后移moveWriter(len);}// 返回可读数据的起始地址const char* begin(){return &_buffer[_reader_idx];}// 返回可读数据的长度size_t readAbleSize(){return (_writer_idx - _reader_idx);}// 返回可写空间的长度size_t writeAbleSize(){return (_buffer.size() - _writer_idx);}// 将读指针向后偏移void moveReader(size_t len){assert(len <= readAbleSize());_reader_idx += len;}// 重置读写位置,初始化缓冲区void reset(){_writer_idx = 0;_reader_idx = 0;}// 对Buffer进行交换void swap(Buffer& buffer){_buffer.swap(buffer._buffer);std::swap(_reader_idx, buffer._reader_idx);std::swap(_writer_idx, buffer._writer_idx);}// 判断缓冲区是否为空bool empty(){return (_reader_idx == _writer_idx);}
private:// 对空间进行扩容void ensureEnoughSize(size_t len){if (len <= writeAbleSize()) return; // 不需要扩容size_t new_size = 0;if (_buffer.size() < THRESHOLD_BUFFER_SIZE) new_size = _buffer.size() * 2 + len;else new_size = _buffer.size() + INCREMENT_BUFFER_SIZE + len;_buffer.resize(new_size);}// 将写指针向后偏移void moveWriter(size_t len){assert((len + _writer_idx) <= _buffer.size());}
private:std::vector<char> _buffer;size_t _reader_idx; // 可读位置指针size_t _writer_idx; // 可写位置指针
};
这里需要注意一种情况:当我们进行扩容时,如果按照规则扩容完成后,仍然不满足。所以,我们应该判断一下,这里就直接简单操作了,因为未来在异步日志器类中控制之后,是不会扩容的。现在扩容只是为了进行测试。
我们对上面的缓冲区进行测试,思路是将一个文件的所有内容都放入缓冲区,再将缓冲区的内容写入到另外一个文件,查看两个文件的内容是否一致。
int main()
{// 读取文件数据,一点点写入缓冲区,最终将缓冲区数据写入文件,判断生产的新文件与源文件是否一致std::ifstream ifs("./logfile/test.log", std::ios::binary);if(ifs.is_open() == false) return -1;ifs.seekg(0, std::ios::end); // 读写位置跳转到文件末尾size_t fsize = ifs.tellg(); // 获取当前读写位置相较于起始位置的偏移量,这就是文件的大小ifs.seekg(0, std::ios::beg); // 重新跳转到起始位置std::string body;body.resize(fsize);// 将文件内容全部放到body中,这里不能使用body.c_str(),因为c_str返回的是const char*ifs.read(&body[0], fsize);if(ifs.good() == false) {std::cout << "read error" << std::endl;return -2;}ifs.close();std::cout << "body大小: " << body.size() << std::endl;cxflog::Buffer buffer;// 将body内的数据放入缓冲区buffer中for(int i = 0;i < body.size();i ++){buffer.push(&body[i], 1);}std::cout << "缓冲区可读空间大小: " << buffer.readAbleSize() << std::endl;std::ofstream ofs("./logfile/tmp.log", std::ios::binary);// 将buffer内的数据全部写入文件tmp.log中while(buffer.readAbleSize()){ofs.write(buffer.begin(), 1);buffer.moveReader(1);}return 0;
}
异步工作器的设计与实现
一个异步工作器中会有一个异步工作线程,异步工作器使用的是双缓冲区。
- 业务线程将日志消息写入生产者缓冲区
- 异步工作线程对消费者缓冲区内的数据进行数量,若消费者缓冲区中没有数据则交换缓冲区
成员变量:
- 双缓冲区
- 互斥锁
- 两个条件变量。生产者的条件变量和消费者的条件变量。
- 回调函数。针对消费者缓冲区内部数据的处理接口,由外界传入,告诉异步工作线程数据该如何处理。
// 异步工作器的状态
enum class AsyncType
{ASYNC_SAFE, // 安全状态,缓冲区写满了就阻塞,避免资源消耗的风险 ASYNC_UNSAFE // 不安全状态,缓冲区写满了就扩容,常用于测试。不考虑安全,无限扩容
};
using Functor = std::function<void(Buffer&)>;
// 异步工作器
class AsyncLooper
{
public:using ptr = std::shared_ptr<AsyncLooper>;AsyncLooper(const Functor& cb, const AsyncType& looper_type = AsyncType::ASYNC_SAFE) :_stop(false),_thread(std::thread(&AsyncLooper::threadEntry, this)),_callBack(cb),_looper_type(looper_type){}~AsyncLooper() { stop(); }// 停止异步工作器void stop(){_stop = true;// 唤醒所有的异步工作线程,并等待异步工作线程退出// 当所有的异步工作线程退出时,代表已经将缓冲区内所有的数据都处理完了_cond_con.notify_all();_thread.join();}// 向生产者缓冲区中写入数据void push(const char* data, size_t len){std::unique_lock<std::mutex> lock(_mutex);// 当工作器为安全状态时,使用条件变量判断能否向生产者缓冲区写入数据// 不能则进行等待,业务线程阻塞if (_looper_type == AsyncType::ASYNC_SAFE)_cond_pro.wait(lock, [&]() { return _pro_buf.writeAbleSize() >= len; });// 向缓冲区写入数据_pro_buf.push(data, len);// 唤醒异步工作线程_cond_con.notify_one();}
private:// 异步工作线程的入口函数void threadEntry(){while (true){// 程序刚开始运行时,消费者缓冲区肯定是没有数据的// 所以,应该判断生产者缓冲区是否有数据,有则交换,没有则阻塞{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);// 已经交换了,生产者缓冲区一定有空闲位置,唤醒生产者if (_looper_type == AsyncType::ASYNC_SAFE)_cond_pro.notify_all();}// 消费者缓冲区有数据了,异步工作线程就可以调用用户注册的接口进行业务处理_callBack(_con_buf);// 处理完成后,初始化消费者缓冲区_con_buf.reset();}}
private:Functor _callBack; // 对缓冲区数据进行处理的回调函数,由异步工作器使用者传入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; // 异步工作线程AsyncType _looper_type; // 当前工作器是否安全
};
异步日志器的设计与实现
异步日志器的设计:
- 继承于Logger日志器基类。对于写日志操作进行重写,不再将数据直接写入文件,而是通过异步工作器将数据放入到缓冲区当中。
- 通过异步工作器,进行日志数据的实际落地。
// 异步日志器
class AsyncLogger : public Logger
{
public:AsyncLogger(const std::string& logger_name, LogLevel::value level, Formatter::ptr& formatter,std::vector<LogSink::ptr>& sinks, AsyncType looper_type) :Logger(logger_name, level, formatter, sinks),_looper(std::make_shared<AsyncLooper>(std::bind(&AsyncLogger::realLog, this, std::placeholders::_1), looper_type)){}// 将数据写入缓冲区void log(const char* data, size_t len) override{_looper->push(data, len);}// 将缓冲区内的数据进行实际落地,注册到异步工作器中异步工作线程的回调函数void realLog(Buffer& buf){if (_sinks.empty()) return;for (auto& sink : _sinks){sink->log(buf.begin(), buf.readAbleSize());}}
private:AsyncLooper::ptr _looper; // 异步工作器
};
日志器建造者的完善
// 日志器类型
enum class LoggerType
{LOGGER_SYNC, // 同步LOGGER_ASYNC // 异步
};// 使用建造者模式来建造日志器,而不要让用户直接去构造日志器,简化用户的使用复杂度
// 1. 抽象一个日志器建造者类(完成日志器对象所需零部件的构建和日志器的构建)
// a. 设置日志器类型
// b. 将不同类型日志器的创建放到同一个日志器建造者类中完成
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 buildEnableUnSafeAsync() { _looper_type = AsyncType::ASYNC_UNSAFE; }void buildLoggerName(const std::string& name) { _logger_name = name; }void buildLoggerLevel(LogLevel::value level) { _limit_level = level; }void buildFormatter(const std::string& pattern){_formatter = std::make_shared<Formatter>(pattern);}template<typename SinkType, typename ...Args>void buildSink(Args &&...args){LogSink::ptr psink = SinkFactory::create<SinkType>(std::forward<Args>(args)...);_sinks.push_back(psink);}virtual Logger::ptr build() = 0;
protected:LoggerType _logger_type; // 日志器类型std::string _logger_name; // 日志器名称// 这里不需要原子类型,因为这里不涉及多线程操作LogLevel::value _limit_level; // 输出限制等级Formatter::ptr _formatter; // 格式化模块对象std::vector<LogSink::ptr> _sinks; // 落地模块对象数组AsyncType _looper_type; // 异步日志器是否安全
};// 2. 派生出具体的建造者类 - 局部日志器的建造者 和 全局的日志器建造者(后面添加了全局单例管理器之后,将日志器添加全局管理)
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()){buildSink<StdoutSink>();}if (_logger_type == LoggerType::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);}
};
我们来测试一下异步写日志的功能是否正常,先来测试一下安全的:
int main()
{std::unique_ptr<cxflog::LoggerBuilder> builder(new cxflog::LocalLoggerBuilder());builder->buildLoggerName("async_logger");builder->buildLoggerLevel(cxflog::LogLevel::value::WARN);builder->buildFormatter("[%c]%m%n");builder->buildLoggerType(cxflog::LoggerType::LOGGER_ASYNC);builder->buildSink<cxflog::FileSink>("./logfile/async.log");builder->buildSink<cxflog::StdoutSink>();cxflog::Logger::ptr logger = builder->build();size_t cursize = 0, count = 0;while(cursize < 1024 * 1024 * 5){logger->debug(__FILE__, __LINE__, "%s", "测试日志");logger->info(__FILE__, __LINE__, "%s", "测试日志");logger->warn(__FILE__, __LINE__, "%s", "测试日志");logger->error(__FILE__, __LINE__, "%s", "测试日志");logger->fatal(__FILE__, __LINE__, "%s", "测试日志");// 实际上会生产更多,因为一条日志在组织后,大小会大于20cursize += 80;}return 0;
}
是正常的,再来测试一下不安全的:
int main()
{std::unique_ptr<cxflog::LoggerBuilder> builder(new cxflog::LocalLoggerBuilder());builder->buildLoggerName("async_logger");builder->buildLoggerLevel(cxflog::LogLevel::value::WARN);builder->buildFormatter("[%c]%m%n");builder->buildLoggerType(cxflog::LoggerType::LOGGER_ASYNC);builder->buildEnableUnSafeAsync();builder->buildSink<cxflog::FileSink>("./logfile/async.log");builder->buildSink<cxflog::StdoutSink>();cxflog::Logger::ptr logger = builder->build();size_t count = 0;while(count < 500000){logger->fatal(__FILE__, __LINE__, "测试日志-%d", count ++);}return 0;
}
并且可以感觉到安全的比不安全的更慢。
日志器管理模块 - 单例模式
日志器管理模块的设计思想
日志的输出,我们希望能够在任意位置都可以进行,但是当我们创建了一个日志器之后,就会收到日志器所在作用域的访问属性限制。所以,为了突破访问区域的限制,我们创建一个日志器管理类,我们创建了日志器之后,可以将日志器对象添加到日志器管理类对象中,进行管理。日志器管理类是单例类,这样的话,我们就可以在任意位置通过管理器单例获取到指定的日志器来进行日志输出了。
为了方便用户的使用,也就是用户在不创建任何日志器的情况下,也能进行标准输出的打印。我们让单例管理器创建的时候,默认先创建一个日志器,用于进行标准输出的打印。
管理的成员:
- 默认日志器
- 所管理的日志器的数组
- 互斥锁。保证数组的线程安全。
提供的接口:
- 添加日志器
- 判断是否管理了指定名称的日志器
- 获取指定名称的日志器
- 获取默认日志器
日志器管理类的实现
// 日志器管理类 - 单例模式
class LoggerManager
{
public:static LoggerManager& getInstance(){// 在C++11之后,针对静态局部变量,编译器在编译的层面实现了线程安全// 当静态局部变量在没有构造完成之前,其他的线程进入是会被阻塞的static LoggerManager eton;return eton;}// 添加日志器void addLogger(Logger::ptr& logger){if (hasLogger(logger->name())) return;std::unique_lock<std::mutex> lock(_mutex);_loggers.insert(std::make_pair(logger->name(), logger));}// 判断是否管理了指定名称的日志器bool hasLogger(const std::string& name){std::unique_lock<std::mutex> lock(_mutex);auto it = _loggers.find(name);if (it == _loggers.end()) return false;return true;}// 获取指定名称的日志器Logger::ptr getLogger(const std::string& name){std::unique_lock<std::mutex> lock(_mutex);auto it = _loggers.find(name);if (it == _loggers.end()) return Logger::ptr();return it->second;}// 获取默认日志器Logger::Ptr rootLogger(){return _root_logger;}
private:LoggerManager() {// 通过日志器建造者建造一个默认日志器,并将默认日志器也添加到管理数组中std::unique_ptr<cxflog::LoggerBuilder> builder(new cxflog::LocalLoggerBuilder());builder->buildLoggerName("root");_root_logger = builder->build();_loggers.insert(std::make_pair("root", _root_logger));}
private:std::mutex _mutex;Logger::ptr _root_logger; // 默认日志器std::unordered_map<std::string, Logger::ptr> _loggers; // 日志器名称与指针的映射关系
};
全局日志器建造者的实现
我们前面已经有了一个日志器建造者LocalLoggerBuilder。我们未来使用日志系统时,都是通过日志器管理单例访问日志器,从而进行日志输出的。而我们通过LocalLoggerBuilder建造一个日志器时,是需要手动添加到日志器管理类中的,这显然是十分不便于使用者使用的。所以,我们还要再设计一个建造者,这个建造者每建造一个日志器,都会自动将日志器添加到日志器管理单例中。
- LocalLoggerBuilder是局部建造者。因为我们通过这个建造者建造一个日志器对象后,不会自动添加到全局的日志器管理单例中,这个日志器对象可以局部使用。
- GlobalLoggerBuilder是全局建造者。因为我们通过这个建造者建造一个日志器对象后,会自动添加到全局的日志器管理单例中,这样在项目的任何地方都可以使用。
// 全局建造者
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()){buildSink<StdoutSink>();}Logger::ptr logger;if (_logger_type == LoggerType::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;}
};
测试一下:
void test_log()
{// 从全局的日志器管理单例中获取名为async_logger的日志器,并通过这个日志器进行日志输出cxflog::Logger::ptr logger = cxflog::LoggerManager::getInstance().getLogger("async_logger");size_t count = 0;while(count < 500000){logger->fatal(__FILE__, __LINE__, "测试日志-%d", count ++);}
}int main()
{// 在main函数中定义一个全局建造者,并建造一个名为async_logger的日志器std::unique_ptr<cxflog::LoggerBuilder> builder(new cxflog::GlobalLoggerBuilder());builder->buildLoggerName("async_logger");builder->buildLoggerLevel(cxflog::LogLevel::value::WARN);builder->buildFormatter("[%c]%m%n");builder->buildLoggerType(cxflog::LoggerType::LOGGER_ASYNC);builder->buildEnableUnSafeAsync();builder->buildSink<cxflog::FileSink>("./logfile/async.log");builder->buildSink<cxflog::StdoutSink>();builder->build();test_log();return 0;
}
我们在main函数中定义了一个全局建造者,并建造了一个名为async_logger的日志器,在另一个函数中通过日志器管理单例获取名为async_logger的日志器,并通过这个日志器进行日志输出。
是没有问题的。
全局接口 - 代理模式
全局接口设计思想
我们要创建一些全局接口,简化用户的使用。我们上面在使用日志系统时,是存在一些问题的:
- 需要用户自己传入文件名和行号
- 用户通过单例对象获取日志器很麻烦
设计:
- 提供获取指定日志器的全局接口,以避免用户自己操作单例对象
- 使用宏函数对日志器的接口进行代理 - 代理模式
- 提供宏函数,直接通过默认日志器进行日志的标准输出打印,这种方式不用获取日志器
全局接口实现
// 提供获取指定日志器的全局接口,避免用户自己操作单例对象
Logger::ptr getLogger(const std::string& name)
{return cxflog::LoggerManager::getInstance().getLogger(name);
}
Logger::ptr rootLogger()
{return cxflog::LoggerManager::getInstance().rootLogger();
}// 使用宏函数对日志器接口进行代理 - 代理模式
#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__)// 提供宏函数,直接通过默认日志器进行日志的标准输出打印(不用获取日志器)
#define DEBUG(fmt, ...) cxflog::rootLogger()->debug(fmt, ##__VA_ARGS__)
#define INFO(fmt, ...) cxflog::rootLogger()->info(fmt, ##__VA_ARGS__)
#define WARN(fmt, ...) cxflog::rootLogger()->warn(fmt, ##__VA_ARGS__)
#define ERROR(fmt, ...) cxflog::rootLogger()->error(fmt, ##__VA_ARGS__)
#define FATAL(fmt, ...) cxflog::rootLogger()->fatal(fmt, ##__VA_ARGS__)
现在我们来测试一下。首先测试一下,通过默认日志器进行标准输出。
int main()
{DEBUG("%s", "测试日志");INFO("%s", "测试日志");WARN("%s", "测试日志");ERROR("%s", "测试日志");FATAL("%s", "测试日志");return 0;
}
是没有问题的。默认的日志器是向标准输出进行输出的,如果我们想要将日志写到文件或滚动文件中,就需要自己创建日志器。
void test_log()
{// 通过全局接口获取单例对象,从而获取指定名称的日志器cxflog::Logger::ptr logger = cxflog::getLogger("async_logger");// 日志器通过宏函数对日志进行输出logger->debug("%s", "测试日志");logger->info("%s", "测试日志");logger->warn("%s", "测试日志");logger->error("%s", "测试日志");logger->fatal("%s", "测试日志");
}int main()
{// 通过全局建造者建造一个日志器// 在main函数中定义一个全局建造者,并建造一个名为async_logger的日志器std::unique_ptr<cxflog::LoggerBuilder> builder(new cxflog::GlobalLoggerBuilder());builder->buildLoggerName("async_logger");builder->buildLoggerLevel(cxflog::LogLevel::value::WARN);builder->buildFormatter("[%c][%f:%l]%m%n");builder->buildLoggerType(cxflog::LoggerType::LOGGER_ASYNC);builder->buildEnableUnSafeAsync();builder->buildSink<cxflog::FileSink>("./logfile/async.log");builder->buildSink<cxflog::StdoutSink>();builder->build();test_log();return 0;
}