Linux应用框架cpp-tbox之日志系统设计

cpp-tbox项目链接 https://gitee.com/cpp-master/cpp-tbox

更多精彩内容欢迎关注微信公众号:码农练功房
往期精彩内容:
Linux应用框架cpp-tbox之弱定义

简介

cpp-tbox的日志系统有如下特点:

1) 有三种日志输出渠道:stdout + filelog + syslog

  • stdout,将日志通过 std::cout 输出到终端;
  • syslog,将日志通过 syslog() 输出到系统日志;
  • filelog,将日志写入到指定目录下,以格式:前缀.年月日_时分秒.进程号.log 的文件中。文件大小超过1M则另创建新的日志文件。由于写文件效率低,该输出渠道采用前后端模式。

三种渠道可以在启动参数中选定一个或同时多种,也可在运行时通过终端更改。

2) 根据日志等级渲染不同颜色,一目了然,内容详尽
日志内容包含了:等级、时间(精确到微秒)、线程号、模块名、函数名、正文、文件名、行号。方便快速定位问题。
请添加图片描述

3) 灵活的日志输出过滤器,且能运行时修改

可在程序运行时针对不同的模块单独设置日志等级,如下:
请添加图片描述

整体结构图

下图是cpp-tbox中日志系统的代码模型,这里只标识出了主干接口,同时为了排版方便,去除了有些接口的参数,但是不影响我们理解整个结构。
请添加图片描述

输出通道

cpp-tbox支持三种日志输出渠道,可以在启动参数中选定一个或同时多种输出渠道,也可在运行时通过终端更改。这个是怎么实现的呢?

cpp-tbox\modules\base\log_imp.cpp中你可以找到答案:

// cpp-tbox\modules\base\log_imp.cpp
struct OutputChannel {
    uint32_t id;
    LogPrintfFuncType func;
    void *ptr;
};
std::vector<OutputChannel> _output_channels;

这里在源文件的匿名空间中定义了OutputChannel这个数据结构,其中ptr为指向各个具体Sink类的this指针,func为函数指针,指向具体Sink类的日志处理接口。

需要使能某种输出方式时,把对应类添加到这个输出通道。要关闭某种输出方式时,把对应类从通道中删除即可。

通道删除、添加由以下接口实现,其中id主要在关闭输出方式时使用:

// cpp-tbox\modules\base\log_imp.cpp
uint32_t LogAddPrintfFunc(LogPrintfFuncType func, void *ptr)
bool LogRemovePrintfFunc(uint32_t id)

通道添加、删除接口被Sink::enable和Sink::disable接口所调用。

日志数据包

在日志前端每条日志都会被打包成如下数据结构,送给输出通道。

//  cpp-tbox\modules\base\log_imp.cpp
struct LogContent {
    long thread_id;         //!< 线程ID
    struct {
        uint32_t sec;       //!< 秒
        uint32_t usec;      //!< 微秒
    } timestamp;            //!< 时间戳

    const char *module_id;  //!< 模块名
    const char *func_name;  //!< 函数名
    const char *file_name;  //!< 文件名
    int         line;       //!< 行号
    int         level;      //!< 日志等级
    uint32_t    text_len;   //!< 内容大小
    const char *text_ptr;   //!< 内容地址
};

在输出通道中,最终会调用Sink::onLogFrontEnd这个日志前端接口(纯虚接口),而这个接口由各个派生类实现,这里用到了模板方法(Template Method)。

// cpp-tbox\modules\log\sink.cpp
// Sink::HandleLog被注册到OutputChannel.func
void Sink::HandleLog(const LogContent *content, void *ptr)
{
    Sink *pthis = static_cast<Sink*>(ptr);
    pthis->handleLog(content);
}

void Sink::handleLog(const LogContent *content)
{
    if (!filter(content->level, content->module_id))
        return;
    // 最终会调用到Sink::onLogFrontEnd 
    // 由各个子类去实现
    onLogFrontEnd(content);
}

同步输出

SyncStdoutSink实现了onLogFrontEnd接口,将日志内容直接同步输出到标准输出:

// cpp-tbox\modules\log\sync_stdout_sink.cpp
void SyncStdoutSink::onLogFrontEnd(const LogContent *content)
{
    udpateTimestampStr(content->timestamp.sec);

    //! 开启色彩,显示日志等级
    if (enable_color_)
        printf("\033[%sm", LOG_LEVEL_COLOR_CODE[content->level]);

    //! 打印等级、时间戳、线程号、模块名
    printf("%c %s.%06u %ld %s ", LOG_LEVEL_LEVEL_CODE[content->level],
           timestamp_str_, content->timestamp.usec,
           content->thread_id, content->module_id);

    if (content->func_name != nullptr)
        printf("%s() ", content->func_name);

    if (content->text_len > 0)
        printf("%s ", content->text_ptr);

    if (content->file_name != nullptr)
        printf("-- %s:%d", content->file_name, content->line);

    if (enable_color_)
        puts("\033[0m");    //! 恢复色彩
}

异步输出

AsyncSyslogSink、AsyncFileSink、AsyncStdoutSink将日志内容通过异步方式分别写入系统日志、文件、标准输出。

AsyncSink这个类的提取是为了代码复用,实际代码中AsyncStdoutSink并未被使用。

异步输出的这几个类,将日志内容先写入AsyncPipe管理的内存

// cpp-tbox\modules\log\async_sink.cpp
void AsyncSink::onLogFrontEnd(const LogContent *content)
{
    async_pipe_.append(content, sizeof(LogContent));
    if (content->text_len != 0)
        async_pipe_.append(content->text_ptr, content->text_len);
}

AsyncPipe内部的后台线程会在合适的时机,将日志内容最终写入目的地。AsyncPipe是整个异步输出的核心类:

//cpp-tbox\modules\util\async_pipe.h
class AsyncPipe {
  public:
    AsyncPipe();
    ~AsyncPipe();

  public:
    using Callback = std::function<void(const void *, size_t)>;
    struct Config {
        size_t buff_size = 1024;    //!< 缓冲大小,默认1KB
        size_t buff_min_num = 2;    //!< 缓冲保留个数,默认2
        size_t buff_max_num = 10;   //!< 缓冲最大个数,默认5
        size_t interval = 1000;     //!< 同步间隔,单位ms,默认1秒
    };

    bool initialize(const Config &cfg);     //! 初始化
    void setCallback(const Callback &cb);   //! 设置回调

    void append(const void *data_ptr, size_t data_size); //! 异步写入
    void cleanup(); //! 清理

  private:
    class Impl;
    Impl *impl_ = nullptr;
};

可以看到这里使用了"Impl"模式,隔离了接口和实现,减少编译时依赖,提高封装性。

AsyncPipe的功能是按照配置,以一定同步间隔,将数据写入目的地,具体目的地由设置的回调函数决定:

// cpp-tbox\modules\log\async_sink.cpp
void AsyncSink::onEnable()
{
    if (!async_pipe_.initialize(cfg_))
        return;

    using namespace std::placeholders;
    async_pipe_.setCallback(std::bind(&AsyncSink::onLogBackEndReadPipe, this, _1, _2));
    is_pipe_inited_ = true;
}

回调接口的设计使得AsyncPipe不再局限于写日志这一场景,在需要以时间间隔同步数据的业务场景下,AsyncPipe都能够被使用。

AsyncPipe的具体实现请查看:cpp-tbox\modules\util\async_pipe.cpp

AsyncSink::onLogBackEndReadPipe则是从AsyncPipe回调的字节流中,恢复出日志数据包(这部分逻辑和接收TCP数据流,恢复出消息类似)。

// cpp-tbox\modules\log\async_sink.cpp
void AsyncSink::onLogBackEndReadPipe(const void *data_ptr, size_t data_size)
{
    constexpr auto LogContentSize = sizeof(LogContent);
    const char *p = reinterpret_cast<const char*>(data_ptr);

    buffer_.reserve(buffer_.size() + data_size);
    std::back_insert_iterator<std::vector<char>>  back_insert_iter(buffer_);
    std::copy(p, p + data_size, back_insert_iter);

    bool is_need_flush = false;
    while (buffer_.size() >= LogContentSize) {
        auto content = reinterpret_cast<LogContent*>(buffer_.data());
        auto frame_size = LogContentSize + content->text_len;
        if (frame_size > buffer_.size())    //! 总结长度不够
            break;
        content->text_ptr = reinterpret_cast<const char *>(content + 1);
        onLogBackEnd(content);
        is_need_flush = true;
        buffer_.erase(buffer_.begin(), (buffer_.begin() + frame_size));
    }

    if (is_need_flush) {
        flushLog();
        if (buffer_.capacity() > 1024)
            buffer_.shrink_to_fit();
    }
}

在 onLogBackEnd中会调用各个子类的appendLog方法,将恢复的日志数据写入子类的缓冲区,在需要数据落盘的时候,再调用各个子类的appendLog方法(这里又一次用到了模板方法)。

可靠性设计

往文件写日志的一个常见问题是,万一程序崩溃,那么最后几条日志便会丢失。cpp-tbox是怎么保证程序崩溃时,还能够日志落盘的呢?

cpp-tbox在框架启动时,设置了一个异常退出函数,在发生崩溃时,这个函数会被调用:

// cpp-tbox\modules\main\run_in_backend.cpp
// cpp-tbox\modules\main\run_in_frontend.cpp

error_exit_func = [&] {
    log.cleanup();
    while (_runtime->error_exit_wait)
        std::this_thread::sleep_for(std::chrono::seconds(1));
};

日志滚动

如果以本地文件为日志目的地,那么日志滚动(rolling)是必须的。cpp-tbox按照文件大小主动滚动日志文件,这个大小可以在配置文件中配置(max_size以kb为单位):

"log": {
    "file": {
        "enable": true,
        "enable_color": false,
        "path": "/home/root/user/log",
        "prefix": "serv",
        "levels": {
            "": 6
        },
        "max_size": 8192
    },
    "stdout": {
        "enable":false,
        "enable_color": true,
        "levels": {
            "": 6
        }
    },
    "syslog": {
        "enable": false,
        "levels": {
            "": 4
        }
    }
}

当日志flush到文件时,先把日志写入文件,写入后,如果发现超过max_size,则关闭当前文件描述符。在下次有新的日志数据过来时,创建一个新的日志文件,把数据写入这个新的文件,并更新symlink,使prefix.latest.log始终指向最新的日志文件。

日志归档

日志归档(archive)和压缩不是日志系统应有的功能,而应该交给专门的脚本去做。这样做的好处是如果想更换日志压缩算法或者归档策略不用改动业务程序,改改周边配套脚本就行了。

cpp-tbox提供了日志归档脚本,位于cpp-tbox\modules\log\scripts下。

我们可以配置一个定时任务,按照一定的时间间隔,调用日志归档脚本,把当前的日志归档。

总结

  1. 异步日志系统通过不阻塞主线程的方式记录日志,提高了应用程序性能和响应速度,这种思路可以应用在对性能敏感、高并发度要求较高的应用环境中。
  2. 回调机制可以通知被调用者进行进一步处理,这种方式促进了代码的解耦合,增强了灵活性和模块间的交互。
  3. IMPL模式是C++中一种重要的设计技巧,尤其在库开发中,用来隔离接口和实现,优化编译时间和提高代码的维护性。
  4. 模板方法的应用
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值