本文要聊的,是设计模式那点事。先从基本概念入手,把23种常见设计模式挨个过一遍,认认脸。然后以日志系统为例,讲讲日志到底有什么用、该长什么样、又有哪些实现方案。
代码实践这块,重点落在策略模式上,看它怎么在日志组件里落地,又怎么把解耦和扩展这两件事,办得漂漂亮亮。
目录
[1.1 经典的23种设计模式](#1.1 经典的23种设计模式)
[1.1.1 开发中最常见的设计模式](#1.1.1 开发中最常见的设计模式)
[1.1.2 实际开发中经常遇到的设计模式](#1.1.2 实际开发中经常遇到的设计模式)
[1.1.3 使用场景相对较少的设计模式](#1.1.3 使用场景相对较少的设计模式)
[2.1 日志是什么:概念与实际作用](#2.1 日志是什么:概念与实际作用)
[2.2 一条日志应该包含哪些信息](#2.2 一条日志应该包含哪些信息)
[2.3 一个日志系统可以如何实现](#2.3 一个日志系统可以如何实现)
[3.1 Log.hpp:日志核心实现](#3.1 Log.hpp:日志核心实现)
[3.2 Mutex.hpp:互斥锁封装](#3.2 Mutex.hpp:互斥锁封装)
[3.3 代码中的问题与优化](#3.3 代码中的问题与优化)
[3.3.1 致命问题:GuardMutex按值传参,实际上没有锁住原对象](#3.3.1 致命问题:GuardMutex按值传参,实际上没有锁住原对象)
[3.3.2 虚析构函数为什么需要virtual](#3.3.2 虚析构函数为什么需要virtual)
[3.3.3 全局Logger对象为什么会违反ODR](#3.3.3 全局Logger对象为什么会违反ODR)
[3.4 策略模式如何落地到Log.hpp](#3.4 策略模式如何落地到Log.hpp)
[3.4.1 抽象策略:LogFlashStrategy](#3.4.1 抽象策略:LogFlashStrategy)
[3.4.2 具体策略一:ConsoleLogStrategy](#3.4.2 具体策略一:ConsoleLogStrategy)
[3.4.3 具体策略二:FileLogStrategy](#3.4.3 具体策略二:FileLogStrategy)
[3.4.4 策略模式为日志系统带来了什么](#3.4.4 策略模式为日志系统带来了什么)
一、认识设计模式:为什么需要设计模式
软件开发这行,规模越做越大,人越来越多。每个人技术底子不一样,踩过的坑也不一样,写出来的代码自然参差不齐。要是每个问题都从零摸索,同一个坑反复踩,那成本就太高了。
于是,前辈们把那些反复出现、又特别典型的软件设计场景拎出来,一个个总结成标准解法。这些被反复验证过的方案,就是设计模式。
说白了,它就是前人留下的经验地图,告诉你在什么路口该往哪拐,别每次都自己硬闯。
1.1 经典的23种设计模式
经典的GoF(Gang of Four)设计模式,一共23种,按套路分成三大类:创建型、结构型、行为型。不过真放到工业界里,出镜率可是天差地别。我们按实际开发中的使用频次,把它们分成三档来看。
1.1.1 开发中最常见的设计模式
这几个,几乎天天打交道,必须烂熟于心。
-
单例模式(Singleton):一个类,全局只允许有一个实例,还得给外界留一个统一的访问入口。省资源,也省心。
-
工厂方法模式(Factory Method):把"造对象"这件事交给子类去定,父类只负责定接口。要什么,子类自己看着办。
-
抽象工厂模式(Abstract Factory):比工厂方法更上一层楼,专门用来造"一族"相关或互相依赖的对象,而且不用指明具体类。
-
策略模式(Strategy):把一堆算法分别封装起来,让它们能互相替换。本篇的重头戏,后面会细讲。
-
观察者模式(Observer):对象之间一对多的依赖关系。一个对象状态一变,所有依赖它的对象自动收到通知。
-
适配器模式(Adapter):把一个类的接口,转换成客户期望的另一个接口。插头不对,加个转换头就好。
-
装饰器模式(Decorator):动态地给对象叠 buff,加职责。比继承灵活得多,想加就加,想撤就撤。
1.1.2 实际开发中经常遇到的设计模式
这一档,不算天天见,但关键时刻能派上大用场。
-
建造者模式(Builder):把复杂对象的构建过程和它的表示拆开,同样的构建流程,能造出不同的样子。
-
代理模式(Proxy):给别的对象找个"替身",由替身来控制对原对象的访问。想加控制、加缓存、加日志,都从这儿下手。
-
外观模式(Facade):给一整套子系统接口,配一个统一的高层入口。外面看着简单,里面再乱也不关你事。
-
模板方法模式(Template Method):把算法的骨架定死,某些具体步骤推迟到子类去实现。骨架不动,血肉自填。
-
组合模式(Composite):把对象拼成树形结构,用来表示"部分-整体"的层次关系。整体和部分,一视同仁。
-
命令模式(Command):把请求封装成对象,让请求的发起者和执行者彻底解耦。发号施令的,不用管谁去干活。
-
状态模式(State):对象内部状态一变,行为也跟着变。看起来像是换了个人,其实还是它自己。
1.1.3 使用场景相对较少的设计模式
这些模式,平时不太露面,但面试或特定场景里偶尔会冒出来。
-
原型模式(Prototype):不从头造,直接拷贝现有的原型对象来生成新对象。
-
桥接模式(Bridge):把抽象部分和实现部分拆开,让它们各自独立地变化,互不牵制。
-
享元模式(Flyweight):靠共享技术,撑起大量细粒度对象。省内存的一把好手。
-
责任链模式(Chain of Responsibility):请求沿着一条处理链往下传,谁接得住谁处理,接不住就往下递。
-
迭代器模式(Iterator):顺序访问聚合对象里的元素。现代编程语言的标准库大多已经内置了,自己手写的场景反而不多。
-
中介者模式(Mediator):用一个中介对象,把一堆对象之间的复杂交互全揽过来,降低耦合。
-
备忘录模式(Memento):在不破坏封装的前提下,把对象的内部状态记下来,将来还能恢复回去。后悔药本药。
-
访问者模式(Visitor):不改变元素类的前提下,给这些元素定义新的操作。元素不动,操作随便加。
-
解释器模式(Interpreter):给定一门语言,定义它的文法表示,再配一个解释器。用到的场景比较专,一般业务代码里很少碰。
二、从零认识日志系统
2.1 日志是什么:概念与实际作用
计算机里的日志,说白了就是一本"运行日记"------系统和软件在跑的过程中,发生了什么、出了什么事,一笔一笔都记在上面。它是系统维护、故障排查、安全管理的得力工具,主要干三件事:
-
监控运行状态:实时盯着程序的执行流程,看看它健不健康、走到哪一步了。
-
记录异常信息:系统一出错,当场把现场数据抓下来,别让线索溜走。
-
定位与修复问题:帮程序员快速找到 Bug 的根儿,对症下药,精准修复。
2.2 一条日志应该包含哪些信息
设计一套标准的日志系统,先得把输出格式定清楚。通常来说,分核心必选和扩展可选两档。
核心必选指标:
-
时间戳:日志是什么时候产生的,精确记录。
-
日志等级:这条日志有多严重?DEBUG、INFO、WARNING、ERROR、FATAL,标清楚。
-
日志内容:具体描述了什么,文本也好,数据细节也罢,得说明白。
扩展可选指标:
-
文件名与行号:精准定位到产生日志的那一行源码,省得满项目翻。
-
进程与线程 ID:多线程或并发环境下,一眼区分是哪个执行流在说话。
2.3 一个日志系统可以如何实现
实际开发里,现成的优秀日志库一抓一大把,spdlog、glog、Boost.Log、Log4cxx,随便挑一个都能用。但为了把日志系统的设计思想吃透,我们这个项目不打算直接拿现成的,而是自己动手写一个。写的时候,会引入设计模式里的策略模式,让日志的输出行为灵活可换,想怎么打就怎么打。
三、日志系统的完整代码与实现分析
整套日志系统由两个头文件撑起来:Log.hpp负责日志的核心逻辑与策略模式,Mutex.hpp负责互斥锁的封装。下面逐块拆开看。
3.1 Log.hpp:日志核心实现
这个文件是整个日志系统的心脏。它把日志的格式、等级、输出策略、刷盘方式,全都包了进去。
cpp
#ifndef __LOG_HPP__
#define __LOG_HPP__
#include "Mutex.hpp"
#include <filesystem>
#include <iostream>
#include <ctime>
#include <string>
#include <sys/types.h>
#include <unistd.h>
#include <fstream>
#include <sstream>
#include <memory>
#include <cstdio>
namespace LogModule
{
const std::string CAGE = "\n";
// 日志刷盘策略的抽象基类
class LogFlashStrategy
{
public:
~LogFlashStrategy() = default;
virtual void SyncLog(const std::string& message) = 0;
};
// 控制台输出策略
class ConsoleLogStrategy : public LogFlashStrategy
{
public:
void SyncLog(const std::string& logmessage) override
{
MyMutex::GuardMutex _guardmutex(_mutex);
std::cout << logmessage << CAGE;
}
private:
MyMutex::Mutex _mutex;
};
const std::string default_filename = "log.txt";
const std::string default_filepath = "./log";
// 文件输出策略
class FileLogStrategy : public LogFlashStrategy
{
public:
FileLogStrategy(const std::string file = default_filename,
const std::string path = default_filepath)
: _filename(file), _filepath(path)
{
MyMutex::GuardMutex _guardmutex(_mutex);
if (std::filesystem::exists(_filepath))
return;
try
{
std::filesystem::create_directories(_filepath);
}
catch (const std::filesystem::filesystem_error& _exception)
{
std::cout << _exception.what() << CAGE;
}
}
void SyncLog(const std::string& logmessage)
{
MyMutex::GuardMutex _guardmutex(_mutex);
std::string pathfile = _filepath +
(_filepath.back() == '/' ? "" : "/") + _filename;
std::ofstream out(pathfile, std::ios::app);
if (!out.is_open())
return;
out << logmessage << CAGE;
out.close();
}
private:
MyMutex::Mutex _mutex;
std::string _filename;
std::string _filepath;
};
// 日志等级
enum class LEVEL
{
DEBUG,
INFO,
WARNING,
ERROR,
FATAL
};
// 获取当前时间字符串
std::string GetCurrentTime()
{
time_t cur_time = time(nullptr);
struct tm result;
localtime_r(&cur_time, &result);
char timebuffer[128];
snprintf(timebuffer, sizeof(timebuffer), "%4d-%02d-%02d %02d:%02d:%02d",
result.tm_year + 1900,
result.tm_mon + 1,
result.tm_mday,
result.tm_hour,
result.tm_min,
result.tm_sec);
return timebuffer;
}
// 等级转字符串
std::string LevelToString(LEVEL level)
{
switch (level)
{
case LEVEL::DEBUG: return "DEBUG";
case LEVEL::INFO: return "INFO";
case LEVEL::WARNING: return "WARNING";
case LEVEL::ERROR: return "ERROR";
case LEVEL::FATAL: return "FATAL";
default: return "UNKNOWN";
}
}
class Logger
{
public:
Logger()
{
EnableConsolLogStrategy();
}
void EnableConsolLogStrategy()
{
_current_flash_tsrategy = std::make_unique<ConsoleLogStrategy>();
}
void EnableFileLogStrategy()
{
_current_flash_tsrategy = std::make_unique<FileLogStrategy>();
}
// 内部类:一条日志消息
class LogMessage
{
public:
LogMessage(LEVEL& level, std::string& file, size_t line, LogModule::Logger& logger)
: _current_time(GetCurrentTime()),
_level(level),
_pid(getpid()),
_src_file(file),
_line(line),
_logger(logger)
{
std::stringstream ret;
ret << "[" << _current_time << "]"
<< "[" << LevelToString(_level) << "]"
<< "[" << _pid << "]"
<< "[" << _src_file << "]"
<< "[" << _line << "] -";
_hole_message = ret.str();
}
template<class T>
LogMessage& operator<<(const T& info)
{
std::stringstream ss;
ss << info;
_hole_message += ss.str();
return *this;
}
~LogMessage()
{
if (_logger._current_flash_tsrategy)
_logger._current_flash_tsrategy->SyncLog(_hole_message);
}
private:
std::string _current_time;
LEVEL _level;
pid_t _pid;
std::string _src_file;
size_t _line;
std::string _hole_message;
LogModule::Logger& _logger;
};
LogMessage operator()(LEVEL level, std::string filename, size_t line)
{
return LogMessage(level, filename, line, *this);
}
private:
std::unique_ptr<LogFlashStrategy> _current_flash_tsrategy;
};
Logger logger;
#define LOG(level) logger(level, __FILE__, __LINE__)
#define ENABLE_CONSOLE_LOG_STRATEGY() logger.EnableConsolLogStrategy()
#define ENABLE_FILE_LOG_STRATEGY() logger.EnableFileLogStrategy()
}
#endif
核心设计拆解:
策略模式是本文件的灵魂。 LogFlashStrategy定义了刷盘策略的抽象接口,ConsoleLogStrategy和FileLogStrategy各自实现。Logger里揣着一个unique_ptr<LogFlashStrategy>,运行时想切哪个策略,调一下EnableXXX就行。日志输出到哪儿,跟日志怎么组织、怎么调用,彻底解耦。
**LogMessage是个RAII风格的临时对象。**LOG(LEVEL::INFO) << "hello" << 42这行代码,会先构造一个LogMessage,把时间戳、等级、PID、文件名、行号拼进_hole_message。接着 << 运算符把内容往上追加。等这条语句结束,LogMessage析构,析构函数里调SyncLog,一条日志就刷出去了。整条日志的拼装和输出,靠对象的生命周期自动完成,用起来像流水一样自然。
宏LOG负责偷懒。 把__FILE__和__LINE__自动塞进去,调用方只需要关心等级和内容,文件名行号这些"体力活",宏替你干了。
3.2 Mutex.hpp:互斥锁封装
cpp
#ifndef __MUTEX_HPP__
#define __MUTEX_HPP__
#include <pthread.h>
namespace MyMutex
{
class Mutex
{
public:
Mutex() { pthread_mutex_init(&_mutex, nullptr); }
void Lock() { pthread_mutex_lock(&_mutex); }
void UnLock() { pthread_mutex_unlock(&_mutex); }
~Mutex() { pthread_mutex_destroy(&_mutex); }
private:
pthread_mutex_t _mutex;
};
class GuardMutex
{
public:
GuardMutex(Mutex mutex)
: _mutex(mutex)
{
_mutex.Lock();
}
~GuardMutex()
{
_mutex.UnLock();
}
private:
Mutex _mutex;
};
}
#endif
Mutex是基础互斥量的薄封装。 构造初始化、析构销毁,Lock/UnLock包住pthread_mutex_lock/unlock,用起来省心。
GuardMutex是RAII守卫。 构造即加锁,析构即解锁,离开作用域自动释放。思路是对的,但这份实现里藏着几个严重问题,我们放到3.3里专门说。
3.3 代码中的问题与优化
框架和设计思路都没问题,策略模式用得很地道,RAII日志消息也漂亮。但代码里有几处硬伤,有的会直接导致功能失效,有的是隐藏的性能坑。
3.3.1 致命问题:GuardMutex按值传参,实际上没有锁住原对象
cpp
class GuardMutex
{
public:
GuardMutex(Mutex mutex) // 按值传递!拷贝了一份互斥量
: _mutex(mutex) // 又拷贝了一份
{
_mutex.Lock();
}
~GuardMutex()
{
_mutex.UnLock();
}
private:
Mutex _mutex; // 按值成员,再拷贝一份
};
pthread_mutex_t是系统资源,不是普通对象。按值拷贝一个Mutex,等于在内存里克隆了一个毫无关联的锁。原锁和副本锁,彼此不认。你锁住的是副本,别的线程锁的是原锁,两边各锁各的,互斥保护完全失效。
更糟的是,pthread_mutex_t的拷贝行为本身就是未定义的。拷贝一份互斥量,在 POSIX 标准里根本不允许。
正确写法,必须传引用、存引用:
cpp
class GuardMutex
{
public:
GuardMutex(Mutex& mutex) // 传引用
: _mutex(mutex) // 存引用
{
_mutex.Lock();
}
~GuardMutex()
{
_mutex.UnLock();
}
private:
Mutex& _mutex; // 引用成员
};
3.3.2 虚析构函数为什么需要virtual
cpp
class LogFlashStrategy
{
public:
~LogFlashStrategy() = default; // 没有 virtual!
virtual void SyncLog(const std::string& message) = 0;
};
Logger里用的是std::unique_ptr<LogFlashStrategy>,指向派生类对象。如果基类析构函数不是虚函数,delete一个基类指针时,派生类的析构函数不会被调用。派生类里那些成员,比如FileLogStrategy里的Mutex,就没人清理,资源泄漏。
改成:
cpp
virtual ~LogFlashStrategy() = default;
3.3.3 全局Logger对象为什么会违反ODR
cpp
Logger logger; // 定义在头文件里
头文件被多个.cpp包含时,每个翻译单元都会生成一份logger的定义,链接时直接冲突,重复定义。要么改成inline,要么放进.cpp里,要么加static(但那样每个编译单元一份,不共享)。
3.4 策略模式如何落地到Log.hpp
在日志模块的实际落地里,有两件事必须分开看:一件是日志消息怎么生成、怎么格式化;另一件是日志刷完之后往哪儿送,终端、磁盘,还是网络。这是两个独立的关注点,硬捆在一起,代码迟早变成一团乱麻。
策略模式出场,就是来拆这个结的。它把"日志刷新"抽象成一个统一的策略接口,让日志系统做到对扩展开放、对修改封闭。想加一种新的刷盘方式?加个新策略就行,原有代码一行不动。
3.4.1 抽象策略:LogFlashStrategy
LogFlashStrategy是所有刷盘策略的基类,也就是那个"抽象接口"。它定下了一条铁律,所有具体策略,都必须实现统一的刷新行为。
cpp
class LogFlashStrategy
{
public:
virtual ~LogFlashStrategy() = default;
virtual void SyncLog(const std::string& message) = 0;
};
两个细节,各有讲究。
**虚析构函数:**确保通过基类指针销毁派生类策略对象时,能正确调用子类的析构函数,防止内存泄漏。Logger里用的是unique_ptr<LogFlashStrategy>,少了这个virtual,派生类的资源就没人收拾。
**纯虚函数SyncLog:**定义日志刷新的标准接口。它接收已经格式化好的日志字符串,至于具体怎么刷、刷到哪儿,全部延迟到子类里实现。接口定死,实现自由。
3.4.2 具体策略一:ConsoleLogStrategy
ConsoleLogStrategy继承自LogFlashStrategy,负责把日志同步刷新到终端控制台。
cpp
class ConsoleLogStrategy : public LogFlashStrategy
{
public:
void SyncLog(const std::string& logmessage) override
{
MyMutex::GuardMutex _guardmutex(_mutex);
std::cout << logmessage << CAGE;
}
private:
MyMutex::Mutex _mutex;
};
**线程安全保证:**多线程并发打印日志时,控制台输出极容易字符穿插、乱成一团。这里用RAII机制的锁GuardMutex把输出过程整个罩住,保证每一条日志都能完整、连贯地落到屏幕上,不会你半句我半句。
3.4.3 具体策略二:FileLogStrategy
FileLogStrategy同样继承自LogFlashStrategy,负责把日志持久化写入本地磁盘。
cpp
const std::string default_filename = "log.txt";
const std::string default_filepath = "./log"; // ./log/log.txt
class FileLogStrategy : public LogFlashStrategy
{
public:
FileLogStrategy(const std::string file = default_filename,
const std::string path = default_filepath)
: _filename(file), _filepath(path)
{
MyMutex::GuardMutex _guardmutex(_mutex);
if (std::filesystem::exists(_filepath))
return;
try
{
std::filesystem::create_directories(_filepath);
}
catch (const std::filesystem::filesystem_error& _exception)
{
std::cout << _exception.what() << CAGE;
}
}
void SyncLog(const std::string& logmessage) override
{
MyMutex::GuardMutex _guardmutex(_mutex);
std::string pathfile = _filepath +
(_filepath.back() == '/' ? "" : "/") + _filename;
std::ofstream out(pathfile, std::ios::app);
if (!out.is_open())
return;
out << logmessage << CAGE;
out.close();
}
private:
MyMutex::Mutex _mutex;
std::string _filename;
std::string _filepath;
};
三个设计点,各有各的用处。
**目录自动创建:**构造函数里用C++17的std::filesystem::create_directories,先检测、后创建,路径不存在就给你搭好。日志目录这种事,不该让用户手动去建,代码自己搞定,健壮性直接上来了。
**追加写入模式:**SyncLog里用std::ios::app打开文件,新日志老老实实追加到文件末尾,绝不覆盖已有记录。日志这东西,最忌讳的就是写着写着把前面的给冲了。
**并发文件保护:**写入前后同样加锁。多个线程同时往同一个文件里写,不加锁,内容穿插、数据损坏,谁也跑不掉。一把锁下去,写文件的动作变成串行,内容完整,数据安全。
这就是策略模式在日志系统里的真实价值:把"怎么刷"和"刷到哪"彻底拆开,让变化的部分独立、稳定,让不变的部分复用、沉淀。
3.4.4 策略模式为日志系统带来了什么
核心逻辑与刷盘细节彻底解耦。 Logger手里只攥着一个LogFlashStrategy的基类指针,要刷盘的时候,直接一句SyncLog甩过去。至于底层到底是打印到控制台,还是写入磁盘,它一概不关心,也压根不需要知道。各管各的,谁也不越界。
扩展性极佳,完美贴合开闭原则(OCP)。 后面要是想加新的刷盘方式------比如按日志等级拆文件存储的GroupLogStrategy,或者把日志通过网络发出去的NetLogStrategy------只需要继承LogFlashStrategy,把新的策略类实现出来就行。现有的控制台策略、文件策略,一行代码都不用动。对扩展开放,对修改封闭,这句话在日志系统里落得实实在在。
业界最通用的五种核心日志等级
日志等级这东西,得有个统一的标尺,不然一条日志出来,谁也不知道它到底有多严重。下面这五档,是业界最通用的划分:
| 日志等级 | 代表含义 | 典型应用场景 | 影响范围与处理建议 |
| DEBUG | 调试信息 | 详细的函数入参、中间变量值、执行路径等细节。 | 仅用于开发调试,生产环境默认关闭。 |
| INFO | 正常信息 | 系统启动/关闭、关键业务流程节点、成功交易通知。 | 记录系统正常运行轨迹,供日常运维与留痕查验。 |
| WARN/WARNING | 潜在警告 | 接口重试成功、配置参数缺省、资源利用率过高。 | 系统尚能继续运行,但存在安全隐患,需定期排查。 |
| ERROR | 局部错误 | 捕获到非预期异常、单次请求失败、数据库超时。 | 局部功能受损但未导致整体宕机,需尽快定位修复。 |
| FATAL/CRITICAL | 致命崩溃 | 内存溢出(OOM)、核心服务宕机、主数据库断开。 | 服务彻底不可用或系统崩溃,必须触发紧急告警并人工抢修。 |
|---|
从DEBUG到FATAL,严重程度一路往上走。DEBUG 是给开发者自己看的,生产环境里基本关着;INFO记录系统正常走路的轨迹;WARN是系统还能跑,但得留个心眼;ERROR是局部出了问题,得赶紧查;FATAL就是天塌了,必须立刻拉响警报,人工上场抢修。
五档分级,各司其职。一条日志出来,扫一眼等级,就知道该用什么姿态应对,这就是分级的意义。
如果这篇文章对你有帮助,别忘了点个赞、点个收藏、点个关注。你的每一次反馈,都是我继续硬核输出的最大动力。我们下篇见。