自定义日志缓冲区(上)c++20

引言

本篇文章使用c++20标准实现的一个自定义的日志缓冲区。打印日志对于每一个项目来说都是必不可少的,在muduo库里面我们也简单的实现了一个日志的打印,大家如果有兴趣可以看一看我之前的这个博客写文章-CSDN创作中心。不过那个是比较简易的,而本篇文章的上下篇将完成一个复杂,高性能的打印日志器。

日志

打印日志是为了可以更快的发现错误,而我们的错误是分等级的,有的错误无伤大雅,而有的错误一旦发生就会造成损失,所以我们需要给日志分几个级别、

然后对于日志所对外提供的函数,一共就两个,一个是把Level转化成string的类型,因为我们的enum底层还是uint8类型,我们为了可以更好的看出到底是什么类型的错误,需要转化成字符串。

还有一个是把一个字符串形式的日志级别,转换成 LogLevel::Level 枚举值,一般的用法如下:

复制代码
auto level = LogLevel::FromString("debug");
cpp 复制代码
class PANS_API LogLevel final 
{
public:
    enum class Level : std::uint8_t
    {
        LOG_DEBUG, // 调试细节
        LOG_INFO,  // 记录正常运行的信息
        LOG_WARN,  // 可能存在的问题
        LOG_ERROR, // 操作失败,需要检查
        LOG_FATAL, // 程序如果继续运行会造成损失
        LOG_OFF,   // 关闭日志
    };

    [[nodiscard]] static std::string_view ToString(Level level) noexcept;
    [[nodiscard]] static LogLevel::Level FromString(std::string_view value) noexcept;
};

我们这里用宏来简化代码的书写,并且因为这个宏是定义在函数的内部,所以当我们用完这个宏之后,我们需要及时的释放掉。FromString这个函数的思路就是创建一个数组来存储大写的value,然后返回这个,所以主要的作用就是转化为大写,因为外部可能会传递小写的,类似于debug之类的。

cpp 复制代码
std::string_view LogLevel::ToString(Level level) noexcept 
{
    switch (level)
    {
#define XX(name) case Level::LOG_##name : return #name;
        XX(DEBUG)
        XX(INFO)
        XX(WARN)
        XX(ERROR)
        XX(FATAL)
        XX(OFF)
    }
#undef XX
    return "UNKNOWN";
}

// 把一个字符串形式的日志级别,转换成 LogLevel::Level 枚举值
LogLevel::Level LogLevel::FromString(std::string_view value) noexcept
{
    std::array<char, 6> normalized{};
    std::transform(value.begin(), value.end(), normalized.begin(), [](unsigned char character){
        return static_cast<char>(std::toupper(character));
    });
    const std::string_view upper_value(normalized.data(), value.size());
#define XX(name) if(upper_value == #name) return Level::LOG_##name;
    XX(DEBUG)
    XX(INFO)
    XX(WARN)
    XX(FATAL)
    XX(OFF)
    XX(ERROR)
#undef XX
    return Level::LOG_OFF;
}

缓冲区

接下来就是比较关键的缓冲区,其实大部分的项目里面打印的日志都是在堆上打印的,堆上写完即释放,里面比较典型的就是sylar的服务器架构,就是用std::stringstream,这个的优点就是比较的简单容易实现。但是缺点也比较明显,我们拿到数据的时候需要用std::string来接受,多了一次拷贝,而自定义的缓冲区我们可以开窗口,用string_view来直接拿数据。

因为一般的日志都是少于200字的,栈的空间完全足够,但是也不排除一些比较长的日志信息,这个时候我们就需要把日志的内容放在堆上,方便扩容处理。所以我们的目的就是如果日志信息比较少就在栈上,日志比较长就在堆上。那么我们很容易想到的架构就是array和vector

这里解释一下,我们在缓冲区对象里面的成员array和成员一起被放在了栈上,随着缓冲区一起被销毁,而vector的一些属性,比如size,type放在了栈上,其数据是放在了堆上,方便扩容。

我们这里还是用的类模板,因为我们的内联缓冲区的大小还是不确定的,其中对外提供的API主要就是append,其中的逻辑是如果堆上没有内容并且要写的内容没有超过限制,那么就写入inline

如果超出了,并且overflow是空的,那么我们先把之前在inline的数据通过insert拷贝到overflow里面,再把当前的数据insert到end()后面。

这里要说明一下子std::string_view,这个性能很高,因为其不需要拷贝到string,里面管理了一个指针ptr和string的大小size,所以我们

cpp 复制代码
// 自定义一个日志缓冲区,针对高频,小数据量的日志场景,在栈上分配日志缓冲区,与超长日志,再在堆上分配内存
template <std::size_t INLINE_CAPACITY>
class InlineBuffer
{
public:
    void append(const char* data, std::size_t size)
    {
        if (size == 0)
        {
            return;
        }
        if (overflow_.empty() && size + size_ <= INLINE_CAPACITY)
        {
            std::memcpy(inline_.data() + size_, data, size);
            size_ += size;
            return;
        }

        if (overflow_.empty())
        {
            const std::size_t required_capacity = size_ + size;
            overflow_.reserve(std::max(required_capacity, 2 * INLINE_CAPACITY));
            overflow_.insert(overflow_.end(), inline_.data(), inline_.data() + size_);
        }
        overflow_.insert(overflow_.end(), data, data + size);
        size_ += size;
    }

    void append(std::string_view value)
    {
        append(value.data(), value.size());
    }

    void append(char value)
    {
        append(&value, 1);
    }

    [[nodiscard]] const char* data() const noexcept
    {
        return overflow_.empty() ? inline_.data() : overflow_.data();
    }

    [[nodiscard]] std::size_t size() const noexcept
    {
        return size_;
    }

    [[nodiscard]] std::string_view view() const noexcept
    {
        return {data(), size()};
    }

private:
    std::array<char, INLINE_CAPACITY> inline_{};
    std::vector<char> overflow_;
    std::size_t size_;
};

为了继续可以使用流式输入的接口,我们重新实现了一个streambuf,然后重写对应的接口。

这个是我们的目标,但是与系统提供put_area不同的是,我们有自己的缓冲区了,系统的put_area会维护两个指针pptr和epptr,而我们因为没有提供这个put_area所以不会进入if()里面,而是直接return,执行overflow函数(我们需要重写)

std::ostream 负责格式化输出,std::streambuf 负责把数据交给实际的存储对象。

streambuf 默认没有自己的输出缓冲区,pbase/pptr/epptr 都为空;

如果通过 setp() 设置缓冲区,sputc() 会先判断 pptr() < epptr(), 有空间就直接写入,没有空间才调用 overflow()。

多个字符通过 sputn() 进入虚函数 xsputn()。 本类没有调用 setp(),因此不使用 streambuf 自己的缓冲区:

单字符 -> sputc() -> 无可用空间 -> overflow() -> InlineBuffer

多字符 -> sputn() -> xsputn() -> InlineBuffer

所以 SmallStreamBuffer 的作用就是: 把 std::ostream 的输出接口直接转接到自己的 InlineBuffer。

cpp 复制代码
// 继承std::streambuf是为了能够能够继续使用流式输入接口,兼容ostream生态
template <std::size_t INLINE_CAPACITY>
class SmallStreamBuffer : public std::streambuf
{
public:
    explicit SmallStreamBuffer(InlineBuffer<INLINE_CAPACITY>& buffer) noexcept
        : buffer_(buffer)
    {}

protected:
    std::streamsize xsputn(const char* data, std::streamsize size) override
    {
        if (size <= 0)
        {
            return 0;
        }

        buffer_.append(data, static_cast<std::size_t>(size));
        return size;
    }

    int_type overflow(int_type character) override
    {
        // overflow,除了可以接受一般字符,还可能会接收流终止符EOF
        // 如果我们遇到了EOF, 把它转成非EOF值向上报告写入成功,实际上不向Buffer里写入任何值。
        if (traits_type::eq_int_type(character, traits_type::eof()))
        {
            return traits_type::not_eof(character);
        }
        buffer_.append(traits_type::to_char_type(character));
        return character;
    }

private:
    InlineBuffer<INLINE_CAPACITY>& buffer_;
};

总结

本篇文章到这里就结束了!!!!希望可以帮助大家理解~~~

相关推荐
wabs6661 小时前
关于二叉树【力扣199.二叉树的右视图的思考】
数据结构·c++·算法·leetcode·二叉树·层序遍历
邪修king1 小时前
Re: Linux系统篇(十八):进程篇(七): 进程深度解析:从 fork 创建到退出的完整旅程(附写时拷贝原理 + 代码实战)
java·linux·运维
Golden_Chen1 小时前
Win10 WSL安装ubuntu
linux
RobinDevNotes1 小时前
RoCEv2如何扛起大模型训练网络
linux·网络·ai·网络linux
hope_wisdom1 小时前
C/C++数据结构之二叉树的遍历
c语言·数据结构·c++·二叉树·深度优先·广度优先
亿朝亿夕2 小时前
linux基础命令
linux
modelmd2 小时前
Linux SIGTERM 信号
linux
keyipatience2 小时前
IP网络层
服务器·网络·网络协议·ubuntu·http
cpp_learner2 小时前
C++ 实现责任链模式(Chain of Responsibility):从一堆 if-else 到可插拔的处理管道
c++·设计模式