基础工具与日志格式化模块实现
文章目录
- 基础工具与日志格式化模块实现
-
- 一、实用工具类设计
-
- [1.1 获取系统时间](#1.1 获取系统时间)
- [1.2 文件相关操作](#1.2 文件相关操作)
- 二、日志等级类
- 三、日志消息类
- 四、日志输出格式化模块
-
- [4.1 格式化子项](#4.1 格式化子项)
- [4.2 格式化字符串构建器](#4.2 格式化字符串构建器)
- [4.3 格式化模块总结](#4.3 格式化模块总结)
- 五、总结
这是一套 C++ 日志系统学习笔记 ,涵盖同步与异步双模式 日志的设计与实现。核心特点:模块化分层(格式->落地->日志器->建造者接口)、双缓冲区生产者-消费者模型、多种设计模式(单例/工厂/代理/建造者)应用、C++11 多线程与智能指针实践,代码可直接在 Linux 下编译测试。
这是这个项目的第二篇开发笔记,感兴趣的大佬可以看看之前的相关内容
一、实用工具类设计
本节正式开始编写日志系统项目代码,首先设计实用工具类。实用工具类包含四个功能接口:获取系统时间、判断文件是否存在、获取文件所在目录路径和创建目录。
1.1 获取系统时间
该功能封装在 Date 类中,通过静态成员函数 getTime 提供。getTime 函数内部调用标准库 time 函数获取系统时间,并将 time_t 类型结果返回。实现非常简单,仅需包含 ctime/time.h 头文件。Date 类同样放置在 logsys::util 命名空间下。静态成员函数设计使得调用时无需创建类实例,直接通过 Date::getTime() 即可获取系统时间。该功能将用于日志文件命名,以时间作为日志文件名称的一部分,实现按时间滚动的日志文件管理策略。
cpp
#include <ctime>
namespace logsys{
namespace util{
class Date{
public:
//获取当前时间
static time_t getTime(){
return time(nullptr) + 8LL * 3600;
}
};
}
}
在实际使用中发现这个函数没有校准时区,我们使用的是北京时间,因此在 time 函数之后需要加上 8LL * 3600。
1.2 文件相关操作
判断文件是否存在接口使用 access 函数或 stat 函数实现,access 函数在 Linux 下可用,stat 函数具有更好的跨平台兼容性。获取文件所在路径接口接收文件路径名,返回其所在目录路径。创建目录接口用于在指定路径创建目录。
cpp
#include <unistd.h>
#include <sys/types.h>
#include <sys/stat.h>
class File{
public:
//判断文件是否存在
static bool exist(const std::string& pathName){
struct stat filestat;
if(stat(pathName.c_str(), &filestat) < 0){
return false;
}
return true;
}
//获取文件路径
static std::string getFilePath(const std::string& pathName){
//查找最后一个分隔符
int pos = pathName.find_last_of("/\\");
if(pos == std::string::npos){
//如果没有找到,说明就是在当前文件中
return ".";
}
return pathName.substr(0, pos + 1);
}
//创建文件路径
static void creatDirectory(const std::string& pathName){
int index = 0;
int pos = 0;
while(index < pathName.size()){
pos = pathName.find_first_of("/\\", index);
if(pos == std::string::npos){
//如果没有找到,说明文件路径只有一级
mkdir(pathName.c_str(), 0777);
break;
}
std::string filePtah = pathName.substr(0, pos + 1);//每次都要从开始逐级创建
if(exist(filePtah)){
//如果文件路径已经存在
index = pos + 1;
continue;
}
mkdir(filePtah.c_str(), 0777);
index = pos + 1;
}
}
};
判断文件存在性的两种实现方法:
- 第一种使用 access 系统调用函数。
- 第二种使用 stat 函数获取文件属性信息,若 stat 调用成功返回 true 表示文件存在,失败返回 false。
二、日志等级类
日志等级类的主要功能是枚举出日志系统中的不同日志等级及其含义,并提供将日志等级枚举转换为对应字符串的接口。
- 日志等级包括:未知等级(UNKNOW,设置为 0)、调试等级(DEBUG,用于程序调试)、提示等级(INFO,用于程序运行过程提示)、警告等级(WARNING,用于可能出现问题的情景)、错误等级(ERROR,用于程序出现错误时)、致命错误等级(FATAL,用于程序必须退出的严重错误)。
- 日志系统会设置一个默认输出等级,只有日志等级大于等于默认限制等级时才会输出。例如,当默认等级设置为 ERROR 时,只有 ERROR 和 FATAL 等级的日志会输出。
- 日志等级类还应该包括将我们自己定义的日志等级(枚举类型)转化为字符串的接口。
cpp
#include <string>
namespace logsys
{
class LogLevel
{
public:
enum VALUE
{
UNKNOW = 0, // 未知日志级别
DEBUG, // 调试日志级别
INFO, // 信息日志级别
WARNING, // 警告日志级别
ERROR, // 错误日志级别
FATAL // 致命错误日志级别
};
static const std::string levToStr(VALUE level)
{
switch (level)
{
case UNKNOW:
return "UNKNOW";
case DEBUG:
return "DEBUG";
case INFO:
return "INFO";
case WARNING:
return "WARNING";
case ERROR:
return "ERROR";
case FATAL:
return "FATAL";
default:
return "UNKNOW";
}
}
};
}
三、日志消息类
日志消息类的设计目的是存储一条日志消息所需的各项要素。这些要素包括时间、日志等级、源文件名称、源代码行号、线程 ID、日志主体消息和日志器名称。
- 时间:记录错误产生的时间,必须存在。
- 日志等级:描述日志信息的类型,如提示信息或致命错误,用于过滤排查。
- 源文件名称和源代码行号:用于定位错误出现的具体代码位置。
- 线程 ID:用于识别是哪个线程或进程产生的错误。
- 日志主体消息:是具体的错误描述,如"创建套接字失败"。
- 日志器名称:用于在多日志器系统中分辨不同项目组的日志输出。
cpp
#include "logLevel.hpp"
#include "util.hpp"
#include <thread>
namespace logsys
{
struct LogMessage
{
time_t _time;
size_t _line;
std::thread::id _tid;
std::string _loggerName;
std::string _fileName;
LogLevel::VALUE _level;
std::string _message;
LogMessage() {}
LogMessage(size_t line, const std::string &loggerName, const std::string &fileName,
LogLevel::VALUE level, const std::string &message)
: _line(line), _loggerName(loggerName), _fileName(fileName),
_level(level), _message(message) {
_time = util::Date::getTime();
_tid = std::this_thread::get_id();
}
};
}
时间戳通过调用 util::Date::getTime() 自动设置,线程 ID 通过 std::this_thread::get_id() 自动获取。
四、日志输出格式化模块
为提供灵活的日志输出格式定制能力,模块的主要功能是对日志消息进行字符串格式化,组织成指定格式的字符串后输出。
4.1 格式化子项
这个模块包含格式化子项基类 ,以及根据这个基类派生的各项子类。
为了便于扩展一条日志中的其他元素,我们为一条日志消息所需的各项要素分别派生一个子类对这个元素进行格式化输出(比如日期格式化子项、线程 ID 格式化子项、日志器名称格式化子项、日志级别格式化子项、文件名和行号格式化子项、消息内容格式化子项和换行符格式化子项),后续如果用户需要其他类型的可以新增派生类进行扩展。
cpp
namespace logsys
{
// 格式化内容构建器的基类
class FormatItem
{
public:
using Ptr = std::shared_ptr<FormatItem>;
virtual void format(std::ostream &out, LogMessage &msg) = 0;
};
// 时间格式化
class TimeFormatItem : public FormatItem
{
public:
TimeFormatItem(const std::string &fmt = "%H:%M:%S") : _time_fmt(fmt) {}
void format(std::ostream &out, LogMessage &msg) override
{
struct tm t;
localtime_r(&msg._time, &t);
char s[32] = {0};
strftime(s, 31, _time_fmt.c_str(), &t);
out << s;
}
private:
std::string _time_fmt;
};
// 行号格式化
class LineFormatItem : public FormatItem
{
public:
void format(std::ostream &out, LogMessage &msg) override
{
out << msg._line;
}
};
// 线程ID格式化
class TidFormatItem : public FormatItem
{
public:
void format(std::ostream &out, LogMessage &msg) override
{
out << msg._tid;
}
};
// 日志器名称格式化
class LoggerNameFormatItem : public FormatItem
{
public:
void format(std::ostream &out, LogMessage &msg) override
{
out << msg._loggerName;
}
};
// 文件名格式化
class FileNameFormatItem : public FormatItem
{
public:
void format(std::ostream &out, LogMessage &msg) override
{
out << msg._fileName;
}
};
// 日志等级格式化
class LevelFormatItem : public FormatItem
{
public:
void format(std::ostream &out, LogMessage &msg) override
{
out << LogLevel::levToStr(msg._level);
}
};
// 日志主要信息
class MsgFormatItem : public FormatItem
{
public:
void format(std::ostream &out, LogMessage &msg) override
{
out << msg._message;
}
};
// 制表符格式化
class TabFormatItem : public FormatItem
{
public:
void format(std::ostream &out, LogMessage &msg) override
{
out << "\t";
}
};
// 换行格式化
class NLineFormatItem : public FormatItem
{
public:
void format(std::ostream &out, LogMessage &msg) override
{
out << "\n";
}
};
// 其他字符格式
class OtherFormatItem : public FormatItem
{
public:
OtherFormatItem(const std::string &str) : _str(str) {}
void format(std::ostream &out, LogMessage &msg) override
{
out << _str;
}
private:
std::string _str;
};
}
4.2 格式化字符串构建器
这个类才是构建格式化日志信息的构建者。当用户创建这样一个构建器对象时,需要传递一个格式化字符串构建规则(字符串类型,如:"[%d{%H:%M:%S}][%t][%c][%f:%l][%p]%T%m%n"),之后构建器解析这个参数,将对应的格式化子项按顺序加入到格式化子项数组中,当需要格式化一条具体的日志消息时,系统会按照这个数组中子项的顺序,依次从日志消息中提取对应的数据元素,并将它们按照指定的格式组合成最终的输出字符串。
成员变量
这个类中有两个成员变量:格式化字符串构建规则 _fmt 和格式化子项数组 _formatItems。
用户可以根据需要自定义格式化字符串,调整日志输出的详细程度。模块会解析格式化字符串,从中提取各个格式化字符,并创建对应的格式化子项对象,最后将对应的格式化子项按顺序加入到格式化子项数组中。
cpp
class Formater
{
private:
std::string _fmt; // 格式化字符串构建规则
std::vector<FormatItem::Ptr> _formatItems;
};
格式化字符串构建规则解析函数
parse 函数负责解析格式化字符串(如 "[%d{%H:%M:%S}][%t][%c][%f:%l][%p]%T%m%n"),将其分解为各个格式化子项。该函数需要处理格式化字符串 _fmt 中的普通字符和格式化字符(以 % 开头的字符),对于普通字符直接创建 OtherFormatItem 对象,对于格式化字符则根据字符类型创建对应的格式化子项对象。特别地,对于日期格式化字符 %d,还需要解析其后可能跟随的时间格式子串 {%H:%M:%S}。解析完成后,将生成的格式化子项对象存储在 items 数组中,供后续格式化操作使用。由于格式化必须基于有效的解析结果,因此在构造函数中调用 parse 函数后使用 assert 断言确保解析成功。
基本思路:格式化字符串分为原始字符串和格式化字符两种类型。
- 原始字符串不以百分号起始,而格式化字符以百分号开头。
- 当遇到百分号时,需要判断其后是否为另一个百分号,如果是则代表转义字符,否则后接的字符即为格式化字符。
- 格式化字符可能包含子格式,通过花括号标识。
格式化规则字符串解析的具体实现步骤:
解析思想是从前往后逐个字符处理,识别百分号起始的格式化字符和普通原始字符串。当遇到百分号时,检查其后字符是否为另一个百分号以判断转义情况。格式化字符后可能跟随花括号包裹的子格式内容,需要特别处理。
解析过程中需要将原始字符串、格式化字符标识符及其子格式内容保存到临时数组中。每个处理步骤得到的信息包括键值对:原始字符串的键为空,值为字符串内容;格式化字符的键为标识符,值为子格式内容或空。解析完成后,根据临时数组内容按顺序创建格式化子项对象并添加到成员数组 items 中。
cpp
namespace logsys
{
class Formater
{
public:
// 解析格式化字符串构建规则,构建格式化内容构建器
bool parsefmt()
{
std::list<std::pair<std::string, std::string>> pairlist;
size_t index = 0, pos = 0;
while (index < _fmt.size())
{
pos = _fmt.find("%", index);
if (pos == std::string::npos)
{
// 如果没有找到,代表后面没有格式化信息,但可能有其他字符
pairlist.push_back({"", _fmt.substr(index)});
break;
}
// 如果能找到"%",先处理%之前的字符串
if(pos > index)
{
pairlist.push_back({"", _fmt.substr(index, pos - index)});
}
if (pos + 1 >= _fmt.size())
{
return false; // 如果没有找到符合的格式化规则,说明格式化字符串有误,解析失败
}
// 我们需要判断是%还是%%
if (_fmt[pos + 1] == '%')
{
pairlist.push_back({"", "%"});
index = pos + 2;
continue;
}
else
{
// 我们需要先识别后面有没有'{}',这是%x的格式化子项
std::string val = "";
if (pos + 2 < _fmt.size() && _fmt[pos + 2] == '{')
{
size_t bpos = _fmt.find("}", pos + 3);
if (bpos != std::string::npos)
{
val = _fmt.substr(pos + 3, bpos - (pos + 3));
// 下一次循环开始查找位置从 bpos + 1 ---> }的下一位
index = bpos + 1;
}
else
{
return false; // 如果没有找到},说明格式化字符串有误,解析失败
}
}
else
{
index = pos + 2;
}
if (_fmt[pos + 1] == 'd')
pairlist.push_back({"d", val});
else if (_fmt[pos + 1] == 'T')
pairlist.push_back({"T", val});
else if (_fmt[pos + 1] == 't')
pairlist.push_back({"t", val});
else if (_fmt[pos + 1] == 'p')
pairlist.push_back({"p", val});
else if (_fmt[pos + 1] == 'c')
pairlist.push_back({"c", val});
else if (_fmt[pos + 1] == 'f')
pairlist.push_back({"f", val});
else if (_fmt[pos + 1] == 'l')
pairlist.push_back({"l", val});
else if (_fmt[pos + 1] == 'm')
pairlist.push_back({"m", val});
else if (_fmt[pos + 1] == 'n')
pairlist.push_back({"n", val});
else
return false; // 如果没有找到符合的格式化规则,说明格式化字符串有误,解析失败
}
}
// 根据解析结果创建格式化子项
for (auto &pair : pairlist)
{
_formatItems.push_back(createFormatItem(pair.first, pair.second));
}
return true;
}
private:
FormatItem::Ptr createFormatItem(const std::string &key, const std::string &val)
{
if (key == "d")
return std::make_shared<TimeFormatItem>(val);
if (key == "T")
return std::make_shared<TabFormatItem>();
if (key == "t")
return std::make_shared<TidFormatItem>();
if (key == "p")
return std::make_shared<LevelFormatItem>();
if (key == "c")
return std::make_shared<LoggerNameFormatItem>();
if (key == "f")
return std::make_shared<FileNameFormatItem>();
if (key == "l")
return std::make_shared<LineFormatItem>();
if (key == "m")
return std::make_shared<MsgFormatItem>();
if (key == "n")
return std::make_shared<NLineFormatItem>();
return std::make_shared<OtherFormatItem>(val);
}
};
}
格式化字符串包含多个格式化字符:%d 表示日期,%t 表示线程 ID,%p 表示日志级别,%c 表示日志器名称,%f 表示文件名,%l 表示行号,%m 表示日志消息,%n 表示换行。通过组合这些格式化字符,用户可以自定义日志输出格式。
构造函数
当用户创建这样一个构建器对象时,需要传递一个格式化字符串构建规则(字符串类型)。
cpp
namespace logsys
{
class Formater
{
// 构造函数,设置格式化字符串构建规则
using Ptr = std::shared_ptr<Formater>;
Formater(const std::string &fmt = "[%d{%H:%M:%S}][%t][%c][%f:%l][%p]%T%m%n")
: _fmt(fmt)
{
assert(parsefmt()); // 解析构建规则必须成功
}
};
}
格式化输出函数
我们重载了两个不同的版本,一个指定输出流,另一个直接返回格式化之后的字符串。
当我们需要通过 LogMessage 获取其指定格式的输出时,用这个函数即可。
cpp
namespace logsys
{
class Formater
{
// 指定输出流,对logMessage进行格式化
void format(std::ostream &out, LogMessage &msg)
{
for (auto &item : _formatItems)
{
item->format(out, msg);
}
}
// 对logMessage进行格式化,返回格式化后的字符串
std::string format(LogMessage &msg)
{
std::stringstream ss;
for (auto &item : _formatItems)
{
item->format(ss, msg);
}
return ss.str();
}
};
}
4.3 格式化模块总结
日志格式化模块主要由两个核心组件构成:格式化字符串和格式化子项数组。格式化字符串是用户定义的模板,指定了日志消息的输出格式,其中包含各种格式化指令和普通文本。格式化子项数组则是通过对格式化字符串进行解析后生成的一系列处理指令,每个指令对应一种特定的数据处理方式。
当模块工作时:
- 首先会对格式化字符串进行词法分析和语法分析,将其分解为多个格式化子项,并按照出现的顺序存储在数组中。
- 然后,在处理具体的日志消息时,系统会遍历这个子项数组,对于每个子项,从日志消息中提取相应的数据,按照子项定义的格式进行处理,并将结果追加到输出缓冲区中。
例如,遇到日期子项时,会从日志消息中提取时间戳,并按照指定的时间格式进行转换;遇到文件名子项时,会提取源代码文件名;遇到消息主体子项时,会提取主要的日志内容文本。所有子项处理完毕后,输出缓冲区中就包含了完整格式化后的日志消息。
五、总结
本文实现了日志系统的四个基础模块:工具类(Date 和 File)提供了时间获取和文件操作的支持;日志等级类定义了六种日志级别及其字符串转换;日志消息类封装了一条日志的所有关键信息;格式化模块通过灵活的格式化字符串解析机制,支持用户自定义日志输出格式。在下一篇文章中,我们将继续实现日志落地模块和日志器模块。