C++ 策略模式实战:写一个可以“一键换输出“的日志系统

学完线程互斥之后,我用 C++ 手写了一个日志系统,顺带搞懂了策略模式。这篇文章记录整个过程,代码可以直接编译运行,适合和我一样刚学 C++、刚接触设计模式的同学。

一、从一个让人头疼的问题说起

写项目离不开日志:程序跑到哪了、哪里报错了,都要靠日志排查。

但日志有个麻烦事:不同阶段,我们想让它输出到不同的地方。

  • 开发调试时,希望日志直接打印在屏幕上,颜色越醒目越好;

  • 程序上线后,希望日志写进文件,方便留存和排查;

  • 以后做服务器,可能还想把日志发到网络、写进数据库......

那日志类该怎么写?最直觉的想法是这样:

cpp 复制代码
void log(const std::string& msg) {
    if (target == CONSOLE) {
        std::cout << msg << std::endl;      // 输出到屏幕
    } else if (target == FILE) {
        ofs_ << msg << std::endl;           // 输出到文件
    }
    // 以后想加"网络输出"?继续 else if ...
}

写着写着就会发现三个问题:

  1. 每加一种输出方式,都要回来改这个 log 函数,改多了容易出错;

  2. 日志的"拼接、加锁、过滤"和"具体怎么输出"混在一起,类越来越臃肿;

  3. 想换个输出方式,得改代码、重新编译,不够灵活。

策略模式(Strategy Pattern) 就是专门解决这类问题的:把"会变化的那部分"抽出来,封装成一个个可以互相替换的策略。

二、先打个比方:寄快递

想象你要寄一份文件:

  • 追求速度,选顺丰;

  • 追求便宜,选中通;

  • 文件太大,选德邦。

"寄文件"这件事不变,变的是"用什么方式寄"。你不需要为此变成快递专家,只需要在下单时选一家快递公司。

回到日志:

  • "记日志"这件事不变:拼接时间、级别、内容,然后输出;

  • 变的是"输出到哪":屏幕、文件、网络......

每一种输出方式,就是一个"策略"。策略模式让日志器可以在运行中随时"换一家快递公司",而它自己的代码一行不改。

三、策略模式的三个角色

角色 对应到我们的代码 职责
抽象策略 LogStrategy(接口) 规定"输出"长什么样:一个 write 函数
具体策略 ConsoleStrategy、FileStrategy 真正实现输出:打屏幕 / 写文件
上下文(使用者) Logger 拼好日志,交给手里的策略去输出

三者的关系一句话概括:Logger 拿着一个策略接口的指针,调用它干活;具体是谁,运行时才确定。

四、完整代码

下面这份代码保存为 logger.cpp,g++ -std=c++11 logger.cpp -o logger 可以直接编译运行。

cpp 复制代码
#include <iostream>
#include <fstream>
#include <string>
#include <sstream>
#include <memory>
#include <mutex>
#include <thread>
#include <chrono>
#include <ctime>

// ---------- 日志级别 ----------
enum class LogLevel { DEBUG, INFO, WARNING, ERROR, FATAL };

std::string toString(LogLevel level) {
    switch (level) {
        case LogLevel::DEBUG:   return "DEBUG";
        case LogLevel::INFO:    return "INFO";
        case LogLevel::WARNING: return "WARNING";
        case LogLevel::ERROR:   return "ERROR";
        case LogLevel::FATAL:   return "FATAL";
    }
    return "UNKNOWN";
}

// ================= 角色一:策略接口 =================
// 只规定一件事:不管什么输出方式,都必须提供 write 函数
class LogStrategy {
public:
    virtual ~LogStrategy() = default;
    virtual void write(LogLevel level, const std::string& message) = 0;
    //          ^^^^^^^ 纯虚函数(= 0):子类必须自己实现,父类不实现
};

// ================= 角色二:具体策略 =================
// 策略 1:输出到控制台,按级别显示不同颜色
class ConsoleStrategy : public LogStrategy {
public:
    void write(LogLevel level, const std::string& message) override {
        std::cout << colorOf(level) << message << "\033[0m" << std::endl;
    }
private:
    const char* colorOf(LogLevel level) {
        switch (level) {
            case LogLevel::DEBUG:   return "\033[36m";  // 青
            case LogLevel::INFO:    return "\033[32m";  // 绿
            case LogLevel::WARNING: return "\033[33m";  // 黄
            case LogLevel::ERROR:   return "\033[31m";  // 红
            case LogLevel::FATAL:   return "\033[35m";  // 紫
        }
        return "";
    }
};

// 策略 2:追加写入文件
class FileStrategy : public LogStrategy {
public:
    explicit FileStrategy(const std::string& filename)
        : ofs_(filename, std::ios::app) {}   // app = 追加模式,不清空原有内容
    void write(LogLevel level, const std::string& message) override {
        ofs_ << message << std::endl;
    }
private:
    std::ofstream ofs_;
};

// ================= 角色三:日志器(上下文) =================
class Logger {
public:
    // 创建日志器时传入初始策略
    explicit Logger(std::shared_ptr<LogStrategy> strategy)
        : strategy_(std::move(strategy)) {}

    // 【核心】运行时切换策略,Logger 自身代码不用改
    void setStrategy(std::shared_ptr<LogStrategy> strategy) {
        strategy_ = std::move(strategy);
    }

    void setMinLevel(LogLevel level) { minLevel_ = level; }

    void log(LogLevel level, const std::string& content) {
        if (level < minLevel_) return;   // 级别不够,直接丢弃

        // 拼接一整行日志:[时间][级别][线程id] 内容
        std::ostringstream oss;
        oss << "[" << timestamp() << "]"
            << "[" << toString(level) << "]"
            << "[tid:" << std::this_thread::get_id() << "] "
            << content;

        std::lock_guard<std::mutex> lock(mutex_);  // 多线程下防止日志交错
        strategy_->write(level, oss.str());        // 交给策略输出
    }

    // 便捷接口
    void debug(const std::string& msg)   { log(LogLevel::DEBUG, msg); }
    void info(const std::string& msg)    { log(LogLevel::INFO, msg); }
    void warning(const std::string& msg) { log(LogLevel::WARNING, msg); }
    void error(const std::string& msg)   { log(LogLevel::ERROR, msg); }
    void fatal(const std::string& msg)   { log(LogLevel::FATAL, msg); }

private:
    std::string timestamp() {
        auto now = std::chrono::system_clock::now();
        std::time_t t = std::chrono::system_clock::to_time_t(now);
        char buf[64];
        std::strftime(buf, sizeof(buf), "%Y-%m-%d %H:%M:%S", std::localtime(&t));
        return buf;
    }

    std::shared_ptr<LogStrategy> strategy_;  // 父类指针,指向某个具体策略
    LogLevel minLevel_ = LogLevel::DEBUG;
    std::mutex mutex_;
};

// ================= 使用演示 =================
int main() {
    Logger logger(std::make_shared<ConsoleStrategy>());  // 先用控制台

    logger.info("程序启动");
    logger.debug("调试信息");
    logger.warning("磁盘空间不足");

    logger.setStrategy(std::make_shared<FileStrategy>("app.log"));  // 一键切到文件
    logger.error("这条写进了 app.log");
    logger.fatal("这条也写进了 app.log");

    return 0;
}

五、代码里最关键的三处

整份代码看着长,真正的"门道"只有三处。

1. 纯虚函数------把"规定"和"实现"分开

cpp 复制代码
virtual void write(LogLevel level, const std::string& message) = 0;

LogStrategy 里这个 = 0 表示:这个函数我不写实现,谁继承我谁就必须写。这样所有策略都有了统一的"长相",Logger 才能无差别地调用它们。

2. 父类指针指向子类对象------多态

cpp 复制代码
std::shared_ptr<LogStrategy> strategy_;

strategy_ 的类型是父类 LogStrategy,但指向的可能是 ConsoleStrategy,也可能是 FileStrategy。所以这句:

cpp 复制代码
strategy_->write(level, oss.str());

到底执行"打屏幕"还是"写文件",运行时才决定。新增一种输出方式,只需要新写一个策略类,这一行代码永远不变。

3. 运行时切换------策略模式的灵魂

cpp 复制代码
logger.setStrategy(std::make_shared<FileStrategy>("app.log"));

开发时用控制台策略,上线时换成文件策略。真实项目里,这个选择甚至可以来自配置文件,程序启动时读配置决定用哪个策略------代码完全不用动。

六、为什么这样设计更好

对比一下开头那段 if-else 写法:

| if-else 写法 | 策略模式 |
|------------|----------------|---------------------------|
| 新增"网络输出" | 改 Logger,加一个分支 | 只新增一个 NetworkStrategy 类 |
| Logger 类体积 | 越来越臃肿 | 只负责拼日志和调度,很稳定 |
| 切换输出方式 | 改代码、重新编译 | 运行中随时切换,可来自配置 |

这正是设计模式里常说的开闭原则:对扩展开放(可以加新策略),对修改关闭(不用改老代码)。

七、彩蛋:那把锁是干什么的

你可能注意到 log 函数里有一行:

cpp 复制代码
std::lock_guard<std::mutex> lock(mutex_);

如果你开两个线程同时打日志,不加锁时两条日志的内容会交错混在一行里,谁也看不懂。加上锁之后,同一时刻只有一个线程能执行"输出"这一步。

有兴趣可以做个实验:两个线程各调 1000 次 logger.info(),分别编译带锁和不带锁的版本,对比输出文件------你会亲眼看到"线程竞争"这个抽象概念变成肉眼可见的混乱。这也是我刚学的线程互斥最直接的应用场景。

八、后续可以扩展的方向

这个日志系统还有很多可以继续做的地方,按难度排列:

  1. 滚动文件策略 :文件写满 1MB 就改名 app.log.1 另起新文件(就是新的 RollingFileStrategy);

  2. 日志格式可配置:时间格式、级别开关交给配置项;

  3. 简单工厂 + 策略 :读配置文件里的 "console" / "file:app.log",自动创建对应策略;

  4. 异步日志:日志先放进队列,由专门线程落盘(这个需要生产者消费者模型,正好和线程池章节联动)。

九、总结

这篇文章的核心其实就一句话:

把"会变化的部分"(输出方式)抽象成接口,封装成可替换的策略,由上下文在运行时选择和切换。

策略模式不是高高在上的理论,它就是"避免在一堆 if-else 里改代码"的正规解法。亲手写一遍这个日志系统,比背十遍定义都管用。


如果这篇文章对你有帮助,欢迎评论区交流。下篇文章打算记录用条件变量手写 BlockingQueue 的过程,敬请期待。

相关推荐
xiaoye-duck1 小时前
《C++ 项目:手写大模型接入 SDK》项目背景、目标及名词解释
服务器·网络·c++·语言模型
艾莉丝努力练剑1 小时前
【AI大模型接入SDK】ChatServer接口设计规范
网络·c++·人工智能·学习·大模型·设计规范
纪念 2291 小时前
C++算法(三)
开发语言·c++·算法
csdn_zhangchunfeng1 小时前
linux 如何安装rst2man命令
开发语言·chrome·python
sejinan1 小时前
新版!IPTV直播源列表 V1.7 正式发布:接口大厅与会员定制生态解读
开发语言·php
我喜欢就喜欢1 小时前
DeepSeek Harness 开源贡献手记:从零到合入主线的完整历程
开发语言·人工智能·copilot
AbandonForce1 小时前
递归、搜索与回溯:从板书到二叉树解题方法
c++·算法·决策树
“AI国潮设计-小江”2 小时前
《【AIGC实战】Python+SDXL批量生成“猫咪英歌舞蛋挞”IP,附保持角色一致性的代码逻辑》
开发语言·人工智能·python·prompt·aigc
艾莉丝努力练剑2 小时前
【AI大模型接入SDK】ChatServer HTTP接口模块实现总览
网络·c++·人工智能·网络协议·学习·http·架构