日志落地与日志器模块实现
文章目录
这是一套 C++ 日志系统学习笔记 ,涵盖 同步与异步双模式 日志的设计与实现。核心特点:模块化分层(格式→落地→日志器→建造者接口)、双缓冲区生产者-消费者模型、多种设计模式(单例/工厂/代理/建造者)应用、C++11 多线程与智能指针实践,代码可直接在 Linux 下编译测试。
一、日志落地模块
日志落地模块的核心功能是将格式化后的日志消息输出到指定位置。该模块支持将日志同时输出到多个不同位置,包括标准输出、指定文件和滚动文件三种主要方式。
- 标准输出适用于调试和测试阶段,而正式项目运行中更常用的是将日志写入文件。
- 写入指定文件的方式便于事后分析系统运行状况,但会导致文件不断增大。
- 滚动文件方案可以按时间或大小进行文件切换,例如每天生成一个新文件或当文件达到 1GB 时切换,便于日志管理和清理。
模块设计上支持扩展更多落地方向,如数据库或远程服务器。为实现良好的扩展性,将采用抽象基类、派生不同方向子类和使用工厂模式的实现思路。抽象基类定义统一接口,不同落地方向从基类派生具体实现,通过工厂模式管理对象创建,实现创建与表示的分离,便于后续功能扩展和调整。
在代码实现层面,日志落地模块的开发分为三个主要步骤:
- 第一步是抽象出日志落地的基类,定义统一的接口规范。
- 第二步根据不同落地方向派生具体子类,如标准输出、文件输出和滚动文件输出等实现。
- 第三步采用工厂模式来管理这些类的创建过程,实现创建逻辑与使用逻辑的分离。
1.1 日志落地基类
抽象出日志落地的基类,定义统一的接口规范,无论后续定义的子类的落地方向是哪种,我们统一使用 log 函数作为将日志格式化数据落地的接口。
cpp
#include "util.hpp"
#include <memory>
#include <fstream>
#include <cassert>
#include <sstream>
namespace logsys
{
class LogSink
{
public:
using Ptr = std::shared_ptr<LogSink>;
LogSink() {}
virtual ~LogSink() {};
virtual void log(const char *data, size_t size) = 0;
};
}
1.2 日志落地各项子类
有了统一的父类定义标准接口,我们可以派生各种各样的子类,这里我们展示标准输出、文件输出和滚动文件输出等实现。
标准输出子类
标准输出日志的实现是最简单的,直接将数据写入 std::cout。但不能使用流操作符,因为流操作无法指定数据大小,而且通常以反斜杠零作为结束符,但日志输出不一定都是字符串。因此使用重载函数 write 进行写入,该函数接收 data 和 size 两个参数,表示从 data 位置开始写入 size 长度的数据。这个实现非常简单直接。
cpp
namespace logsys
{
// 标准输出
class StdoutSink : public LogSink
{
public:
void log(const char *data, size_t size) override
{
// 这里不直接用<< 因为<<会识别数据类型进行输出,而我们需要的是直接输出
std::cout.write(data, size);
}
};
}
文件日志子类
构造函数需要完成两个操作:首先创建日志文件所在目录,然后创建并打开日志文件。使用 util.hpp 中的 File 工具类,调用 creatDirectory 创建目录,并通过 getFilePath 函数获取文件所在路径。打开文件时使用 ofstream 的 open 方法,以二进制和追加模式(ios::binary | ios::app)打开。使用 assert 断言确保文件成功打开,否则程序退出。数据写入同样使用 write 方法,并可以通过 assert 判断写入是否成功,失败时程序退出。
cpp
namespace logsys
{
// 指定文件输出
class FileSink : public LogSink
{
public:
FileSink(const std::string &fileName) : _fileName(fileName)
{
// 创建文件路径
util::File::creatDirectory(util::File::getFilePath(_fileName));
// 打开文件
_ofs.open(_fileName, std::ios::binary | std::ios::app);
// 判断是否打开成功
assert(_ofs.is_open());
}
~FileSink()
{
if(_ofs.is_open())
{
_ofs.close();
}
}
void log(const char *data, size_t size) override
{
_ofs.write(data, size);
if (_ofs.good() == false)
{
std::cout << "日志输出失败\n";
}
}
private:
std::string _fileName;
std::ofstream _ofs;
};
}
滚动文件日志子类
滚动文件的策略有多种方式,这里我们采用当一个文件写入日志到达一定大小时就会创建一个新文件。因此每一次写入日志 log 时,我们都需要先判断当前文件的大小,如果文件太大,就关闭当前文件,创建一个新文件并打开。
构造函数首先需要创建文件路径并打开文件,但基础文件名需要转换为实际文件名,我们创建一个内部成员函数 buildNewFileName 来实现这个功能。实现方法是获取系统时间(使用 util::Date::getTime),将时间戳转换为包含年月日时分秒的时间结构(localtime_r)。然后使用 stringstream 将基础文件名和时间信息拼接成实际文件名。
cpp
namespace logsys
{
// 滚动文件输出
class ScrollSinkBySize : public LogSink
{
public:
ScrollSinkBySize(const std::string &basename, size_t maxsize)
: _maxsize(maxsize), _baseName(basename), _cursize(0), _filecount(0)
{
// 创建文件路径
std::string fileName = buildNewFileName();
util::File::creatDirectory(util::File::getFilePath(fileName));
// 打开文件
_ofs.open(fileName, std::ios::binary | std::ios::app);
// 判断是否打开成功
assert(_ofs.is_open());
}
~ScrollSinkBySize()
{
if(_ofs.is_open())
{
_ofs.close();
}
}
void log(const char *data, size_t size) override
{
// 如果当前文件大小大于或等于设置的最大文件大小,创建一个新的文件进行输出
if(_cursize >= _maxsize)
{
// 关闭原文件
_ofs.close();
// 创建新文件
std::string fileName = buildNewFileName();
// 打开文件
_ofs.open(fileName, std::ios::binary | std::ios::app);
// 判断是否打开成功
assert(_ofs.is_open());
// 重置当前文件大小
_cursize = 0;
}
_ofs.write(data, size);
if (_ofs.good() == false)
{
std::cout << "日志输出失败\n";
}
_cursize += size;
}
private:
// 构造一个新文件名
const std::string buildNewFileName()
{
struct tm t;
time_t time = util::Date::getTime();
localtime_r(&time, &t);
std::stringstream ss;
ss << _baseName << t.tm_year + 1900 << "_" << t.tm_mon + 1 << "_"
<< t.tm_mday << "_" << t.tm_hour << t.tm_min << t.tm_sec
<< "-" << _filecount++ << ".log";
return ss.str();
}
private:
std::string _baseName;
std::string _filePath;
std::ofstream _ofs;
size_t _filecount;
size_t _maxsize;
size_t _cursize;
};
}
1.3 工厂模式设计
使用工厂模式封装日志落地对象的生产过程,使用模板参数控制输出类型,template <typename SinkType> 直接 return make_shared 通过模板参数类型创建对象。但各落地类型构造参数不同,StdoutSink 无需参数,FileSink 需要一个参数,ScrollSinkBySize 需要两个参数。
使用不定参数函数解决参数传递问题,定义 typename 参数包类型 Args,通过完美转发将参数传递给构造函数,用 ... 展开参数包。用户可以根据需要传递不同参数创建不同落地对象。
cpp
namespace logsys
{
class SinkFactory
{
public:
template<typename SinkType, typename ...Args>
static LogSink::Ptr creat(Args &&...args)
{
return std::make_shared<SinkType>(std::forward<Args>(args)...);
}
};
}
二、日志器模块
日志器模块是对前几个模块的整合,通过创建一个日志器来简化日志输出过程。日志器模块需要管理格式化模块对象、日志落地模块对象、默认日志输出限制等级和互斥锁。
- 格式化模块对象 负责对日志消息进行格式化,日志落地模块对象负责将格式化后的消息落地。
- 默认日志输出限制等级用于控制哪些等级的日志可以输出,只有大于等于限制等级的日志才能输出。
- 互斥锁用于保证多线程环境下日志输出的线程安全性,避免出现冲突。
日志器模块支持同步日志和异步日志两种模式。两种日志器类的唯一区别在于落地方式不同(注意:落地方式与落地方向不是一个概念,落地方向由日志落地模块管理)。
- 同步日志直接写入磁盘或标准输出。
- 异步日志先将日志写入内存,再由异步线程写入磁盘或标准输出。
实现时先抽象出一个 Logger 基类,然后派生出同步日志器类 SyncLogger 和异步日志器类 AsyncLogger。落地操作被抽象出来,由不同的日志器类实现各自的落地操作。基类指针用于管理和操作不同的日志器子类对象,模块之间的关联关系通过基类进行管理,而不是通过具体类。这种设计思想便于扩展和维护。
2.1 日志器基类
日志器基类的成员包括日志器名称、日志输出限制等级、格式化模块对象、互斥锁和落地模块对象数组。这些成员共同完成日志的格式化、输出控制、线程安全和多位置输出等功能。
cpp
std::mutex _mutex; // 锁
std::string _name; // 日志器名称
std::atomic<LogLevel::VALUE> _limitLvl; // 日志器限制输出等级
std::vector<LogSink::Ptr> _sinkPtrs; // 一个日志器可以有多个不同的输出目的地
Formater::Ptr _formater; // 日志信息格式化构建器
- 定义日志器名称为 string 类型。
- 日志输出限制等级定义为原子类型,以避免频繁加锁导致的性能问题。
- 格式化模块对象使用智能指针管理,以提高资源管理效率,一个日志器的每一条日志输出格式应该为统一的,Formater 对象只需要一个。
- 由于一个日志器可以有多个不同的输出目的地,所以日志落地模块可能不止一个,我们使用 vector 进行管理,当我们需要将日志落地的时候,遍历 vector,调用 LogSink 中的 log 方法即可。
cpp
namespace logsys
{
class Logger
{
public:
using Ptr = std::shared_ptr<Logger>;
Logger(const std::string &name,
LogLevel::VALUE limitLvl,
Formater::Ptr &formater,
std::vector<LogSink::Ptr> &sinkPtrs)
: _name(name), _limitLvl(limitLvl),
_formater(formater), _sinkPtrs(sinkPtrs)
{
}
// 行号、文件名、格式化字符串构建规则、日志信息主体需要用户传递(不定参)
void debug(const size_t line, const std::string &fileName, const std::string &fmt, ...)
{
// 判断日志等级是否能输出
if (LogLevel::VALUE::DEBUG < _limitLvl)
return;
// 将不定参数转化为字符串
va_list ap;
va_start(ap, fmt);
char *res;
int ret = vasprintf(&res, fmt.c_str(), ap);
if (ret == -1)
{
std::cout << "vasprintf err!";
return;
}
va_end(ap); // 将ap指针置空
// 构建日志信息
LogMessage msg(line, _name, fileName, LogLevel::VALUE::DEBUG, res);
// 将日志信息格式化
std::stringstream ss;
_formater->format(ss, msg);
// 日志落地,与日志器类型有关
log(ss.str().c_str(), ss.str().size());
// 释放内存
free(res);
}
void info(const size_t line, const std::string &fileName, const std::string &fmt, ...)
{
if (LogLevel::VALUE::INFO < _limitLvl)
return;
va_list ap;
va_start(ap, fmt);
char *res;
int ret = vasprintf(&res, fmt.c_str(), ap);
if (ret == -1)
{
std::cout << "vasprintf err!";
return;
}
va_end(ap);
LogMessage msg(line, _name, fileName, LogLevel::VALUE::INFO, res);
std::stringstream ss;
_formater->format(ss, msg);
log(ss.str().c_str(), ss.str().size());
free(res);
}
void warning(const size_t line, const std::string &fileName, const std::string &fmt, ...)
{
if (LogLevel::VALUE::WARNING < _limitLvl)
return;
va_list ap;
va_start(ap, fmt);
char *res;
int ret = vasprintf(&res, fmt.c_str(), ap);
if (ret == -1)
{
std::cout << "vasprintf err!";
return;
}
va_end(ap);
LogMessage msg(line, _name, fileName, LogLevel::VALUE::WARNING, res);
std::stringstream ss;
_formater->format(ss, msg);
log(ss.str().c_str(), ss.str().size());
free(res);
}
void error(const size_t line, const std::string &fileName, const std::string &fmt, ...)
{
if (LogLevel::VALUE::ERROR < _limitLvl)
return;
va_list ap;
va_start(ap, fmt);
char *res;
int ret = vasprintf(&res, fmt.c_str(), ap);
if (ret == -1)
{
std::cout << "vasprintf err!";
return;
}
va_end(ap);
LogMessage msg(line, _name, fileName, LogLevel::VALUE::ERROR, res);
std::stringstream ss;
_formater->format(ss, msg);
log(ss.str().c_str(), ss.str().size());
free(res);
}
void fatal(const size_t line, const std::string &fileName, const std::string &fmt, ...)
{
if (LogLevel::VALUE::FATAL < _limitLvl)
return;
va_list ap;
va_start(ap, fmt);
char *res;
int ret = vasprintf(&res, fmt.c_str(), ap);
if (ret == -1)
{
std::cout << "vasprintf err!";
return;
}
va_end(ap);
LogMessage msg(line, _name, fileName, LogLevel::VALUE::FATAL, res);
std::stringstream ss;
_formater->format(ss, msg);
log(ss.str().c_str(), ss.str().size());
free(res);
}
// 获取日志器名称
const std::string &getName() const
{
return _name;
}
protected:
// 日志器的核心函数,其他不同级别的日志函数调用这个核心函数
virtual void log(const char *data, size_t size) = 0;
protected:
std::mutex _mutex; // 锁
std::string _name; // 日志器名称
std::atomic<LogLevel::VALUE> _limitLvl; // 日志器限制输出等级
std::vector<LogSink::Ptr> _sinkPtrs; // 一个日志器可以有多个不同的输出目的地
Formater::Ptr _formater; // 日志信息格式化构建器
};
}
上述代码中可以看到,每个级别的日志函数都遵循相同的模式:
- 判断日志等级是否达到输出限制,若低于限制等级则直接返回。
- 使用
va_list和vasprintf将不定参数格式化为字符串。 - 构建
LogMessage对象,封装日志的各项要素。 - 使用
Formater对日志消息进行格式化,得到格式化后的字符串。 - 调用纯虚函数
log完成实际的日志落地操作(由子类实现具体策略)。 - 释放
vasprintf分配的内存。
2.2 同步日志器子类
同步日志器通过加锁机制保证线程安全,使用 std::unique_lock 管理互斥锁,在锁的有效期内自动加锁和解锁。若日志目标容器不为空则遍历日志目标数组,通过迭代器调用每个日志目标的落地函数,传入日志数据和长度完成日志输出。
cpp
class SyncLogger : public Logger
{
public:
SyncLogger(const std::string &name,
LogLevel::VALUE limitLvl,
Formater::Ptr &formater,
std::vector<LogSink::Ptr> &sinkPtrs)
: Logger(name, limitLvl, formater, sinkPtrs)
{
}
protected:
void log(const char *data, size_t size) override
{
std::unique_lock<std::mutex> lock(_mutex);
for (auto &s : _sinkPtrs)
{
s->log(data, size);
}
}
};
同步日志器的工作原理非常简单:当业务线程调用日志输出函数(如 debug、info 等)时,经过日志等级判断、消息格式化和字符串构建后,最终调用 log 函数。在 log 函数内部,首先加锁保证线程安全,然后遍历所有日志落地目标(LogSink),将格式化后的日志数据写入每个目标。
这种设计虽然保证了线程安全,但也带来了一个潜在问题:如果某个落地目标写入速度较慢(如网络文件系统或高延迟磁盘),业务线程会被阻塞,影响程序的响应性能。这正是异步日志器要解决的问题。
三、总结
本文实现了日志系统的日志落地模块和同步日志器模块。日志落地模块采用抽象基类加派生类的设计,支持标准输出、文件输出和滚动文件输出三种方式,并通过工厂模式简化了落地对象的创建。日志器模块整合了格式化模块和落地模块,提供了五种日志级别的输出接口,并通过原子类型和互斥锁保证了线程安全。
同步日志器虽然实现简单、逻辑清晰,但在高并发或慢速 I/O 场景下可能导致业务线程阻塞。在下一篇文章中,我们将实现异步日志器,通过双缓冲区设计解决性能问题,并完成日志系统的完整集成。