
5個常用設計模式新手避坑指南:面試不掛實戰(zhàn)能跑
面試官問:“單例模式怎么保證線程安全?”你張嘴就來“加鎖”,結果被追問“雙重檢查鎖DCL為什么需要volatile?”直接卡殼,面經上寫的套路在真實場景里根本行不通。
做開發(fā)這幾年,見過太多新手把設計模式當成背題工具,代碼里硬套模板,結果項目里全是“偽模式”。今天咱們不聊虛的,直接上手寫一個日志記錄系統(tǒng),用5個最常用設計模式解決真實痛點。看完這篇,你不僅能應付面試,還能在簡歷里寫出“重構過日志模塊”這種硬通貨。
項目目標:為什么不用工廠硬寫if-else
假設我們要做一個日志系統(tǒng),支持控制臺輸出、文件輸出、數據庫輸出三種方式。新手第一反應通常是這樣的:
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包負責創(chuàng)建,隔離“怎么創(chuàng)建”和“怎么用”
core包只放接口和上下文,這是系統(tǒng)的“骨架”很多新手犯的錯誤是把所有類塞在一個包里,結果類之間互相依賴,改一處崩一片。記?。喊Y構就是你的依賴關系圖。
核心代碼實現:5個模式逐個拆解
1. 策略模式:讓日志類型可插拔
先定義接口,這是所有策略模式的起點:
public interface ILogger {void log(LogEvent event);void close(); // 資源釋放
}注意:接口里必須包含close()方法。新手經常忽略資源釋放,導致文件句柄泄漏、數據庫連接耗盡。參考Apache Commons Logging的開發(fā)者文檔,所有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()方法里創(chuàng)建PrintWriter,每次日志都打開文件,性能直接崩掉。資源初始化應該在構造函數或工廠里完成,而不是每次調用時。
2. 工廠方法:解耦創(chuàng)建邏輯
新手最愛犯的錯:在業(yè)務代碼里直接new FileLogger()。這樣如果將來要改成從配置文件讀取路徑,或者根據環(huán)境動態(tài)選擇,就得改所有調用處。
工廠方法模式解決的就是這個問題:
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);}
}關鍵細節(jié):用靜態(tài)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(); // 競態(tài)條件!}return instance;}
}這段代碼在多線程環(huán)境下會創(chuàng)建多個實例。正確寫法用雙重檢查鎖:
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依賴外部配置(比如數據庫連接池),構造函數里初始化會導致循環(huán)依賴。此時應該用延遲初始化或依賴注入。
4. 觀察者模式:解耦事件分發(fā)
目前LoggerContext直接調用所有l(wèi)ogger,如果未來要加“日志監(jiān)控”“告警推送”等功能,就得改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) {// 分發(fā)日志到所有l(wèi)oggerfor (ILogger logger : loggers) {logger.log(event);}// 通知所有監(jiān)聽器for (ConsumerLogEvent listener : listeners) {try {listener.accept(event);} catch (Exception e) {// 關鍵:監(jiān)聽器異常不能影響主流程System.err.println(Listener error: + e.getMessage());}}}
}實戰(zhàn)場景:加一個告警監(jiān)聽器,當出現ERROR級別日志時發(fā)送郵件:
LoggerContext.getInstance().addEventListener(event - {if (ERROR.equals(event.getLevel())) {// 發(fā)送告警郵件alertService.send(event.getMessage());}
});這樣告警邏輯完全獨立于日志核心,新增監(jiān)控功能不用改LoggerContext。
5. 享元模式:復用日志事件對象
LogEvent對象在高頻日志場景下會創(chuàng)建大量實例,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是創(chuàng)建時固定的,所以可以安全復用。
實際項目中,Logback用到了更復雜的對象池,但核心思想一致:高頻創(chuàng)建的小對象,盡量復用。
運行與測試:驗證模式真的有用
寫個測試類驗證一下:
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. 添加告警監(jiān)聽器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里也記錄了這兩條日志。關鍵點:新增“郵件告警”功能時,我們只加了一個監(jiān)聽器,沒改任何核心類。這就是設計模式的價值。
測試避坑:單例模式測試時,每個測試用例后必須shutdown()并重置instance(反射),否則測試間互相污染
文件日志測試要用臨時目錄,別污染項目目錄
多線程測試要用CountDownLatch或CompletableFuture,別用Thread.sleep()硬等優(yōu)化擴展:從能用到好用
基礎功能跑通后,還有幾個進階點:
1. 異步日志
同步日志會阻塞業(yè)務線程。參考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. 動態(tài)配置熱更新
用觀察者模式監(jiān)聽配置文件變化,運行時切換日志級別:
// 監(jiān)聽application.properties變化
FileWatcher.watch(application.properties, changes - {String level = changes.get(log.level);context.setLogLevel(level);
});3. 優(yōu)雅降級
日志系統(tǒng)不能因為IO錯誤拖垮主業(yè)務。所有l(wèi)ogger的log()方法必須捕獲異常:
@Override
public void log(LogEvent event) {try {// 寫入邏輯} catch (Exception e) {// 降級:只打stderr,不拋異常System.err.println(Log failed: + e.getMessage());}
}4. 性能指標
加埋點統(tǒng)計日志耗時、隊列積壓量,用Micrometer暴露到Prometheus:
Timer.builder(logger.write).tag(logger, type).register(meterRegistry).record(() - logger.log(event));小結:模式是手段不是目的
這套日志系統(tǒng)用到了5個設計模式,但每個模式都解決了具體問題:策略模式:日志類型可插拔
工廠方法:解耦創(chuàng)建邏輯
單例模式:上下文唯一性
觀察者模式:事件通知解耦
享元模式:對象復用降GC新手最常見的誤區(qū):為了用模式而用模式。比如明明用簡單工廠就夠,非要搞出抽象工廠+建造者,代碼復雜度爆炸。判斷標準很簡單:如果不用這個模式,代碼會怎樣? 如果答案是“多改幾處if-else”“資源泄漏”“線程不安全”,那這個模式就有價值;如果只是“看起來更高級”,那就別用。
設計模式的終極目標不是炫技,而是讓代碼在需求變化時少改、改得安全。下次寫代碼前,先問自己:這里有沒有重復邏輯?有沒有資源泄漏風險?有沒有線程安全問題?答案指向哪個模式,就用哪個。
你在項目里踩過哪個設計模式的坑?比如單例在Spring里怎么用的?策略模式怎么配合配置中心?評論區(qū)聊聊,咱們互相避坑。