一、日志系统:每个项目都绕不开的“老伙计”
说实话,我做了十几年C++开发,几乎每个项目都要跟日志系统打交道。小到嵌入式设备,大到分布式服务器集群,日志就像飞机的黑匣子——平时没人注意,一旦出问题,它就是救命稻草。
但日志系统真的那么简单吗?
你可能写过这样的代码:
// 初级写法:到处是printf
printf("[INFO] 用户登录成功\n");
printf("[ERROR] 数据库连接失败\n");
嗯,这种写法在小项目里凑合能用。但一旦项目膨胀到几十万行代码,你就会发现——
- 想改输出目标?得把所有printf找出来改一遍
- 想加个时间戳?每个地方都得手动加
- 想区分日志级别?抱歉,没有统一管理
我在2015年接手过一个金融交易系统,前任留下的日志代码就是这种“散装”风格。结果有一次线上排查问题,日志文件里全是INFO级别的“心跳”消息,真正的ERROR日志被淹没了。那次事故让我下定决心:日志系统必须设计好,而且要遵循设计原则。
核心观点:日志系统看似简单,却是检验设计原则掌握程度的“试金石”。多级别日志 + 多输出目标,这两个需求天然适合用设计原则来解构。
二、需求拆解:日志系统到底要解决什么问题?
我们先理清楚需求。一个工业级的日志系统,通常要满足:
- 多级别日志:DEBUG、INFO、WARN、ERROR、FATAL
- 多输出目标:控制台、文件、网络、数据库……
- 运行时切换:线上可以动态调整日志级别
- 性能要求:不能因为写日志拖慢业务逻辑
你想想看,如果把这些需求揉在一起,用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结构图:日志系统的设计脉络
下面这张图展示了整个日志系统的架构。我特意把适配器模式和开闭原则的体现位置标了出来:
五、避坑指南:我踩过的那些坑
日志系统看起来简单,但实际落地时坑不少。我分享几个亲身经历:
坑一:日志写入阻塞业务线程
我曾经在一个高并发服务里,直接把日志写入文件。结果有一次磁盘I/O抖动,日志写入卡住了,导致整个业务线程阻塞,请求大量超时。
解决方案:日志写入必须异步。可以用一个独立的日志线程,或者用双缓冲队列。Logger只负责把日志消息push到队列,由后台线程批量写入。
坑二:日志级别判断放错了位置
有人把日志级别判断放在适配器里,而不是Logger里。结果每个适配器都要做一次判断,性能浪费不说,还导致日志级别不一致。
正确做法:级别判断只在Logger中做一次,适配器只管写入。
坑三:忘记处理格式化异常
格式化日志时,如果消息里包含特殊字符(比如换行符),可能会导致日志文件格式错乱。我曾经排查过一个Bug,最后发现是日志消息里带了\r\n,把日志文件的行结构破坏了。
建议:在formatMessage里对特殊字符做转义或替换。
六、总结:设计原则不是纸上谈兵
回过头来看,这个日志系统的设计其实很简单:
- 单一职责让每个类只做一件事
- 开闭原则让扩展变得安全
- 依赖倒置让高层模块不依赖细节
- 适配器模式把不同的输出目标统一起来
这些原则和模式,单独看都很抽象。但一旦放到具体的场景里,你会发现它们就像乐高积木——每个积木都很简单,组合起来却能搭建出强大的系统。
我个人觉得,学习设计原则最好的方式,就是拿自己手头的项目去“重构”。比如你现在就可以看看项目里的日志代码,能不能用今天讲的方式重新组织一下?
嗯,今天就聊到这里。记住:好的设计不是一次完成的,而是在不断重构中打磨出来的。
课后思考:如果现在要新增一个“按天滚动日志文件”的功能,你的代码需要改动哪些地方?按照今天的设计,只需要新增一个DailyRollingFileTarget类,继承LogTarget,然后注册到Logger里。完全不需要修改Logger本身。这就是开闭原则的力量。
公众号:蓝海资料掘金营,微信deep3321