57、STL与日志系统:日志级别、日志格式化、日志轮转

日志系统,说白了就是程序的「黑匣子」。

我做了这么多年C++开发,几乎每个线上项目都离不开它。没有日志,出了问题你连从哪查起都不知道。但很多人写日志就是简单printf一下,等到日志文件撑爆磁盘、或者想查某个级别的信息却淹没在大量无用输出里时,才后悔当初没好好设计。

这一章,我们就用STL来打造一个轻量但实用的日志系统。我会把日志级别、格式化输出、文件轮转这几个核心点都讲透。

日志级别:从DEBUG到FATAL

日志级别是日志系统的第一道门槛。我个人习惯至少分四级:DEBUG、INFO、WARN、ERROR。有些场景还会加一个FATAL,表示程序即将崩溃。

为什么要分级?你想想看,开发时你需要看到所有细节,但线上运行时,DEBUG信息会刷爆你的磁盘。所以我们要在运行时能动态调整输出级别。

核心思路:用枚举定义级别,用比较运算符控制输出。

enum class LogLevel : uint8_t {
    DEBUG = 0,
    INFO  = 1,
    WARN  = 2,
    ERROR = 3,
    FATAL = 4
};

class Logger {
public:
    void setLevel(LogLevel level) { minLevel_ = level; }
    
    void log(LogLevel level, const std::string& msg) {
        if (level < minLevel_) return;  // 级别不够,直接丢弃
        // 后续处理...
    }
    
private:
    LogLevel minLevel_ = LogLevel::INFO;  // 默认只输出INFO及以上
};

嗯,这里要注意:级别比较用小于号,因为数值越小越详细。我曾经见过有人搞反了,结果线上DEBUG日志满天飞,磁盘直接打满。那次教训挺深刻的。

日志格式化:用STL字符串流搞定

日志不能光秃秃地输出一条消息。我们需要时间戳、线程ID、文件名、行号这些信息。用C的sprintf?太容易出错了。我推荐用std::ostringstream,安全又灵活。

#include <sstream>
#include <iomanip>
#include <chrono>
#include <ctime>

std::string formatLog(LogLevel level, const std::string& file, 
                      int line, const std::string& msg) {
    std::ostringstream oss;
    
    // 时间戳
    auto now = std::chrono::system_clock::now();
    auto timeT = std::chrono::system_clock::to_time_t(now);
    oss << std::put_time(std::localtime(&timeT), "%Y-%m-%d %H:%M:%S");
    
    // 日志级别
    static const char* levelNames[] = {"DEBUG", "INFO", "WARN", "ERROR", "FATAL"};
    oss << " [" << levelNames[static_cast<int>(level)] << "]";
    
    // 位置信息
    oss << " (" << file << ":" << line << ")";
    
    // 消息正文
    oss << " " << msg;
    
    return oss.str();
}

这里有个小技巧:std::put_time配合std::localtime能精确到秒。如果你需要毫秒级精度,可以用std::chrono::duration_cast。我在做金融交易系统时,日志精度要求到毫秒,就是靠这个搞定的。

避坑指南:我曾经在日志格式化里直接传文件名指针,结果文件重命名后指针悬空了。后来我改用std::string_view或者直接传std::string,再也没出过问题。

日志轮转:别让日志撑爆磁盘

日志轮转是生产环境的必备功能。说白了就是:当日志文件太大时,自动切分成新文件,并清理旧的。

我常用的策略有两种:按文件大小轮转,或者按时间轮转。这里我实现一个按大小轮转的版本,用STL的std::filesystem来操作文件。

#include <fstream>
#include <filesystem>
#include <vector>
#include <algorithm>

namespace fs = std::filesystem;

class RotatingFileLogger {
public:
    RotatingFileLogger(const std::string& baseName, 
                       size_t maxFileSize = 10 * 1024 * 1024,  // 10MB
                       int maxFiles = 5)
        : baseName_(baseName), maxFileSize_(maxFileSize), maxFiles_(maxFiles) {
        openCurrentFile();
    }
    
    void write(const std::string& formattedLog) {
        currentFile_ << formattedLog << "\n";
        currentFile_.flush();
        
        if (currentFile_.tellp() > maxFileSize_) {
            rotate();
        }
    }
    
private:
    void openCurrentFile() {
        currentFile_.open(baseName_, std::ios::app);
    }
    
    void rotate() {
        currentFile_.close();
        
        // 删除最旧的文件
        fs::path oldestFile = baseName_ + "." + std::to_string(maxFiles_);
        if (fs::exists(oldestFile)) {
            fs::remove(oldestFile);
        }
        
        // 依次重命名:log.txt.4 -> log.txt.5, log.txt.3 -> log.txt.4, ...
        for (int i = maxFiles_ - 1; i >= 1; --i) {
            fs::path from = baseName_ + "." + std::to_string(i);
            fs::path to   = baseName_ + "." + std::to_string(i + 1);
            if (fs::exists(from)) {
                fs::rename(from, to);
            }
        }
        
        // 当前文件重命名为 .1
        fs::rename(baseName_, baseName_ + ".1");
        
        // 重新打开新文件
        openCurrentFile();
    }
    
    std::string baseName_;
    size_t maxFileSize_;
    int maxFiles_;
    std::ofstream currentFile_;
};

这段代码的核心逻辑是:每次写入后检查文件大小,超了就轮转。轮转时把旧文件依次编号,最老的删掉。这样你永远只保留最近N个日志文件。

注意:多线程环境下,上面的代码不是线程安全的。你需要加锁,或者用原子操作。我一般用std::mutex配合std::lock_guard来保护writerotate方法。

整合:一个完整的日志系统

把上面三块拼起来,就是一个可用的日志系统了。我还加了一个宏,方便在代码里快速打日志。

#define LOG_DEBUG(msg)  logger.log(LogLevel::DEBUG, __FILE__, __LINE__, msg)
#define LOG_INFO(msg)   logger.log(LogLevel::INFO,  __FILE__, __LINE__, msg)
#define LOG_WARN(msg)   logger.log(LogLevel::WARN,  __FILE__, __LINE__, msg)
#define LOG_ERROR(msg)  logger.log(LogLevel::ERROR, __FILE__, __LINE__, msg)

// 使用示例
LOG_INFO("Server started on port " + std::to_string(port));
LOG_ERROR("Connection failed: " + errMsg);

我个人习惯把logger做成全局单例,这样任何地方都能直接打日志。但要注意,全局对象的初始化顺序是个坑。我一般用std::call_once来延迟初始化,确保安全。

知识体系图

下面这张图展示了本章的核心结构,从日志级别到最终输出,一目了然。

日志系统核心结构 日志级别枚举 DEBUG INFO WARN / ERROR / FATAL 日志格式化 时间戳 (std::put_time) 级别名称 文件名:行号 + 消息 日志轮转 按大小轮转 文件重命名 自动清理旧文件 整合:Logger类 + 宏定义 输出到文件 / 控制台

总结

这一章我们做了三件事:用枚举控制日志级别,用ostringstream格式化输出,用filesystem实现文件轮转。这三块组合起来,就是一个能上生产环境的日志系统。

当然,真实项目里还会加更多功能,比如异步写入、网络传输、日志压缩等。但核心骨架就是这些。你掌握了这个,再去看spdlog、log4cpp这些库的源码,会发现它们本质上也是这套思路。

最后提醒一句:日志是给未来的自己看的。写清楚点,别偷懒。你永远不知道明天线上会出什么幺蛾子。

核心要点回顾:

  • 日志级别用枚举,运行时动态过滤
  • 格式化用std::ostringstream,安全高效
  • 轮转用std::filesystem,按大小或时间切分
  • 多线程环境记得加锁
公众号:蓝海资料掘金营,微信 deep3321