引言
本篇文章还是对于日志器的实现,我们的日志输出会输出到不同的地方,有时候是终端,有时候是网络,有时候是文件里面,所以我们需要规定其输出的地方,这也就是appender的作用。
appender.h
这个类的实现我们是要对外面公开的,但是里面的细节我们会隐藏。并且我们Appender是一个基类,而其派生类我们是要实现我们特定输出的位置。我们要实现这个方案其实和上一个format的架构思想基本一样,在类里面声明一个impl,类外定义,最后我们外部通过传入不同的impl,实现不同的方案。
还有一个是AppenderAccess,这个类是为了代理我们的内部权限,严格控制谁可以访问我们的Appender私有实现,也就是说凡是访问Appender的类,都需要在AppenderAccess里面存储。在这个代码的最后我们提供了所有对应的外部可以访问的接口,这个接口实际上调用的是AppenderAcess里面的接口。我们之所以加了这一层,就是为了确保只有特定的类可以访问这个Appender,这里面也涉及到了friend友元类。
可能这里还不是很清楚,我们往后面看。
cpp
// 用来代理我们的内部权限,严格控制谁可以访问我们的Appender私有实现
namespace detail {
class AppenderAccess;
}
class PANS_API Appender final
{
public:
// 声明一个内部实现内,类外定义(我们的输出地方可能是网络,可能是终端。。。不同的输出地方需要不同的处理,所以全部在impl的派生类里面实现)
class Impl;
~Appender() = default;
Appender(const Appender&) = delete;
Appender(Appender&& ) = delete;
Appender& operator=(const Appender&) = delete;
Appender& operator=(Appender&&) = delete;
void setLevel(LogLevel::Level level) noexcept;
[[nodiscard]] LogLevel::Level getLevel() const noexcept;
// 把数据写入脏页,我们的std::endl就是换行加上flush
void flush();
// 把脏页的数据写入磁盘,达到数据同步的效果
void sync();
private:
// 而我们通过构造函数传进来对应的指针,就可以输出到对应的地方
explicit Appender(std::unique_ptr<Impl> impl) noexcept;
std::unique_ptr<Impl> impl_;
friend class detail::AppenderAccess;
};
using AppenderPtr = std::shared_ptr<Appender>;
[[nodiscard]] PANS_API AppenderPtr MakeStdoutAppender();
[[nodiscard]] PANS_API AppenderPtr MakeFileAppender(std::string file_name);
}
这个就是impl的实现,本质上是一个基类,也就是里面的函数都是虚函数,实现的方法都是交给派生类。
flush和sync:
这里我们补充一下操作系统的知识:
当我们要写入一些内容的时候,会先写入缓存之中,这个缓存并不会显示在终端上面,所以我们如果先printf("xxxx"),然后while()阻塞,那么我们的终端不会显示,因为并没有触发我们flush的条件,导致内容在缓存里面,而不在内存里面。但是如果我们加了std::endl,那么cout的内容就会立马出来,因为std::endl不仅仅是换行,还执行了flush的命令。当flush写入内存之后,我们称其为脏页,也就是内存里面的数据还没有同步到磁盘上面,只要在这一瞬间断电了,那么这一块内容就会消失,因为没有保存到磁盘里面。
所以我们需要进行sync强制同步的操作,把数据写入磁盘。
我们的对脏页的同步操作,系统也会执行,一般来说,5s系统会强制把脏页的内容写入磁盘,3s会对脏页添加上过期的标志,下一次同步sync的时候会先处理这一部分过期的内容,或者脏页的内容超过了内存的10%,会立刻sync。但是正式因为有时间的限制,操作系统不可能每时每刻都在读取脏页的内容,所以一旦不幸运的事情(系统崩溃)发生,我们的内容全部都没有了。
然后还有一个AppenderAccess类的声明,里面就是我们要定向输出的函数,这几个函数会返回对应的Append指针,最后一个append函数就是统一调用这些派生类appender。
cpp
class Appender::Impl
{
public:
virtual ~Impl() = default;
void append(LogLevel::Level level, std::string_view formatted_record) noexcept;
void setLevel(LogLevel::Level level) noexcept;
[[nodiscard]] LogLevel::Level getLevel() const noexcept;
void flush();
void sync();
protected:
virtual void writeUnlocked(std::string_view formatted_record) noexcept = 0;
virtual void flushUnlocked() noexcept = 0;
virtual void syncUnlocked() noexcept = 0;
std::mutex mutex_;
private:
std::atomic<LogLevel::Level> level_{LogLevel::Level::LOG_DEBUG};
};
namespace detail
{
class AppenderAccess final
{
public:
[[nodiscard]] static AppenderPtr MakeStdoutAppender();
[[nodiscard]] static AppenderPtr MakeFileAppender(std::string file_name);
static void Append(const AppenderPtr& appender, LogLevel::Level level, std::string_view formatted_record);
};
从这部分代码就可以看出来,我们外部传入Appender的impl,然后Appender会调用impl各自的实现。
cpp
Appender::Appender(std::unique_ptr<Impl> impl) noexcept
: impl_(std::move(impl))
{}
void Appender::setLevel(LogLevel::Level level) noexcept
{
impl_->setLevel(level);
}
LogLevel::Level Appender::getLevel() const noexcept
{
return impl_->getLevel();
}
void Appender::flush()
{
impl_->flush();
}
void Appender::sync()
{
impl_->sync();
}
我们只会输出等级比我们设定大的日志,虽然我们函数里面都标志了noexcept,但是因为lock_guard这里会抛出异常,有可能没有抢到锁的情况,所以我们需要捕捉这个异常,然后写入stderr
这里解释一下为什么我们要写入stderr,而不是stdout
stderr是立刻输出的,没有对应的缓冲区,但是stdout有对应的缓冲区,也就是说如果我们没有flush,那么输入进stdout的内容有可能不会打印出来,而这个是一个异常的报错,绝对不允许这种情况发生。
如果没有报错,那么我们正常的写入,如果是致命的错误就需要flush,如果不是就不着急,因为flush也需要消耗性能的。
cpp
void Appender::Impl::append(LogLevel::Level level, std::string_view formatted_record) noexcept
{
if (static_cast<u8>(level) < static_cast<u8>(this->getLevel()))
{
return;
}
// std::mutex::lock()函数,在极端情况下会抛出std::system_error异常, lock_guard的构造函数也不是noexcept的
try
{
std::lock_guard<std::mutex> lock(mutex_);
writeUnlocked(formatted_record);
if (level == LogLevel::Level::LOG_FATAL)
{
flushUnlocked();
}
}
catch(...)
{
constexpr std::string_view message = "pans logger: appender operation failed\n";
std::fwrite(message.data(), 1, sizeof(message), stderr);
}
}
这些是公共函数,本质上就是调用派生类重写的函数
cpp
void Appender::Impl::setLevel(LogLevel::Level level) noexcept
{
level_.store(level, std::memory_order_release);
}
LogLevel::Level Appender::Impl::getLevel() const noexcept
{
return level_.load(std::memory_order_acquire);
}
// 把数据从程序的内存刷到操作系统缓冲区
void Appender::Impl::flush()
{
std::lock_guard<std::mutex> lock(mutex_);
flushUnlocked();
}
void Appender::Impl::sync()
{
std::lock_guard<std::mutex> lock(mutex_);
syncUnlocked();
}
这个是终端的虚函数实现,因为目的地是打印到终端上面,不需要写到磁盘上面,所以不需要sync
cpp
class StdoutAppenderImpl final : public Appender::Impl
{
protected:
void writeUnlocked(std::string_view formatted_record) noexcept override
{
std::cout.write(formatted_record.data(), static_cast<std::streamsize>(formatted_record.size()));
}
void flushUnlocked() noexcept override
{
std::cout.flush();
}
// 这个是输出到终端,不需要同步到磁盘,所以还是flush(也可以不写)
void syncUnlocked() noexcept override
{
flushUnlocked();
}
};
这个是输出到文件里面,我们这里用的是c提供的FILE,为什么不用c++20提供的std::ofstream呢,因为c++20底层没有提供得到文件描述符,这个在c++26里面才出现,但是因为我们使用c++20实现的,所以这里只能用FILE
cpp
class FileAppenderImpl final : public Appender::Impl
{
public:
explicit FileAppenderImpl(const std::string& file_name)
{
if (file_name.empty())
{
throw std::invalid_argument("logger file name cannot be empty");
}
/*
a(append):追加写入,保留文件原来的内容,写入的数据放到文件末尾。文件不存在时会创建。
b(binary):二进制模式,不进行文本转换,例如 Windows 下不会把 \n 自动转换成 \r\n。Linux 下文本模式和二进制模式通常没有区别。
*/
file_ = std::fopen(file_name.c_str(), "ab");
if (file_ == nullptr)
{
// 通用错误类别,用来说明一个错误码应该按照什么规则解释,常用于解释 errno
// 错误码只是数字,需要搭配错误类别才能确定含义
throw std::system_error(errno, std::generic_category(), "failed to open logger file: " + file_name);
}
}
~FileAppenderImpl() override
{
if (file_ != nullptr)
{
std::fflush(file_);
std::fclose(file_);
}
}
protected:
void writeUnlocked(std::string_view formatted_record) noexcept override
{
std::fwrite(formatted_record.data(), 1, formatted_record.size(), file_);
}
void flushUnlocked() noexcept override
{
std::fflush(file_);
}
void syncUnlocked() noexcept override
{
flushUnlocked();
::fdatasync(::fileno(file_));
}
private:
std::FILE* file_ = nullptr;
};
这个是最好帮助我们理解整个架构的代码,我们Access会返回每个派生类的指针,然后通过那这个指针传入append。最后我们要把指针返回到我们对外的接口里面。
cpp
AppenderPtr detail::AppenderAccess::MakeStdoutAppender()
{
return AppenderPtr(new Appender(std::make_unique<StdoutAppenderImpl>()));
}
AppenderPtr detail::AppenderAccess::MakeFileAppender(std::string file_name)
{
return AppenderPtr(new Appender(std::make_unique<FileAppenderImpl>(file_name)));
}
void detail::AppenderAccess::Append(const AppenderPtr& appender, LogLevel::Level level, std::string_view formatted_record)
{
if (appender != nullptr)
{
appender->impl_->append(level, formatted_record);
}
}
AppenderPtr MakeStdoutAppender()
{
return detail::AppenderAccess::MakeStdoutAppender();
}
AppenderPtr MakeFileAppender(std::string file_name)
{
return detail::AppenderAccess::MakeFileAppender(std::move(file_name));
}
总结
这个架构还是挺复杂的,需要多多体会~~~