C++日志系统实现(一)
开端
最近我在自己的电脑上编写文章的时候,我发现一个问题:在本地编写的文章如果要通过复制粘贴的方式上传到CSDN、博客园等等平台上时,本地的图片无法一键上传上去。
这是因为,复制粘贴整篇文章的时候,网页不会为我自动识别出里面的图片并上传。并且由于图片的链接指向的是我自己的电脑上的图片,因此这些博客平台的服务自然是无法找到的。
于是我就想着自己实现一个图片资源的上传、下载服务器并部署在运行此网站的云服务器上,我将图片上传到服务器上然后就可以通过URL访问上传的图片资源。
本地电脑上可以通过PicGo+自开发插件或者CopyQ+自定义脚本来实现快捷键快速上传。
但是,毕竟是自己开发的图片资源服务器,因此在使用过程中经常出现各种问题。每次出现问题的时候,都需要为服务器进行手把手调试,调试半天才复现问题。
因此,我特别希望服务器能够自己出现问题的时候将重要的信息记录下来,方便出现问题之后我能够快速定位问题所在。因此,我决定实现一个日志系统来达成这个目标。
需求分析
首先我要搞清楚自己对于日志系统的需求是什么,明确好这个系统的范围,以免做一些无意义的事。
- 日志必须能够输出足够的信息,以便我能够快速定位服务器的问题所在。
- 日志系统的接口必须足够简单,使用方便。
- 日志输出的速度这里由于我的服务器只是个人使用,因此性能方面要求其实不高,但未来有时间会进行优化。
- 日志能够输出到不同的位置,比如通过网络将日志输出到另一台服务器、将日志输出到本机的文件中、将日志输出到控制台等等。用户可以能够自行扩展输出的位置。
- 日志输出需尽量不阻塞正常业务的运行。
以上需求,真正可以提取出来的业务需求其实只有一个:日志输出。就是给该系统一个字符串(日志信息),然后它会将日志信息和其他信息,如:日志记录所在的文件名以及行号、日志等级、记录发生的时间等等信息,一起输出到某个特定的位置。除此之外的需求是对该日志系统的性能需求。
系统设计
这里的"User"泛指所有使用日志系统的代码。接下来我来介绍日志系统中各个组件的功能:
- LogEvent,当我们进行日志记录时,我们可能需要记录下很多信息,比如:日志信息、日志记录的时间、日志等级、Logger的名称、文件名、行号、线程号等等,这些信息都是一条日志的一部分,我们需要一个对象将这些信息存储起来。
- LogFormatter,负责将LogEvent中的各个字段转化成一个字符串,如图所示的"[2024-11-20] [DEBUG] [/main.cpp:30] Hello World"就是转化后的结果,我称这个过程为日志的格式化。如果日志系统不需要灵活地设置日志格式,我们完全可以在LogEvent中提供一种格式化日志的方法。如果"User"需要灵活地设置日志格式,那么将格式化日志的方法从LogEvent中分离出来,让LogFormatter负责格式化日志并提供更改日志格式的功能,用户通过往Logger中设置符合特定格式的LogFormatter从而达成灵活设置日志格式的目的。
- LogAppender,负责将接收到的字符串输出到不同的位置,比如输出到控制台、文件或者网络。用户可以通过实现LogAppender接口,然后将其设置到Logger中,实现输出到自定义的地方的效果。
- Logger,负责将LogEvent、LogFormatter、LogAppender这些组件组合起来向"User"提供方便的日志接口。
实现上述系统的最简单方式是Logger在接收到从User来的字符串后,自行采集其他信息并将这些信息封装成LogEvent,然后交给LogFormatter进行格式化。然后将格式化结果交给LogAppender进行处理,LogAppender将信息同步输出到指定的位置。
这样做有一个问题:LogAppender的输出操作很多时候都是非常耗时的I/O操作,用户在调用接口进行日志输出时,I/O操作的耗时会阻塞业务的运行。解决这个问题的方法:
- 在Logger和LogAppender的通道之间提供异步机制,比如生产者消费者模型,使得I/O操作从业务线程中分离出来。
这个异步机制即可以是Logger提供的,也可以是LogAppender提供的。
为Logger添加异步机制使其变成AsyncLogger的话,任何组合在AsyncLogger中的LogAppender都会自动获得异步机制。
在Logger和同步LogAppender之间添加一个AsyncProxyAppender,则任何通过AsyncProxyAppender进行输出的LogAppender都会自动获得异步机制。
两种方式都可以达成异步日志I/O的目的,这里我使用AsyncProxyAppender来实现异步机制,因为我觉得将异步机制独立成一个组件能够让日志系统的耦合度降低一些。如果异步机制耦合在Logger中的话,那么将来要改异步机制的时候,我们就需要修改AsyncLogger。但使用AsyncProxyAppender的话,我们只需要替换一个AsyncProxyAppender就可以替换异步机制了。
据此,我们可以画出日志系统的顺序图:
接口设计
实现
使用C++语言来实现上述设计,将日志系统中的所有内容包含在adalog
命名空间中。
LogLevel
// LogLevel.h
#pragma once#include <string>namespace adalog
{class LogLevel{public:enum Value {DEBUG = 0,INFO,WARN,ERROR,FATAL};static std::string ToString(LogLevel::Value level);};
} // namespace adalog// LogLevel.cpp
#include "adalog/LogLevel.h"namespace adalog
{std::string LogLevel::ToString(LogLevel::Value level){switch (level) {case LogLevel::DEBUG:return "DEBUG";case LogLevel::INFO:return "INFO";case LogLevel::WARN:return "WARN";case LogLevel::ERROR:return "ERROR";case LogLevel::FATAL:return "FATAL";}return "UNKNOWN";}
} // namespace adalog
LogEvent
// LogEvent.h
#pragma once#include "adalog/LogLevel.h"
#include <chrono>
#include <thread>
#include <memory>namespace adalog
{class LogEvent{public:using TimePoint = std::chrono::system_clock::time_point;using Ptr = std::shared_ptr<LogEvent>;virtual ~LogEvent() = default;explicit LogEvent(const std::string& payload, const std::string& logger_name, LogLevel::Value log_level, std::chrono::system_clock::time_point log_time,const std::string& file_name,size_t line,std::thread::id thread_id);std::string GetPayload() const;std::string GetLoggerName() const;LogLevel::Value GetLogLevel() const;TimePoint GetLogTime() const;std::string GetFileName() const;size_t GetLine() const;std::thread::id GetThreadId() const;private:std::string payload_;std::string logger_name_;LogLevel::Value log_level_;TimePoint log_time_;std::string file_name_;size_t line_;std::thread::id thread_id_;};} // namespace adalog// LogEvent.cpp
#include "adalog/LogEvent.h"namespace adalog
{LogEvent::LogEvent(const std::string& payload, const std::string& logger_name, LogLevel::Value log_level, std::chrono::system_clock::time_point log_time,const std::string& file_name,size_t line,std::thread::id thread_id): payload_(payload), logger_name_(logger_name), log_level_(log_level), log_time_(log_time), file_name_(file_name), line_(line), thread_id_(thread_id){}std::string LogEvent::GetPayload() const{return payload_;}std::string LogEvent::GetLoggerName() const{return logger_name_;}LogLevel::Value LogEvent::GetLogLevel() const{return log_level_;}LogEvent::TimePoint LogEvent::GetLogTime() const{return log_time_;}std::string LogEvent::GetFileName() const{return file_name_;}size_t LogEvent::GetLine() const{return line_;}std::thread::id LogEvent::GetThreadId() const{return thread_id_;}} // namespace adalog
LogAppender
// LogAppender.h
#pragma once#include <cstddef>namespace adalog
{class LogAppender{public:virtual ~LogAppender() = default;virtual void Append(const char* data, size_t len) = 0;};
} // namespace adalog
// ConsoleAppender.h
#pragma once
#include "adalog/LogAppender.h"namespace adalog
{class ConsoleAppender : public LogAppender{public:void Append(const char* data, size_t len) override;};
} // namespace adalog// ConsoleAppender.cpp
#include "adalog/appender/ConsoleAppender.h"
#include <iostream>namespace adalog
{void ConsoleAppender::Append(const char* data, size_t len){std::cout.write(data, len);}
} // namespace adalog
这里只是完成一个最简单的输出到控制台的LogAppender用于测试日志系统是否正常工作。
LogFormatter
LogFormatter
可以是实现上有那么一点难度的对象。
/*** 自定义输出格式详解:* %n: Logger的名称* %l: 日志等级* %t: 线程ID* %v: 日志正文* %Y: 年份* %m: 月份, 01 ~ 12* %d: 日期,01 ~ 31* %H: 小时, 00 ~ 12* %M: 分钟, 00 ~ 59* %S: 秒数, 00 ~ 59* %F: 日志记录发生时所在文件的文件名* %L: 日志记录发生时所在文件的行号** 默认输出格式: "[%Y-%m-%d %H:%M:%S] [%l] [%n] [%t] [%F:%L] %v\n"*/
当LogFormatter设置好输出格式时,我们就可以调用Format(LogEvent)
将LogEvent
上的信息,映射到输出格式上。可以这样说,LogFormatter
实际上是在维护一个LogEvent -> string
的映射逻辑。如果你学过编译原理,那么你会发现Format()
方法实际上是一个有限状态机。当读到%
的时候,确认后面的字符是哪个特殊字符,确认完之后,我们在LogEvent
中找到相应的信息,然后将其输出。如果没有读到%
,那么直接将该字符输出即可。
因此,可以这样实现Format()
方法:
std::string LogFormatter::Format(LogEvent::Ptr event) {std::stringstream ss;int idx = 0;while(idx < pattern_.size()){if (pattern_[idx] == '%'){idx++;if (pattern_[idx] == 'n') {ss << event->GetLoggerName();}else if (pattern_[idx] == 'l') {ss << LogLevel::ToString(event->GetLogLevel());}else if (pattern_[idx] == 't') {ss << event->GetThreadId();}else if (pattern_[idx] == 'v') {ss << event->GetPayload();}else if (pattern_[idx] == 'Y') {auto log_time = event->GetLogTime();auto year = GetYear(log_time);ss << year;}else if (pattern_[idx] == 'm') {auto log_time = event->GetLogTime();auto month = GetMonth(log_time);ss << month;}else if (pattern_[idx] == 'd') {auto log_time = event->GetLogTime();auto day = GetDay(log_time);ss << day;}else if (pattern_[idx] == 'H') {auto log_time = event->GetLogTime();auto hour = GetHour(log_time);ss << hour;}else if (pattern_[idx] == 'M') {auto log_time = event->GetLogTime();auto minute = GetMinute(log_time);ss << minute;}else if (pattern_[idx] == 'S') {auto log_time = event->GetLogTime();auto second = GetSecond(log_time);ss << second;}else if (pattern_[idx] == 'F') {ss << event->GetFileName();}else if (pattern_[idx] == 'L') {ss << event->GetLine();}idx++;}else {int begin = idx;while (idx < pattern_.size() && pattern_[idx] != '%')idx++;// 此时idx要么指向%要么就在pattern_的末尾了ss << pattern_.substr(begin, idx - begin);}}return ss.str();}
这里是边解析边输出。我采用的做法是:先从pattern中将LogEvent -> string
的映射关系提取出来,然后实际调用时直接使用这个映射关系即可。
具体的实现思想是:
- 首先将pattern按是否是格式化字符分块,如:
[%Y-%m-%d]
,可以分成[
,%Y
,-
,%m
,-
,%d
,]
。 - 依照pattern制作一个Item的列表,Item实现了一个
Append(LogEvent)
方法。使用相同的LogEvent顺序调用列表中的Item:
-
- 第一个Item会输出
[
; - 第二个Item会输出LogEvent中的年份;
- 第三个Item会输出
-
; - 第四个Item会输出LogEvent中的月份;
- 第五个Item会输出
-
; - 第六个Item会输出LogEvent中的日份;
- 第七个Item会输出
]
; - 以此类推,将这些Item的
Append
方法顺序组合起来,就是Format的结果。
- 第一个Item会输出
下面就是具体的代码实现,如果觉得下面的代码不好理解,那么你也可以直接使用上面的边解析pattern边输出的方式,只不过代码可能需要改变一些以确保线程安全。
// LogFormatter.h
#pragma once#include "adalog/LogEvent.h"
#include <memory>
#include <list>#if __cplusplus >= 201703L#include <shared_mutex>
#else#include <mutex>
#endifnamespace adalog
{/*** 自定义输出格式详解:* %n: Logger的名称* %l: 日志等级* %t: 线程ID* %v: 日志正文* %Y: 年份* %m: 月份, 01 ~ 12* %d: 日期,01 ~ 31* %H: 小时, 00 ~ 12* %M: 分钟, 00 ~ 59* %S: 秒数, 00 ~ 59* %F: 日志记录发生时所在文件的文件名* %L: 日志记录发生时所在文件的行号** 默认输出格式: "[%Y-%m-%d %H:%M:%S] [%l] [%n] [%t] [%F:%L] %v\n"*/class LogFormatter{public:using Ptr = std::shared_ptr<LogFormatter>;class PatternItem;public:LogFormatter();LogFormatter(const std::string& pattern);virtual ~LogFormatter();virtual std::string Format(LogEvent::Ptr event);void SetPattern(const std::string& pattern);private:std::string pattern_;std::list<std::unique_ptr<PatternItem>> pattern_items_;// 允许多个线程访问Format(),但只允许一个线程访问SetPattern()
#if __cplusplus >= 201703Lstd::shared_mutex mtx_;
#elsestd::mutex mtx_;
#endif};class LogFormatter::PatternItem{public:virtual ~PatternItem() = default;virtual void Append(std::ostream& os, LogEvent::Ptr event) = 0;};} // namespace adalog// LogFormatter.cpp
#include "adalog/LogFormatter.h"
#include <chrono>
#include <ctime>
#include <iomanip>
#include <mutex>
#include <sstream>namespace adalog
{class LoggerNameItem : public LogFormatter::PatternItem{public:void Append(std::ostream& os, LogEvent::Ptr event) override{ os << event->GetLoggerName();}};class LogLevelItem : public LogFormatter::PatternItem{public:void Append(std::ostream& os, LogEvent::Ptr event) override{os << LogLevel::ToString(event->GetLogLevel());}};class ThreadIdItem : public LogFormatter::PatternItem{public:void Append(std::ostream& os, LogEvent::Ptr event) override{os << event->GetThreadId();}};class PayloadItem : public LogFormatter::PatternItem{public:void Append(std::ostream& os, LogEvent::Ptr event) override{os << event->GetPayload();}};class YearItem : public LogFormatter::PatternItem{public:void Append(std::ostream& os, LogEvent::Ptr event) override{std::time_t time = std::chrono::system_clock::to_time_t(event->GetLogTime());std::tm* local_time = std::localtime(&time);int year = local_time->tm_year + 1900;os << year;}};class MonthItem : public LogFormatter::PatternItem{public:void Append(std::ostream& os, LogEvent::Ptr event) override{std::time_t time = std::chrono::system_clock::to_time_t(event->GetLogTime());std::tm* local_time = std::localtime(&time);auto month = static_cast<unsigned int>(local_time->tm_mon + 1); // 月份范围是0 - 11,需加1os << std::setw(2) << std::setfill('0') << month << std::setfill(' ');}};class DayItem : public LogFormatter::PatternItem{public:void Append(std::ostream& os, LogEvent::Ptr event) override{std::time_t time = std::chrono::system_clock::to_time_t(event->GetLogTime());std::tm* local_time = std::localtime(&time);auto day = static_cast<unsigned int>(local_time->tm_mday); // 月份范围是0 - 11,需加1os << std::setw(2) << std::setfill('0') << day << std::setfill(' ');}};class HourItem : public LogFormatter::PatternItem{public:void Append(std::ostream& os, LogEvent::Ptr event) override{std::time_t time = std::chrono::system_clock::to_time_t(event->GetLogTime());std::tm* local_time = std::localtime(&time);os << std::put_time(local_time, "%H");}};class MinuteItem : public LogFormatter::PatternItem{public:void Append(std::ostream& os, LogEvent::Ptr event) override{std::time_t time = std::chrono::system_clock::to_time_t(event->GetLogTime());std::tm* local_time = std::localtime(&time);os << std::put_time(local_time, "%M");}};class SecondItem : public LogFormatter::PatternItem{public:void Append(std::ostream& os, LogEvent::Ptr event) override{std::time_t time = std::chrono::system_clock::to_time_t(event->GetLogTime());std::tm* local_time = std::localtime(&time);os << std::put_time(local_time, "%S");}};class FileNameItem : public LogFormatter::PatternItem{public:void Append(std::ostream& os, LogEvent::Ptr event) override{os << event->GetFileName();}};class LineNumberItem : public LogFormatter::PatternItem{public:void Append(std::ostream& os, LogEvent::Ptr event) override{os << event->GetLine();}};class OtherStringItem : public LogFormatter::PatternItem{public:OtherStringItem(const std::string& str) : str_(str) {}void Append(std::ostream& os, LogEvent::Ptr event) override{os << str_;}private:std::string str_;};LogFormatter::LogFormatter(){SetPattern("[%Y-%m-%d %H:%M:%S] [%l] [%n] [%t] [%F:%L] %v\n");}LogFormatter::LogFormatter(const std::string& pattern){SetPattern(pattern);}LogFormatter::~LogFormatter() {}void LogFormatter::SetPattern(const std::string& pattern){
#if __cplusplus >= 201703Lstd::unique_lock<std::shared_mutex> lock(mtx_);
#elsestd::unique_lock<std::mutex> lock(mtx_);
#endifif (!pattern_items_.empty())pattern_items_.clear();pattern_ = pattern;int idx = 0;while(idx < pattern_.size()){if (pattern_[idx] == '%'){idx++;if (pattern_[idx] == 'n')pattern_items_.emplace_back(std::make_unique<LoggerNameItem>());else if (pattern_[idx] == 'l')pattern_items_.emplace_back(std::make_unique<LogLevelItem>());else if (pattern_[idx] == 't')pattern_items_.emplace_back(std::make_unique<ThreadIdItem>());else if (pattern_[idx] == 'v')pattern_items_.emplace_back(std::make_unique<PayloadItem>());else if (pattern_[idx] == 'Y')pattern_items_.emplace_back(std::make_unique<YearItem>());else if (pattern_[idx] == 'm')pattern_items_.emplace_back(std::make_unique<MonthItem>());else if (pattern_[idx] == 'd')pattern_items_.emplace_back(std::make_unique<DayItem>());else if (pattern_[idx] == 'H')pattern_items_.emplace_back(std::make_unique<HourItem>());else if (pattern_[idx] == 'M')pattern_items_.emplace_back(std::make_unique<MinuteItem>());else if (pattern_[idx] == 'S')pattern_items_.emplace_back(std::make_unique<SecondItem>());else if (pattern_[idx] == 'F')pattern_items_.emplace_back(std::make_unique<FileNameItem>());else if (pattern_[idx] == 'L')pattern_items_.emplace_back(std::make_unique<LineNumberItem>());idx++;}else {int begin = idx;while (idx < pattern_.size() && pattern_[idx] != '%')idx++;// 此时idx要么指向%要么就在pattern_的末尾了pattern_items_.emplace_back(std::make_unique<OtherStringItem>(pattern_.substr(begin, idx - begin)));}}}std::string LogFormatter::Format(LogEvent::Ptr event) {
#if __cplusplus >= 201703Lstd::shared_lock<std::shared_mutex> lock(mtx_);
#elsestd::unique_lock<std::mutex> lock(mtx_);
#endif std::stringstream ss;for (auto& item : pattern_items_)item->Append(ss, event);return ss.str();}} // namespace adalog
Logger
namespace adalog
{class Logger{public:using Ptr = std::shared_ptr<Logger>;Logger(const std::string& logger_name, LogLevel::Value log_level, std::list<LogAppender::Ptr> appenders,LogFormatter::Ptr formatter = std::make_shared<LogFormatter>());std::string GetLoggerName() const;LogLevel::Value GetLogLevel() const;void AddAppender(LogAppender::Ptr appender);void Log(LogLevel::Value log_level, LogEvent::Ptr event);void Debug(const std::string& payload, const std::string& file_name,size_t line);void Info (const std::string& payload, const std::string& file_name,size_t line);void Warn (const std::string& payload, const std::string& file_name,size_t line);void Error(const std::string& payload, const std::string& file_name,size_t line);void Fatal(const std::string& payload, const std::string& file_name,size_t line);private:std::string logger_name_;LogLevel::Value log_level_;std::list<LogAppender::Ptr> appenders_;LogFormatter::Ptr formatter_;};
}
用户能够使用logger.Debug("hello", __FILE__, __LINE__)
的方式来调用接口,但每次调用接口都需要指定__FILE__
和__LINE__
太麻烦了,因此可以使用宏来简化接口。
#define ADALOG_DEFAULT_DEBUG(payload) adalog::LoggerManager::GetInstance().GetLogger("default_logger")->Debug(payload, __FILE__, __LINE__);
#define ADALOG_DEFAULT_INFO(payload) adalog::LoggerManager::GetInstance().GetLogger("default_logger")->Info(payload, __FILE__, __LINE__);
#define ADALOG_DEFAULT_WARN(payload) adalog::LoggerManager::GetInstance().GetLogger("default_logger")->Warn(payload, __FILE__, __LINE__);
#define ADALOG_DEFAULT_ERROR(payload) adalog::LoggerManager::GetInstance().GetLogger("default_logger")->Error(payload, __FILE__, __LINE__);
#define ADALOG_DEFAULT_FATAL(payload) adalog::LoggerManager::GetInstance().GetLogger("default_logger")->Fatal(payload, __FILE__, __LINE__);#define ADALOG_DEBUG(logger_name, payload) adalog::LoggerManager::GetInstance().GetLogger(logger_name)->Debug(payload, __FILE__, __LINE__);
#define ADALOG_INFO(logger_name, payload) adalog::LoggerManager::GetInstance().GetLogger(logger_name)->Info(payload, __FILE__, __LINE__);
#define ADALOG_WARN(logger_name, payload) adalog::LoggerManager::GetInstance().GetLogger(logger_name)->Warn(payload, __FILE__, __LINE__);
#define ADALOG_ERROR(logger_name, payload) adalog::LoggerManager::GetInstance().GetLogger(logger_name)->Error(payload, __FILE__, __LINE__);
#define ADALOG_FATAL(logger_name, payload) adalog::LoggerManager::GetInstance().GetLogger(logger_name)->Fatal(payload, __FILE__, __LINE__);
这里为什么要使用LoggerManager
呢?是因为如果没有LoggerManager,每次我们要使用Logger都必须创建一个Logger对象出来,这样实在是太麻烦了。因此我们预先创建好Logger并将其注册到LoggerManager中,想要使用Logger直接使用LoggerName去获取即可。
再通过宏简化调用的方式,这样就能够做到简单调用了。
class LoggerBuilder // Builder方便创建Logger对象{public:LoggerBuilder();LoggerBuilder& BuildLoggerName(const std::string& logger_name);LoggerBuilder& BuildLogLevel(LogLevel::Value log_level);LoggerBuilder& BuildAppender(LogAppender::Ptr appender);Logger::Ptr BuildLogger();private:std::string logger_name_;LogLevel::Value log_level_;std::list<LogAppender::Ptr> appenders_;};class LoggerManager // LoggerManager使用单例模式在全局作用域内存储和获取Logger{public:static LoggerManager& GetInstance();void AddLogger(Logger::Ptr logger);Logger::Ptr GetLogger(const std::string& logger_name);private:LoggerManager();std::unordered_map<std::string, Logger::Ptr> logger_table_;};
AsyncProxyAppender
实现异步的惯用手段就是生产者消费者模型。多个生产者往队列中生产产品,消费者从队列中取出产品并消费。这是可行的。
但会在日志系统中,使用队列作为生产者和消费者之间的中间件,会出现问题:
- 当日志输出请求的数量非常大时,会导致队列占用很多内存资源,从而导致程序崩溃。
因此,最稳妥的方式不是使用队列,而是使用固定大小的缓冲区。虽然使用缓冲区会在日志输出量高的时候导致业务逻辑阻塞,但它的稳定性、可靠性更高。
在下一节我会讲述使用缓冲池 + 线程池实现AsyncProxyAppender
的方式。
源码地址
feiX/adalog