一、日志系统:每个项目都绕不开的“老伙计”

说实话,我做了十几年C++开发,几乎每个项目都要跟日志系统打交道。小到嵌入式设备,大到分布式服务器集群,日志就像飞机的黑匣子——平时没人注意,一旦出问题,它就是救命稻草。

但日志系统真的那么简单吗?

你可能写过这样的代码:

// 初级写法:到处是printf
printf("[INFO] 用户登录成功\n");
printf("[ERROR] 数据库连接失败\n");

嗯,这种写法在小项目里凑合能用。但一旦项目膨胀到几十万行代码,你就会发现——

  • 想改输出目标?得把所有printf找出来改一遍
  • 想加个时间戳?每个地方都得手动加
  • 想区分日志级别?抱歉,没有统一管理

我在2015年接手过一个金融交易系统,前任留下的日志代码就是这种“散装”风格。结果有一次线上排查问题,日志文件里全是INFO级别的“心跳”消息,真正的ERROR日志被淹没了。那次事故让我下定决心:日志系统必须设计好,而且要遵循设计原则

核心观点:日志系统看似简单,却是检验设计原则掌握程度的“试金石”。多级别日志 + 多输出目标,这两个需求天然适合用设计原则来解构。

二、需求拆解:日志系统到底要解决什么问题?

我们先理清楚需求。一个工业级的日志系统,通常要满足:

  1. 多级别日志:DEBUG、INFO、WARN、ERROR、FATAL
  2. 多输出目标:控制台、文件、网络、数据库……
  3. 运行时切换:线上可以动态调整日志级别
  4. 性能要求:不能因为写日志拖慢业务逻辑

你想想看,如果把这些需求揉在一起,用if-else硬编码,会是什么样子?

// 反面教材:硬编码的日志系统
void log(const std::string& msg, int level) {
    if (level >= currentLevel) {
        if (outputTarget == 0) {
            std::cout << msg << std::endl;
        } else if (outputTarget == 1) {
            writeToFile(msg);
        } else if (outputTarget == 2) {
            sendToNetwork(msg);
        }
        // 再加一个目标?继续加else if...
    }
}

这种代码,我称之为“面条式日志”。每次新增输出目标,都要改这个函数。每次新增日志级别,也要改。违反了开闭原则,也违反了单一职责原则

那怎么设计才优雅?

三、设计原则的“组合拳”

我个人习惯用三个设计原则来构建日志系统:

设计原则 在日志系统中的应用
单一职责原则 每个类只负责一件事:日志级别判断、输出格式化、目标写入
开闭原则 新增输出目标时,不修改现有代码,只扩展新类
依赖倒置原则 高层模块(日志记录器)依赖抽象(输出接口),不依赖具体实现

说白了,就是让日志系统的各个部分各司其职,然后通过接口把它们串联起来。

3.1 适配器模式:统一输出接口

适配器模式在这里的作用,是把“不同的输出目标”包装成统一的接口。就像电源适配器,不管你是美标插座还是欧标插座,插上适配器都能用。

先定义抽象接口:

// 日志输出目标接口
class LogTarget {
public:
    virtual ~LogTarget() = default;
    virtual void write(const std::string& message) = 0;
};

然后为每个输出目标实现适配器:

// 控制台输出
class ConsoleTarget : public LogTarget {
public:
    void write(const std::string& message) override {
        std::cout << message << std::endl;
    }
};

// 文件输出
class FileTarget : public LogTarget {
public:
    explicit FileTarget(const std::string& filename) 
        : file_(filename, std::ios::app) {}
    
    void write(const std::string& message) override {
        if (file_.is_open()) {
            file_ << message << std::endl;
        }
    }
private:
    std::ofstream file_;
};

// 网络输出(通过UDP发送)
class NetworkTarget : public LogTarget {
public:
    void write(const std::string& message) override {
        // 实际项目中这里会实现UDP/TCP发送
        sendUdpPacket(message);
    }
};

你看,每个适配器只关心一件事:把日志写到自己的目标里。这就是单一职责

3.2 日志级别:用枚举+过滤器

日志级别用枚举定义,清晰明了:

enum class LogLevel {
    DEBUG,
    INFO,
    WARN,
    ERROR,
    FATAL
};

然后设计一个过滤器,决定哪些级别的日志可以输出:

class LogFilter {
public:
    explicit LogFilter(LogLevel minLevel) : minLevel_(minLevel) {}
    
    bool shouldLog(LogLevel level) const {
        return static_cast<int>(level) >= static_cast<int>(minLevel_);
    }
    
    void setMinLevel(LogLevel level) { minLevel_ = level; }
    
private:
    LogLevel minLevel_;
};

这里有个小技巧:日志级别的数值顺序要跟严重程度一致。我见过有人把DEBUG设成0,FATAL设成4,也有人反过来。我个人习惯越严重数值越大,这样比较符合直觉。

3.3 日志记录器:组合起来

现在我们把各个部件组合起来:

class Logger {
public:
    Logger() : filter_(LogLevel::INFO) {}
    
    void addTarget(std::shared_ptr<LogTarget> target) {
        targets_.push_back(target);
    }
    
    void setLevel(LogLevel level) {
        filter_.setMinLevel(level);
    }
    
    void log(LogLevel level, const std::string& message) {
        if (!filter_.shouldLog(level)) {
            return;  // 级别不够,直接跳过
        }
        
        std::string formatted = formatMessage(level, message);
        
        for (auto& target : targets_) {
            target->write(formatted);
        }
    }
    
private:
    std::string formatMessage(LogLevel level, const std::string& msg) {
        // 格式化:时间戳 + 级别 + 消息
        auto now = std::chrono::system_clock::now();
        auto time = std::chrono::system_clock::to_time_t(now);
        
        std::string levelStr;
        switch (level) {
            case LogLevel::DEBUG: levelStr = "DEBUG"; break;
            case LogLevel::INFO:  levelStr = "INFO";  break;
            case LogLevel::WARN:  levelStr = "WARN";  break;
            case LogLevel::ERROR: levelStr = "ERROR"; break;
            case LogLevel::FATAL: levelStr = "FATAL"; break;
        }
        
        return "[" + std::ctime(&time) + "] [" + levelStr + "] " + msg;
    }
    
    LogFilter filter_;
    std::vector<std::shared_ptr<LogTarget>> targets_;
};

使用起来非常直观:

int main() {
    Logger logger;
    
    // 添加多个输出目标
    logger.addTarget(std::make_shared<ConsoleTarget>());
    logger.addTarget(std::make_shared<FileTarget>("app.log"));
    logger.addTarget(std::make_shared<NetworkTarget>());
    
    // 设置日志级别
    logger.setLevel(LogLevel::WARN);  // 只输出WARN及以上
    
    // 写日志
    logger.log(LogLevel::INFO, "用户登录");   // 被过滤掉
    logger.log(LogLevel::ERROR, "数据库超时"); // 输出到所有目标
    
    return 0;
}

小提示:实际项目中,Logger通常设计为单例,或者通过依赖注入传递。我个人倾向于用依赖注入,方便单元测试时替换为Mock对象。

四、SVG结构图:日志系统的设计脉络

下面这张图展示了整个日志系统的架构。我特意把适配器模式开闭原则的体现位置标了出来:

日志系统架构图(适配器模式 + 开闭原则) 客户端代码 Logger(日志记录器) 依赖抽象接口,不依赖具体实现 LogTarget(抽象接口) ConsoleTarget 控制台输出 FileTarget 文件输出 NetworkTarget 网络输出 开闭原则:新增输出目标时,只需新增一个继承LogTarget的类,无需修改Logger

五、避坑指南:我踩过的那些坑

日志系统看起来简单,但实际落地时坑不少。我分享几个亲身经历:

坑一:日志写入阻塞业务线程

我曾经在一个高并发服务里,直接把日志写入文件。结果有一次磁盘I/O抖动,日志写入卡住了,导致整个业务线程阻塞,请求大量超时。

解决方案:日志写入必须异步。可以用一个独立的日志线程,或者用双缓冲队列。Logger只负责把日志消息push到队列,由后台线程批量写入。

坑二:日志级别判断放错了位置

有人把日志级别判断放在适配器里,而不是Logger里。结果每个适配器都要做一次判断,性能浪费不说,还导致日志级别不一致。

正确做法:级别判断只在Logger中做一次,适配器只管写入。

坑三:忘记处理格式化异常

格式化日志时,如果消息里包含特殊字符(比如换行符),可能会导致日志文件格式错乱。我曾经排查过一个Bug,最后发现是日志消息里带了\r\n,把日志文件的行结构破坏了。

建议:在formatMessage里对特殊字符做转义或替换。

六、总结:设计原则不是纸上谈兵

回过头来看,这个日志系统的设计其实很简单:

  • 单一职责让每个类只做一件事
  • 开闭原则让扩展变得安全
  • 依赖倒置让高层模块不依赖细节
  • 适配器模式把不同的输出目标统一起来

这些原则和模式,单独看都很抽象。但一旦放到具体的场景里,你会发现它们就像乐高积木——每个积木都很简单,组合起来却能搭建出强大的系统。

我个人觉得,学习设计原则最好的方式,就是拿自己手头的项目去“重构”。比如你现在就可以看看项目里的日志代码,能不能用今天讲的方式重新组织一下?

嗯,今天就聊到这里。记住:好的设计不是一次完成的,而是在不断重构中打磨出来的

课后思考:如果现在要新增一个“按天滚动日志文件”的功能,你的代码需要改动哪些地方?按照今天的设计,只需要新增一个DailyRollingFileTarget类,继承LogTarget,然后注册到Logger里。完全不需要修改Logger本身。这就是开闭原则的力量。


公众号:蓝海资料掘金营,微信deep3321