学完线程互斥之后,我用 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 ...
}
写着写着就会发现三个问题:
-
每加一种输出方式,都要回来改这个 log 函数,改多了容易出错;
-
日志的"拼接、加锁、过滤"和"具体怎么输出"混在一起,类越来越臃肿;
-
想换个输出方式,得改代码、重新编译,不够灵活。
策略模式(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(),分别编译带锁和不带锁的版本,对比输出文件------你会亲眼看到"线程竞争"这个抽象概念变成肉眼可见的混乱。这也是我刚学的线程互斥最直接的应用场景。
八、后续可以扩展的方向
这个日志系统还有很多可以继续做的地方,按难度排列:
-
滚动文件策略 :文件写满 1MB 就改名
app.log.1另起新文件(就是新的RollingFileStrategy); -
日志格式可配置:时间格式、级别开关交给配置项;
-
简单工厂 + 策略 :读配置文件里的
"console"/"file:app.log",自动创建对应策略; -
异步日志:日志先放进队列,由专门线程落盘(这个需要生产者消费者模型,正好和线程池章节联动)。
九、总结
这篇文章的核心其实就一句话:
把"会变化的部分"(输出方式)抽象成接口,封装成可替换的策略,由上下文在运行时选择和切换。
策略模式不是高高在上的理论,它就是"避免在一堆 if-else 里改代码"的正规解法。亲手写一遍这个日志系统,比背十遍定义都管用。
如果这篇文章对你有帮助,欢迎评论区交流。下篇文章打算记录用条件变量手写 BlockingQueue 的过程,敬请期待。