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 {@Overridepublic void log(LogEvent event) {// 格式化输出,这里简化处理System.out.printf("[%s] %s: %s%n", event.getLevel(), event.getTime(), event.getMessage());}@Overridepublic 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);}}@Overridepublic void log(LogEvent event) {// 加锁保证多线程写入安全synchronized (writer) {writer.printf("[%s] %s: %s%n", event.getLevel(), event.getTime(), event.getMessage());}}@Overridepublic void close() {writer.close(); // 关键:必须关闭}
}
避坑点:很多新手在log()方法里创建PrintWriter,每次日志都打开文件,性能直接崩掉。资源初始化应该在构造函数或工厂里完成,而不是每次调用时。
2. 工厂方法:解耦创建逻辑
新手最爱犯的错:在业务代码里直接new FileLogger()。这样如果将来要改成从配置文件读取路径,或者根据环境动态选择,就得改所有调用处。
工厂方法模式解决的就是这个问题:
public class LoggerFactory {private static final Map<String, Function<String, 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) {Function<String, 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 List<ILogger> 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 List<ILogger> loggers = new CopyOnWriteArrayList<>();private final List<Consumer<LogEvent>> listeners = new CopyOnWriteArrayList<>();public void addEventListener(Consumer<LogEvent> listener) {listeners.add(listener);}public void log(LogEvent event) {// 分发日志到所有loggerfor (ILogger logger : loggers) {logger.log(event);}// 通知所有监听器for (Consumer<LogEvent> 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 BlockingQueue<LogEvent> 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里怎么用的?策略模式怎么配合配置中心?评论区聊聊,咱们互相避坑。