工廠模式進階用法,如何動態選擇物件?

JAVA旭陽發表於2023-03-10

前言

工廠設計模式可能是最常用的設計模式之一,我想大家在自己的專案中都用到過。可能你會不屑一顧,但這篇文章不僅僅是關於工廠模式的基本知識,更是討論如何在執行時動態選擇不同的方法進行執行,你們可以看看是不是和你們專案中用的一樣?

歡迎關注個人公眾號【JAVA旭陽】交流溝通

小菜鳥的問題

直接上例子說明,設計一個日誌記錄的功能,但是支援記錄到不同的地方,例如:

  • 記憶體中
  • 磁碟上的檔案
  • 資料庫
  • 百度網盤等遠端儲存服務

面對這麼一個需求,你會怎麼做呢?我們先來看看小菜鳥的做法吧。

  1. 小菜鳥建立了一個Logger
class Logger {
    public void log(String message, String loggerMedium) {}
}
  1. 小菜鳥想都不想,直接一通if else
class Logger {
    public void log(String message, String loggerMedium) {
        if (loggerMedium.equals("MEMORY")) {
            logInMemory(message);
        } else if (loggerMedium.equals("FILE")) {
            logOnFile(message);
        } else if (loggerMedium.equals("DB")) {
            logToDB(message);
        } else if (loggerMedium.equals("REMOTE_SERVICE")) {
            logToRemote(message);
        }
    }

    private void logInMemory(String message) {
        // Implementation
    }

    private void logOnFile(String message) {
        // Implementation
    }

    private void logToDB(String message) {
        // Implementation
    }

    private void logToRemote(String message) {
        // Implementation
    }
}

現在突然說要增加一種儲存介質FLASH_DRIVE,就要改了這個類?不拍改錯嗎?也不符合“開閉原則”,而且隨著儲存介質變多,類也會變的很大,小菜鳥懵逼了,不知道怎麼辦?

有沒有更好的方法呢?

這時候小菜鳥去找你幫忙,你一頓操作,改成了下面這樣:

class InMemoryLog {
    public void logToMemory(String message) {
        // Implementation
    }
}

class FileLog {
    public void logToFile(String message) {
        //Implementation
    }
}

class DBLog {
    public void logToDB(String message) {
        // Implementation
    }
}

class RemoteServiceLog {
    public void logToService(String message) {
        // Implementation
    }
}

class Logger {
    private InMemoryLog mLog;
    private FileLog fLog;
    private DBLog dbLog;
    private RemoteServiceLog sLog;
    
    public Logger() {
        mLog = new InMemoryLog();
        fLog = new FileLog();
        dbLog = new DBLog();
        sLog = new RemoteServiceLog();
    }

    public void log(String message, String loggerMedium) {
        if (loggerMedium.equals("MEMORY")) {
            mLog.logToMemory(message);
        } else if (loggerMedium.equals("FILE")) {
            fLog.logToFile(message);
        } else if (loggerMedium.equals("DB")) {
            dbLog.logToDB(message);
        } else if (loggerMedium.equals("REMOTE_SERVICE")) {
            sLog.logToService(message);
        }
    }
}

在這個實現中,你已經將單獨的程式碼分離到它們對應的檔案中,但是Logger類與儲存介質的具體實現緊密耦合,如FileLogDBLog等。隨著儲存介質的增加,類中將引入更多的例項Logger

還有什麼更好的辦法嗎?

你想了想,上面的實現都是直接寫具體的實現類,是面向實現程式設計,更合理的做法是面向介面程式設計,介面意味著協議,契約,是一種更加穩定的方式。

  1. 定義一個日誌操作的介面
public interface LoggingOperation {
    void log(String message);
}
  1. 實現這個介面
class InMemoryLog implements LoggingOperation {
    public void log(String message) {
        // Implementation
    }
}

class FileLog implements LoggingOperation {
    public void log(String message) {
        //Implementation
    }
}

class DBLog implements LoggingOperation {
    public void log(String message) {
        // Implementation
    }
}

class RemoteServiceLog implements LoggingOperation {
    public void log(String message) {
        // Implementation
    }
}
  1. 你定義了一個類,據傳遞的引數,在執行時動態選擇具體實現,這就是所謂的工廠類,不過是基礎版。
class LoggerFactory {
    public static LoggingOperation getInstance(String loggerMedium) {
        LoggingOperation op = null;
        switch (loggerMedium) {
            case "MEMORY":
                op = new InMemoryLog();
                break;
            case "FILE":
                op = new FileLog();
                break;
            case "DB":
                op = new DBLog();
                break;
            case "REMOTE_SERVICE":
                op = new RemoteServiceLog();
                break;
        }

        return op;
    }
}
  1. 現在你的 Logger類的實現就是下面這個樣子了。
class Logger {
    public void log(String message, String loggerMedium) {
        LoggingOperation instance = LoggerFactory.getInstance(loggerMedium);
        instance.log(message);
    }
}

這裡的程式碼變得非常統一,建立實際儲存例項的責任已經轉移到LoggerFactory,各個儲存類只實現它們如何將訊息記錄到它們的特定介質,最後該類Logger只關心透過LoggerFactory將實際的日誌記錄委託給具體的實現。這樣,程式碼就很鬆耦合了。你想要新增一個新的儲存介質,例如FLASH_DRIVE,只需建立一個實現LoggingOperation介面的新類並將其註冊到LoggerFactory中就好了。這就是工廠模式可以幫助您動態選擇實現的方式。

還能做得更好嗎?

你已經完成了一個松耦合的設計,但是想象一下假如有數百個儲存介質的場景,所以我們最終會在工廠類LoggerFactory中的switch case部分case數百個。這看起來還是很糟糕,如果管理不當,它有可能成為技術債務,這該怎麼辦呢?

擺脫不斷增長的if else或者 switch case的一種方法是維護類中所有實現類的列表,LoggerFactory程式碼如下所示:

class LoggerFactory {
    private static final List<LoggingOperation> instances = new ArrayList<>();

    static {
        instances.addAll(Arrays.asList(
                new InMemoryLog(),
                new FileLog(),
                new DBLog(),
                new RemoteServiceLog()
        ));
    }

    public static LoggingOperation getInstance(ApplicationContext context, String loggerMedium) {
        for(LoggingOperation op : instances) {
            // 比如判斷StrUtil.equals(loggerMedium, op.getType()) op本身新增一個type
        }

        return null;
    }
}

但是請注意,還不夠,在所有上述實現中,無論if else、switch case 還是上面的做法,都是讓儲存實現與LoggerFactory緊密耦合的。你新增一種實現,就要修改LoggerFactory,有什麼更好的做法嗎?

逆向思維一下,我們是不是讓具體的實現主動註冊上來呢?透過這種方式,工廠不需要知道系統中有哪些例項可用,而是例項本身會註冊並且如果它們在系統中可用,工廠就會為它們提供服務。具體程式碼如下:

class LoggerFactory {
    private static final Map<String, LoggingOperation> instances = new HashMap<>();

    public static void register(String loggerMedium, LoggingOperation instance) {
        if (loggerMedium != null && instance != null) {
            instances.put(loggerMedium, instance);
        }
    }

    public static LoggingOperation getInstance(String loggerMedium) {
        if (instances.containsKey(loggerMedium)) {
            return instances.get(loggerMedium);
        }
        return null;
    }
}

在這裡,LoggerFactory提供了一個register註冊的方法,具體的儲存實現可以呼叫該方法註冊上來,儲存在工廠的instancesmap物件中。

我們來看看具體的儲存實現註冊的程式碼如下:

class RemoteServiceLog implements LoggingOperation {
    static {
        LoggerFactory.register("REMOTE", new RemoteServiceLog());
    }

    public void log(String message) {
        // Implementation
    }
}

由於註冊應該只發生一次,所以它發生在static類載入器載入儲存類時的塊中。

但是又有一個問題,預設情況下JVM不載入類RemoteServiceLog,除非它由應用程式在外部例項化或呼叫。因此,儘管儲存類有註冊的程式碼,但實際上註冊並不會發生,因為沒有被JVM載入,不會呼叫static程式碼塊中的程式碼, 你又犯難了。

你靈機一動,LoggerFactory是獲取儲存例項的入口點,能否在這個類上做點文章,就寫下了下面的程式碼:

class LoggerFactory {
    private static final Map<String, LoggingOperation> instances = new HashMap<>();

    static {
        try {
            loadClasses(LoggerFactory.class.getClassLoader(), "com.alvin.storage.impl");
        } catch (Exception e) {
            // log or throw exception.
        }
    }

    public static void register(String loggerMedium, LoggingOperation instance) {
        if (loggerMedium != null && instance != null) {
            instances.put(loggerMedium, instance);
        }
    }

    public static LoggingOperation getInstance(String loggerMedium) {
        if (instances.containsKey(loggerMedium)) {
            return instances.get(loggerMedium);
        }
        return null;
    }

    private static void loadClasses(ClassLoader cl, String packagePath) throws Exception {

        String dottedPackage = packagePath.replaceAll("[/]", ".");

        URL upackage = cl.getResource(packagePath);
        URLConnection conn = upackage.openConnection();

        String rr = IOUtils.toString(conn.getInputStream(), "UTF-8");

        if (rr != null) {
            String[] paths = rr.split("\n");

            for (String p : paths) {
                if (p.endsWith(".class")) {
                    Class.forName(dottedPackage + "." + p.substring(0, p.lastIndexOf('.')));
                }

            }
        }
    }
}

在上面的實現中,你使用了一個名為loadClasses的方法,該方法掃描提供的包名稱com.alvin.storage.impl並將駐留在該目錄中的所有類載入到類載入器。以這種方式,當類載入時,它們的static塊被初始化並且它們將自己註冊到LoggerFactory中。

如何在 SpringBoot 中實現此技術?

你突然發現你的應用是springboot應用,突然想到有更方便的解決方案。

因為你的儲存實現類都被標記上註解@Component,這樣 Spring 會在應用程式啟動時自動載入類,它們會自行註冊,在這種情況下你不需要使用loadClasses功能,Spring 會負責載入類。具體的程式碼實現如下:

class LoggerFactory {
    private static final Map<String, Class<? extends LoggingOperation>> instances = new HashMap<>();

    public static void register(String loggerMedium, Class<? extends LoggingOperation> instance) {
        if (loggerMedium != null && instance != null) {
            instances.put(loggerMedium, instance);
        }
    }

    public static LoggingOperation getInstance(ApplicationContext context, String loggerMedium) {
        if (instances.containsKey(loggerMedium)) {
            return context.getBean(instances.get(loggerMedium));
        }
        return null;
    }
}

getInstance需要傳入ApplicationContext物件,這樣就可以根據型別獲取具體的實現了。

修改所有儲存實現類,如下所示:

import org.springframework.stereotype.Component;

@Component
class RemoteServiceLog implements LoggingOperation {
    static {
        LoggerFactory.register("REMOTE", RemoteServiceLog.class);
    }

    public void log(String message) {
        // Implementation
    }
}

總結

我們透過一個例子,不斷迭代帶大家理解了工廠模式,工廠模式是一種建立型設計模式,用於建立同一型別的不同實現物件。我們來總結下這種動態選擇物件工廠模式的優缺點。

優點:

  • 容易管理。在新增新的儲存類時,只需將該類放入特定包中,在static程式碼塊中註冊它自己到工廠中。
  • 松耦合,當您新增新的儲存實現時,您不需要在工廠類中進行任何更改。
  • 遵循SOLID程式設計原則。

缺點:

  • 如果是用原生透過類載入的方式,代價比較大,因為它涉及 I/O 操作。但是如果使用的是SpringBoot,則無需擔心,因為框架本身會呼叫元件。
  • 需要額外編寫一個static塊,註冊自己到工廠中,一不小心就遺漏了。

歡迎關注個人公眾號【JAVA旭陽】交流溝通

相關文章