同步异步日志系统设计与实现
项目简介
日志系统是任何大型软件系统中不可或缺的一部分。它不仅用于记录系统运行状态,还能帮助开发者进行调试和性能分析。本文将介绍一个高效的同步异步日志系统的设计与实现,重点关注内存管理和高并发处理。
项目源码如下:同步异步日志系统 ljxlog
设计目标
- 高性能:日志系统应能处理高并发的日志写入请求,确保日志记录的及时性。
- 低延迟:日志写入操作应尽可能快,减少对主业务线程的影响。
- 可扩展:系统应能适应不同规模的应用需求,支持多种日志格式和输出方式。
- 可靠性:日志数据应能持久化存储,防止数据丢失。
所谓同步异步日志系统,就是既支持在单线程环境下的同步日志写入,也支持在多线程环境下的异步日志写入。我们的设计原则是:在单线程环境下,日志写入操作直接进行,保证简单高效;在多线程环境下,日志写入操作通过异步队列进行,减少锁竞争,提高并发性能。还有一点最重要的,那就是高内聚低耦合,日志系统应与业务逻辑解耦,方便维护和扩展。
系统架构 – 原型剖析
这里我并不会直接将整个系统的架构一开始就甩给大家,而是会一步步引导大家理解每个组件的设计思路和实现细节。我们从这个项目的使用者的角度,来由外及里地分析这个日志系统的架构设计。
我们在使用这个日志器之前,第一件事当然是去构建这个日志器对象了:
#include "ljxlog.hpp"
using namespace ljxlog;
int main() {
// 同步控制台 logger(默认模式:"[%d{%H:%M:%S}][%p] %m%n")
auto lg = init_stdout_logger("console", Level::DEBUG);
LOGINFO(lg, "num={} str={}", 42, "ok"); // 显式指定 logger
}
这是一个简单的同步日志器的使用示例,我们通过 init_stdout_logger 函数创建了一个同步控制台日志器 lg,并使用 LOGINFO 宏来记录日志信息。接下来,我们将深入探讨这个日志系统的各个组件和实现细节。
首先,我们可以看到,我们使用的这些函数或宏都来自于 ljxlog 命名空间下的 ljxlog.hpp 头文件。那么,这个头文件中都包含了哪些内容呢?让我们一起来看看。
日志器构造
inline Logger::ptr init_stdout_logger(const std::string& name = "default",
Level min_level = Level::DEBUG,
const std::string& pattern = "[%d{%H:%M:%S}][%p] %m%n")
{
std::unique_ptr<Logger::Builder> builder = std::make_unique<GlobalLoggerBuilder>();
builder->buildLoggerName(name);
builder->buildLimitLevel(min_level);
builder->buildType(LoggerType::LOGGER_SYNC);
builder->buildSink<StdOutLogSink>();
builder->buildFormat(pattern);
auto lg = builder->build();
default_logger_name() = name;
return lg;
}
可以看到,我们简单的对 init_stdout_logger 的一次调用,内部实现其实是通过一个 Logger::Builder 构建器来一步步构建出一个日志器对象的。这里我们使用了建造者模式,将日志器的各个属性和组件的构建过程进行了封装,使得日志器的创建过程更加清晰和灵活。
因此,就让我们来看一下 Logger::Builder 这个建造者类的设计吧。
Logger::Builder – 日志器建造者
class Builder
{
public:
// limit_level:最低输出等级
Builder(LoggerType type = LoggerType::LOGGER_SYNC, Level limit_level = Level::DEBUG)
: _limit_level(limit_level)
{
}
virtual ~Builder() = default;
public:
// 设置日志器名称(用于 %c)
void buildLoggerName(const std::string &name)
{
_logger_name = name;
}
// 设置最低输出等级
void buildLimitLevel(const Level &level)
{
_limit_level = level;
}
......
// 更多的建造选项
protected:
std::string _logger_name;
std::vector<LogSink::ptr> _sinks;
Format::ptr _format;
std::atomic<Level> _limit_level;
LoggerType _type;
// 异步空间检查策略已固定为强制检查,移除开关
};
可以看到,Logger::Builder 类中定义了多个用于设置日志器属性的构建方法,如 buildLoggerName 和 buildLimitLevel 等。通过这些方法,我们可以灵活地配置日志器的各个方面。因此,建造者就是我们实现一个日志器的入口,而通过这个入口,我们可以一步步地构建出一个完整的日志器对象。
建造者的存在,不仅使得日志器的创建过程更加清晰和灵活,还提高了代码的可维护性和可扩展性。未来如果我们需要添加新的日志器类型或配置选项,只需在建造者类中添加相应的方法即可,而不需要修改现有的日志器类。同时,对于用户来说,使用建造者模式也更加方便,他们只需调用相应的构建方法即可创建出符合需求的日志器对象,而不需要了解日志器的内部实现细节。
日志记录 – 等级划分
在之前的示例中,我们在隐式利用构造器来创建一个同步控制台日志器后,就调用了 LOGINFO 宏来记录日志信息。那么,这个宏的实现又是怎样的呢?让我们一起来看看。
#define LOGINFO(logger, fmt, ...) do { auto __lg = (logger); if (__lg) __lg->info (__FILE__, __LINE__, fmt, ##__VA_ARGS__); } while (0)
void info(const std::string &filename, size_t line, const char *fmt, Args... args)
{
// 先检查该等级是否需要落地
if (Level::INFO < _limit_level)
return;
log(Level::INFO, std::move(filename), line, fmt, args...);
}
可以看到,LOGINFO 宏实际上是对日志器的 info 方法的一个封装。它首先获取传入的日志器对象 logger,然后调用其 info 方法来记录日志信息。这里还使用了 __FILE__ 和 __LINE__ 宏来获取当前文件名和行号,以便在日志中记录日志来源。
而在 info 方法中,我们首先检查日志等级是否满足输出条件,如果满足,则调用 log 方法来进行实际的日志记录操作。这样设计的好处是,我们可以在日志记录之前进行等级过滤,避免不必要的日志写入操作,提高性能。
不论是 info 还是 debug、error 等方法,都是类似的实现方式。它们都通过调用 log 方法来进行实际的日志记录操作。那么,log 方法的实现又是怎样的呢?让我们继续深入探讨。
void log(Level level, const std::string &filename, size_t line, const char *fmt, Args const... args)
{
std::string msg;
try
{
msg = std::vformat(fmt, std::make_format_args(args...));
}
catch (const std::format_error &e)
{
msg = "Invalid log format: " + std::string(fmt);
}
// std::string msg = std::vformat(fmt, std::make_format_args(std::forward<Args>(args)...));
LogMsg lmsg(_logger_name, filename, line, std::move(msg), level);
std::stringstream ss;
_format->format(ss, lmsg);
logManage(ss.str());
}
在 log 方法中,我们首先使用 std::vformat 函数将传入的格式化字符串和参数进行格式化,生成最终的日志消息字符串。然后,我们创建一个 LogMsg 对象来封装日志信息,包括日志器名称、文件名、行号、日志消息和日志等级。接着,我们使用日志格式化器 _format 来对日志消息进行格式化,并将结果写入一个字符串流 ss 中。最后,我们调用 logManage 方法来进行日志的实际写入操作。logManage 方法的实现会根据日志器的类型(同步或异步)来决定日志的写入方式。我们来具体看一下 logManage 方法的实现。
日志写入 – 同步与异步
首先我们来看一下同步日志器中的 logManage 方法实现:
void logManage(const std::string &msg) override
{
std::unique_lock<std::mutex> lock(_mtx);
if (_sinks.empty())
{
return;
}
for (auto &sink : _sinks)
sink->log(msg.c_str(), msg.size());
}
同步日志器的 logManage 方法中,我们首先获取一个互斥锁 _mtx,以确保在多线程环境下对日志接收器 _sinks 的访问是线程安全的。然后,我们检查日志接收器列表是否为空,如果不为空,则遍历每个日志接收器,并调用其 log 方法来写入日志消息。
同样的,异步日志器也有自己的 logManage 方法实现,但实现逻辑稍微有一些复杂,
void logManage(const std::string &msg) override
{
_looper->push(msg);
}
这么短?其实不然,异步日志器的 logManage 方法非常简洁,它只是将日志消息推送到一个异步队列 _looper 中。具体的日志写入操作会在另一个线程中进行,以减少对主业务线程的影响。因此,异步日志器的重头戏在于这个异步队列的实现,我们的异步日志器会将所有的日志消息存储在一个内存池中,然后通过一个独立的线程不断地从内存池中取出日志消息,并写入到日志接收器中。
因此我们可以总结出:普通的同步日志器直接在主线程中进行日志写入操作,而异步日志器则通过一个独立的线程来处理日志写入操作,从而提高了日志系统的并发性能和响应速度。
架构总览
这样一来,我们对整个日志系统的架构设计和实现细节有了一个清晰的了解:
- 日志器的创建通过建造者模式进行,灵活配置日志器的各个属性。
- 日志记录通过等级划分进行过滤,确保只记录符合条件的日志信息。
- 日志写入支持同步和异步两种模式,满足不同的性能需求。
- 同步日志器直接在主线程中写入日志,适用于低并发场景。
- 异步日志器通过独立线程处理日志写入,适用于高并发场景。
我们画一个流程图来对这个项目的架构做一个初步的总结:
graph TD
%% 先加样式定义(和你成功的代码保持一致,兜底渲染)
classDef keyNode fill:#f9f,stroke:#333,stroke-width:2px;
classDef decisionNode fill:#9ff,stroke:#333,stroke-width:2px;
%% 所有节点文本用<br/>占位(哪怕无换行,也让解析器识别为“富文本”)
StartUser["用户<br/>"]:::keyNode --> Builder["日志构建器<br/>"]
Builder --> Config{"配置属性<br/>"}:::decisionNode
%% 配置属性分支(每个节点唯一,无重复)
Config --> NameNode["日志器名称<br/>"]
Config --> MinLevel["最低等级<br/>"]
Config --> ModeNode["模式<br/>"]
Config --> SinkNode["接收器<br/>"]
Config --> FormatNode["格式<br/>"]
Builder --> LoggerNode["日志器<br/>"]:::keyNode
%% 等级检查分支
LoggerNode --> LevelCheck["等级检查<br/>"]
LevelCheck -->|不满足| IgnoreNode["忽略<br/>"]
LevelCheck -->|满足| GenMsg["生成消息<br/>"]
GenMsg --> FormatMsg["格式化消息<br/>"]
%% 同步分支(节点全唯一,避免重复引用)
FormatMsg --> SyncNode["同步处理<br/>"]
SyncNode --> LockNode["加锁<br/>"]
LockNode --> IterateSink_Sync["遍历接收器<br/>"]
IterateSink_Sync --> WriteNode["写入<br/>"]
%% 异步分支(节点全唯一,和同步分支彻底分开)
FormatMsg --> AsyncNode["异步处理<br/>"]
AsyncNode --> PushQueue["推送队列<br/>"]
PushQueue --> ThreadProcess["线程处理<br/>"]
ThreadProcess --> PopMsg["取消息<br/>"]
PopMsg --> IterateSink_Async["遍历接收器<br/>"]
%% 可选:给关键连线加样式(和你成功的代码对齐)
linkStyle 0 stroke:#666,stroke-width:1px;
linkStyle 10 stroke:#f66,stroke-width:1.5px,color:red;
在遍历接收器阶段,我们会通过不同的落地接收器来将日志消息写入到不同的目标中,比如控制台、文件等。每个接收器都有自己的实现逻辑,确保日志消息能够正确地写入到指定的位置。这个将会在后面详细讲解。
通过这个流程图,我们可以清晰地看到日志系统的各个组件之间的关系和数据流动过程。用户通过日志构建器配置日志器的属性,生成日志器对象。日志器在记录日志时,首先进行等级检查,然后根据配置的模式选择同步或异步处理方式,最终将日志消息写入到指定的日志接收器中。
项目实现
基础工具类
在实现日志系统之前,我们需要一些基础的工具类来支持日志系统的功能。首先,我们打印的每一条日志都需要知道打印的时间,同时,我们在日志落地的时候很多时候需要落地到文件中,因此我们需要一个文件操作的工具类。下面是这两个基础工具类的实现:
时间工具类
class Date
{
public:
static time_t now()
{
return std::time(nullptr);
}
};
文件工具类
class File
{
public:
static bool exists(const std::string &pathname)
{
struct stat st;
if (stat(pathname.c_str(), &st) < 0)
return false;
return true;
}
static std::string getPath(const std::string &pathname)
{
size_t pos = pathname.find_last_of("/\\");
if (pos == std::string::npos)
return ".";
return pathname.substr(0, pos + 1);
}
static void createDirectory(const std::string &pathname)
{
//./abc/bcd/cde
size_t pos = 0, begin = 0;
while (begin < pathname.size())
{
pos = pathname.find_first_of("/\\", begin);
// 路径即为目录名且目录不存在则创建
if (pos == std::string::npos)
{
if (!exists(pathname))
#ifdef _WIN32
fs::create_directory(pathname.c_str());
#else
mkdir(pathname.c_str(), 0777);
#endif
return;
}
else
{
std::string parent_path = pathname.substr(0, pos);
begin = pos + 1;
#ifdef _WIN32
if (!exists(pathname))
fs::create_directory(parent_path.c_str());
#else
mkdir(parent_path.c_str(), 0777);
#endif
}
}
}
};
文件工具类中,我们实现了检查文件是否存在、获取文件路径以及创建目录等功能。这些功能在日志系统中非常重要,尤其是在将日志写入文件时。
创建文件的逻辑采用的是递归创建目录的方式,确保日志文件所在的目录结构存在。每次循环做的都是同样的事情:
- 查找下一个路径分隔符的位置
- 截取当前路径
- 检查路径是否存在,若不存在则创建
- 更新起始位置,继续查找下一个路径分隔符
日志信息类
而我们的每一条日志都需要有一个日志信息类来封装日志的各个属性,通过这个日志信息类,不仅可以方便我们去构造一条日志的每一个部分,同时也方便我们在日志格式化的时候去获取每一个属性。下面是日志信息类的实现:
struct LogMsg {
size_t _line;//行号
time_t _time;//时间
std::thread::id _tid;//线程ID
std::string _name;//名称
std::string _file;//文件名
std::string _payload;//消息
Level _level;//等级
LogMsg(const std::string &name, const std::string file, size_t line, const std::string &&payload,
Level level): _name(name), _file(file), _payload(std::move(payload)), _level(level),
_line(line), _time(Date::now()), _tid(std::this_thread::get_id()) {}
};
日志等级类
为了规范日志的等级,我们定义了一个日志等级枚举类 Level,并提供了一个将日志等级转换为字符串的函数 toString。这样,我们可以在日志输出时使用字符串形式的日志等级,便于阅读和理解。下面是日志等级类的实现:
enum class Level
{
UNKNOW = 0,
DEBUG,
INFO,
WARNING,
ERROR,
FATAL,
OFF
};
inline const char* toString(Level level)
{
switch (level)
{
case Level::DEBUG: return "DEBUG";
case Level::INFO: return "INFO";
case Level::WARNING: return "WARNING";
case Level::ERROR: return "ERROR";
case Level::FATAL: return "FATAL";
case Level::OFF: return "OFF";
default: return "UNKNOWN";
}
}
日志信息类 LogMsg 封装了日志的各个属性,包括行号、时间、线程 ID、日志器名称、文件名、日志消息和日志等级。通过这个类,我们可以方便地构造和管理每一条日志的信息。
日志格式化
日志格式化是日志系统中的一个重要环节。通过日志格式化器,我们可以将日志信息按照指定的格式进行输出,满足不同的日志需求。下面是我们的日志格式的一个基本规定,所有的日志格式化操作都会遵循这个规定:
格式化规则撰写
模式串(_pattern)语法
- 由“普通文本”和“占位符”两部分组成;普通文本原样输出。
- 占位符以 ‘%’ 开头,后接一个字母键;可选跟随一段子格式参数:%
{参数}。 - 目前只有时间占位符 %d 会消费 {参数},作为 strftime 的格式串;其它占位符即使写了 {…} 也会被忽略,不报错。
支持的占位符(key → 含义)
- d{fmt}:时间,fmt 为 strftime 格式(默认 %H:%M:%S,若空则回退到默认)。
- T :制表符 \t。
- t :线程 ID(LogMsg::_tid)。
- p :日志级别(toString(LogMsg::_level))。
- c :logger 名称(LogMsg::_name)。
- f :源文件名(LogMsg::_file)。
- l :源码行号(LogMsg::_line)。
- m :日志正文(LogMsg::_payload)。
- n :换行符 \n。
转义规则
- %% → 输出字面量 ‘%’
- %{ → 输出字面量 ‘{‘(不会进入“子格式”解析)
- 右花括号 ‘}’:在非子格式环境中为普通字符,直接写即可;不支持 %} 作为转义。
错误与边界
- 若 ‘%’ 位于模式尾部或其后不是字母(例如 %1、%}),抛出异常:”… is not a formatting character”。
- 若进入子格式块(见 ‘%x{…’)却未匹配到 ‘}’,抛出异常:”expected ‘}’ after ‘{‘“。
- 子格式不支持嵌套大括号:遇到的第一个 ‘}’ 即视为结束。
执行流程
- 构造 Format 时调用 parsePattern() 将模式串编译为一组 FormatItem;随后每次 format() 只需顺序输出,避免重复解析开销。
示例
- 默认:
[%d{%H:%M:%S}][%t][%p][%c][%f:%l] %m%n - 自定义:
[%d{%F %T}][%p] %m%n→[2025-09-22 14:33:10][INFO] message。
- 默认:
日志器信息类中的每一个元素并不是都会被用到,因此我们需要一个日志格式化器来根据用户指定的格式,将日志信息类中的各个元素进行格式化输出。具体选择打印哪一类的元素由用户来决定,因此我们应该通过用户提供的格式串检索出来我们需要打印的日志元素,然后再将这些元素进行格式化输出。
日志格式化项(多态实现)
那么问题来了,难道每一个元素的输出都需要我们的日志格式化器去实现吗?答案是否定的。我们可以通过设计一个日志格式化项的接口类 FormatItem,然后为每一种日志元素实现一个具体的格式化项类,这样我们的日志格式化器只需要根据用户提供的格式串来选择合适的格式化项进行输出即可,这里用到了多态的思想,不仅使得结构更加清晰,也方便后续的扩展和维护。下面是日志格式化器和日志格式化项的实现:
class FormatItem
{
public:
using ptr = std::shared_ptr<FormatItem>;
public:
virtual void format(std::ostream &os, const LogMsg &msg) = 0;
};
class LineFormatItem: public FormatItem
{
public:
// 输出源码行号(LogMsg::_line)。构造参数目前未使用。
LineFormatItem(const std::string &str){};
void format(std::ostream &os, const LogMsg &msg) override
{
os << msg._line;
}
};
class TimeFormatItem: public FormatItem
{
public:
// 输出时间。_format 为 strftime 格式串;若传入为空则回退到默认 "%H:%M:%S"。
// 支持的常见符号如:%F(YYYY-MM-DD)、%T(HH:MM:SS)、%Y、%m、%d、%H、%M、%S 等。
TimeFormatItem(const std::string &str = "%H:%M:%S"):_format(str){
if(_format.empty()) _format = "%H:%M:%S";
};
void format(std::ostream &os, const LogMsg &msg) override
{
time_t t = msg._time;
struct tm _tm;
#ifdef _WIN32
localtime_s(&_tm, &t);
#else
localtime_r(&t, &_tm);
#endif
char s[128];
strftime(s, 127, _format.c_str(), &_tm);
os << s;
}
private:
std::string _format;
};
class ThreadFormatItem: public FormatItem
{
public:
// 输出线程ID(LogMsg::_tid)。构造参数目前未使用。
ThreadFormatItem(const std::string &str){};
void format(std::ostream &os, const LogMsg &msg) override
{
os << msg._tid;
}
};
class NameFormatItem: public FormatItem
{
public:
// 输出 logger 名称(LogMsg::_name)。构造参数目前未使用。
NameFormatItem(const std::string &str){};
void format(std::ostream &os, const LogMsg &msg) override
{
os << msg._name;
}
};
class FileFormatItem: public FormatItem
{
public:
// 输出源文件名(LogMsg::_file)。构造参数目前未使用。
FileFormatItem(const std::string &str){};
void format(std::ostream &os, const LogMsg &msg) override
{
os << msg._file;
}
};
class PayLoadFormatItem: public FormatItem
{
public:
// 输出日志正文(LogMsg::_payload)。构造参数目前未使用。
PayLoadFormatItem(const std::string &str){};
void format(std::ostream &os, const LogMsg &msg) override
{
os << msg._payload;
}
};
class LevelFormatItem: public FormatItem
{
public:
// 输出日志级别字符串。构造参数目前未使用。
LevelFormatItem(const std::string &str){};
void format(std::ostream &os, const LogMsg &msg) override
{
os << toString(msg._level);
}
};
class TabFormatItem: public FormatItem
{
public:
// 输出制表符 '\t'。构造参数目前未使用。
TabFormatItem(const std::string &str){};
void format(std::ostream &os, const LogMsg &msg) override
{
os << '\t';
}
};
class NLineFormatItem: public FormatItem
{
public:
// 输出换行符 '\n'(非平台专用行结束符)。构造参数目前未使用。
NLineFormatItem(const std::string &str){};
void format(std::ostream &os, const LogMsg &msg) override
{
os << '\n';
}
};
class OtherFormatItem: public FormatItem
{
public:
// 输出普通文本片段(与占位符相对)。
OtherFormatItem(const std::string &str):message(str){};
void format(std::ostream &os, const LogMsg &msg) override
{
os << message;
}
private:
std::string message;
};
针对于每一个日志元素,我们都实现了一个具体的格式化项类,如 TimeFormatItem 用于格式化时间,LevelFormatItem 用于格式化日志等级等。每个格式化项类都继承自 FormatItem 接口类,并实现了 format 方法,用于将对应的日志信息输出到指定的输出流中。
这样一来,我们就可以通过 FormatItem 接口类来实现日志格式化器,根据用户提供的格式串选择合适的格式化项进行输出,从而实现灵活的日志格式化功能。下面是日志格式化器的实现:
class Format
{
public:
using ptr = std::shared_ptr<Format>;
Format(const std::string &pattern = "[%d{%H:%M:%S}][%t][%p][%c][%f:%l] %m%n"):_pattern(pattern)
{
assert(parsePattern());
}
void format(std::ostream &out, LogMsg &msg)
{
// 将预编译好的 _items 逐个写入输出流。
for(auto &item: _items)
{
item->format(out, msg);
}
// 最后将 '\n' 也写入流中,确保每条日志独占一行
out << '\n';
};
std::string format(LogMsg msg)
{
std::stringstream ss;
for(auto &item: _items)
{
item->format(ss, msg);
}
// 最后将 '\n' 也写入流中,确保每条日志独占一行
ss << '\n';
return ss.str();
}
private:
bool parsePattern()
{
// 将模式串解析为三元组序列 (key, value, isFormatting)
// - isFormatting = false:普通文本,key 为空,value 为文本内容
// - isFormatting = true :占位符,key 为占位符字母,value 为可选的 {…} 子格式内容
// 其中 value 就是你在阅读代码时看到的那个变量,用来承接花括号里解析出来的参数字符串。
int pos = 0;
std::vector<std::tuple<std::string, std::string, bool>> v;//true表示是格式化字符,否则为文本内容
std::string text_inf;//文本信息
while(pos < _pattern.size())
{
//若不是%,则将该段文本信息全部读完
if(_pattern[pos] != '%')
{
while(pos < _pattern.size() && _pattern[pos] != '%') text_inf += _pattern[pos++];
continue;
}
//此时说明是%,则需要先检测%后面是不是还是%,若是,则转义为%
//第一种,%后面没有符号了,此时语法有误
if(++pos == _pattern.size())
{
throw std::runtime_error("expected a formatting character after '%'");
return false;
}
//第二种,%后面有符号,此时就需要分类讨论了
//1.%后面仍然为%,说明现在仍然在处理文本信息,先把这个%读取了,然后continue
if(_pattern[pos] == '%' || _pattern[pos] == '{')
{
// '%%' → 输出 '%';'%{' → 输出 '{'。均作为普通文本追加到 text_inf。
text_inf += _pattern[pos++];
continue;
}
//到这来,说明确实没有文本内容了,先检测text_inf是否有内容,有的话就需要push
if(text_inf.size())
{
v.push_back({"", text_inf, false});
text_inf.clear();
}
//2.%后面不再是%,将其视为格式化字符
//初检测,如果压根就不是字母,则说明一定出错了
if(!isalpha(_pattern[pos]))
{
throw std::runtime_error(_pattern.substr(pos - 1, 2) + " is not a formatting character");
return false;
}
std::string key, value;
key.push_back(_pattern[pos]);
//检测后面紧接着的是否是{,若是的则说明还有子格式需要处理fe
if(++pos != _pattern.size() && _pattern[pos] == '{')
{
++pos;
// 读取到配对的 '}' 为止,将花括号内的内容保存到 value(即子格式参数)
while(pos != _pattern.size() && _pattern[pos] != '}') value.push_back(_pattern[pos++]);
//压根没找到 },格式有误
if(pos == _pattern.size())
{
throw std::runtime_error("expected '}' after '{'");
return false;
}
++pos;
}
v.push_back({key, value, true});
}
//将内容映射到_items中
for(auto &tp: v)
{
if(std::get<2>(tp) == false)
{
_items.push_back(FormatItem::ptr(new OtherFormatItem(std::get<1>(tp))));
}
else
{
std::string key = std::get<0>(tp), value = std::get<1>(tp);
//若不是时间元素却拥有value,说明格式有问题(开发阶段问题,利用assert检查即可)
// if(key != "d" && value.size())
// {
// assert(false);
// return false;
// }
auto it = createItem(key, value);
if(it.get() == nullptr)
{
throw std::runtime_error("%" + key + " is not a formatting character");
return false;
}
_items.push_back(it);
}
}
return true;
}
FormatItem::ptr createItem(const std::string &key, const std::string &value)
{
// 根据占位符 key 创建对应的输出单元;除 %d 外,其它项目前忽略 value。
if(key == "d") return FormatItem::ptr(new TimeFormatItem(value));
if(key == "T") return FormatItem::ptr(new TabFormatItem(value));
if(key == "t") return FormatItem::ptr(new ThreadFormatItem(value));
if(key == "p") return FormatItem::ptr(new LevelFormatItem(value));
if(key == "c") return FormatItem::ptr(new NameFormatItem(value));
if(key == "f") return FormatItem::ptr(new FileFormatItem(value));
if(key == "l") return FormatItem::ptr(new LineFormatItem(value));
if(key == "m") return FormatItem::ptr(new PayLoadFormatItem(value));
if(key == "n") return FormatItem::ptr(new NLineFormatItem(value));
return nullptr;
}
private:
std::string _pattern;
std::vector<FormatItem::ptr> _items;
};
承接上文,这里我们先通过 parsePattern 方法将用户提供的格式串解析为一组格式化项,然后在 format 方法中依次调用每个格式化项的 format 方法,将日志信息输出到指定的输出流中。通过这种方式,我们实现了灵活的日志格式化功能,满足了不同的日志输出需求。
这里最大的难点在于如何将用户提供的格式串解析为一组格式化项。下面做详细的讲解:
解析格式串
核心函数讲解:parsePattern
在该函数中,我们需要将模式串解析为三元组序列 tuple<key, value, isFormatting>,其中:
- isFormatting = false:普通文本,key 为空,value 为文本内容
- isFormatting = true :占位符,key 为占位符字母,value 为可选的 {…} 子格式内容
我们利用 pos 变量来遍历整个模式串 _pattern,并使用一个字符串 text_inf 来临时存储普通文本内容,如下:
int pos = 0;
std::vector<std::tuple<std::string, std::string, bool>> v; // true表示是格式化字符,否则为文本内容
std::string text_inf; //文本信息
pos 会从 0 开始遍历模式串,直到遍历完整个字符串。在遍历过程中,我们会根据当前字符是否为 ‘%’ 来决定是处理普通文本还是占位符。
- 处理普通文本
如果当前字符不是 ‘%’,我们就将其视为普通文本,继续读取直到遇到下一个 ‘%’ 或字符串末尾为止。读取的文本内容会被追加到text_inf中:
if(_pattern[pos] != '%')
{
while(pos < _pattern.size() && _pattern[pos] != '%') text_inf += _pattern[pos++];
continue;
}
- 处理占位符
否则,说明当前字符是 ‘%’,我们需要进一步处理占位符。首先,我们检查 ‘%’ 后面是否还有字符:
if(++pos == _pattern.size())
{
throw std::runtime_error("expected a formatting character after '%'");
return false;
}
如果找不到后续字符,说明格式有误,我们抛出异常,此时用户会收到错误提示。接下来,我们检查 ‘%’ 后面的字符:
//1.%后面仍然为%,说明现在仍然在处理文本信息,先把这个%读取了,然后continue
if(_pattern[pos] == '%' || _pattern[pos] == '{')
{
// '%%' → 输出 '%';'%{' → 输出 '{'。均作为普通文本追加到 text_inf。
text_inf += _pattern[pos++];
continue;
}
如果是 ‘%%’ 或 ‘%{‘,我们将其视为转义字符,直接将 ‘%’ 或 ‘{‘ 追加到 text_inf 中,然后继续处理下一个字符。否则,我们就开始处理真正的占位符:
当然,可别忘了我们的 text_inf 里可能已经积累了一些普通文本内容,在处理占位符之前,我们需要先将这些内容保存下来:
//到这来,说明确实没有文本内容了,先检测text_inf是否有内容,有的话就需要push
if(text_inf.size())
{
v.push_back({"", text_inf, false});
text_inf.clear();
}
然后再去处理占位符:
//初检测,如果压根就不是字母,则说明一定出错了
if(!isalpha(_pattern[pos]))
{
throw std::runtime_error(_pattern.substr(pos - 1, 2) + " is not a formatting character");
return false;
}
std::string key, value;
key.push_back(_pattern[pos]);
字段格式化的时候我们的确不会对 value 进行处理,但进行 O(1) 时间复杂度的字母检测可以保证用户的错误输入在 createItem 的时候被限制在字母范围内,从而避免不必要的错误。接下来,我们检查占位符后面是否有子格式参数:
//检测后面紧接着的是否是{,若是的则说明还有子格式需要处理
if(++pos != _pattern.size() && _pattern[pos] == '{')
{
++pos;
// 读取到配对的 '}' 为止,将花括号内的内容保存到 value(即子格式参数)
while(pos != _pattern.size() && _pattern[pos] != '}') value.push_back(_pattern[pos++]);
//压根没找到 },格式有误
if(pos == _pattern.size())
{
throw std::runtime_error("expected '}' after '{'");
return false;
}
++pos;
}
// 最后将解析得到的三元组保存下来
v.push_back({key, value, true});
大家可能会疑惑,为什么还会有子格式需要处理呢,回顾我们上面的格式规则撰写:
- d{fmt}:时间,fmt 为 strftime 格式(默认 %H:%M:%S,若空则回退到默认)。
- T :制表符 \t。
- t :线程 ID(LogMsg::_tid)。
- p :日志级别(toString(LogMsg::_level))。
- c :logger 名称(LogMsg::_name)。
- f :源文件名(LogMsg::_file)。
- l :源码行号(LogMsg::_line)。
- m :日志正文(LogMsg::_payload)。
- n :换行符 \n。
可以看到,映入眼帘的第一个规则–时间占位符 %d 是唯一一个会消费子格式参数的占位符,因此我们需要将子格式参数保存下来,以便后续在创建时间格式化项时使用。而且时间参数是日志中几乎必带的参数,因此我们必须支持这个功能。至此,我们就完成了对模式串的解析工作,接下来我们只需要将解析得到的三元组序列映射到具体的格式化项即可:
for(auto &tp: v)
{
if(std::get<2>(tp) == false)
{
_items.push_back(FormatItem::ptr(new OtherFormatItem(std::get<1>(tp))));
}
else
{
std::string key = std::get<0>(tp), value = std::get<1>(tp);
//若不是时间元素却拥有value,说明格式有问题(开发阶段问题,利用assert检查即可)
// if(key != "d" && value.size())
// {
// assert(false);
// return false;
// }
auto it = createItem(key, value);
if(it.get() == nullptr)
{
throw std::runtime_error("%" + key + " is not a formatting character");
return false;
}
_items.push_back(it);
}
}
我们遍历解析得到的三元组序列 v,对于每个三元组,如果 isFormatting 为 false,则创建一个 OtherFormatItem 来处理普通文本;否则,根据占位符 key 调用 createItem 方法来创建对应的格式化项,并将其添加到 _items 列表中。通过这种方式,我们实现了对日志格式串的解析和格式化项的创建,为日志格式化功能奠定了基础。其中,createItem 方法根据占位符 key 创建对应的格式化项实例:
FormatItem::ptr createItem(const std::string &key, const std::string &value)
{
// 根据占位符 key 创建对应的输出单元;除 %d 外,其它项目前忽略 value。
if(key == "d") return FormatItem::ptr(new TimeFormatItem(value));
if(key == "T") return FormatItem::ptr(new TabFormatItem(value));
if(key == "t") return FormatItem::ptr(new ThreadFormatItem(value));
if(key == "p") return FormatItem::ptr(new LevelFormatItem(value));
if(key == "c") return FormatItem::ptr(new NameFormatItem(value));
if(key == "f") return FormatItem::ptr(new FileFormatItem(value));
if(key == "l") return FormatItem::ptr(new LineFormatItem(value));
if(key == "m") return FormatItem::ptr(new PayLoadFormatItem(value));
if(key == "n") return FormatItem::ptr(new NLineFormatItem(value));
return nullptr;
}
可以看到,如果我们通过占位符 key 找不到对应的格式化项,我们就返回一个空指针,表示创建失败。这意味着我们将所有的 key 的可能映射都对比了一遍,时间复杂度就远不及 O(1),可见我们在前面做的字母检测是多么的重要。通过以上的设计和实现,我们成功地构建了一个灵活且可扩展的日志格式化系统,满足了不同的日志输出需求。
目前我们的日志格式串解析都是单字符的,因此可以作出如下的优化:
FormatItem::ptr createItem(const std::string &key, const std::string &value)
{
// 确保 key 是单个字符
if (key.length() != 1) return nullptr;
switch (key[0]) {
case 'd': return FormatItem::ptr(new TimeFormatItem(value));
case 'T': return FormatItem::ptr(new TabFormatItem(value));
case 't': return FormatItem::ptr(new ThreadFormatItem(value));
case 'p': return FormatItem::ptr(new LevelFormatItem(value));
case 'c': return FormatItem::ptr(new NameFormatItem(value));
case 'f': return FormatItem::ptr(new FileFormatItem(value));
case 'l': return FormatItem::ptr(new LineFormatItem(value));
case 'm': return FormatItem::ptr(new PayLoadFormatItem(value));
case 'n': return FormatItem::ptr(new NLineFormatItem(value));
default: return nullptr;
}
}
在这个优化版本中,我们首先确保 key 是单个字符,然后使用 switch 语句来根据字符的 ASCII 值进行分支判断。这样做的好处是,switch 语句在编译时可以被优化为跳转表,从而实现更快的查找速度,接近于 O(1) 的时间复杂度。相比于之前的多次字符串比较,这种方式更加高效,尤其是在日志系统中频繁调用的情况下,可以显著提升性能。
不过即便我们不手动优化,编译器在优化阶段也可能会将多次字符串比较优化为跳转表,因此实际性能提升可能并不明显。但从代码可读性和维护性的角度来看,使用 switch 语句仍然是一个不错的选择。
好啦,这样一来,我们就完成了日志格式化器的设计和实现。通过解析用户提供的格式串,我们能够灵活地将日志信息按照指定的格式进行输出,满足不同的日志需求。在实际使用中,用户可以根据自己的需求自定义日志格式,从而更好地管理和分析日志数据。
日志落地
想要将日志信息写入到指定的位置,我们需要设计日志接收器(Sink)来处理日志的落地操作。日志接收器负责将格式化后的日志消息写入到不同的目标中,比如控制台、文件等。下面是日志接收器的设计和实现:
日志落地器的实现,我们采用的是工厂模式来创建不同类型的日志接收器。首先,我们定义一个日志接收器的接口类 LogSink,然后为每种具体的日志接收器实现一个子类,同样使用的是多态的思想。下面是日志接收器的接口类和两个具体实现的示例:
class LogSink
{
public:
using ptr = std::shared_ptr<LogSink>;
public:
LogSink() {};
virtual ~LogSink() {};
virtual void log(const char *data, size_t len) = 0;
virtual void close() {};
private:
std::atomic<bool> _flushing = false;
};
以后,所有的日志落地器都会继承自这个接口类,并实现 log 方法来处理日志写入操作。因此我们以后只需要接收用户提供的日志落地器数组:std::vector<LogSink::ptr> sinks,然后遍历这个数组,调用每个落地器的 log 方法即可将日志写入到指定的位置。下面是两个具体的日志接收器实现示例:
// 标准输出落地
class StdOutLogSink : public LogSink
{
public:
StdOutLogSink() {};
void log(const char *data, size_t len) override
{
std::cout.write(data, len);
}
};
// 指定文件落地
class FixedFileLogSink : public LogSink
{
public:
FixedFileLogSink(const std::string &filename)
: _filename(filename)
{
File::createDirectory(File::getPath(filename));
_ofs.open(_filename, std::ios::app | std::ios::binary);
assert(_ofs.is_open());
}
void log(const char *data, size_t len) override
{
std::lock_guard<std::mutex> lock(_file_mtx);
_ofs.write(data, len);
assert(_ofs.good());
}
void close() override
{
std::lock_guard<std::mutex> lock(_file_mtx);
if(_ofs.is_open()) {
_ofs.close();
}
}
~FixedFileLogSink()
{
_ofs.close();
}
private:
std::string _filename;
std::ofstream _ofs;
std::mutex _file_mtx;
};
第一个是标准输出落地,这个很简单,我们直接将日志消息写入到标准输出流 std::cout 中即可。
第二个是指定文件落地,这个稍微复杂一些,我们需要在构造函数中打开指定的文件,并在 log 方法中将日志消息写入到文件中。为了保证线程安全,我们使用了互斥锁 std::mutex 来保护文件写入操作,确保多个线程同时写入日志时不会发生数据竞争。
可以看到,在指定文件落地时,我们就需要检查文件是否存在,若不存在则创建相应的目录结构,这里我们就用到了前面实现的文件工具类 File。通过这种方式,我们实现了灵活的日志落地功能,满足了不同的日志输出需求。用户可以根据自己的需求选择合适的日志接收器,将日志消息写入到指定的位置。
当然,还有一个默认的日志落地方案–滚动文件落地(按照文件大小),这个方案会根据文件大小来滚动日志文件,避免单个日志文件过大或者过旧。下面是滚动文件落地器的实现示例:
class RollBySizeLogSink : public LogSink
{
public:
RollBySizeLogSink(const std::string &filename, size_t max_size, bool cst_inc = false)
: _filename(filename),
_max_size(max_size),
_cur_size(0),
_cur_suffix(1),
_last_time(0),
_cst_inc(cst_inc)
{
if(max_size == 0) throw std::runtime_error("文件大小不能为0");
File::createDirectory(File::getPath(filename));
}
void log(const char *data, size_t len) override
{
std::lock_guard<std::mutex> lock(_file_mtx);
// 如果没有创建文件,则创建一个新文件
if(!_ofs.is_open())
{
std::string new_file_name = newFileName();
_ofs.open(new_file_name, std::ios::app | std::ios::binary);
assert(_ofs.is_open());
_cur_size = 0;
}
// 不应该急着直接将日志落地,首先应当检查缓冲区能否完全写进当前日志文件
size_t cur_len = len;
bool size_maybe_too_large = false;
while(_cur_size + cur_len > _max_size) {
// 首先计算出预截断位置
size_t pos = _max_size - _cur_size;
// 查询从文件开始到 pos,最后一个 '\n' 的位置
size_t last_n_pos = pos;
while(last_n_pos > 0 && data[last_n_pos - 1] != '\n') --last_n_pos;
// 若找到了,则将 [0, last_n_pos) 写入当前文件
if(last_n_pos > 0) {
_ofs.write(data, last_n_pos);
assert(_ofs.good());
// 更新当前文件大小
_cur_size += last_n_pos;
// 更新 data 指针与 cur_len
data += last_n_pos;
cur_len -= last_n_pos;
if(cur_len == 0) return; // 全部写完,直接返回
// 否则需要新建一个文件继续写入剩余数据
_ofs.close();
std::string new_file_name = newFileName();
_ofs.open(new_file_name, std::ios::app | std::ios::binary);
assert(_ofs.is_open());
_cur_size = 0;
}
else {
// 如果 size_maybe_too_large 已经是 true,则说明已经尝试过创建新文件了,仍然找不到 '\n',只能强制截断到 pos 位置
if(size_maybe_too_large) {
_ofs.write(data, pos);
assert(_ofs.good());
// 更新当前文件大小
_cur_size += pos;
// 更新 data 指针与 cur_len
data += pos;
cur_len -= pos;
size_maybe_too_large = false;
}
else {
// 否则可能是因为日志长度太大,超过日志本身大小,但也有可能是当前日志写满了,需要写入下一个日志,故先创建一个新的文件
size_maybe_too_large = true;
}
// 不论如何一定是需要再创建一个新文件的,然后继续尝试写入剩余数据
// 新建一个文件继续写入剩余数据
_ofs.close();
std::string new_file_name = newFileName();
_ofs.open(new_file_name, std::ios::app | std::ios::binary);
assert(_ofs.is_open());
_cur_size = 0;
}
}
// 此时剩余数据可以直接写入当前文件
_ofs.write(data, cur_len);
assert(_ofs.good());
_cur_size += cur_len;
}
void close() override {
std::lock_guard<std::mutex> lock(_file_mtx);
if(_ofs.is_open()) {
_ofs.close();
}
}
std::string newFileName()
{
time_t t = ljxlog::Date::now();
struct tm _tm;
#ifdef _WIN32
localtime_s(&_tm, &t);
#else
localtime_r(&t, &_tm);
#endif
char s[128];
strftime(s, 127, "%Y%m%d%H%M%S", &_tm);
std::string ret = _filename + s;
if(!_cst_inc)
{
if (_last_time != t) _cur_suffix = 1;
_last_time = t;
}
ret += "-" + std::to_string(_cur_suffix++);
return ret;
}
~RollBySizeLogSink()
{
_ofs.close();
}
private:
std::string _filename;
std::ofstream _ofs;
size_t _max_size;
size_t _cur_size;
size_t _cur_suffix;
size_t _last_time;
// 超出,若不提前检查,可能会在文件大小超出范围后被检查出来
bool _cst_inc; //是否让文件后缀不断增加,若不断增加,即便文件名不同,也会继承上次的文件后缀加一作为该文件的后缀,
//否则每次文件名不同的时候会使用新的后缀(后缀从1开始重新计算)
// 文件流互斥锁
std::mutex _file_mtx;
};
滚动文件落地的逻辑挺复杂的,这里我们详细讲解一下其中的逻辑:
构造函数中,我们初始化了一些成员变量,包括日志文件名、最大文件大小、当前文件大小、当前文件后缀等。同时,我们还创建了日志文件所在的目录结构,确保日志文件能够正确创建。
在
log方法中,我们首先获取互斥锁,确保线程安全。然后,我们检查当前日志文件是否已经打开,如果没有打开,则调用newFileName方法创建一个新的日志文件,并将当前文件大小重置为 0。接下来,我们检查当前日志消息是否能够完全写入当前日志文件。如果不能完全写入,我们需要进行截断操作,确保日志文件不会超过最大大小限制。具体的截断逻辑如下:
- 我们计算出预截断位置
pos,即当前文件大小加上日志消息长度是否超过最大文件大小。 - 然后,我们查找从文件开始到
pos位置,最后一个换行符\n的位置last_n_pos。如果找到了换行符,我们就将[0, last_n_pos)范围内的日志消息写入当前文件,并更新当前文件大小和日志消息指针。 - 如果没有找到换行符,我们需要判断是否是因为日志消息本身太大,超过了最大文件大小。如果是这样,我们只能强制截断到
pos位置,将日志消息写入当前文件。 - 无论如何,在完成写入操作后,我们都需要关闭当前日志文件,并创建一个新的日志文件,继续写入剩余的日志消息。
- 最后,如果剩余的日志消息可以完全写入当前日志文件,我们就直接将其写入,并更新当前文件大小。
- 我们计算出预截断位置
可以看到,在滚动日志落地方案中,我们有一个选项: _cst_inc:
_cst_inc用于控制文件后缀是否持续递增。如果设置为 true,则文件后缀会不断增加,即使文件名不同也会继承上次的文件后缀加一作为该文件的后缀;如果设置为 false,则每次文件名不同的时候会使用新的后缀(后缀从 1 开始重新计算)。
举个例子,因为我们的文件是由时间戳和后缀组成的:log20231010120000-1、log20231010120000-2、log20231010120100-1 等等。如果我们在同一秒钟内创建了多个日志文件,那么后缀会递增;如果跨越了时间点,后缀会重新从 1 开始计算。
但如果我们设置了 _cst_inc 为 true,那么无论时间戳是否变化,后缀都会持续递增,并不会因为时间戳变了而重新从 1 开始计算。这样做的好处是可以避免文件名重复的问题,确保每个日志文件都有唯一的名称。
还有一点需要注意的是,我们之所以通过查询 pos 位置之前最后一个换行符的方式来获取截断位置,而不是通过检查新插入一段日志后是否会超过日志最大长度限制来判断,是因为在异步日志系统中,每次插入的日志不一定只来自一个线程,可能包含了多个线程的多个日志,因此,通过判断 pos 位置之前最后一个换行符的方式来截断是更加科学的。在多线程并发非常集中紧凑的情况下,一次的缓存大小可能就大于了文件本身。
下面我画一个流程图来帮助大家理解滚动文件落地的逻辑:
graph TD
classDef keyNode fill:#f9f,stroke:#333,stroke-width:2px;
classDef decisionNode fill:#9ff,stroke:#333,stroke-width:2px;
classDef processNode fill:#eee,stroke:#333,stroke-width:1px;
%% 初始节点:日志消息触发写入
Start["日志消息触发写入<br/>(携带消息内容+长度)"]:::keyNode --> GetLock["获取文件互斥锁<br/>(确保线程安全)"]:::processNode
%% 检查文件是否已打开
GetLock --> CheckFileOpen["检查当前日志文件是否已打开"]:::decisionNode
CheckFileOpen -->|未打开| GenNewFileName["调用newFileName()生成新文件名"]:::processNode
GenNewFileName --> InitNewFile["初始化新日志文件:<br/>1. 创建目录(若不存在)<br/>2. 打开文件流<br/>3. 当前文件大小重置为0"]:::processNode
InitNewFile --> CalcRemainSize["计算当前文件剩余可写入大小<br/>(剩余大小 = 最大文件大小 - 当前文件大小)"]:::processNode
CheckFileOpen -->|已打开| CalcRemainSize
%% 检查消息是否可完全写入当前文件
CalcRemainSize --> CheckMsgFit["判断日志消息长度是否 ≤ 剩余可写入大小"]:::decisionNode
CheckMsgFit -->|是| WriteDirect["直接写入当前文件<br/>1. 写入消息内容<br/>2. 更新当前文件大小(+消息长度)"]:::processNode
WriteDirect --> ReleaseLock["释放文件互斥锁"]:::processNode
ReleaseLock --> End["写入完成"]:::keyNode
%% 消息不可完全写入,进入截断+滚动逻辑
CheckMsgFit -->|否| CalcTruncatePos["计算预截断位置 pos<br/>(pos = 最大文件大小 - 当前文件大小)"]:::processNode
CalcTruncatePos --> FindLastN["在消息[0, pos)范围内<br/>查找最后一个换行符 '\n' 位置 last_n_pos"]:::processNode
%% 判断是否找到换行符
FindLastN --> HasLastN["是否找到换行符"]:::decisionNode
HasLastN -->|是| WriteUntilN["写入消息[0, last_n_pos)部分到当前文件<br/>1. 更新当前文件大小(+last_n_pos)<br/>2. 截取剩余消息:msg = msg[last_n_pos:], len = len - last_n_pos"]:::processNode
HasLastN -->|否| ForceTruncate["强制截断:<br/>1. 写入消息[0, pos)部分到当前文件<br/>2. 更新当前文件大小(+pos)<br/>3. 截取剩余消息:msg = msg[pos:], len = len - pos"]:::processNode
%% 两种截断分支后均需关闭当前文件并创建新文件
WriteUntilN --> CloseCurrentFile["关闭当前日志文件流"]:::processNode
ForceTruncate --> CloseCurrentFile
%% 生成新文件名(关联_cst_inc逻辑)
CloseCurrentFile --> CheckCstInc["判断_cst_inc配置"]:::decisionNode
CheckCstInc -->|_cst_inc = true| IncSuffixDirect["文件后缀直接递增<br/>(继承上次后缀,不重置)"]:::processNode
CheckCstInc -->|_cst_inc = false| CheckTimestamp["检查当前时间戳与上次是否一致"]:::decisionNode
CheckTimestamp -->|时间戳相同| IncSuffixSameTs["文件后缀递增<br/>(同一时间戳内保持递增)"]:::processNode
CheckTimestamp -->|时间戳不同| ResetSuffixNewTs["文件后缀重置为1<br/>(新时间戳重新计数)"]:::processNode
%% 新文件名生成后,初始化新文件并递归处理剩余消息
IncSuffixDirect --> GenNewFileAfterRoll["生成新日志文件名<br/>(格式:前缀+时间戳-后缀)"]:::processNode
IncSuffixSameTs --> GenNewFileAfterRoll
ResetSuffixNewTs --> GenNewFileAfterRoll
GenNewFileAfterRoll --> InitRolledFile["初始化新日志文件:<br/>1. 打开新文件流<br/>2. 当前文件大小重置为0"]:::processNode
InitRolledFile --> RecheckRemain["重新检查剩余消息长度是否 ≤ 最大文件大小"]:::decisionNode
%% 剩余消息仍超限时,递归触发滚动逻辑;否则直接写入
RecheckRemain -->|仍超限| CalcRemainSize["重新计算剩余可写入大小"]:::processNode
RecheckRemain -->|未超限| WriteRemain["写入剩余消息到新文件<br/>1. 写入剩余内容<br/>2. 更新当前文件大小(+剩余长度)"]:::processNode
WriteRemain --> ReleaseLock
此后,我们就可以通过日志格式化器和日志接收器来实现完整的日志记录功能了。用户只需要创建一个日志格式化器,设置好日志格式串,然后创建一个或多个日志接收器,将其添加到日志系统中即可开始记录日志。
同时,用户还可以自定义日志落地方案,只要用户继承于我们的 LogSink 接口类,并实现 log 方法,就可以将日志消息写入到任意目标中,比如数据库、网络等。这样,我们的日志系统就具备了高度的灵活性和可扩展性,能够满足各种不同的日志记录需求。
日志器基类实现
在日志系统中,我们需要一个日志器(Logger)来管理日志的记录和输出。日志器负责将格式化后的日志消息发送到一个或多个日志接收器(Sink),并提供一些基本的日志记录功能。我们需要提供各个等级的日志记录方法,比如 debug、info、warn、error 等等。之后我们将会基于这个日志器基类实现同步日志器以及异步日志器。
class Logger
{
public:
using ptr = std::shared_ptr<Logger>;
// logger_name:日志器名称(用于格式化 %c)
// format :格式器(Format),决定日志文本的最终输出格式
// sinks :输出目的地集合(控制台、文件、滚动文件等)
// limit_level:输出等级下限(小于该等级的日志直接丢弃)
Logger(const std::string &logger_name, Format::ptr format,
std::vector<LogSink::ptr> &sinks, Level limit_level = Level::DEBUG)
: _logger_name(logger_name), _format(format), _sinks(sinks), _limit_level(limit_level) {}
virtual ~Logger() = default;
public:
virtual void flush() = 0;
template <class... Args>
// DEBUG 级别输出;filename/line 建议使用 __FILE__/__LINE__ 宏传入
void debug(const std::string &filename, size_t line, const char *fmt, Args... args)
{
// 先检查该等级是否需要落地
if (Level::DEBUG < _limit_level)
return;
log(Level::DEBUG, std::move(filename), line, fmt, args...);
}
template <class... Args>
// INFO 级别输出
void info(const std::string &filename, size_t line, const char *fmt, Args... args)
{
// 先检查该等级是否需要落地
if (Level::INFO < _limit_level)
return;
log(Level::INFO, std::move(filename), line, fmt, args...);
}
template <class... Args>
// WARNING 级别输出
void warning(const std::string &filename, size_t line, const char *fmt, Args... args)
{
// 先检查该等级是否需要落地
if (Level::WARNING < _limit_level)
return;
log(Level::WARNING, std::move(filename), line, fmt, args...);
}
template <class... Args>
// ERROR 级别输出
void error(const std::string &filename, size_t line, const char *fmt, Args... args)
{
// 先检查该等级是否需要落地
if (Level::ERROR < _limit_level)
return;
log(Level::ERROR, std::move(filename), line, fmt, args...);
}
template <class... Args>
// FATAL 级别输出
void fatal(const std::string &filename, size_t line, const char *fmt, Args... args)
{
// 先检查该等级是否需要落地
if (Level::FATAL < _limit_level)
return;
log(Level::FATAL, std::move(filename), line, fmt, args...);
}
public:
// 建造者实现,注:不需要指挥者,因为指挥者主要用于确定建造次序的,这里的日志器实现并不需要次序性
// 对于次序性不确定的日志器,将构造顺序的权利交给用户是最好的
class Builder
{
public:
// limit_level:最低输出等级
Builder(LoggerType type = LoggerType::LOGGER_SYNC, Level limit_level = Level::DEBUG)
: _limit_level(limit_level)
{
}
virtual ~Builder() = default;
public:
// 设置日志器名称(用于 %c)
void buildLoggerName(const std::string &name)
{
_logger_name = name;
}
// 设置最低输出等级
void buildLimitLevel(const Level &level)
{
_limit_level = level;
}
// 选择日志器类型:同步 / 异步
void buildType(const LoggerType &type)
{
_type = type;
}
// 设置格式模式串(例如 "[%d{%F %T}][%p][%c] %m%n")
void buildFormat(const std::string format)
{
_format = std::make_shared<Format>(format);
}
// 直接设置已有 Format 实例
void buildFormat(const Format::ptr &format)
{
_format = format;
}
// 添加一个 sink(如 StdOutLogSink、FileLogSink 等),参数由具体 sink 的构造函数决定
template <class T, class... Args>
void buildSink(Args &&...args)
{
auto sink = sinkCreate<T>(std::forward<Args>(args)...);
_sinks.push_back(sink);
}
// 完成构建:若未设置 _format,则使用默认格式;若未设置 sink,则默认使用 StdOutLogSink
virtual ptr build() = 0;
protected:
std::string _logger_name;
std::vector<LogSink::ptr> _sinks;
Format::ptr _format;
std::atomic<Level> _limit_level;
LoggerType _type;
// 异步空间检查策略已固定为强制检查,移除开关
};
protected:
template <class... Args>
// 统一的日志输出核心:
// 1) std::vformat 组装正文(可能抛出 std::format_error → 降级为固定提示)
// 2) 封装为 LogMsg,并交给 Format 生成最终文本
// 3) 交由具体 Logger 的 logManage 落地
void log(Level level, const std::string &filename, size_t line, const char *fmt, Args const... args)
{
std::string msg;
try
{
msg = std::vformat(fmt, std::make_format_args(args...));
}
catch (const std::format_error &e)
{
msg = "Invalid log format: " + std::string(fmt);
}
// std::string msg = std::vformat(fmt, std::make_format_args(std::forward<Args>(args)...));
LogMsg lmsg(_logger_name, filename, line, std::move(msg), level);
std::stringstream ss;
_format->format(ss, lmsg);
logManage(ss.str());
}
// 由子类实现:决定如何把文本写入多个 sink。
virtual void logManage(const std::string &msg) = 0;
std::mutex _mtx;
std::string _logger_name;
std::vector<LogSink::ptr> _sinks;
Format::ptr _format;
std::atomic<Level> _limit_level;
};
日志器基类实现了所有的日志记录方法,并提供了一个统一的日志输出核心 log 方法。这个方法负责将日志消息格式化为最终的文本,并调用子类实现的 logManage 方法将日志消息写入到多个日志接收器中。在日志器基类中,我们还实现了一个建造者模式的 Builder 类,用于构建日志器实例。用户可以通过这个建造者类来设置日志器的名称、格式、日志接收器等属性,然后调用 build 方法来创建日志器实例。
logManage 方法是一个纯虚函数,需要由子类实现。这个方法的作用是将格式化后的日志消息写入到多个日志接收器中。具体的写入方式取决于子类的实现,比如同步日志器会直接将日志消息写入到每个日志接收器中,而异步日志器则会将日志消息放入一个队列中,由后台线程异步处理。这样一来,同步和异步日志器只需要实现各自的 logManage 方法即可,极大地简化了日志器的实现复杂度,且提高了代码的可维护性。
在文章开头,我们使用的日志初始化,就是使用的这个 Logger::Builder 类来构建日志器实例的。通过这种方式,我们实现了一个灵活且可扩展的日志器基类,为后续实现同步日志器和异步日志器奠定了基础。
在这个基类日志器当中还有一个 flush 方法,这个方法是用于强制将日志缓冲区中的日志消息写入到日志接收器中。这个方法需要由子类实现,因为不同的日志器可能有不同的缓冲机制和写入策略。用户可以在需要的时候调用这个方法来确保所有的日志消息都被写入到指定的位置,避免日志丢失。在我们后面讲解异步日志器的时候,我们会重点讲解这个 flush 方法的实现。
同步日志器
同步日志器旨在实现日志的同步写入操作,即每次日志记录请求都会立即将日志消息写入到指定的日志接收器中,适用于对实时性要求较高的场景或者单线程环境。下面是同步日志器的实现示例:
class SyncLogger : public Logger
{
public:
SyncLogger(const std::string &logger_name, Format::ptr format,
std::vector<LogSink::ptr> &sinks, Level limit_level = Level::DEBUG)
: Logger(logger_name, format, sinks, limit_level) {}
~SyncLogger() override {
flush();
}
public:
void flush() override {
// 关闭所有落地方案的文件流做截断
for(auto &sink: _sinks) {
sink->close();
}
}
private:
// 同步写入:在持有 _mtx 的情况下,逐个 sink 写入,调用线程会被阻塞直到写完。
void logManage(const std::string &msg) override
{
std::unique_lock<std::mutex> lock(_mtx);
if (_sinks.empty())
{
return;
}
for (auto &sink : _sinks)
sink->log(msg.c_str(), msg.size());
}
};
同步日志器实现起来非常简单,我们只需要继承自日志器基类 Logger,并实现 logManage 方法即可。在 logManage 方法中,我们首先获取互斥锁 _mtx,确保线程安全。然后,我们遍历所有的日志接收器 _sinks,将格式化后的日志消息写入到每个接收器中。由于这是同步写入操作,调用线程会被阻塞直到所有的日志消息都写入完成。
下面我们详细讲解一下异步日志器的实现:
异步日志器
异步日志器旨在实现日志的异步写入操作,即日志记录请求会将日志消息放入一个队列中,由后台线程异步处理写入操作,适用于高并发场景或者对性能要求较高的环境。下面是异步日志器的实现示例:
class AsyncLogger : public Logger
{
public:
AsyncLogger(const std::string &logger_name, Format::ptr format,
std::vector<LogSink::ptr> &sinks, Level limit_level = Level::DEBUG)
: Logger(logger_name, format, sinks, limit_level),
_looper(std::make_shared<AsyncLooper>(std::bind(&AsyncLogger::logSink, this, std::placeholders::_1))) {}
~AsyncLogger() override {
flush();
}
public:
void flush() override {
_looper->flush();
// 然后关闭所有落地方案的文件流做截断
for(auto &sink: _sinks) {
sink->close();
}
}
private:
// 当前实现:仍是直接写入 sink;
// 若希望完全异步,应在此将 msg 投递给 _looper,由 logSink(Buffer&) 统一落地。
void logManage(const std::string &msg) override
{
_looper->push(msg);
}
// looper 回调:当有 Buffer 可读时,批量写入各 sink。
void logSink(Buffer &buffer)
{
if (_sinks.empty())
{
return;
}
for (auto &sink : _sinks)
sink->log(buffer.begin(), buffer.readAbleSize());
}
private:
AsyncLooper::ptr _looper;
};
这个是异步日志器的实现,我们同样继承自日志器基类 Logger,并实现 logManage 方法。在 logManage 方法中,我们将格式化后的日志消息推送到一个异步循环器 _looper 中。这个异步循环器负责在后台线程中处理日志消息的写入操作。当异步循环器有日志消息可读时,它会调用 logSink 方法,将日志消息批量写入到所有的日志接收器中。
因此,异步日志器实质上是通过一个异步循环器来实现日志的异步写入操作。用户在调用日志记录方法时,日志消息会被迅速放入队列中,而不会阻塞调用线程。后台线程会不断地从队列中取出日志消息,并将其写入到指定的日志接收器中,从而实现高效的日志记录。这样看来,同步日志器和异步日志器的实现都非常简洁明了,且各自满足不同的使用场景需求。而我们现在需要实现的是异步日志器的异步循环器 AsyncLooper,这个类负责管理日志消息的队列和后台线程。下面是异步循环器的实现示例:
异步循环器实现
class AsyncLooper
{
public:
using Func = std::function<void(Buffer &)>;
using ptr = std::shared_ptr<AsyncLooper>;
public:
// cb:消费者回调
AsyncLooper(const Func &cb)
: _task_manage(cb),
_running(true),
_looper(&AsyncLooper::loop, this) {}
~AsyncLooper() { stop(); }
// 生产:提交一条字符串任务
void push(const std::string &msg)
{
//停止任务调度则结束任务添加操作
if(_running == false) return;
//否则在每个生命周期内添加一个任务
{
std::unique_lock<std::mutex> lock(_mtx);
// 等待可写空间 >= 本次任务大小;当前策略会在空间不足时阻塞生产者
_push_cond.wait(lock, [&](){return _push_task.writeAbleSize() >= msg.size();});
_push_task.push(msg.c_str(), msg.size());
}
//此时任务调度线程就可以开始处理任务了
_pop_cond.notify_all();
}
private:
// 事件循环,检测是否有任务可以处理,若有任务则交换缓冲区(上一次锁即可)
void loop()
{
//即便停止任务调度,任务队列中的任务仍需全部完成才能结束,故不能以_running的真与否来判断函数是否继续运行
while(true)
{
//生命周期结束后释放锁
{
std::unique_lock<std::mutex> lock(_mtx);
//只有在任务真正被处理完且_running为false的时候才能退出事件循环,而后回收该线程
if(!_running && _push_task.empty()) return;
//否则继续任务处理
//stop或者有任务待处理都可以直接继续运行代码,无需阻塞
_pop_cond.wait(lock, [&](){return !_push_task.empty() || !_running;});
// 将生产缓冲与消费缓冲交换;交换后在锁外处理,缩短持锁时间
// 此时可能任务已经停止了,需要重新检查_running的值
if(!_running && _push_task.empty()) return;
// 限制单次处理的最大字节数,防止单次任务
_pop_task.swap(_push_task);
}
// 唤醒可能在等待可写空间的生产者
_push_cond.notify_all();
// 唤醒生产者继续生产数据后,消费者就可以调用回调函数处理数据了,读写不冲突
_task_manage(_pop_task);
_pop_task.reset();
if(_flushing && _push_task.empty() && _pop_task.empty()) {
std::unique_lock<std::mutex> lock(_flush_mtx);
_cv.notify_all();
}
}
}
// 停止任务调度
void stop()
{
// 发出停止信号并唤醒消费者,让其在处理完现有任务后正常退出
_running = false;
_pop_cond.notify_all();
// 等待 loop 线程结束;要求 _looper 已创建且可 join
_looper.join();
}
public:
void flush() {
std::unique_lock<std::mutex> lock(_flush_mtx);
_flushing = true;
// 此时 loop 仍然在运行,因此需要等待 push_task 和 pop_task 都为空
_cv.wait(lock, [&](){return _push_task.empty() && _pop_task.empty();});
_flushing = false;
return;
}
private:
std::atomic<bool> _running; // 决定当前工作是否继续运行
std::atomic<bool> _flushing = false; // 是否正在刷新
std::condition_variable _push_cond; // 是否满足任务添加条件
std::condition_variable _pop_cond; // 是否满足任务获取条件
std::condition_variable _cv; // 是否满足任务刷新条件
std::mutex _flush_mtx; // 刷新条件对应锁
std::mutex _mtx; // 条件变量相对应锁
Buffer _push_task; // 任务添加缓冲区
Buffer _pop_task; // 任务获取缓冲区
std::thread _looper; // 事务循环处理器
private:
Func _task_manage;
};
这里我们的异步循环器 AsyncLooper 实现了一个多生产者-单消费者模型,负责管理日志消息的队列和后台线程,底层实质上维护了一个双缓冲区 Buffer 来存储日志消息。生产者通过 push 方法将日志消息放入生产缓冲区 _push_task 中,而消费者则在后台线程中不断地检查生产缓冲区是否有日志消息可读。如果有日志消息,消费者会将生产缓冲区与消费缓冲区 _pop_task 交换,然后调用回调函数 _task_manage 来处理消费缓冲区中的日志消息。此后,生产者就可以将日志数据写入新的生产缓冲区,而消费者则处理旧的消费缓冲区,实现了高效的日志消息处理。
双缓冲区设计
graph TD
classDef producer fill:#ccf,stroke:#333;
classDef consumer fill:#fcc,stroke:#333;
classDef buffer fill:#cfc,stroke:#333;
classDef control fill:#ffc,stroke:#333;
subgraph 多生产者线程 [多生产者线程]
P1[生产者1]:::producer
P2[生产者2]:::producer
P3[生产者N]:::producer
end
subgraph 异步循环器核心 [AsyncLooper 核心]
PB[生产缓冲区 _push_task]:::buffer
CB[消费缓冲区 _pop_task]:::buffer
Lock[[互斥锁]]:::control
Swap[[缓冲区交换]]:::control
BG[后台消费者线程]:::consumer
Proc[[处理回调 _task_manage]]:::control
end
subgraph 日志输出 [日志输出目标]
Sinks[日志接收器集合]
end
%% 生产者流程
P1 -->|push 日志消息| Lock
P2 -->|push 日志消息| Lock
P3 -->|push 日志消息| Lock
Lock -->|写入| PB
PB -->|释放锁| Lock
%% 消费者流程
BG -->|循环检查| Lock
Lock -->|检查有数据?| Check{有数据?}
Check -->|是| Swap
Check -->|否| Lock
%% 缓冲区交换与处理
Swap -->|交换 PB <-> CB| SwapDone
SwapDone -->|释放锁| Lock
SwapDone -->|处理数据| Proc
Proc -->|遍历写入| Sinks
Proc -->|处理完成| BG
而我们的 _task_manage 回调函数则是由异步日志器 AsyncLogger 提供的 logSink 方法实现的。这个方法负责将消费缓冲区中的日志消息批量写入到所有的日志接收器中。通过这种方式,我们实现了一个高效且线程安全的异步日志记录系统,能够满足高并发场景下的日志记录需求。
生产者-消费者工作流程详解
下面我们需要详细讲解一下生产者和消费者各自的工作流程:
生产者
void push(const std::string &msg)
{
//停止任务调度则结束任务添加操作
if(_running == false) return;
//否则在每个生命周期内添加一个任务
{
std::unique_lock<std::mutex> lock(_mtx);
// 等待可写空间 >= 本次任务大小;当前策略会在空间不足时阻塞生产者
_push_cond.wait(lock, [&](){return _push_task.writeAbleSize() >= msg.size();});
_push_task.push(msg.c_str(), msg.size());
}
//此时任务调度线程就可以开始处理任务了
_pop_cond.notify_all();
}
生产者通过 push 方法将日志消息放入生产缓冲区 _push_task 中。首先,生产者会检查异步循环器是否正在运行,如果已经停止运行,则直接返回,结束任务添加操作。否则,生产者会获取互斥锁 _mtx,确保线程安全。接着,生产者会等待条件变量 _push_cond,直到生产缓冲区有足够的可写空间来存放当前日志消息。如果可写空间不足,生产者会阻塞等待,直到有足够的空间可用。一旦有足够的空间,生产者就会将日志消息写入生产缓冲区。最后,生产者会释放互斥锁,并通知消费者线程(通过条件变量 _pop_cond),让其开始处理日志消息。
消费者
void loop()
{
//即便停止任务调度,任务队列中的任务仍需全部完成才能结束,故不能以_running的真与否来判断函数是否继续运行
while(true)
{
//生命周期结束后释放锁
{
std::unique_lock<std::mutex> lock(_mtx);
//只有在任务真正被处理完且_running为false的时候才能退出事件循环,而后回收该线程
if(!_running && _push_task.empty()) return;
//否则继续任务处理
//stop或者有任务待处理都可以直接继续运行代码,无需阻塞
_pop_cond.wait(lock, [&](){return !_push_task.empty() || !_running;});
// 将生产缓冲与消费缓冲交换;交换后在锁外处理,缩短持锁时间
// 此时可能任务已经停止了,需要重新检查_running的值
if(!_running && _push_task.empty()) return;
// 限制单次处理的最大字节数,防止单次任务
_pop_task.swap(_push_task);
}
// 唤醒可能在等待可写空间的生产者
_push_cond.notify_all();
// 唤醒生产者继续生产数据后,消费者就可以调用回调函数处理数据了,读写不冲突
_task_manage(_pop_task);
_pop_task.reset();
if(_flushing && _push_task.empty() && _pop_task.empty()) {
std::unique_lock<std::mutex> lock(_flush_mtx);
_cv.notify_all();
}
}
}
消费者在后台线程中不断地执行 loop 方法,负责处理生产缓冲区中的日志消息。
- 首先,消费者会获取互斥锁
_mtx,确保线程安全 - 然后,消费者会检查异步循环器的运行状态和生产缓冲区是否为空
- 如果异步循环器已经停止运行且生产缓冲区为空,消费者就会退出循环,结束线程
- 否则,消费者会等待条件变量
_pop_cond,直到生产缓冲区中有日志消息可读或者异步循环器停止运行。一旦有日志消息可读,消费者就会将生产缓冲区与消费缓冲区交换,然后释放互斥锁 - 接着,消费者会调用回调函数_task_manage来处理消费缓冲区中的日志消息,将其写入到所有的日志接收器中 - 处理完成后,消费者会重置消费缓冲区,并检查是否需要通知刷新操作完成
通过这种方式,生产者和消费者能够高效地协同工作,实现日志消息的异步写入
可以看到,消费者这里进行了两次检查,一次是在等待条件变量之前,另一次是在交换缓冲区之后。这是为了确保在异步循环器停止运行的情况下,消费者能够正确地退出循环,避免处理空的日志消息。
我们假设一个场景来解释一下为什么这两次检查都是必要的:
假设异步循环器正在运行,生产者正在向生产缓冲区添加日志消息。此时,消费者线程正在等待条件变量 _pop_cond,准备处理日志消息。突然,异步循环器被停止运行(例如,程序正在关闭)。在这种情况下:
- 第一次检查(在等待条件变量之前)确保消费者在异步循环器停止运行且生产缓冲区为空时能够正确地退出循环,避免继续等待条件变量,导致线程阻塞。
- 第二次检查(在交换缓冲区之后)确保即使在等待条件变量期间,异步循环器被停止运行,消费者也能够正确地退出循环,避免处理空的日志消息,从而保证程序的正确性和稳定性。
通过这两次检查,消费者能够在异步循环器停止运行的情况下正确地退出循环,确保日志消息的处理过程不会出现错误或异常情况。
flush 方法实现
void flush() {
std::unique_lock<std::mutex> lock(_flush_mtx);
_flushing = true;
// 此时 loop 仍然在运行,因此需要等待 push_task 和 pop_task 都为空
_cv.wait(lock, [&](){return _push_task.empty() && _pop_task.empty();});
_flushing = false;
return;
}
flush 方法用于强制将日志缓冲区中的日志消息写入到日志接收器中。
- 首先,
flush方法会获取互斥锁_flush_mtx,确保线程安全 - 然后,它会将
_flushing标志设置为 true,表示正在进行刷新操作 - 接着,
flush方法会等待条件变量_cv,直到生产缓冲区_push_task和消费缓冲区_pop_task都为空,表示所有的日志消息都已经被处理完毕。一旦条件满足,flush方法会将_flushing标志设置为 false,表示刷新操作完成,然后返回。
异步日志器实现总结
通过上述实现,我们成功地构建了一个高效且线程安全的异步日志记录系统。异步日志器 AsyncLogger 通过异步循环器 AsyncLooper 实现了日志消息的异步写入操作,满足了高并发场景下的日志记录需求。生产者-消费者模型的设计使得日志消息的处理过程得以高效协同工作,确保了日志记录的实时性和可靠性。
同样的,我们用一个流程图来帮助大家理解异步日志器的工作流程:
graph TD
%% 业务线程1(多线程示例)
A1["调用LOG宏:LOGINFO()"] --> B1["检查日志等级<br/>是否满足条件"]
B1 -- 不满足 --> C1["忽略日志"]
B1 -- 满足 --> D1["格式化日志消息<br/>(生成字符串)"]
D1 --> E1["推送消息到<br/>异步队列"]
%% 业务线程2(体现多线程)
A2["调用LOG宏:LOGERROR()"] --> B2["检查日志等级<br/>是否满足条件"]
B2 -- 不满足 --> C2["忽略日志"]
B2 -- 满足 --> D2["格式化日志消息<br/>(生成字符串)"]
D2 --> E2["推送消息到<br/>异步队列"]
%% 异步队列(线程安全缓冲)
F["异步队列:线程安全的消息队列<br/>(内存池存储)"]
%% 日志处理线程(独立消费)
G["日志处理线程:独立循环运行"] --> H["从队列中取出<br/>日志消息"]
H --> I["遍历所有日志接收器<br/>(文件/控制台/网络)"]
I --> J["写入日志到目标介质"]
J --> G
%% 流程连接
E1 --> F
E2 --> F
F --> H
%% 样式美化(不影响解析)
style F fill:#fdf,stroke:#333,stroke-width:2px
style G fill:#eef,stroke:#333,stroke-width:2px
style A1,A2 fill:#ffe,stroke:#333,stroke-width:1px
本地日志器建造者实现
上面我们分别实现了日志器建造者 Logger::Builder、同步日志器 SyncLogger 以及异步日志器 AsyncLogger。而我们的 Logger::Builder 类是一个抽象类,我们当时将 build 方法定义为纯虚函数,要求子类实现具体的构建逻辑。因此,我们需要对这个建造者类进行具体的封装,实现一个可以根据用户的配置来构建同步或异步日志器的具体建造者类。下面是具体的实现示例:
class LocalLoggerBuilder : public Logger::Builder
{
public:
Logger::ptr build() override
{
// 空处理
if (_logger_name.empty())
{
throw std::runtime_error("日志名为空,无法构建本地日志");
}
if (_format.get() == nullptr)
{
_format = std::make_shared<Format>();
}
if (_sinks.empty())
{
_sinks.push_back(sinkCreate<StdOutLogSink>());
}
// 日志器分类处理
if (_type == LoggerType::LOGGER_SYNC)
{
return std::make_shared<SyncLogger>(_logger_name, _format, _sinks, _limit_level);
}
return std::make_shared<AsyncLogger>(_logger_name, _format, _sinks, _limit_level);
}
};
这样一来,用户将自己的配置传递给 LocalLoggerBuilder,然后调用 build 方法,就可以根据配置来构建同步或异步日志器实例了。这个封装类简化了日志器的构建过程,使得用户只需要关注自己的配置,而不需要关心具体的实现细节。
日志器管理者实现
在日志系统中,我们通常需要一个全局的日志器管理者(Logger Manager)来管理所有的日志器实例。这个管理者负责创建、存储和提供对日志器的访问。下面是全局日志器管理者的实现示例:
class LoggerManager
{
public:
using ptr = std::shared_ptr<LoggerManager>;
static LoggerManager &getInstance()
{
return _lm;
}
// 直接返回 root 日志器
Logger::ptr getRootLogger()
{
return _root_logger;
}
Logger::ptr getLogger(const std::string &name)
{
std::unique_lock<std::mutex> lock(_mtx);
auto it = _loggers.find(name);
if (it != _loggers.end())
{
return it->second;
}
return _root_logger;
}
void addLogger(const std::string &name, Logger::ptr logger)
{
std::unique_lock<std::mutex> lock(_mtx);
if (_loggers.find(name) == _loggers.end())
{
_loggers[name] = logger;
}
}
private:
LoggerManager()
{
std::unique_ptr<Logger::Builder> builder = std::make_unique<LocalLoggerBuilder>();
builder->buildLoggerName("root");
builder->buildType(LoggerType::LOGGER_SYNC);
_root_logger = builder->build();
_loggers["root"] = _root_logger;
}
~LoggerManager() = default;
LoggerManager(const LoggerManager &) = delete;
LoggerManager &operator=(const LoggerManager &) = delete;
LoggerManager(LoggerManager &&) = delete;
LoggerManager &operator=(LoggerManager &&) = delete;
private:
std::unordered_map<std::string, Logger::ptr> _loggers;
std::mutex _mtx;
Logger::ptr _root_logger;
static LoggerManager _lm;
};
inline LoggerManager LoggerManager::_lm = LoggerManager();
这是一个单例模式实现的日志器管理者类 LoggerManager。它提供了获取根日志器和根据名称获取日志器的方法。用户可以通过这个管理者来访问和管理所有的日志器实例。同时,它还提供了添加日志器的方法,允许用户动态地添加新的日志器实例。
利用哈希表 _loggers 来存储日志器实例,确保了日志器的快速访问和管理。通过这种方式,我们实现了一个灵活且高效的日志器管理系统,满足了各种日志记录需求。
这样一来,当用户需要创建一个新的全局日志器时,可以将其添加到 LoggerManager 中,方便后续的访问和管理。同时,用户也可以通过 LoggerManager 获取根日志器,进行默认的日志记录操作。
这就意味着,用户需要先通过 LocalLoggerBuilder 构建一个日志器实例,然后将其添加到 LoggerManager 中。之后,再通过 LoggerManager 来获取这个日志器实例,并使用它进行日志记录操作。
首先抛开行为繁琐不谈,用户如果只通过 LocalLoggerBuilder 构造了一个日志器,并没有将这个日志器添加到 LoggerManager 中,那么这个日志器实例将无法通过 LoggerManager 进行访问和管理。用户只能通过自己持有的日志器实例来进行日志记录操作。
因此,我们有义务设计一个建造即添加的便捷接口,简化用户的使用流程。
全局日志器建造者实现
class GlobalLoggerBuilder : public Logger::Builder
{
public:
Logger::ptr build() override
{
// 空处理
if (_logger_name.empty())
{
throw std::runtime_error("日志名为空,无法构建全局日志");
}
if (_format.get() == nullptr)
{
_format = std::make_shared<Format>();
}
if (_sinks.empty())
{
_sinks.push_back(sinkCreate<StdOutLogSink>());
}
// 日志器分类处理
Logger::ptr logger;
if (_type == LoggerType::LOGGER_SYNC)
{
logger = std::make_shared<SyncLogger>(_logger_name, _format, _sinks, _limit_level);
}
else
{
logger = std::make_shared<AsyncLogger>(_logger_name, _format, _sinks, _limit_level);
}
// 将 logger 插入到 LoggerManager 的 哈希表中去,交由 LoggerManager 管理从而确保 Logger 被全局管理
LoggerManager::getInstance().addLogger(_logger_name, logger);
return logger;
}
};
其设计逻辑和 LocalLoggerBuilder 类似,不同之处在于它在构建日志器实例后,会将这个实例添加到 LoggerManager 中进行全局管理。这样一来,用户只需要通过 GlobalLoggerBuilder 来构建日志器实例,就可以确保这个日志器实例被全局管理,方便后续的访问和使用。
这样一来,当用户需要创建本地日志器时,可以使用 LocalLoggerBuilder,而当用户需要创建全局日志器时,也不需要先使用 LocalLoggerBuilder 去创建一个本地日志器,再将其通过 LoggerManager 添加到全局管理中,可以直接使用 GlobalLoggerBuilder。这两种建造者类分别满足了不同的使用需求,提供了灵活且便捷的日志器构建方式。
面向用户快速上手的接口封装
当前的同步异步日志器对于用户来说已经非常友好了,但我们仍然可以设计一些非常常用的接口,方便用户快速上手使用日志系统。下面是一些常用的接口封装示例:
#pragma once
#include "logger.hpp"
namespace ljxlog {
// 默认 logger 名称(用于宏查找)
inline std::string& default_logger_name()
{
static std::string name = "default";
return name;
}
// 返回 root logger
inline Logger::ptr get_root_logger() {
return LoggerManager::getInstance().getRootLogger();
}
// 根据名字返回对应 logger,若不存在则返回 root logger
inline Logger::ptr get_logger(const std::string& name) {
return LoggerManager::getInstance().getLogger(name);
}
// 一行初始化:同步控制台 logger
inline Logger::ptr init_stdout_logger(const std::string& name = "default",
Level min_level = Level::DEBUG,
const std::string& pattern = "[%d{%H:%M:%S}][%p] %m%n")
{
std::unique_ptr<Logger::Builder> builder = std::make_unique<GlobalLoggerBuilder>();
builder->buildLoggerName(name);
builder->buildLimitLevel(min_level);
builder->buildType(LoggerType::LOGGER_SYNC);
builder->buildSink<StdOutLogSink>();
builder->buildFormat(pattern);
auto lg = builder->build();
default_logger_name() = name;
return lg;
}
// 返回 default logger
inline Logger::ptr get_default_logger() {
static Logger::ptr s_default = []{
// 你也可以改成 init_async_file_logger("default", "/tmp/ljxlog.log", 8*1024*1024)
return init_stdout_logger(default_logger_name(), Level::DEBUG,
"[%d{%H:%M:%S}][%p] %m%n");
}();
return s_default;
}
// 一行初始化:异步 + 按大小滚动的文件 logger
inline Logger::ptr init_async_file_logger(const std::string& name,
const std::string& file_path,
size_t max_size_bytes,
Level min_level = Level::DEBUG,
const std::string& pattern = "[%d{%H:%M:%S}][%t][%p][%c][%f:%l] %m%n")
{
std::unique_ptr<Logger::Builder> builder = std::make_unique<GlobalLoggerBuilder>();
builder->buildLoggerName(name);
builder->buildLimitLevel(min_level);
builder->buildType(LoggerType::LOGGER_ASYNC);
builder->buildSink<RollBySizeLogSink>(file_path, max_size_bytes);
builder->buildFormat(pattern);
auto lg = builder->build(); // 一般已注册到 LoggerManager
default_logger_name() = name;
return lg;
}
// 一行初始化:同步 + 按大小滚动的文件 logger
inline Logger::ptr init_sync_file_logger(const std::string& name,
const std::string& file_path,
size_t max_size_bytes,
Level min_level = Level::DEBUG,
const std::string& pattern = "[%d{%H:%M:%S}][%t][%p][%c][%f:%l] %m%n")
{
std::unique_ptr<Logger::Builder> builder = std::make_unique<GlobalLoggerBuilder>();
builder->buildLoggerName(name);
builder->buildLimitLevel(min_level);
builder->buildType(LoggerType::LOGGER_SYNC);
builder->buildSink<RollBySizeLogSink>(file_path, max_size_bytes);
builder->buildFormat(pattern);
auto lg = builder->build(); // 一般已注册到 LoggerManager
default_logger_name() = name;
return lg;
}
};
// 便捷宏:通过指定的 logger 进行日志落地(自动携带 __FILE__/__LINE__,并进行空指针保护)
#define LOGDEBUG(logger, fmt, ...) do { auto __lg = (logger); if (__lg) __lg->debug (__FILE__, __LINE__, fmt, ##__VA_ARGS__); } while (0)
#define LOGINFO(logger, fmt, ...) do { auto __lg = (logger); if (__lg) __lg->info (__FILE__, __LINE__, fmt, ##__VA_ARGS__); } while (0)
#define LOGWARNING(logger, fmt, ...) do { auto __lg = (logger); if (__lg) __lg->warning(__FILE__, __LINE__, fmt, ##__VA_ARGS__); } while (0)
#define LOGERROR(logger, fmt, ...) do { auto __lg = (logger); if (__lg) __lg->error (__FILE__, __LINE__, fmt, ##__VA_ARGS__); } while (0)
#define LOGFATAL(logger, fmt, ...) do { auto __lg = (logger); if (__lg) __lg->fatal (__FILE__, __LINE__, fmt, ##__VA_ARGS__); } while (0)
// 便捷宏:通过默认 logger 进行日志落地(自动携带 __FILE__/__LINE__,并进行空指针保护)
#define DEBUG(fmt, ...) do { auto __lg = ljxlog::get_default_logger(); if (__lg) __lg->debug (__FILE__, __LINE__, fmt, ##__VA_ARGS__); } while (0)
#define INFO(fmt, ...) do { auto __lg = ljxlog::get_default_logger(); if (__lg) __lg->info (__FILE__, __LINE__, fmt, ##__VA_ARGS__); } while (0)
#define WARNING(fmt, ...) do { auto __lg = ljxlog::get_default_logger(); if (__lg) __lg->warning(__FILE__, __LINE__, fmt, ##__VA_ARGS__); } while (0)
#define ERROR(fmt, ...) do { auto __lg = ljxlog::get_default_logger(); if (__lg) __lg->error (__FILE__, __LINE__, fmt, ##__VA_ARGS__); } while (0)
#define FATAL(fmt, ...) do { auto __lg = ljxlog::get_default_logger(); if (__lg) __lg->fatal (__FILE__, __LINE__, fmt, ##__VA_ARGS__); } while (0)
首先,针对默认日志器和根日志器,我们提供了便捷的获取接口 get_default_logger 和 get_root_logger,方便用户快速访问这两个常用的日志器实例。
get_root_logger方法直接返回全局日志器管理者中的根日志器实例,用户可以通过这个方法获取到默认的日志记录器,进行日志记录操作。get_default_logger方法则返回一个默认的日志器实例。如果用户没有显式地初始化默认日志器,这个方法会自动创建一个同步控制台日志器作为默认日志器,确保用户始终能够获得一个有效的默认日志器实例。- 初始化采用的懒汉式单例模式,只有在第一次调用
get_default_logger方法时,才会创建默认日志器实例,避免了不必要的资源浪费。
其次,我们提供了两三个初始化函数init_async_file_logger、init_stdout_logger和init_sync_file_logger,分别用于快速初始化异步文件日志器、同步控制台日志器和同步文件日志器。用户只需要传入必要的参数,如日志器名称、文件路径、最大文件大小等,即可快速创建并注册一个全局日志器实例,简化了日志器的创建过程。
- 初始化采用的懒汉式单例模式,只有在第一次调用
当用户需要获取一个全局日志器的时候,可以直接调用 get_logger 方法,传入日志器的名称即可。如果指定名称的日志器不存在,则会返回根日志器,确保用户始终能够获得一个有效的日志器实例。
而针对日志打印,我们提供了一组便捷的宏定义,分别用于通过指定的日志器实例进行日志记录和通过默认日志器进行日志记录。这些宏定义自动携带了文件名和行号信息,方便用户在日志中定位问题。同时,这些宏定义还进行了空指针保护,确保在日志器实例为空时不会引发异常。
需要注意的是,这些宏定义使用了 do { ... } while (0) 结构,确保在任何情况下都能正确地展开为单个语句,避免了潜在的语法错误。
同步异步日志器总结
通过上述实现,我们成功地构建了一个功能完善且易于使用的日志记录系统。同步日志器 SyncLogger 和异步日志器 AsyncLogger 分别满足了不同的使用场景需求,用户可以根据自己的需求选择合适的日志器类型。异步日志器通过异步循环器 AsyncLooper 实现了高效的日志消息处理,适用于高并发场景下的日志记录需求。
下面用一个类图来总结同步日志器和异步日志器的实现结构:
classDiagram
direction TB
%% 优化连线样式,避免交叉混乱
skinparam linetype ortho
%% ===================== 基础工具模块 =====================
namespace 基础工具模块 {
class Date {
+ now() : time_t
}
class File {
+ exists(pathname: string) : bool
+ getPath(pathname: string) : string
+ createDirectory(pathname: string) : void
}
class Level {
<<enumeration>>
UNKNOW
DEBUG
INFO
WARNING
ERROR
FATAL
OFF
+ toString(level: Level) : const char*
}
}
%% ===================== 日志消息模块 =====================
namespace 日志消息模块 {
class LogMsg {
+ _line : size_t
+ _time : time_t
+ _tid : thread::id
+ _name : string
+ _file : string
+ _payload : string
+ _level : Level
}
}
%% ===================== 格式化系统模块 =====================
namespace 格式化系统模块 {
class FormatItem {
<<interface>>
+ format(os: ostream, msg: LogMsg) : void
}
class TimeFormatItem {
+ format(os: ostream, msg: LogMsg) : void
}
class LineFormatItem {
+ format(os: ostream, msg: LogMsg) : void
}
class ThreadFormatItem {
+ format(os: ostream, msg: LogMsg) : void
}
class NameFormatItem {
+ format(os: ostream, msg: LogMsg) : void
}
class FileFormatItem {
+ format(os: ostream, msg: LogMsg) : void
}
class PayLoadFormatItem {
+ format(os: ostream, msg: LogMsg) : void
}
class LevelFormatItem {
+ format(os: ostream, msg: LogMsg) : void
}
class TabFormatItem {
+ format(os: ostream, msg: LogMsg) : void
}
class NLineFormatItem {
+ format(os: ostream, msg: LogMsg) : void
}
class OtherFormatItem {
+ format(os: ostream, msg: LogMsg) : void
}
class Format {
+ Format(pattern: string)
+ format(out: ostream, msg: LogMsg) : void
+ format(msg: LogMsg) : string
- parsePattern() : bool
- createItem(key: string, value: string) : FormatItem*
}
}
%% ===================== 输出目标模块 =====================
namespace 输出目标模块 {
class LogSink {
<<interface>>
+ log(data: const char*, len: size_t) : void
+ flush() : void
}
class StdOutLogSink {
+ log(data: const char*, len: size_t) : void
+ flush() : void
}
class FixedFileLogSink {
+ log(data: const char*, len: size_t) : void
+ flush() : void
}
class RollBySizeLogSink {
+ log(data: const char*, len: size_t) : void
+ flush() : void
}
}
%% ===================== 异步核心模块 =====================
namespace 异步核心模块 {
class Buffer {
+ append(data: const char*, len: size_t) : void
+ data() : const char*
+ size() : size_t
+ clear() : void
}
class AsyncLooper {
+ push(msg: string) : void
+ flush() : void
+ start() : void
+ stop() : void
}
}
%% ===================== 日志器核心模块 =====================
namespace 日志器核心模块 {
class Logger {
<<interface>>
+ info(filename: string, line: size_t, fmt: const char*, ...) : void
+ debug(filename: string, line: size_t, fmt: const char*, ...) : void
+ error(filename: string, line: size_t, fmt: const char*, ...) : void
+ fatal(filename: string, line: size_t, fmt: const char*, ...) : void
+ flush() : void
}
class SyncLogger {
+ logManage(msg: string) : void
+ flush() : void
}
class AsyncLogger {
+ logManage(msg: string) : void
+ flush() : void
}
}
%% ===================== 建造者体系模块 =====================
namespace 建造者体系模块 {
class LoggerType {
<<enumeration>>
LOGGER_SYNC
LOGGER_ASYNC
}
class LoggerBuilder {
<<abstract>>
+ buildLoggerName(name: string) : void
+ buildLimitLevel(level: Level) : void
+ buildType(type: LoggerType) : void
+ buildSink() : void
+ buildFormat(pattern: string) : void
+ build() : Logger*
}
class LocalLoggerBuilder {
+ build() : Logger*
}
class GlobalLoggerBuilder {
+ build() : Logger*
}
}
%% ===================== 日志管理模块 =====================
namespace 日志管理模块 {
class LoggerManager {
<<singleton>>
+ getInstance() : LoggerManager&
+ getLogger(name: string) : Logger*
+ registerLogger(name: string, logger: Logger*) : void
}
}
%% ===================== 继承关系 =====================
FormatItem <|-- TimeFormatItem
FormatItem <|-- LineFormatItem
FormatItem <|-- ThreadFormatItem
FormatItem <|-- NameFormatItem
FormatItem <|-- FileFormatItem
FormatItem <|-- PayLoadFormatItem
FormatItem <|-- LevelFormatItem
FormatItem <|-- TabFormatItem
FormatItem <|-- NLineFormatItem
FormatItem <|-- OtherFormatItem
LogSink <|-- StdOutLogSink
LogSink <|-- FixedFileLogSink
LogSink <|-- RollBySizeLogSink
Logger <|-- SyncLogger
Logger <|-- AsyncLogger
LoggerBuilder <|-- LocalLoggerBuilder
LoggerBuilder <|-- GlobalLoggerBuilder
%% ===================== 组合/依赖/关联关系 =====================
Format "1" *-- "*" FormatItem : 包含
Logger o--> "1" Format : 使用
Logger o--> "*" LogSink : 包含
Logger ..> LogMsg : 创建
AsyncLogger --> AsyncLooper : 包含
AsyncLooper o--> "2" Buffer : 双缓冲
LogMsg --> Level : 使用
LogMsg --> Date : 使用
GlobalLoggerBuilder ..> LoggerManager : 注册
LoggerManager o--> "*" Logger : 管理
同时,附上一张用户使用异步日志器的时序图,帮助大家更好地理解异步日志器的工作流程:
sequenceDiagram
participant User
participant Builder as 日志构建器(Builder)
participant Logger as 日志器(Logger)
participant Format as 格式化器(Format)
participant Sink as 日志接收器(LogSink)
participant AsyncQueue as 异步队列(AsyncQueue)
participant AsyncThread as 异步线程
%% 1. 初始化日志器
User->>Builder: 调用init_stdout_logger(配置参数)
Builder->>Builder: 配置日志器属性(名称、级别、模式等)
Builder->>Logger: 构建日志器对象并返回
Logger-->>User: 返回日志器实例(lg)
%% 2. 记录日志流程
User->>Logger: 调用LOGINFO(lg, 日志内容)
Logger->>Logger: 检查日志等级是否满足输出条件
alt 等级不满足
Logger->>Logger: 忽略日志记录
else 等级满足
Logger->>Logger: 生成日志消息(LogMsg)
Logger->>Format: 调用format()格式化日志
Format->>Format: 解析格式串并应用到LogMsg
Format-->>Logger: 返回格式化后的日志字符串
alt 同步模式
Logger->>Logger: 加锁保护接收器访问
Logger->>Sink: 调用sink->log()写入日志
Sink-->>Logger: 完成日志写入
Logger->>Logger: 解锁
else 异步模式
Logger->>AsyncQueue: 将日志消息推入队列
AsyncQueue-->>Logger: 消息入队完成
AsyncThread->>AsyncQueue: 从队列中取出日志消息
AsyncQueue-->>AsyncThread: 返回日志消息
AsyncThread->>Sink: 调用sink->log()写入日志
Sink-->>AsyncThread: 完成日志写入
end
end
功能测试
测试框架
一个好的项目,离不开完善的测试。我们需要对日志系统进行功能测试和性能测试,确保其在各种场景下都能正常工作,并且满足性能要求。
首先给大家看一下整个项目的测试代码目录结构:
╭─ljx@VM-16-15-debian ~/cpp_code/Logging-System/test ‹main*›
╰─➤ tree
.
├── basic
│ ├── test_async_logger.cpp
│ ├── test_builder_and_format.cpp
│ ├── test_fixed_file_sink.cpp
│ ├── test_roll_by_size_sink.cpp
│ ├── test_roll_by_time_sink.cpp
│ └── test_stdout_macros.cpp
├── comprehensive
│ ├── test_async_comprehensive.cpp
│ └── test_sync_comprehensive.cpp
├── stress
│ ├── stopwatch.hpp
│ ├── test_async_mt5_stress.cpp
│ ├── test_async_st_stress.cpp
│ ├── test_sync_mt5_stress.cpp
│ ├── test_sync_st_stress.cpp
│ └── trash_file_sink.hpp
├── test_framework.hpp
└── test_main.cpp
4 directories, 16 files
项目核心源码在 Logging-System 目录下,而测试代码则放在 test 目录下。测试代码分为三大类:
basic:基础功能测试,主要测试各个模块的基本功能是否正常工作comprehensive:综合功能测试,测试日志系统在实际使用中的整体功能是否满足需求stress:压力测试,测试日志系统在高并发和大负载下的性能表现
这么复杂的测试代码,我们自然也需要一个测试框架来支撑。我们自定义了一个非常简单的测试框架 test_framework.hpp,用于组织和运行测试用例。下面是测试框架的核心代码实现:
// ==============================================================================
// 【头文件保护】:防止重复包含(#pragma once 是编译器扩展,比#ifndef 更简洁)
// 作用:确保该头文件在一个编译单元中仅被包含一次,避免重定义错误
// ==============================================================================
#pragma once
// 引入依赖头文件:测试框架所需的基础容器/类型/功能
#include <vector> // 存储测试用例的容器
#include <string> // 字符串处理(本代码未直接用,但为扩展预留)
#include <functional> // 封装测试用例的函数对象(std::function<void()>)
#include <iostream> // 标准输出/错误流(打印断言失败信息)
#include <cstdlib> // 标准库函数(exit(1) 终止程序)
// ==============================================================================
// 【测试框架命名空间】:tinytest
// 作用:隔离测试框架的所有符号,避免与业务代码命名冲突
// ==============================================================================
namespace tinytest
{
// ==========================================================================
// 【测试用例结构体】:TestCase
// 作用:封装单个测试用例的元信息(名称 + 执行函数)
// ==========================================================================
struct TestCase
{
const char *name; // 测试用例名称(C风格字符串,存储用例的宏名)
std::function<void()> fn; // 测试用例的执行函数(无参数/无返回值的函数对象)
// 支持普通函数、lambda、绑定函数等,灵活性高
};
// ==========================================================================
// 【测试用例注册表(单例模式)】:registry()
// 作用:全局唯一存储所有注册的测试用例,核心设计是「函数内静态变量」实现单例
// 特点:
// 1. 懒加载:第一次调用时才初始化vector,避免全局变量初始化顺序问题
// 2. 线程安全(C++11及以上):静态局部变量初始化是原子操作
// 3. 返回引用:允许外部修改(添加测试用例)
// ==========================================================================
inline std::vector<TestCase> ®istry()
{
// 静态局部变量:程序生命周期内仅初始化一次,全局唯一
static std::vector<TestCase> r;
// 返回引用:外部可直接操作这个vector(如push_back添加用例)
return r;
}
// ==========================================================================
// 【测试用例注册器】:Registrar
// 作用:通过构造函数自动将测试用例注册到registry中(RAII思想)
// 使用方式:配合TEST宏,定义静态Registrar对象,触发构造函数完成注册
// ==========================================================================
struct Registrar
{
// 构造函数:接收用例名和函数对象,将用例添加到注册表
// 参数:
// - n: 测试用例名称(C风格字符串)
// - f: 测试用例执行函数(std::function<void()>,支持任意可调用对象)
// 注意:std::move(f) 减少拷贝,提升性能(函数对象可能捕获大量变量)
Registrar(const char *n, std::function<void()> f)
{
// 将测试用例添加到全局注册表
registry().push_back({n, std::move(f)});
}
};
}
// ==============================================================================
// 【核心宏:TEST(name)】:定义并注册测试用例(框架的核心入口)
// 作用:一键完成「函数声明 + 静态注册器定义 + 函数定义」,简化测试用例编写
// 宏展开逻辑(以TEST(test_foo)为例):
// 1. void test_foo(); // 声明测试函数
// 2. static tinytest::Registrar _r_test_foo("test_foo", test_foo); // 静态注册器(自动注册)
// 3. void test_foo() // 定义测试函数(用户编写测试逻辑)
// 关键技巧:
// - _r_##name:宏拼接,生成唯一的注册器变量名(避免重复定义)
// - #name:宏字符串化,将用例名转为C风格字符串
// - static:注册器变量作用域限定在当前编译单元,避免多文件重定义
// ==============================================================================
#define TEST(name) \
void name(); \
static tinytest::Registrar _r_##name(#name, name); \
void name()
// ==============================================================================
// 【宏:FALL(fmt, ...)】:致命错误终止宏(快速失败)
// 作用:打印错误信息到标准错误流,并立即终止程序(退出码1表示异常)
// 设计细节:
// 1. do { ... } while(0):包裹宏逻辑,避免if/for等语句块的语法问题
// 2. fprintf(stderr, ...):输出到标准错误流(stderr),区别于标准输出(stdout)
// 3. ##__VA_ARGS__:兼容C99的可变参数宏,处理空参数场景(避免编译警告)
// 4. exit(1):立即终止程序,退出码1表示测试失败
// 使用场景:测试中遇到不可恢复的错误(如资源初始化失败)
// ==============================================================================
#define FALL(fmt, ...) do { \
fprintf(stderr, "FALL: " fmt "\n", ##__VA_ARGS__); \
exit(1); \
} while(0)
// ==============================================================================
// 【断言宏:EXPECT_TRUE(cond)】:验证条件为真(基础断言)
// 作用:检查条件是否成立,不成立则打印错误信息并终止程序
// 设计细节:
// 1. do { ... } while(0):避免宏展开后的语法错误(如if后无大括号)
// 2. !(cond):条件不成立时触发失败逻辑
// 3. #cond:字符串化条件表达式,打印失败的具体条件
// 4. __FILE__/__LINE__:预定义宏,打印失败的文件和行号(精准定位问题)
// 5. std::cerr:输出到标准错误流(优先于stdout,确保错误信息不被缓冲)
// 6. std::exit(1):终止程序,退出码1表示测试失败
// 使用场景:验证布尔条件(如指针非空、返回值为真)
// ==============================================================================
#define EXPECT_TRUE(cond) \
do \
{ \
if (!(cond)) \
{ \
std::cerr << "EXPECT_TRUE failed: " #cond " at " __FILE__ ":" << __LINE__ << "\n"; \
std::exit(1); \
} \
} while (0)
// ==============================================================================
// 【断言宏:EXPECT_EQ(a, b)】:验证两个值相等(值比较断言)
// 作用:检查a和b是否相等,不相等则打印详细对比信息并终止程序
// 设计细节:
// 1. auto _va = (a); auto _vb = (b);:先计算a/b的值并缓存,避免重复计算(如a是函数调用)
// 2. !(_va == _vb):比较缓存的值,避免原表达式副作用
// 3. #a/#b:字符串化变量/表达式,打印对比的对象
// 4. 打印实际值:输出_va和_vb的具体内容,便于定位相等性问题
// 5. 额外打印原始a/b:补充上下文(如a是复杂表达式时)
// 6. std::exit(1):终止程序,退出码1表示测试失败
// 注意事项:
// - 要求a和b支持==运算符(基础类型/自定义类型需重载==)
// - 缓存值避免副作用:如EXPECT_EQ(foo(), bar()),foo/bar仅调用一次
// 使用场景:验证数值、字符串、对象等的相等性(如返回值、状态值)
// ==============================================================================
#define EXPECT_EQ(a, b) \
do \
{ \
auto _va = (a); \
auto _vb = (b); \
if (!(_va == _vb)) \
{ \
std::cerr << "EXPECT_EQ failed: " #a " vs " #b " got [" << _va << "] [" << _vb << "] at " __FILE__ ":" << __LINE__ << "\n"; \
std::cerr << a << " is not equal to " << b << "\n"; \
std::exit(1); \
} \
} while (0)
// ==============================================================================
// 【使用示例(补充注释)】:
// TEST(test_add)
// {
// EXPECT_EQ(1+1, 2); // 验证相等性,成功
// EXPECT_TRUE(3 > 2); // 验证布尔条件,成功
// // EXPECT_EQ(1+1, 3); // 失败:打印"1+1 vs 3 got [2] [3] at xxx.cpp:xx"
// // FALL("资源初始化失败"); // 致命错误:打印"FALL: 资源初始化失败"并退出
// }
// ==============================================================================
其核心在于宏的使用,通过宏定义简化了测试用例的编写过程。用户只需要使用 TEST(name) 宏来定义一个测试用例,然后在宏展开的函数体内编写测试逻辑即可。测试框架会自动将这个测试用例注册到全局的注册表中,方便后续的测试运行。
有关宏的三个用法有必要强调一下:
do { ... } while (0)结构:这种结构确保宏在任何上下文中都能正确展开为单个语句,避免了潜在的语法错误。例如,在if语句后使用宏时,如果宏内部没有大括号,可能会导致语法错误或逻辑错误。使用do { ... } while (0)可以确保宏始终作为一个完整的语句块执行。##__VA_ARGS__语法:这是 C99 标准引入的可变参数宏语法,允许宏接受可变数量的参数。##运算符用于处理空参数的情况,当宏调用时没有传递任何可变参数时,##__VA_ARGS__会被移除,避免编译器报错。这种语法使得宏更加灵活,能够适应不同的调用场景。#__va和#_vb:这是宏字符串化操作符#的使用示例。它将宏参数转换为字符串字面量,便于在错误信息中打印出具体的表达式内容,帮助用户更好地理解断言失败的原因。
其他细节在代码上面已经说的非常非常非常详细了,希望大家可以认真阅读理解。
除了框架代码之外,我们还需要一个测试运行器 test_main.cpp,用于执行所有注册的测试用例。下面是测试运行器的核心代码实现:
// 测试框架主程序(测试运行器)
// 核心能力:执行注册的测试用例 + 命令行参数筛选测试用例
// 失败规则:断言失败(exit(1))/抛异常均视为测试失败
#include "test_framework.hpp"
#include <exception> // 捕获标准异常
#include <cstring> // 字符串比较(strcmp)
#include <vector> // 存储选中的测试用例
#include <string> // 命令行参数处理
int main(int argc, char **argv)
{
// 命令行参数解析:支持3种模式
// --list 仅列出所有注册的测试用例名
// --case <name> 仅执行指定名称的单个测试用例
// --filter <sub> 执行名称包含指定子串的所有测试用例
bool list = false; // 是否仅列出用例
std::string exact; // --case 指定的精准匹配名
std::string filter; // --filter 指定的模糊匹配子串
// 遍历命令行参数(跳过argv[0]:程序名)
for (int i = 1; i < argc; ++i)
{
if (std::strcmp(argv[i], "--list") == 0)
list = true;
else if (std::strcmp(argv[i], "--case") == 0 && i + 1 < argc)
{
exact = argv[++i]; // 取--case后紧跟的用例名
}
else if (std::strcmp(argv[i], "--filter") == 0 && i + 1 < argc)
{
filter = argv[++i]; // 取--filter后紧跟的匹配子串
}
}
// 获取所有已注册的测试用例
auto &all = tinytest::registry();
// 仅列出所有测试用例名,执行后退出
if (list)
{
for (auto &t : all)
std::cout << t.name << "\n";
return 0;
}
// 根据命令行参数筛选要执行的测试用例
std::vector<std::reference_wrapper<tinytest::TestCase>> chosen;
for (auto &t : all)
{
if (!exact.empty())
{
// 精准匹配:仅执行指定名称的用例
if (exact == t.name)
chosen.push_back(t);
}
else if (!filter.empty())
{
// 模糊匹配:执行名称包含指定子串的用例
if (std::string(t.name).find(filter) != std::string::npos)
chosen.push_back(t);
}
else
{
// 无筛选:执行所有用例
chosen.push_back(t);
}
}
// 无匹配用例时报错退出
if (chosen.empty())
{
std::cerr << "No tests selected\n";
return 1;
}
// 执行选中的测试用例,统计失败状态
int failed = 0;
for (auto &r : chosen)
{
auto &t = r.get(); // 解引用获取测试用例对象
try
{
t.fn(); // 执行测试用例函数
}
catch (const std::exception &e)
{
// 捕获标准异常并记录失败
std::cerr << "Test '" << t.name << "' threw: " << e.what() << "\n";
failed = 1;
}
catch (...)
{
// 捕获未知异常并记录失败
std::cerr << "Test '" << t.name << "' threw unknown exception\n";
failed = 1;
}
}
// 测试失败:输出提示并返回非0退出码
if (failed)
{
std::cerr << "Some tests failed\n";
return 1;
}
// 所有测试通过:输出成功信息并返回0
std::cout << "All tests passed (" << chosen.size() << ")\n";
return 0;
}
// 测试框架主程序(测试运行器)
// 核心能力:执行注册的测试用例 + 命令行参数筛选测试用例
// 失败规则:断言失败(exit(1))/抛异常均视为测试失败
#include "test_framework.hpp"
#include <exception> // 捕获标准异常
#include <cstring> // 字符串比较(strcmp)
#include <vector> // 存储选中的测试用例
#include <string> // 命令行参数处理
int main(int argc, char **argv)
{
// 命令行参数解析:支持3种模式
// --list 仅列出所有注册的测试用例名
// --case <name> 仅执行指定名称的单个测试用例
// --filter <sub> 执行名称包含指定子串的所有测试用例
bool list = false; // 是否仅列出用例
std::string exact; // --case 指定的精准匹配名
std::string filter; // --filter 指定的模糊匹配子串
// 遍历命令行参数(跳过argv[0]:程序名)
for (int i = 1; i < argc; ++i)
{
if (std::strcmp(argv[i], "--list") == 0)
list = true;
else if (std::strcmp(argv[i], "--case") == 0 && i + 1 < argc)
{
exact = argv[++i]; // 取--case后紧跟的用例名
}
else if (std::strcmp(argv[i], "--filter") == 0 && i + 1 < argc)
{
filter = argv[++i]; // 取--filter后紧跟的匹配子串
}
}
// 获取所有已注册的测试用例
auto &all = tinytest::registry();
// 仅列出所有测试用例名,执行后退出
if (list)
{
for (auto &t : all)
std::cout << t.name << "\n";
return 0;
}
// 根据命令行参数筛选要执行的测试用例
std::vector<std::reference_wrapper<tinytest::TestCase>> chosen;
for (auto &t : all)
{
if (!exact.empty())
{
// 精准匹配:仅执行指定名称的用例
if (exact == t.name)
chosen.push_back(t);
}
else if (!filter.empty())
{
// 模糊匹配:执行名称包含指定子串的用例
if (std::string(t.name).find(filter) != std::string::npos)
chosen.push_back(t);
}
else
{
// 无筛选:执行所有用例
chosen.push_back(t);
}
}
// 无匹配用例时报错退出
if (chosen.empty())
{
std::cerr << "No tests selected\n";
return 1;
}
// 执行选中的测试用例,统计失败状态
int failed = 0;
for (auto &r : chosen)
{
auto &t = r.get(); // 解引用获取测试用例对象
try
{
t.fn(); // 执行测试用例函数
}
catch (const std::exception &e)
{
// 捕获标准异常并记录失败
std::cerr << "Test '" << t.name << "' threw: " << e.what() << "\n";
failed = 1;
}
catch (...)
{
// 捕获未知异常并记录失败
std::cerr << "Test '" << t.name << "' threw unknown exception\n";
failed = 1;
}
}
// 测试失败:输出提示并返回非0退出码
if (failed)
{
std::cerr << "Some tests failed\n";
return 1;
}
// 所有测试通过:输出成功信息并返回0
std::cout << "All tests passed (" << chosen.size() << ")\n";
return 0;
}
任何测试框架都离不开一个测试运行器,测试运行器负责执行所有注册的测试用例,并根据命令行参数进行筛选。上面的代码实现了一个简单的测试运行器,支持以下功能:
- 列出所有注册的测试用例名(通过
--list参数) - 执行指定名称的单个测试用例(通过
--case <name>参数) - 执行名称包含指定子串的所有测试用例(通过
--filter <sub>参数)
CMake 集成 CTest
大家可能会好奇,这个测试入口函数似乎并没有作出任何类似于测试用例选择、过滤的操作,那么这个功能是如何实现的呢?实际上我们依靠的是 CMake 构建系统,在 CMakeLists.txt 中,我们定义了不同的测试目标,每个测试目标对应一个测试用例文件。通过 add_executable 和 add_test 命令,我们将每个测试用例文件编译为独立的可执行文件,并注册为 CTest 测试套件的一部分。这样,当我们运行 ctest 命令时,CTest 会自动发现并执行所有注册的测试目标。这样设计的好处是:
- 模块化:每个测试用例文件独立,便于维护和扩展
- 并行执行:CTest 支持并行运行测试,提高测试效率
- 灵活配置:可以通过 CTest 的命令行参数控制测试执行行为
- 集成方便:与 CMake 无缝集成,简化构建和测试流程
- 统一管理:CTest 提供了统一的测试报告和结果管理功能
下面我们看一下 CMakeLists.txt 的核心配置:
# ==============================================================================
# 【全局基础配置】:工程级别的核心约定,保证编译环境统一、语法兼容
# ==============================================================================
# 1. 指定CMake最低版本要求:低于3.12会报错,确保后续语法(如INTERFACE库、CTest)兼容
cmake_minimum_required(VERSION 3.12)
# 2. 定义工程信息:
# - NAME: Test(工程名,无实际编译作用,仅标识)
# - LANGUAGES: CXX(仅启用C++编译,排除C/ASM等无关语言,减少编译开销)
project(Test LANGUAGES CXX)
# 3. 配置C++标准:
# - CMAKE_CXX_STANDARD 23:指定使用C++23标准
# - CMAKE_CXX_STANDARD_REQUIRED ON:强制要求编译器支持C++23,不允许降级(如自动用C++20)
# - 注:结合后续clang-19,保证C++23特性完整支持
set(CMAKE_CXX_STANDARD 23)
set(CMAKE_CXX_STANDARD_REQUIRED ON)
# 4. 显式指定编译器:
# - 强制使用clang-19(C/C++分别指定,这里仅用CXX但保留C配置,便于扩展)
# - 目的:统一编译环境,避免系统默认gcc/libstdc++的兼容性问题
set(CMAKE_C_COMPILER clang-19)
set(CMAKE_CXX_COMPILER clang++-19)
# 5. 配置编译/链接参数:
# - -stdlib=libc++:强制使用clang配套的libc++标准库(替代gcc的libstdc++)
# - add_compile_options:给所有编译目标添加编译参数
# - add_link_options:给所有链接目标添加链接参数
# - 注:libc++对C++23特性支持更完整,适合日志库的现代语法
add_compile_options(-stdlib=libc++)
add_link_options(-stdlib=libc++)
# ==============================================================================
# 【公共接口库】:封装所有测试的通用配置,避免重复代码(CMake最佳实践)
# ==============================================================================
# 1. 创建INTERFACE类型的空库(无源码,仅传递配置):
# - INTERFACE库:不生成二进制文件,仅作为"配置容器"
# - 命名:test_common(标识为测试通用配置)
add_library(test_common INTERFACE)
# 2. 绑定公共头文件路径(INTERFACE表示:依赖该库的目标会自动继承这些路径):
# - ${CMAKE_SOURCE_DIR}:工程根目录(日志库核心头文件所在路径)
# - ${CMAKE_SOURCE_DIR}/test:test目录(测试工具/公共宏定义头文件)
# - 作用:所有测试目标只需链接test_common,无需重复写include路径
target_include_directories(test_common INTERFACE
${CMAKE_SOURCE_DIR} # 主项目:日志库核心头文件
${CMAKE_SOURCE_DIR}/test # 测试侧:公共测试工具/宏定义头文件
)
# 3. 定义测试入口文件变量:
# - TEST_MAIN:存储test_main.cpp的绝对路径(所有测试目标复用该入口)
# - 目的:统一测试入口(包含main函数、测试框架初始化、用例解析逻辑)
set(TEST_MAIN ${CMAKE_SOURCE_DIR}/test/test_main.cpp)
# ==============================================================================
# 【基础功能测试模块】:验证日志库单个核心功能的正确性(最小单元测试)
# ==============================================================================
# 1. 创建基础测试可执行文件:
# - 目标名:basic_tests(标识基础功能测试)
# - 源码组成:
# - ${TEST_MAIN}:通用测试入口(解析--case参数、运行指定用例)
# - test/basic/*.cpp:各基础功能的测试源码(如日志输出宏、文件 sink、异步日志等)
add_executable(basic_tests
${TEST_MAIN}
test/basic/test_stdout_macros.cpp # 测试日志输出宏(stdout)
test/basic/test_fixed_file_sink.cpp # 测试固定文件 sink(不滚动)
test/basic/test_roll_by_size_sink.cpp # 测试按大小滚动文件 sink
test/basic/test_roll_by_time_sink.cpp # 测试按时间滚动文件 sink
test/basic/test_async_logger.cpp # 测试异步日志核心逻辑
test/basic/test_builder_and_format.cpp # 测试日志构建器和格式化逻辑
)
# 2. 链接依赖(PRIVATE表示:配置仅作用于basic_tests,不传递):
# - test_common:继承公共头文件路径
# - pthread:链接线程库(日志库涉及多线程,必须依赖)
target_link_libraries(basic_tests PRIVATE test_common pthread)
# ==============================================================================
# 【CTest测试框架配置】:注册测试用例,支持精细化运行/调试(CMake内置测试工具)
# ==============================================================================
# 1. 引入CTest模块 + 启用测试功能:
# - include(CTest):加载CTest核心逻辑
# - enable_testing():激活当前目录的测试功能(必须调用,否则add_test无效)
include(CTest)
enable_testing()
# 2. 注册基础测试「整体用例」:
# - NAME:basic_tests(测试用例名,唯一标识)
# - COMMAND:basic_tests(运行可执行文件,无参数时执行所有基础用例)
add_test(NAME basic_tests COMMAND basic_tests)
# 3. 注册基础测试「单个用例」:
# - 每个用例通过--case参数指定(test_main.cpp内部解析该参数,只运行单个用例)
# - 命名规范:basic.用例名(便于分类和过滤)
# - 目的:支持精准调试(如只跑某一个失败的用例,无需运行全部)
add_test(NAME basic.logger_param_macros_basic COMMAND basic_tests --case logger_param_macros_basic) # 测试日志参数宏基础功能
add_test(NAME basic.fixed_file_sink_basic COMMAND basic_tests --case fixed_file_sink_basic) # 测试固定文件sink基础功能
add_test(NAME basic.roll_by_size_never_exceed COMMAND basic_tests --case roll_by_size_never_exceed) # 测试按大小滚动-不超限场景
add_test(NAME basic.roll_by_time_creates_multiple_files COMMAND basic_tests --case roll_by_time_creates_multiple_files) # 测试按时间滚动-生成多文件
add_test(NAME basic.async_logger_drains_and_orders COMMAND basic_tests --case async_logger_drains_and_orders) # 测试异步日志-排空和顺序性
add_test(NAME basic.builder_default_and_format_items COMMAND basic_tests --case builder_default_and_format_items) # 测试构建器-默认配置和格式化项
add_test(NAME basic.format_placeholders_and_escape COMMAND basic_tests --case format_placeholders_and_escape) # 测试格式化-占位符和转义
# 4. 给基础测试用例打标签:
# - PROPERTIES LABELS "basic":为用例添加"basic"标签
# - 作用:支持按标签过滤测试(如ctest -L basic 只运行基础测试)
set_tests_properties(
basic.logger_param_macros_basic
basic.fixed_file_sink_basic
basic.roll_by_size_never_exceed
basic.roll_by_time_creates_multiple_files
basic.async_logger_drains_and_orders
basic.builder_default_and_format_items
basic.format_placeholders_and_escape
PROPERTIES LABELS "basic"
)
# ==============================================================================
# 【综合功能测试模块】:验证日志库多组件协同的端到端场景
# ==============================================================================
# 1. 创建综合测试可执行文件:
# - 目标名:comprehensive_tests(标识综合功能测试)
# - 源码组成:通用入口 + 综合场景测试源码(异步/同步日志全流程)
add_executable(comprehensive_tests
${TEST_MAIN}
test/comprehensive/test_async_comprehensive.cpp # 异步日志综合场景测试(多sink+多线程+格式化)
test/comprehensive/test_sync_comprehensive.cpp # 同步日志综合场景测试(全功能覆盖)
)
# 2. 链接依赖(同基础测试)
target_link_libraries(comprehensive_tests PRIVATE test_common pthread)
# 3. 注册综合测试单个用例:
# - 命名规范:comprehensive.用例名(分类标识)
# - 覆盖异步/同步日志的全流程场景
add_test(NAME comprehensive.async_logger_comprehensive COMMAND comprehensive_tests --case async_logger_comprehensive) # 异步日志综合测试
add_test(NAME comprehensive.sync_logger_comprehensive COMMAND comprehensive_tests --case sync_logger_comprehensive) # 同步日志综合测试
# 4. 给综合测试用例打标签:便于过滤(ctest -L comprehensive)
set_tests_properties(
comprehensive.async_logger_comprehensive
comprehensive.sync_logger_comprehensive
PROPERTIES LABELS "comprehensive"
)
# ==============================================================================
# 【压力测试模块】:验证日志库在高并发/高吞吐量下的性能和稳定性
# ==============================================================================
# 1. 创建压力测试可执行文件:
# - 目标名:stress_tests(标识压力测试)
# - 源码组成:通用入口 + 压力测试源码(单/多线程、同步/异步对比)
add_executable(stress_tests
${TEST_MAIN}
test/stress/test_async_st_stress.cpp # 单线程异步日志压力测试(基础性能基准)
test/stress/test_async_mt5_stress.cpp # 5线程异步日志压力测试(多线程并发)
test/stress/test_sync_st_stress.cpp # 单线程同步日志压力测试(对比基准)
test/stress/test_sync_mt5_stress.cpp # 5线程同步日志压力测试(多线程并发)
)
# 2. 链接依赖(同基础测试)
target_link_libraries(stress_tests PRIVATE test_common pthread)
# 3. 注册压力测试单个用例:
# - 命名规范:stress.用例名(分类标识)
# - 覆盖不同线程模型的压力场景,便于对比性能
add_test(NAME stress.async_logger_stress_st COMMAND stress_tests --case async_logger_stress_st) # 异步日志-单线程压力测试
add_test(NAME stress.async_logger_stress_mt5 COMMAND stress_tests --case async_logger_stress_mt5) # 异步日志-5线程压力测试
add_test(NAME stress.sync_logger_stress_st COMMAND stress_tests --case sync_logger_stress_st) # 同步日志-单线程压力测试
add_test(NAME stress.sync_logger_stress_mt5 COMMAND stress_tests --case sync_logger_stress_mt5) # 同步日志-5线程压力测试
# 4. 给压力测试用例打标签:便于过滤(ctest -L stress)
set_tests_properties(
stress.async_logger_stress_st
stress.async_logger_stress_mt5
stress.sync_logger_stress_st
stress.sync_logger_stress_mt5
PROPERTIES LABELS "stress"
)
# ==============================================================================
# 【使用说明(补充注释)】:
# 1. 编译:mkdir build && cd build && cmake .. && make -j8
# 2. 运行所有测试:ctest
# 3. 运行指定标签测试:ctest -L basic(基础)/ctest -L stress(压力)
# 4. 运行单个用例:ctest -R basic.fixed_file_sink_basic
# 5. 直接运行可执行文件:./basic_tests --case fixed_file_sink_basic(精准调试)
# ==============================================================================
下面是针对于整个测试工程 CMake 配置的核心功能方法的详细解读:
基础环境锚定:统一编译规则
测试工程首先要保证编译环境的一致性,避免因编译器、C++标准、标准库差异导致测试结果不可复现,核心依赖以下操作:
1. 限定 CMake 版本与工程基础信息
cmake_minimum_required(VERSION 3.12)
project(Test LANGUAGES CXX)
cmake_minimum_required:指定最低兼容版本(如 3.12),避免后续使用高版本语法(如INTERFACE库特性)时出现兼容问题,是跨环境编译的基础保障;project:定义工程名(Test),并指定仅启用 C++编译(LANGUAGES CXX),排除 C/ASM 等无关语言,减少编译开销。
2. 强制 C++标准与编译器
set(CMAKE_CXX_STANDARD 23)
set(CMAKE_CXX_STANDARD_REQUIRED ON)
set(CMAKE_CXX_COMPILER clang++-19)
add_compile_options(-stdlib=libc++)
add_link_options(-stdlib=libc++)
CMAKE_CXX_STANDARD+CMAKE_CXX_STANDARD_REQUIRED:强制使用 C++23 标准,且编译器不支持时直接报错(而非降级),保证代码能使用目标标准的特性;CMAKE_CXX_COMPILER:显式指定编译器(如 clang++-19),避免系统默认编译器(如 GCC)的差异;add_compile_options/add_link_options:全局添加编译/链接参数(如-stdlib=libc++),统一使用 clang 配套的 libc++标准库,解决 C++23 特性兼容问题。
配置复用:INTERFACE 库封装公共依赖
测试工程中多个测试目标(基础/综合/压力测试)会复用相同的头文件路径、编译选项,此时INTERFACE库是 CMake 的“最优解”:
# 定义空的INTERFACE库(仅传递配置,无源码)
add_library(test_common INTERFACE)
# 绑定公共头文件路径,依赖该库的目标自动继承
target_include_directories(test_common INTERFACE
${CMAKE_SOURCE_DIR} # 日志库核心头文件
${CMAKE_SOURCE_DIR}/test # 测试公共头文件
)
核心设计思路
INTERFACE库是“配置容器”:不生成二进制文件,仅封装头文件路径、编译选项、链接依赖等;INTERFACE关键字:表示配置会传递给依赖该库的目标(如后续的basic_tests),避免重复编写target_include_directories;- 复用性提升:新增测试目标时,只需
target_link_libraries(xxx PRIVATE test_common),即可继承所有公共配置。
测试目标构建:多维度测试的分层设计
测试工程按“基础功能 → 综合场景 → 压力测试”拆分多个可执行目标,核心操作是add_executable + target_link_libraries:
# 基础功能测试目标
add_executable(basic_tests
${TEST_MAIN} # 通用测试入口(复用main函数)
test/basic/*.cpp # 基础功能测试源码
)
# 链接依赖:PRIVATE表示配置仅作用于当前目标
target_link_libraries(basic_tests PRIVATE test_common pthread)
关键要点
- 复用测试入口:通过
set(TEST_MAIN ${CMAKE_SOURCE_DIR}/test/test_main.cpp)定义通用测试入口,所有测试目标复用该文件,避免重复编写 main 函数; - 分层构建目标:按测试类型拆分
basic_tests/comprehensive_tests/stress_tests,可单独编译/运行某类测试,提升调试效率; - 链接系统库:
pthread是日志库多线程测试的基础,通过target_link_libraries显式链接,保证跨平台兼容性。
CTest 集成:精细化管理测试用例
CMake 内置的 CTest 框架是测试工程的核心,支持“整体运行 → 单个用例 → 标签过滤”三级管控,核心操作如下:
1. 启用 CTest 框架
include(CTest)
enable_testing()
- 必须先调用这两个指令,否则后续
add_test无效,是 CTest 的“开关”。
2. 注册测试用例:从整体到单个粒度
# 注册“整体用例”:运行所有基础测试
add_test(NAME basic_tests COMMAND basic_tests)
# 注册“单个用例”:通过--case参数指定单个测试
add_test(NAME basic.fixed_file_sink_basic COMMAND basic_tests --case fixed_file_sink_basic)
add_test(NAME 名称 COMMAND 可执行文件 参数):注册测试用例,NAME 需唯一,COMMAND 是测试执行的命令;--case参数:由测试入口程序(test_main.cpp)解析,实现“单个用例精准运行”,适配 VS Code 等工具的单测显示需求。
3. 标签分类:按维度过滤测试
# 给用例打标签:LABELS "basic"
set_tests_properties(basic.fixed_file_sink_basic PROPERTIES LABELS "basic")
- 标签作用:通过
ctest -L basic可只运行“基础功能”测试,ctest -L stress只运行压力测试,大幅提升测试效率; - 分类管理:按测试类型(基础/综合/压力)打标签,适配不同测试场景(如日常调试跑基础用例,发布前跑压力用例)。
核心操作总结:测试工程的 CMake 设计范式
| 核心操作 | 作用 | 设计目标 |
|---|---|---|
INTERFACE库 |
封装公共配置 | 减少重复代码,提升复用性 |
| 多测试目标拆分 | 按测试类型构建可执行文件 | 分层编译/运行,提升调试效率 |
| CTest 多粒度注册 | 整体/单个/标签三级管控 | 精准定位失败用例,降低调试成本 |
| 显式指定编译器/标准 | 统一编译环境 | 保证测试结果跨环境可复现 |
基于上述配置,我们就可以快速上手运行测试:
- 编译:
mkdir build && cd build && cmake .. && make -j8; - 运行所有测试:
ctest; - 按标签过滤:
ctest -L basic(基础测试)/ctest -L stress(压力测试); - 单个用例调试:
ctest -R basic.fixed_file_sink_basic或直接运行./basic_tests --case fixed_file_sink_basic。
这里以 test_async_logger.cpp 中的断言宏为例,详细解读其设计思路和实现细节:
#include <string>
#include <vector>
#include <thread>
#include <chrono>
#include <atomic>
#include "test_framework.hpp"
#include "logger.hpp"
#include "format.hpp"
#include "sink.hpp"
using namespace ljxlog;
namespace
{
// 计数型 sink:统计落地数据中的换行符个数,近似等于日志条数
class CountingSink : public LogSink
{
public:
std::atomic<int> count{0};
void log(const char *d, size_t n) override
{
// count lines by scanning for '\n'
for (size_t i = 0; i < n; ++i)
if (d[i] == '\n')
count.fetch_add(1, std::memory_order_relaxed);
}
};
}
// 目的:验证异步 logger 能够接受多线程写入并最终排空(drain)
// 方法:两个线程并发写入各 N 条,等待一小段时间后检查计数 >= 2N
TEST(async_logger_drains_and_orders)
{
auto fmt = std::make_shared<Format>("%m\n");
auto sink = std::make_shared<CountingSink>();
std::vector<LogSink::ptr> sinks{sink};
auto lg = std::make_shared<AsyncLogger>("async", fmt, sinks, Level::DEBUG);
const int N = 1000;
std::thread t1([&]
{ for(int i=0;i<N;++i) lg->info(__FILE__, __LINE__, "{}", i); });
std::thread t2([&]
{ for(int i=0;i<N;++i) lg->debug(__FILE__, __LINE__, "{}", i); });
t1.join();
t2.join();
// Give time to drain
std::this_thread::sleep_for(std::chrono::milliseconds(200));
EXPECT_TRUE(sink->count.load() >= 2 * N); // Each log adds a trailing newline by Format::format
}
该测试用例中,我们定义了一个自定义的 CountingSink,用于统计日志落地时的换行符数量,从而近似计算日志条数。测试逻辑如下:
- 创建一个异步日志器
AsyncLogger,绑定CountingSink作为日志输出目标; - 启动两个线程,分别向日志器写入各 N 条日志;
- 等待一小段时间,确保异步日志器有足够时间将日志排空;
- 使用
EXPECT_TRUE断言宏验证最终计数是否大于等于 2N,确保所有日志都被正确处理。
可以看到,断言宏 EXPECT_TRUE 的使用极大简化了测试逻辑的编写,只需关注测试内容本身,而无需处理错误打印和退出逻辑。整个测试用例清晰明了,易于理解和维护。
其他测试用例文件请关注源码仓库中的 test/basic/、test/comprehensive/ 和 test/stress/ 目录,里面包含了更多针对日志库各个功能模块的测试实现。这里就不一一赘述了。
性能测试
性能测试主要关注日志库在高并发和高吞吐量场景下的表现。通过设计多线程压力测试用例,模拟实际应用中的日志写入场景,评估日志库的性能瓶颈和优化空间。
可以看到,我们的性能测试(压力测试)主要集中在 test/stress/ 目录下,包含了单线程和多线程的同步/异步日志压力测试用例。通过这些测试,我们可以量化日志库在不同负载下的性能表现,为后续优化提供数据支持。
╭─ljx@VM-16-15-debian ~/cpp_code/Logging-System/test/stress ‹main*›
╰─➤ ll 1 ↵
total 28
-rw-r--r-- 1 ljx ljx 1965 Sep 27 22:52 stopwatch.hpp
-rw-r--r-- 1 ljx ljx 1406 Sep 27 23:28 test_async_mt5_stress.cpp
-rw-r--r-- 1 ljx ljx 1334 Sep 27 23:27 test_async_st_stress.cpp
-rw-r--r-- 1 ljx ljx 1542 Sep 27 23:27 test_sync_mt5_stress.cpp
-rw-r--r-- 1 ljx ljx 1329 Sep 27 23:27 test_sync_st_stress.cpp
-rw-r--r-- 1 ljx ljx 4218 Sep 27 23:33 trash_file_sink.hpp
使用方法和上面提到的功能测试类似,可以通过 CTest 运行所有压力测试,或者单独运行某个压力测试用例:
我们来看其中一个压力测试用例 test_async_mt5_stress.cpp 的核心实现:
#include "ljxlog.hpp"
#include "stopwatch.hpp"
#include "test_framework.hpp"
#include "trash_file_sink.hpp"
#include <thread>
using namespace ljxlog;
TEST(async_logger_stress_mt5)
{
std::shared_ptr<Logger::Builder> builder = std::make_shared<GlobalLoggerBuilder>();
builder->buildType(LoggerType::LOGGER_ASYNC);
builder->buildFormat("%m");
builder->buildLimitLevel(Level::DEBUG);
builder->buildLoggerName("async_mt5");
builder->buildSink<TrashFileSink>();
auto lg = builder->build();
const int N = 1000000;
const std::string inf = std::string(50 - 1, 'A'); // -1 because Format adds a '\n' automatically
const size_t bytes_per_record = inf.size() + 1;
double elapsed_ms = 0.0;
{
ScopedTimer st("async_mt5", std::cout, &elapsed_ms);
std::thread threads[5];
for (int t = 0; t < 5; ++t)
{
threads[t] = std::thread([&]() {
for (int i = 0; i < N; ++i)
LOGINFO(lg, "{}", inf);
lg->flush(); // Ensure all logs are processed before stopping the timer
});
}
for (int t = 0; t < 5; ++t)
threads[t].join();
}
std::cout << "[RESULT] async logger 5 threads: " << N << " logs of "
<< inf.size() << " bytes in " << elapsed_ms << "ms, "
<< mb_per_sec(N * bytes_per_record, elapsed_ms) << " MB/s\n";
}
在该测试用例中,我们创建了一个异步日志器 async_mt5,并启动了 5 个线程,每个线程向日志器写入 1,000,000 条日志。通过 ScopedTimer 计时器,我们测量了整个写入过程的耗时,并计算出日志写入的吞吐量(MB/s)。这种多线程压力测试能够有效模拟实际应用中的高并发日志写入场景,帮助我们评估日志库的性能表现。
性能测试结果
在一台 2 核 2GB Linux 虚拟机上,我们进行了 3 轮压测(每轮含4个用例):
- 负载:每条日志 49 字节正文(总约 50B/条,含换行)
- 条数:每个用例 1,000,000 条
- 落地:TrashFileSink(固定容量环形文件,真实磁盘写,文件不增长)
- 计时口径:包含异步 flush 排空
- 结果汇总(单位:MB/s,越大越好):
第一轮:
async 单线程:11.3163
async 5 线程:4.26752
sync 单线程:5.47903
sync 5 线程:1.65319
第二轮:async 5 线程:4.20709
async 单线程:9.75334
sync 单线程:4.58941
sync 5 线程:1.66723
第三轮:async 单线程:11.3959
async 5 线程:3.64851
sync 单线程:5.07511
sync 5 线程:1.73009
Windows 端测试(本机)
环境信息:
- 操作系统:Microsoft Windows 11 家庭中文版(10.0.26220,64 位)
- 处理器:13th Gen Intel(R) Core(TM) i9-13900H(14 核 / 20 线程,基准 2.60GHz)
- 设备:ASUSTeK COMPUTER INC. ASUS TUF Gaming F15 FX507VV_FX507VV
- 物理内存:16 GB
- 编译器:GCC g++ 14.2.0(MinGW)
- 构建类型:Debug
- 压力测试设置与 Linux 端一致(1,000,000 条,每条 49B 正文,TrashFileSink 磁盘写,包含异步排空)。
三轮结果(单位:MB/s,越大越好):
第一轮:
async 单线程:30.4666
async 5 线程:13.1029
sync 单线程:16.5848
sync 5 线程:2.02832
第二轮:async 单线程:31.2447
async 5 线程:13.2957
sync 单线程:16.8805
sync 5 线程:2.0899
第三轮:async 单线程:29.8012
async 5 线程:13.2911
sync 单线程:16.8227
sync 5 线程:2.06058
我们可以得出平均性能数据:
Linux 虚拟机平均结果(单位:MB/s)
| 测试用例 | 第一轮 | 第二轮 | 第三轮 | 平均值 |
|---|---|---|---|---|
| async 单线程 | 11.3163 | 9.75334 | 11.3959 | 10.8218 |
| async 5 线程 | 4.26752 | 4.20709 | 3.64851 | 4.0410 |
| sync 单线程 | 5.47903 | 4.58941 | 5.07511 | 5.0478 |
| sync 5 线程 | 1.65319 | 1.66723 | 1.73009 | 1.6835 |
Windows 本机平均结果(单位:MB/s)
| 测试用例 | 第一轮 | 第二轮 | 第三轮 | 平均值 |
|---|---|---|---|---|
| async 单线程 | 30.4666 | 31.2447 | 29.8012 | 30.5042 |
| async 5 线程 | 13.1029 | 13.2957 | 13.2911 | 13.2299 |
| sync 单线程 | 16.5848 | 16.8805 | 16.8227 | 16.7627 |
| sync 5 线程 | 2.02832 | 2.0899 | 2.06058 | 2.0596 |
至此,我们完成了日志库的功能测试和性能测试模块的设计与实现。通过系统化的测试用例和详尽的性能评估,我们确保了日志库在各种使用场景下的正确性和高效性。希望本文能为大家提供有价值的参考,助力大家构建更可靠的日志系统。