常用的设计模式新手避坑
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 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) {// 分发日志到所有loggerfor (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里怎么用的?策略模式怎么配合配置中心?评论区聊聊,咱们互相避坑。