项目学习笔记:C++基础同步输出日志库

基础同步日志库:C++实现------学习笔记

摘要 :日志库核心作用便是留底查错,以方便的记录程序都做了什么,并且也方便程序员查找程序错误点。本篇学习笔记主要记录作者构建基础同步日志库的个人感悟与心得,各位后来者也可以以此借鉴。(本篇文章为纯新手向,希望学习spdlog库或更高级的日志库的请移步其他文章)


1:核心特性

双sink输出 :即我们希望实现用户调用特定函数或接口实现CLI与文件文件同步输出,因为我们要实现日志库的核心特性可视化持久化

该两项核心特性是区别平时我们打印字符串调试,保证程序崩溃日志可溯源。

我们先看函数实现代码(我先完成朴素实现,再慢慢引入RAII,时间戳,日志等级等等功能)。

c++ 复制代码
void Write(std::string msg){
    std::ofstream logFile;//创建文件流
    logFile.open("log.txt" , std::ios::out | std::ios::app);//以写入与追加模式打开文件
    if(! logFile.is_open()){//判断文件是否成功打开,若打开不成功,返回错误提示并终止函数
        std::cout << "File open failed!" << std::endl;
        return;
    }

    //输出用户自定义的message
    std::cout << msg <<std::endl;
    logFile << msg << std::endl;

    logFile.close();

    return;
}

知识点一下,sink即数据流的目的地,CLI窗口,文件流,网络都可以是sink,我们这里所讨论的sink暂时只有CLI窗口与文件流。

该阶段我并没有给出类的构造,我们暂时只实现与理解双sink输出。

该函数便是我们后续一切附加功能的原型,当然为了符合OOP特性,我们一切还是需要将该函数进行封装成类,仅对外暴露接口。

所以,我们创建了两个文件

Logger.h

c++ 复制代码
class Logger{
private:
    std::ofstream logFile_;//文件流成员
    bool is_openLog_ = true;//类是否成功创建成员变量

    //构造函数打开文件流,若文件打开不成功,将类打开状态设为假,同时返回消息,让用户自行处理
    Logger(){
        logFile_.open("log.txt",std::ios::out | std::ios::app);
        if(! logFile_.is_open()){
            std::cout << "File open failed!" << std::endl;
            is_openLog_ = false;
        }
    }
    ~Logger(){
        logFile_.close();
    }

public:
    //类单例化,保证安全
    static Logger& getInstance(){
        static Logger instance;
        return instance;
    }

    //删除所有拷贝构造与移动构造函数,严格保证单例需求
    Logger(const Logger&) = delete;
    Logger& operator=(const Logger&) = delete;
    Logger(Logger&&) = delete;
    Logger& operator=(const Logger&&) = delete;

    bool is_open();//判断类是否创建成功
    void Write(const std::string&);//日志输出函数
};

以下是Logger.cpp的两个函数

c++ 复制代码
//模仿fstream库中的is_open函数
bool Logger::is_open(){
    return is_openLog_;
}

void Logger::Write(const std::string& msg){
    //判断类是否启动成功
    if(! is_openLog_){
        std::cout << "Object construction failed!" << std::endl;
        return;
    }

    std::cout  << msg <<std::endl;
    logFile_ << msg << std::endl;

    return;
}

在该阶段,并无什么高深的原理,我们只是将两个输出合并为了一项。

当然,对于基本的日志库来说,我们还有两个东西没解决,那就是时间戳与日志等级,这就是我接下来要讲的。

2:日志等级与拼接字符串

只有设立日志等级,我们才能更加清楚程序运行的情况,也能更好的排查问题。

我们先看代码,我这里使用的是枚举方案,根据自身喜好,也可以使用宏常量或别名定义

c++ 复制代码
//日志等级枚举
enum class LogLevel{
    TRACE = 10,
    DEBUG = 20,
    INFO = 30,
    WARN = 40,
    ERROR = 50

};

这里的枚举赋值在我们这种基础版中并非必须,只是我个人倾向

在枚举值设定完成,我们还需要再在Logger类中新增一个私有成员函数,这个成员函数很简单,只是负责接收对于等级,随后返回等级字符串,供我们在Write函数中来拼接字符串

c++ 复制代码
//根据日志等级返回字符串
std::string logger::Level_to_String(LogLevel level){
    switch(level){
        case LogLevel::TRACE: {
            return "[TRACE]";
        }
        case LogLevel::DEBUG: {            
            return "[DEBUG]";
        }
        case LogLevel::INFO: {
            return "[INFO]";
        }
        case LogLevel::WARN: {
            return "[WARN]";
        }
        case LogLevel::ERROR: {
            return "[ERROR]";
        }
        default: {
            return "[UNKNOWN]";
        }
    }
}

原理很简单,那么进入下一个阶段,我们将改造Write,但放心,只是些小小的改动,我们只需要将Level_to_String函数返回的字符串,拼接在message之前就好了。

c++ 复制代码
void logger::Write(const std::string& msg,const LogLevel& level){
	if(! is_openLog_){
        std::cout << "Object construction failed!" << std::endl;
        return;
    }

    std::string level_str = Level_to_String(level);
    
    std::cout << level_str << msg <<std::endl;
    logFile_ << level_str << msg << std::endl;
    
    return;
}

到此,我们的基础同步日志走到了第二阶段,那么就可以进行第三步了,再拼接一个时间戳。

3:构造时间戳与拼接

时间戳是保证我们能区分该日志是什么时候产生的,而不至于陷入靠猜的底部,同样也是解决排查日志不方便的痛点来的。

作者用的是C++17标准的chrono时间库,C++11有同库,但作者并不确定能否运行,请自行排查库的兼容问题,C++11以下请用ctime或time.h替换,但底层构造逻辑都一样。

在构造时间戳字符串之前,我们先明确,我们最终的时间戳是要精确到微秒级别,因为对于工业级产品,每秒日志可达上百万,秒与毫秒是不够用的

c++ 复制代码
//返回当前时间戳字符串
std::string Logger::Time_to_String(){
    auto now = std::chrono::system_clock::now();
    std::ostringstream oss;

    //日历获取
    auto sec_since_epoch = std::chrono::duration_cast<std::chrono::seconds>(now.time_since_epoch());
    std::time_t now_time_t = sec_since_epoch.count();
    std::tm* now_tm = std::localtime(&now_time_t);
    oss << std::put_time(now_tm, "%Y-%m-%d %H:%M:%S");
    std::string calstr = oss.str();
    oss.str("");
    oss.clear();

    //获取微秒数
    auto us_since_epoch = std::chrono::duration_cast<std::chrono::microseconds>(now.time_since_epoch());
    auto us_part = us_since_epoch - std::chrono::duration_cast<std::chrono::microseconds>(sec_since_epoch);
    oss << std::setw(6) << std::setfill('0') << us_part.count();
    std::string usstr = oss.str();

    std::string timestr = "[" + calstr + "." + usstr + "]";
    return timestr;

}

这个部分我会一条条拆解,因为chrono确实复杂了一些,我当时也折腾了一会。

3.1:定义变量

c++ 复制代码
auto now = std::chrono::system_clock::now();
std::ostringstream oss;

我们先获取当前系统时间值,存储在auto变量中,方便我们后续进行运算

而oss的作用是方便我们后续进行数据与字符串的转换操作。

3.2:获取当前日历

c++ 复制代码
auto sec_since_epoch = std::chrono::duration_cast<std::chrono::seconds>(now.time_since_epoch());
std::time_t now_time_t = sec_since_epoch.count();
std::tm* now_tm = std::localtime(&now_time_t);
oss << std::put_time(now_tm, "%Y-%m-%d %H:%M:%S");
std::string calstr = oss.str();
oss.str("");
oss.clear();

sec_since_epoch先获获取向下取整后的当前的整秒数

再将整秒数转换为time_t类型,以方便跟C风格日历兼容,随后立刻将转换好的类型通过localtime来转换为日历结构体。(注意,localtime是非线程安全的,如果需要请自行更换线程安全的函数。)

随后oss接收格式化后的日历类型,按照固定格式格式化,calstr接收oss转换后的字符串

最后,清空oss,因为我们后面还要用他。

3.3:获取当前微秒数

c++ 复制代码
auto us_since_epoch = std::chrono::duration_cast<std::chrono::microseconds>(now.time_since_epoch());
auto us_part = us_since_epoch - std::chrono::duration_cast<std::chrono::microseconds>(sec_since_epoch);
oss << std::setw(6) << std::setfill('0') << us_part.count();
std::string usstr = oss.str();

us_since_epoch同样向下取整获取微秒数。

随后,将我们前面获取的纪元总秒数sec_since_epoch强制转换为微秒类型,通过us减去sec,我们就可以得到当前不满一秒的总微秒偏移量。

随后使用setw强制格式6位,不足填充0,然后输出我们得到的结果到oss

最后依然由oss转换为字符串,这步结束oss是否清空取决于自己,我是觉得后续用不到了,系统会自动回收,没必要了

3.4:拼接字符串,随后返回并处理

c++ 复制代码
std::string timestr = "[" + calstr + "." + usstr + "]";
return timestr;

这里就不用展开讲了,只是拼接字符串而已

随后我们在Write中如何使用该函数?

很简单,如下:

c++ 复制代码
void Logger::Write(const std::string& msg,const LogLevel& level){
    if(! is_openLog_){
        std::cout << "Object construction failed!" << std::endl;
        return;
    }

    std::string level_str = Level_to_String(level);
    std::string time_str = Time_to_String();

    std::cout << time_str << level_str << msg <<std::endl;
    logFile_ << time_str << level_str << msg << std::endl;

    return;

}

到这里其实我们整个基础同步函数的主要工作就做完了,但我们还有一个最重要的收尾工作。那就是线程安全。

4:线程安全

为何要保证线程安全?因为如果出现多线程情况,日志的输出就会变得异常诡异。最常见的就是格式错乱,所以为了保证安全,我们需要给这个线程套上线程锁。

但这里的工作其实蛮简单的,接触过线程的肯定都知道,如果有连线程锁都不知道的,给我老老实实去补线程相关知识啊喂

我这里直接给代码

首先给Logger类中新增成员变量

c++ 复制代码
std::mutex mtx_;

随后只需要将Write输出字符串的地方套一层锁

c++ 复制代码
    {
        std::lock_guard<std::mutex> lock(mtx_);
        
        std::cout << time_str << level_str << msg <<std::endl;
        logFile_ << time_str << level_str << msg << std::endl;
    }

请注意,大括号的作用是保证作用域范围。

直到这里,我们整个基础同步输出日志库的整个代码其实就写完了。但我其实后续还加了两个东西

一个是流式输出,例如TRACE << "hello"这种更自然的语法,我引入了LogBuilder辅助类,利用 RAII 特性在析构时自动调用Write。由于涉及临时对象和模板重载,代码量稍大,在这一版中我觉得没必要展开否则会导致篇幅过长,若有兴趣,各位可以用我的思路去试试

另一个是双缓冲输出,这是一个工业日志库的必要构建,但同样实现及其复杂,同样会导致篇幅过长。我简单概括一下这东西的作用,在我们这一版中,我在我自身的环境下测试大约为6500EPS,也就是每秒6500条日志,但加了双缓冲后,达到了3.5万EPS,但这不是双缓冲的极限,这是作者本身拼接字符串的性能开销拖慢了双缓冲,若各位有兴趣可以自行去查询搜索。

相关推荐
Lyra_Infra2 小时前
OpenClaw 升级及 Channel 安装故障排查与遗留问题分析
后端·npm
江小渔2 小时前
训练工程与训练平台入门 31 安装 Kubeflow Trainer,并跑第一个 TrainJob
后端
用户938515635072 小时前
从 0 拆解一个 Next.js 笔记系统:npx、App Router、RSC 与组件规划全记录
前端·后端·全栈
fatcoder3 小时前
玩转Docker 06 — 容器网络
前端·后端·docker
feng尘3 小时前
深入浅出 Java:ThreadLocal 为什么会产生内存泄漏?
后端
懒人wsh4 小时前
不上悲观锁也不上 Redis:一个 AI 平台的钱包并发是怎么搞的
后端
神奇小汤圆4 小时前
记一次线上翻车:加了Redisson分布式锁,数据还是被并发打穿了
后端