
5个常用设计模式新手避坑指南:面试不挂实战能跑
面试官问:“单例模式怎么保证线程安全?”你张嘴就来“加锁”,结果被追问“双重检查锁DCL为什么需要volatile?”直接卡壳,面经上写的套路在真实场景里根本行不通。
做开发这几年,见过太多新手把设计模式当成背题工具,代码里硬套模板,结果项目里全是“伪模式”。今天咱们不聊虚的,直接上手写一个日志记录系统,用5个最常用设计模式解决真实痛点。看完这篇,你不仅能应付面试,还能在简历里写出“重构过日志模块”这种硬通货。
项目目标:为什么不用工厂硬写if-else
假设我们要做一个日志系统,支持控制台输出、文件输出、数据库输出三种方式。新手第一反应通常是这样的:
public class Logger {
public void log(String message) {
String type = config.get(log.type);
if (console.equals(type)) {
System.out.println(message);
} else if (file.equals(type)) {
// 文件写入逻辑
} else if (db.equals(type)) {
// 数据库写入逻辑
}
}
}
这段代码的问题显而易见:每加一种日志类型,就要改Logger类,违反开闭原则。更致命的是,配置解析、资源释放、错误处理全混在一起,维护起来简直是噩梦。
我们的目标是:用5个设计模式重构这个模块,让新增日志类型时零改动核心类,同时保证线程安全、资源可控。最终代码会参考Logback、SLF4J等成熟框架的设计思路,但用极简代码实现核心思想。
目录结构:先搭骨架再填肉
别一上来就写代码,先把目录结构定下来。设计模式的本质是结构,结构对了,代码自然清晰:
src/main/java/com/example/logger/
├── core/
│ ├── ILogger.java # 抽象日志接口(策略模式)
│ ├── LoggerContext.java # 上下文管理(单例+观察者)
│ └── LogEvent.java # 日志事件对象(享元模式)
├── strategy/
│ ├── ConsoleLogger.java # 控制台策略
│ ├── FileLogger.java # 文件策略
│ └── DbLogger.java # 数据库策略
├── factory/
│ └── LoggerFactory.java # 工厂方法
└── util/
└── ResourcePool.java # 资源池(池化思想)
这个结构有几个关键点:
strategy包放所有具体策略,核心类不直接依赖它们
factory包负责创建,隔离“怎么创建”和“怎么用”
core包只放接口和上下文,这是系统的“骨架”
很多新手犯的错误是把所有类塞在一个包里,结果类之间互相依赖,改一处崩一片。记住:包结构就是你的依赖关系图。
核心代码实现:5个模式逐个拆解
1. 策略模式:让日志类型可插拔
先定义接口,这是所有策略模式的起点:
public interface ILogger {
void log(LogEvent event);
void close(); // 资源释放
}
注意:接口里必须包含close()方法。新手经常忽略资源释放,导致文件句柄泄漏、数据库连接耗尽。参考Apache Commons Logging的开发者文档,所有IO相关组件都必须实现Closeable接口。
控制台策略实现:
public class ConsoleLogger implements ILogger {
@Override
public void log(LogEvent event) {
// 格式化输出,这里简化处理
System.out.printf([%s] %s: %s%n,
event.getLevel(),
event.getTime(),
event.getMessage());
}
@Override
public void close() {
// 控制台无需释放资源,但方法必须存在
}
}
文件策略要注意资源管理:
public class FileLogger implements ILogger {
private final PrintWriter writer;
private final String filePath;
public FileLogger(String filePath) {
this.filePath = filePath;
try {
this.writer = new PrintWriter(new FileWriter(filePath, true), true);
} catch (IOException e) {
throw new RuntimeException(Failed to open log file: + filePath, e);
}
}
@Override
public void log(LogEvent event) {
// 加锁保证多线程写入安全
synchronized (writer) {
writer.printf([%s] %s: %s%n,
event.getLevel(),
event.getTime(),
event.getMessage());
}
}
@Override
public void close() {
writer.close(); // 关键:必须关闭
}
}
避坑点:很多新手在log()方法里创建PrintWriter,每次日志都打开文件,性能直接崩掉。资源初始化应该在构造函数或工厂里完成,而不是每次调用时。
2. 工厂方法:解耦创建逻辑
新手最爱犯的错:在业务代码里直接new FileLogger()。这样如果将来要改成从配置文件读取路径,或者根据环境动态选择,就得改所有调用处。
工厂方法模式解决的就是这个问题:
public class LoggerFactory {
private static final MapString, FunctionString, ILogger STRATEGY_MAP = new HashMap();
static {
// 注册所有策略
STRATEGY_MAP.put(console, path - new ConsoleLogger());
STRATEGY_MAP.put(file, path - new FileLogger(path));
STRATEGY_MAP.put(db, path - new DbLogger(path));
}
public static ILogger create(String type, String path) {
FunctionString, ILogger factory = STRATEGY_MAP.get(type);
if (factory == null) {
throw new IllegalArgumentException(Unknown logger type: + type);
}
return factory.apply(path);
}
}
关键细节:用静态Map注册策略,而不是if-else判断。这样新增日志类型时,只需要:
实现ILogger接口
在STRATEGY_MAP里加一行注册
核心类零改动,这就是开闭原则的落地。
3. 单例模式:上下文只有一份
LoggerContext负责管理所有日志实例,必须保证全局唯一。但单例模式新手常写成这样:
public class LoggerContext {
private static LoggerContext instance;
public static LoggerContext getInstance() {
if (instance == null) {
instance = new LoggerContext(); // 竞态条件!
}
return instance;
}
}
这段代码在多线程环境下会创建多个实例。正确写法用双重检查锁:
public class LoggerContext {
private static volatile LoggerContext instance;
private final ListILogger loggers = new CopyOnWriteArrayList();
private LoggerContext() {}
public static LoggerContext getInstance() {
if (instance == null) { // 第一次检查
synchronized (LoggerContext.class) {
if (instance == null) { // 第二次检查
instance = new LoggerContext();
}
}
}
return instance;
}
public void registerLogger(ILogger logger) {
loggers.add(logger);
}
public void log(LogEvent event) {
for (ILogger logger : loggers) {
logger.log(event);
}
}
public void shutdown() {
for (ILogger logger : loggers) {
logger.close();
}
loggers.clear();
}
}
为什么需要volatile:这是面试高频考点。不加volatile,instance可能未完全初始化就被其他线程看到,导致空指针或半初始化对象。JMM(Java内存模型)保证volatile变量写入后的可见性,防止指令重排序。
避坑点:单例模式不是万能的。如果LoggerContext依赖外部配置(比如数据库连接池),构造函数里初始化会导致循环依赖。此时应该用延迟初始化或依赖注入。
4. 观察者模式:解耦事件分发
目前LoggerContext直接调用所有logger,如果未来要加“日志监控”“告警推送”等功能,就得改LoggerContext。观察者模式解决的就是这种“一对多”通知场景。
简化版实现:
public class LoggerContext {
private final ListILogger loggers = new CopyOnWriteArrayList();
private final ListConsumerLogEvent listeners = new CopyOnWriteArrayList();
public void addEventListener(ConsumerLogEvent listener) {
listeners.add(listener);
}
public void log(LogEvent event) {
// 分发日志到所有logger
for (ILogger logger : loggers) {
logger.log(event);
}
// 通知所有监听器
for (ConsumerLogEvent listener : listeners) {
try {
listener.accept(event);
} catch (Exception e) {
// 关键:监听器异常不能影响主流程
System.err.println(Listener error: + e.getMessage());
}
}
}
}
实战场景:加一个告警监听器,当出现ERROR级别日志时发送邮件:
LoggerContext.getInstance().addEventListener(event - {
if (ERROR.equals(event.getLevel())) {
// 发送告警邮件
alertService.send(event.getMessage());
}
});
这样告警逻辑完全独立于日志核心,新增监控功能不用改LoggerContext。
5. 享元模式:复用日志事件对象
LogEvent对象在高频日志场景下会创建大量实例,GC压力大。享元模式通过对象池复用不可变对象。
简化版LogEvent:
public class LogEvent {
private final String level;
private final String message;
private final long timestamp;
public LogEvent(String level, String message) {
this.level = level;
this.message = message;
this.timestamp = System.currentTimeMillis();
}
// getter方法
public String getLevel() { return level; }
public String getMessage() { return message; }
public long getTime() { return timestamp; }
}
注意:享元模式要求对象不可变。如果LogEvent字段可变,复用会导致数据污染。这里timestamp是创建时固定的,所以可以安全复用。
实际项目中,Logback用到了更复杂的对象池,但核心思想一致:高频创建的小对象,尽量复用。
运行与测试:验证模式真的有用
写个测试类验证一下:
public class LoggerTest {
public static void main(String[] args) throws Exception {
// 1. 获取单例上下文
LoggerContext context = LoggerContext.getInstance();
// 2. 注册多种日志策略
context.registerLogger(LoggerFactory.create(console, null));
context.registerLogger(LoggerFactory.create(file, app.log));
// 3. 添加告警监听器
context.addEventListener(event - {
if (ERROR.equals(event.getLevel())) {
System.out.println(ALERT: + event.getMessage());
}
});
// 4. 记录日志
context.log(new LogEvent(INFO, System started));
context.log(new LogEvent(ERROR, Database connection failed));
// 5. 关闭资源
Thread.sleep(100); // 等待文件写入
context.shutdown();
}
}
运行结果:
[INFO] 2024-01-15 10:30:00: System started
[ERROR] 2024-01-15 10:30:00: Database connection failed
ALERT: Database connection failed
文件app.log里也记录了这两条日志。关键点:新增“邮件告警”功能时,我们只加了一个监听器,没改任何核心类。这就是设计模式的价值。
测试避坑:
单例模式测试时,每个测试用例后必须shutdown()并重置instance(反射),否则测试间互相污染
文件日志测试要用临时目录,别污染项目目录
多线程测试要用CountDownLatch或CompletableFuture,别用Thread.sleep()硬等
优化扩展:从能用到好用
基础功能跑通后,还有几个进阶点:
1. 异步日志
同步日志会阻塞业务线程。参考Logback的AsyncAppender,用Disruptor或阻塞队列实现异步:
// 伪代码
private final BlockingQueueLogEvent queue = new ArrayBlockingQueue(10000);
private final ExecutorService executor = Executors.newSingleThreadExecutor();
public void log(LogEvent event) {
queue.offer(event); // 非阻塞,满则丢弃
}
// 后台线程消费队列
executor.submit(() - {
while (true) {
LogEvent event = queue.take();
for (ILogger logger : loggers) {
logger.log(event);
}
}
});
2. 动态配置热更新
用观察者模式监听配置文件变化,运行时切换日志级别:
// 监听application.properties变化
FileWatcher.watch(application.properties, changes - {
String level = changes.get(log.level);
context.setLogLevel(level);
});
3. 优雅降级
日志系统不能因为IO错误拖垮主业务。所有logger的log()方法必须捕获异常:
@Override
public void log(LogEvent event) {
try {
// 写入逻辑
} catch (Exception e) {
// 降级:只打stderr,不抛异常
System.err.println(Log failed: + e.getMessage());
}
}
4. 性能指标
加埋点统计日志耗时、队列积压量,用Micrometer暴露到Prometheus:
Timer.builder(logger.write)
.tag(logger, type)
.register(meterRegistry)
.record(() - logger.log(event));
小结:模式是手段不是目的
这套日志系统用到了5个设计模式,但每个模式都解决了具体问题:
策略模式:日志类型可插拔
工厂方法:解耦创建逻辑
单例模式:上下文唯一性
观察者模式:事件通知解耦
享元模式:对象复用降GC
新手最常见的误区:为了用模式而用模式。比如明明用简单工厂就够,非要搞出抽象工厂+建造者,代码复杂度爆炸。判断标准很简单:如果不用这个模式,代码会怎样? 如果答案是“多改几处if-else”“资源泄漏”“线程不安全”,那这个模式就有价值;如果只是“看起来更高级”,那就别用。
设计模式的终极目标不是炫技,而是让代码在需求变化时少改、改得安全。下次写代码前,先问自己:这里有没有重复逻辑?有没有资源泄漏风险?有没有线程安全问题?答案指向哪个模式,就用哪个。
你在项目里踩过哪个设计模式的坑?比如单例在Spring里怎么用的?策略模式怎么配合配置中心?评论区聊聊,咱们互相避坑。