1. 程式人生 > 其它 >抽象工廠模式、工廠模式、建造者模式、原型模式

抽象工廠模式、工廠模式、建造者模式、原型模式

抽象工廠模式、工廠模式、建造者模式、原型模式

工廠模式很重要,後面的很多架構設計,都是工廠模式聯合著其它設計模式使用。

  1. 一般情況下,工廠模式分為三種更加細分的型別:簡單工廠、工廠方法和抽象工廠。不過,在 GoF 的《設計模式》一書中,它將簡單工廠模式看作是工廠方法模式的一種特例,所以工廠模式只被分成了工廠方法和抽象工廠兩類。實際上,前面一種分類方法更加常見,所以,在今天的講解中,我們沿用第一種分類方法。
  2. 在這三種細分的工廠模式中,簡單工廠、工廠方法原理比較簡單,在實際的專案中也比較常用。而抽象工廠的原理稍微複雜點,在實際的專案中相對也不常用。所以,我們今天講解的重點是前兩種工廠模式。對於抽象工廠,稍微瞭解一下即可。
  3. 除此之外,我們講解的重點也不是原理和實現,因為這些都很簡單,重點還是帶你搞清楚應用場景:什麼時候該用工廠模式?相對於直接 new 來建立物件,用工廠模式來建立究竟有什麼好處呢?

簡單工廠(Simple Factory)

首先,我們來看,什麼是簡單工廠模式。我們通過一個例子來解釋一下。

在下面這段程式碼中,我們根據配置檔案的字尾(json、xml、yaml、properties),選擇不同的解析器(JsonRuleConfigParser、XmlRuleConfigParser……),將儲存在檔案中的配置解析成記憶體物件 RuleConfig。

public class RuleConfigSource {
  public RuleConfig load(String ruleConfigFilePath) {
    String ruleConfigFileExtension = getFileExtension(ruleConfigFilePath);
    IRuleConfigParser parser = null;
    if ("json".equalsIgnoreCase(ruleConfigFileExtension)) {
      parser = new JsonRuleConfigParser();
    } else if ("xml".equalsIgnoreCase(ruleConfigFileExtension)) {
      parser = new XmlRuleConfigParser();
    } else if ("yaml".equalsIgnoreCase(ruleConfigFileExtension)) {
      parser = new YamlRuleConfigParser();
    } else if ("properties".equalsIgnoreCase(ruleConfigFileExtension)) {
      parser = new PropertiesRuleConfigParser();
    } else {
      throw new InvalidRuleConfigException(
             "Rule config file format is not supported: " + ruleConfigFilePath);
    }

    String configText = "";
    //從ruleConfigFilePath檔案中讀取配置文字到configText中
    RuleConfig ruleConfig = parser.parse(configText);
    return ruleConfig;
  }

  private String getFileExtension(String filePath) {
    //...解析檔名獲取副檔名,比如rule.json,返回json
    return "json";
  }
}

為了讓程式碼邏輯更加清晰,可讀性更好,我們要善於將功能獨立的程式碼塊封裝成函式。按照這個設計思路,我們可以將程式碼中涉及 parser 建立的部分邏輯剝離出來,抽象成 createParser() 函式。重構之後的程式碼如下所示:

  public RuleConfig load(String ruleConfigFilePath) {
    String ruleConfigFileExtension = getFileExtension(ruleConfigFilePath);
    IRuleConfigParser parser = createParser(ruleConfigFileExtension);
    if (parser == null) {
      throw new InvalidRuleConfigException(
              "Rule config file format is not supported: " + ruleConfigFilePath);
    }

    String configText = "";
    //從ruleConfigFilePath檔案中讀取配置文字到configText中
    RuleConfig ruleConfig = parser.parse(configText);
    return ruleConfig;
  }

  private String getFileExtension(String filePath) {
    //...解析檔名獲取副檔名,比如rule.json,返回json
    return "json";
  }

  private IRuleConfigParser createParser(String configFormat) {
    IRuleConfigParser parser = null;
    if ("json".equalsIgnoreCase(configFormat)) {
      parser = new JsonRuleConfigParser();
    } else if ("xml".equalsIgnoreCase(configFormat)) {
      parser = new XmlRuleConfigParser();
    } else if ("yaml".equalsIgnoreCase(configFormat)) {
      parser = new YamlRuleConfigParser();
    } else if ("properties".equalsIgnoreCase(configFormat)) {
      parser = new PropertiesRuleConfigParser();
    }
    return parser;
  }
}

為了讓類的職責更加單一(設計模式原則中的單一職責,)、程式碼更加清晰,我們還可以進一步將 createParser() 函式剝離到一個獨立的類中,讓這個類只負責物件的建立。而這個類就是我們現在要講的簡單工廠模式類。具體的程式碼如下所示:

public class RuleConfigSource {
  public RuleConfig load(String ruleConfigFilePath) {
    String ruleConfigFileExtension = getFileExtension(ruleConfigFilePath);
    IRuleConfigParser parser = RuleConfigParserFactory.createParser(ruleConfigFileExtension);
    if (parser == null) {
      throw new InvalidRuleConfigException(
              "Rule config file format is not supported: " + ruleConfigFilePath);
    }

    String configText = "";
    //從ruleConfigFilePath檔案中讀取配置文字到configText中
    RuleConfig ruleConfig = parser.parse(configText);
    return ruleConfig;
  }

  private String getFileExtension(String filePath) {
    //...解析檔名獲取副檔名,比如rule.json,返回json
    return "json";
  }
}

public class RuleConfigParserFactory {
  public static IRuleConfigParser createParser(String configFormat) {
    IRuleConfigParser parser = null;
    if ("json".equalsIgnoreCase(configFormat)) {
      parser = new JsonRuleConfigParser();
    } else if ("xml".equalsIgnoreCase(configFormat)) {
      parser = new XmlRuleConfigParser();
    } else if ("yaml".equalsIgnoreCase(configFormat)) {
      parser = new YamlRuleConfigParser();
    } else if ("properties".equalsIgnoreCase(configFormat)) {
      parser = new PropertiesRuleConfigParser();
    }
    return parser;
  }
}
  1. 在上面的程式碼實現中,我們每次呼叫 RuleConfigParserFactory 的 createParser() 的時候,都要建立一個新的 parser。
  2. 實際上,如果 parser 可以複用,為了節省記憶體和物件建立的時間,我們可以將 parser 事先建立好快取起來。當呼叫 createParser() 函式的時候,我們從快取中取出 parser 物件直接使用。這有點類似單例模式和簡單工廠模式的結合,具體的程式碼實現如下所示。在接下來的講解中,我們把上一種實現方法叫作簡單工廠模式的第一種實現方法,把下面這種實現方法叫作簡單工廠模式的第二種實現方法。
public class RuleConfigParserFactory {
  private static final Map<String, RuleConfigParser> cachedParsers = new HashMap<>();

  static {
    cachedParsers.put("json", new JsonRuleConfigParser());
    cachedParsers.put("xml", new XmlRuleConfigParser());
    cachedParsers.put("yaml", new YamlRuleConfigParser());
    cachedParsers.put("properties", new PropertiesRuleConfigParser());
  }

  public static IRuleConfigParser createParser(String configFormat) {
    if (configFormat == null || configFormat.isEmpty()) {
      return null;//返回null還是IllegalArgumentException全憑你自己說了算
    }
    IRuleConfigParser parser = cachedParsers.get(configFormat.toLowerCase());
    return parser;
  }
}
  1. 對於上面兩種簡單工廠模式的實現方法,如果我們要新增新的 parser,那勢必要改動到 RuleConfigParserFactory 的程式碼,那這是不是違反開閉原則呢?實際上,如果不是需要頻繁地新增新的 parser,只是偶爾修改一下 RuleConfigParserFactory 程式碼,稍微不符合開閉原則,也是完全可以接受的。
  2. 除此之外,在 RuleConfigParserFactory 的第一種程式碼實現中,有一組 if 分支判斷邏輯,是不是應該用多型或其他設計模式來替代呢?實際上,如果 if 分支並不是很多,程式碼中有 if 分支也是完全可以接受的。應用多型或設計模式來替代 if 分支判斷邏輯,也並不是沒有任何缺點的,它雖然提高了程式碼的擴充套件性,更加符合開閉原則,但也增加了類的個數,犧牲了程式碼的可讀性。關於這一點,我們在後面章節中會詳細講到。
  3. 總結一下,儘管簡單工廠模式的程式碼實現中,有多處 if 分支判斷邏輯,違背開閉原則,但權衡擴充套件性和可讀性,這樣的程式碼實現在大多數情況下(比如,不需要頻繁地新增 parser,也沒有太多的 parser)是沒有問題的。

工廠方法(Factory Method)

如果我們非得要將 if 分支邏輯去掉,那該怎麼辦呢?比較經典處理方法就是利用多型。按照多型的實現思路,對上面的程式碼進行重構。重構之後的程式碼如下所示:

public interface IRuleConfigParserFactory {
  IRuleConfigParser createParser();
}

public class JsonRuleConfigParserFactory implements IRuleConfigParserFactory {
  @Override
  public IRuleConfigParser createParser() {
    return new JsonRuleConfigParser();
  }
}

public class XmlRuleConfigParserFactory implements IRuleConfigParserFactory {
  @Override
  public IRuleConfigParser createParser() {
    return new XmlRuleConfigParser();
  }
}

public class YamlRuleConfigParserFactory implements IRuleConfigParserFactory {
  @Override
  public IRuleConfigParser createParser() {
    return new YamlRuleConfigParser();
  }
}

public class PropertiesRuleConfigParserFactory implements IRuleConfigParserFactory {
  @Override
  public IRuleConfigParser createParser() {
    return new PropertiesRuleConfigParser();
  }
}

實際上,這就是工廠方法模式的典型程式碼實現。這樣當我們新增一種 parser 的時候,只需要新增一個實現了 IRuleConfigParserFactory 介面的 Factory 類即可。所以,工廠方法模式比起簡單工廠模式更加符合開閉原則。

從上面的工廠方法的實現來看,一切都很完美,但是實際上存在挺大的問題。問題存在於這些工廠類的使用上。接下來,我們看一下,如何用這些工廠類來實現 RuleConfigSource 的 load() 函式。具體的程式碼如下所示:

public class RuleConfigSource {
  public RuleConfig load(String ruleConfigFilePath) {
    String ruleConfigFileExtension = getFileExtension(ruleConfigFilePath);

    IRuleConfigParserFactory parserFactory = null;
    if ("json".equalsIgnoreCase(ruleConfigFileExtension)) {
      parserFactory = new JsonRuleConfigParserFactory();
    } else if ("xml".equalsIgnoreCase(ruleConfigFileExtension)) {
      parserFactory = new XmlRuleConfigParserFactory();
    } else if ("yaml".equalsIgnoreCase(ruleConfigFileExtension)) {
      parserFactory = new YamlRuleConfigParserFactory();
    } else if ("properties".equalsIgnoreCase(ruleConfigFileExtension)) {
      parserFactory = new PropertiesRuleConfigParserFactory();
    } else {
      throw new InvalidRuleConfigException("Rule config file format is not supported: " + ruleConfigFilePath);
    }
    IRuleConfigParser parser = parserFactory.createParser();

    String configText = "";
    //從ruleConfigFilePath檔案中讀取配置文字到configText中
    RuleConfig ruleConfig = parser.parse(configText);
    return ruleConfig;
  }

  private String getFileExtension(String filePath) {
    //...解析檔名獲取副檔名,比如rule.json,返回json
    return "json";
  }
}
  1. 從上面的程式碼實現來看,工廠類物件的建立邏輯又耦合進了 load() 函式中,跟我們最初的程式碼版本非常相似,引入工廠方法非但沒有解決問題,反倒讓設計變得更加複雜了。那怎麼來解決這個問題呢?
  2. 我們可以為工廠類再建立一個簡單工廠,也就是工廠的工廠,用來建立工廠類物件。這段話聽起來有點繞,我把程式碼實現出來了,你一看就能明白了。其中,RuleConfigParserFactoryMap 類是建立工廠物件的工廠類,getParserFactory() 返回的是快取好的單例工廠物件。
public class RuleConfigSource {
  public RuleConfig load(String ruleConfigFilePath) {
    String ruleConfigFileExtension = getFileExtension(ruleConfigFilePath);

    IRuleConfigParserFactory parserFactory = RuleConfigParserFactoryMap.getParserFactory(ruleConfigFileExtension);
    if (parserFactory == null) {
      throw new InvalidRuleConfigException("Rule config file format is not supported: " + ruleConfigFilePath);
    }
    IRuleConfigParser parser = parserFactory.createParser();

    String configText = "";
    //從ruleConfigFilePath檔案中讀取配置文字到configText中
    RuleConfig ruleConfig = parser.parse(configText);
    return ruleConfig;
  }

  private String getFileExtension(String filePath) {
    //...解析檔名獲取副檔名,比如rule.json,返回json
    return "json";
  }
}

//因為工廠類只包含方法,不包含成員變數,完全可以複用,
//不需要每次都建立新的工廠類物件,所以,簡單工廠模式的第二種實現思路更加合適。
public class RuleConfigParserFactoryMap { //工廠的工廠
  private static final Map<String, IRuleConfigParserFactory> cachedFactories = new HashMap<>();

  static {
    cachedFactories.put("json", new JsonRuleConfigParserFactory());
    cachedFactories.put("xml", new XmlRuleConfigParserFactory());
    cachedFactories.put("yaml", new YamlRuleConfigParserFactory());
    cachedFactories.put("properties", new PropertiesRuleConfigParserFactory());
  }

  public static IRuleConfigParserFactory getParserFactory(String type) {
    if (type == null || type.isEmpty()) {
      return null;
    }
    IRuleConfigParserFactory parserFactory = cachedFactories.get(type.toLowerCase());
    return parserFactory;
  }
}
  1. 當我們需要新增新的規則配置解析器的時候,我們只需要建立新的 parser 類和 parser factory 類,並且在 RuleConfigParserFactoryMap 類中,將新的 parser factory 物件新增到 cachedFactories 中即可。程式碼的改動非常少,基本上符合開閉原則。
  2. 實際上,對於規則配置檔案解析這個應用場景來說,工廠模式需要額外建立諸多 Factory 類,也會增加程式碼的複雜性,而且,每個 Factory 類只是做簡單的 new 操作,功能非常單薄(只有一行程式碼),也沒必要設計成獨立的類,所以,在這個應用場景下,簡單工廠模式簡單好用,比工廠方法模式更加合適。

那什麼時候該用工廠方法模式,而非簡單工廠模式呢?

  1. 我們前面提到,之所以將某個程式碼塊剝離出來,獨立為函式或者類,原因是這個程式碼塊的邏輯過於複雜,剝離之後能讓程式碼更加清晰,更加可讀、可維護。但是,如果程式碼塊本身並不複雜,就幾行程式碼而已,我們完全沒必要將它拆分成單獨的函式或者類。
  2. 基於這個設計思想,當物件的建立邏輯比較複雜,不只是簡單的 new 一下就可以,而是要組合其他類物件,做各種初始化操作的時候,我們推薦使用工廠方法模式,將複雜的建立邏輯拆分到多個工廠類中,讓每個工廠類都不至於過於複雜。而使用簡單工廠模式,將所有的建立邏輯都放到一個工廠類中,會導致這個工廠類變得很複雜。
  3. 除此之外,在某些場景下,如果物件不可複用,那工廠類每次都要返回不同的物件。如果我們使用簡單工廠模式來實現,就只能選擇第一種包含 if 分支邏輯的實現方式。如果我們還想避免煩人的 if-else 分支邏輯,這個時候,我們就推薦使用工廠方法模式。

抽象工廠(Abstract Factory)

  1. 講完了簡單工廠、工廠方法,我們再來看抽象工廠模式。抽象工廠模式的應用場景比較特殊,沒有前兩種常用,所以不是我們學習的重點,你簡單瞭解一下就可以了。
  2. 在簡單工廠和工廠方法中,類只有一種分類方式。比如,在規則配置解析那個例子中,解析器類只會根據配置檔案格式(Json、Xml、Yaml……)來分類。但是,如果類有兩種分類方式,比如,我們既可以按照配置檔案格式來分類,也可以按照解析的物件(Rule 規則配置還是 System 系統配置)來分類,那就會對應下面這 8 個 parser 類。
針對規則配置的解析器:基於介面IRuleConfigParser
JsonRuleConfigParser
XmlRuleConfigParser
YamlRuleConfigParser
PropertiesRuleConfigParser

針對系統配置的解析器:基於介面ISystemConfigParser
JsonSystemConfigParser
XmlSystemConfigParser
YamlSystemConfigParser
PropertiesSystemConfigParser
  1. 針對這種特殊的場景,如果還是繼續用工廠方法來實現的話,我們要針對每個 parser 都編寫一個工廠類,也就是要編寫 8 個工廠類。如果我們未來還需要增加針對業務配置的解析器(比如 IBizConfigParser),那就要再對應地增加 4 個工廠類。而我們知道,過多的類也會讓系統難維護。這個問題該怎麼解決呢?
  2. 抽象工廠就是針對這種非常特殊的場景而誕生的。我們可以讓一個工廠負責建立多個不同型別的物件(IRuleConfigParser、ISystemConfigParser 等),而不是隻建立一種 parser 物件。這樣就可以有效地減少工廠類的個數。具體的程式碼實現如下所示:
public interface IConfigParserFactory {  IRuleConfigParser createRuleParser();  ISystemConfigParser createSystemParser();  //此處可以擴充套件新的parser型別,比如IBizConfigParser}public class JsonConfigParserFactory implements IConfigParserFactory {  @Override  public IRuleConfigParser createRuleParser() {    return new JsonRuleConfigParser();  }  @Override  public ISystemConfigParser createSystemParser() {    return new JsonSystemConfigParser();  }}public class XmlConfigParserFactory implements IConfigParserFactory {  @Override  public IRuleConfigParser createRuleParser() {    return new XmlRuleConfigParser();  }  @Override  public ISystemConfigParser createSystemParser() {    return new XmlSystemConfigParser();  }}// 省略YamlConfigParserFactory和PropertiesConfigParserFactory程式碼

如何設計實現一個Dependency Injection框架?

當建立物件是一個“大工程”的時候,我們一般會選擇使用工廠模式,來封裝物件複雜的建立過程,將物件的建立和使用分離,讓程式碼更加清晰。那何為“大工程”呢?上面我們講了兩種情況,一種是建立過程涉及複雜的 if-else 分支判斷,另一種是物件建立需要組裝多個其他類物件或者需要複雜的初始化過程。

今天,我們再來講一個建立物件的“大工程”,依賴注入框架,或者叫依賴注入容器(Dependency Injection Container),簡稱 DI 容器。在今天的講解中,我會帶你一塊搞清楚這樣幾個問題:DI 容器跟我們講的工廠模式又有何區別和聯絡?DI 容器的核心功能有哪些,以及如何實現一個簡單的 DI 容器?

工廠模式和 DI 容器有何區別?

  1. 實際上,DI 容器底層最基本的設計思路就是基於工廠模式的。DI 容器相當於一個大的工廠類,負責在程式啟動的時候,根據配置(要建立哪些類物件,每個類物件的建立需要依賴哪些其他類物件)事先建立好物件。當應用程式需要使用某個類物件的時候,直接從容器中獲取即可。正是因為它持有一堆物件,所以這個框架才被稱為“容器”。
  2. DI 容器相對於我們上面講的工廠模式的例子來說,它處理的是更大的物件建立工程。上面講的工廠模式中,一個工廠類只負責某個類物件或者某一組相關類物件(繼承自同一抽象類或者介面的子類)的建立,而 DI 容器負責的是整個應用中所有類物件的建立。
  3. 除此之外,DI 容器負責的事情要比單純的工廠模式要多。比如,它還包括配置的解析、物件生命週期的管理。接下來,我們就詳細講講,一個簡單的 DI 容器應該包含哪些核心功能。

DI 容器的核心功能有哪些?

總結一下,一個簡單的 DI 容器的核心功能一般有三個:配置解析、物件建立和物件生命週期管理。

首先,我們來看配置解析。

  1. 在上面講的工廠模式中,工廠類要建立哪個類物件是事先確定好的,並且是寫死在工廠類程式碼中的。作為一個通用的框架來說,框架程式碼跟應用程式碼應該是高度解耦的,DI 容器事先並不知道應用會建立哪些物件,不可能把某個應用要建立的物件寫死在框架程式碼中。所以,我們需要通過一種形式,讓應用告知 DI 容器要建立哪些物件。這種形式就是我們要講的配置。
  2. 我們將需要由 DI 容器來建立的類物件和建立類物件的必要資訊(使用哪個建構函式以及對應的建構函式引數都是什麼等等),放到配置檔案中。容器讀取配置檔案,根據配置檔案提供的資訊來建立物件。
  3. 下面是一個典型的 Spring 容器的配置檔案。Spring 容器讀取這個配置檔案,解析出要建立的兩個物件:rateLimiter 和 redisCounter,並且得到兩者的依賴關係:rateLimiter 依賴 redisCounter。
public class RateLimiter {  private RedisCounter redisCounter;  public RateLimiter(RedisCounter redisCounter) {    this.redisCounter = redisCounter;  }  public void test() {    System.out.println("Hello World!");  }  //...}public class RedisCounter {  private String ipAddress;  private int port;  public RedisCounter(String ipAddress, int port) {    this.ipAddress = ipAddress;    this.port = port;  }  //...}配置檔案beans.xml:<beans>   <bean id="rateLimiter" class="com.xzg.RateLimiter">      <constructor-arg ref="redisCounter"/>   </bean>    <bean id="redisCounter" class="com.xzg.redisCounter">     <constructor-arg type="String" value="127.0.0.1">     <constructor-arg type="int" value=1234>   </bean></beans>

其次,我們再來看物件建立。

  1. 在 DI 容器中,如果我們給每個類都對應建立一個工廠類,那專案中類的個數會成倍增加,這會增加程式碼的維護成本。要解決這個問題並不難。我們只需要將所有類物件的建立都放到一個工廠類中完成就可以了,比如 BeansFactory。
  2. 你可能會說,如果要建立的類物件非常多,BeansFactory 中的程式碼會不會線性膨脹(程式碼量跟建立物件的個數成正比)呢?實際上並不會。待會講到 DI 容器的具體實現的時候,我們會講“反射”這種機制,它能在程式執行的過程中,動態地載入類、建立物件,不需要事先在程式碼中寫死要建立哪些物件。所以,不管是建立一個物件還是十個物件,BeansFactory 工廠類程式碼都是一樣的。

最後,我們來看物件的生命週期管理。

  1. 上面我們講到,簡單工廠模式有兩種實現方式,一種是每次都返回新建立的物件,另一種是每次都返回同一個事先建立好的物件,也就是所謂的單例物件。在 Spring 框架中,我們可以通過配置 scope 屬性,來區分這兩種不同型別的物件。scope=prototype 表示返回新建立的物件,scope=singleton 表示返回單例物件。
  2. 除此之外,我們還可以配置物件是否支援懶載入。如果 lazy-init=true,物件在真正被使用到的時候(比如:BeansFactory.getBean(“userService”))才被被建立;如果 lazy-init=false,物件在應用啟動的時候就事先建立好。
  3. 不僅如此,我們還可以配置物件的 init-method 和 destroy-method 方法,比如 init-method=loadProperties(),destroy-method=updateConfigFile()。DI 容器在建立好物件之後,會主動呼叫 init-method 屬性指定的方法來初始化物件。在物件被最終銷燬之前,DI 容器會主動呼叫 destroy-method 屬性指定的方法來做一些清理工作,比如釋放資料庫連線池、關閉檔案。

如何實現一個簡單的 DI 容器?

用 Java 語言來實現一個簡單的 DI 容器,核心邏輯只需要包括這樣兩個部分:配置檔案解析、根據配置檔案通過“反射”語法來建立物件。

最小原型設計

因為我們主要是講解設計模式,所以,在今天的講解中,我們只實現一個 DI 容器的最小原型。像 Spring 框架這樣的 DI 容器,它支援的配置格式非常靈活和複雜。為了簡化程式碼實現,重點講解原理,在最小原型中,我們只支援下面配置檔案中涉及的配置語法。

配置檔案beans.xml<beans>   <bean id="rateLimiter" class="com.xzg.RateLimiter">      <constructor-arg ref="redisCounter"/>   </bean>    <bean id="redisCounter" class="com.xzg.redisCounter" scope="singleton" lazy-init="true">     <constructor-arg type="String" value="127.0.0.1">     <constructor-arg type="int" value=1234>   </bean></bean

最小原型的使用方式跟 Spring 框架非常類似,示例程式碼如下所示:

public class Demo {  public static void main(String[] args) {    ApplicationContext applicationContext = new ClassPathXmlApplicationContext(            "beans.xml");    RateLimiter rateLimiter = (RateLimiter) applicationContext.getBean("rateLimiter");    rateLimiter.test();    //...  }}

提供執行入口

面向物件設計的最後一步是:組裝類並提供執行入口。在這裡,執行入口就是一組暴露給外部使用的介面和類。通過剛剛的最小原型使用示例程式碼,我們可以看出,執行入口主要包含兩部分:ApplicationContext 和 ClassPathXmlApplicationContext。其中,ApplicationContext 是介面,ClassPathXmlApplicationContext 是介面的實現類。兩個類具體實現如下所示:

public interface ApplicationContext {  Object getBean(String beanId);}public class ClassPathXmlApplicationContext implements ApplicationContext {  private BeansFactory beansFactory;  private BeanConfigParser beanConfigParser;  public ClassPathXmlApplicationContext(String configLocation) {    this.beansFactory = new BeansFactory();    this.beanConfigParser = new XmlBeanConfigParser();    loadBeanDefinitions(configLocation);  }  private void loadBeanDefinitions(String configLocation) {    InputStream in = null;    try {      in = this.getClass().getResourceAsStream("/" + configLocation);      if (in == null) {        throw new RuntimeException("Can not find config file: " + configLocation);      }      List<BeanDefinition> beanDefinitions = beanConfigParser.parse(in);      beansFactory.addBeanDefinitions(beanDefinitions);    } finally {      if (in != null) {        try {          in.close();        } catch (IOException e) {          // TODO: log error        }      }    }  }  @Override  public Object getBean(String beanId) {    return beansFactory.getBean(beanId);  }}

從上面的程式碼中,我們可以看出,ClassPathXmlApplicationContext 負責組裝 BeansFactory 和 BeanConfigParser 兩個類,串聯執行流程:從 classpath 中載入 XML 格式的配置檔案,通過 BeanConfigParser 解析為統一的 BeanDefinition 格式,然後,BeansFactory 根據 BeanDefinition 來建立物件。

配置檔案解析

配置檔案解析主要包含 BeanConfigParser 介面和 XmlBeanConfigParser 實現類,負責將配置檔案解析為 BeanDefinition 結構,以便 BeansFactory 根據這個結構來建立物件。配置檔案的解析比較繁瑣,不涉及我們要講的理論知識,不是我們講解的重點,所以這裡我只給出兩個類的大致設計思路,並未給出具體的實現程式碼。如果感興趣的話,你可以自行補充完整。具體的程式碼框架如下所示:

public interface BeanConfigParser {  List<BeanDefinition> parse(InputStream inputStream);  List<BeanDefinition> parse(String configContent);}public class XmlBeanConfigParser implements BeanConfigParser {  @Override  public List<BeanDefinition> parse(InputStream inputStream) {    String content = null;    // TODO:...    return parse(content);  }  @Override  public List<BeanDefinition> parse(String configContent) {    List<BeanDefinition> beanDefinitions = new ArrayList<>();    // TODO:...    return beanDefinitions;  }}public class BeanDefinition {  private String id;  private String className;  private List<ConstructorArg> constructorArgs = new ArrayList<>();  private Scope scope = Scope.SINGLETON;  private boolean lazyInit = false;  // 省略必要的getter/setter/constructors   public boolean isSingleton() {    return scope.equals(Scope.SINGLETON);  }  public static enum Scope {    SINGLETON,    PROTOTYPE  }    public static class ConstructorArg {    private boolean isRef;    private Class type;    private Object arg;    // 省略必要的getter/setter/constructors  }}

核心工廠類設計

  1. 最後,我們來看,BeansFactory 是如何設計和實現的。這也是我們這個 DI 容器最核心的一個類了。它負責根據從配置檔案解析得到的 BeanDefinition 來建立物件。
  2. 如果物件的 scope 屬性是 singleton,那物件建立之後會快取在 singletonObjects 這樣一個 map 中,下次再請求此物件的時候,直接從 map 中取出返回,不需要重新建立。如果物件的 scope 屬性是 prototype,那每次請求物件,BeansFactory 都會建立一個新的物件返回。
  3. 實際上,BeansFactory 建立物件用到的主要技術點就是 Java 中的反射語法:一種動態載入類和建立物件的機制。我們知道,JVM 在啟動的時候會根據程式碼自動地載入類、建立物件。至於都要載入哪些類、建立哪些物件,這些都是在程式碼中寫死的,或者說提前寫好的。但是,如果某個物件的建立並不是寫死在程式碼中,而是放到配置檔案中,我們需要在程式執行期間,動態地根據配置檔案來載入類、建立物件,那這部分工作就沒法讓 JVM 幫我們自動完成了,我們需要利用 Java 提供的反射語法自己去編寫程式碼。
  4. 搞清楚了反射的原理,BeansFactory 的程式碼就不難看懂了。具體程式碼實現如下所示:
public class BeansFactory {  private ConcurrentHashMap<String, Object> singletonObjects = new ConcurrentHashMap<>();  private ConcurrentHashMap<String, BeanDefinition> beanDefinitions = new ConcurrentHashMap<>();  public void addBeanDefinitions(List<BeanDefinition> beanDefinitionList) {    for (BeanDefinition beanDefinition : beanDefinitionList) {      this.beanDefinitions.putIfAbsent(beanDefinition.getId(), beanDefinition);    }    for (BeanDefinition beanDefinition : beanDefinitionList) {      if (beanDefinition.isLazyInit() == false && beanDefinition.isSingleton()) {        createBean(beanDefinition);      }    }  }  public Object getBean(String beanId) {    BeanDefinition beanDefinition = beanDefinitions.get(beanId);    if (beanDefinition == null) {      throw new NoSuchBeanDefinitionException("Bean is not defined: " + beanId);    }    return createBean(beanDefinition);  }  @VisibleForTesting  protected Object createBean(BeanDefinition beanDefinition) {    if (beanDefinition.isSingleton() && singletonObjects.contains(beanDefinition.getId())) {      return singletonObjects.get(beanDefinition.getId());    }    Object bean = null;    try {      Class beanClass = Class.forName(beanDefinition.getClassName());      List<BeanDefinition.ConstructorArg> args = beanDefinition.getConstructorArgs();      if (args.isEmpty()) {        bean = beanClass.newInstance();      } else {        Class[] argClasses = new Class[args.size()];        Object[] argObjects = new Object[args.size()];        for (int i = 0; i < args.size(); ++i) {          BeanDefinition.ConstructorArg arg = args.get(i);          if (!arg.getIsRef()) {            argClasses[i] = arg.getType();            argObjects[i] = arg.getArg();          } else {            BeanDefinition refBeanDefinition = beanDefinitions.get(arg.getArg());            if (refBeanDefinition == null) {              throw new NoSuchBeanDefinitionException("Bean is not defined: " + arg.getArg());            }            argClasses[i] = Class.forName(refBeanDefinition.getClassName());            argObjects[i] = createBean(refBeanDefinition);          }        }        bean = beanClass.getConstructor(argClasses).newInstance(argObjects);      }    } catch (ClassNotFoundException | IllegalAccessException            | InstantiationException | NoSuchMethodException | InvocationTargetException e) {      throw new BeanCreationFailureException("", e);    }    if (bean != null && beanDefinition.isSingleton()) {      singletonObjects.putIfAbsent(beanDefinition.getId(), bean);      return singletonObjects.get(beanDefinition.getId());    }    return bean;  }}
  1. 執行入口那裡呼叫addBeanDefinitions
  2. 然後addBeanDefinitions再呼叫createBean利用反射建立物件,如果物件的 scope 屬性是 singleton,那物件建立之後會快取在 singletonObjects 這樣一個 map 中
  3. 最後最小原型設計那裡再呼叫getBean從singletonObjects 獲取物件。

建造者模式【常用】

只要是標註常用的,基本上用的都比較多,無論篇幅長短,都不要忽視。

建造者模式的原理和程式碼實現非常簡單,掌握起來並不難,難點在於應用場景。比如,你有沒有考慮過這樣幾個問題:直接使用建構函式或者配合 set 方法就能建立物件,為什麼還需要建造者模式來建立呢?建造者模式和工廠模式都可以建立物件,那它們兩個的區別在哪裡呢?

為什麼需要建造者模式?

  1. 在平時的開發中,建立一個物件最常用的方式是,使用 new 關鍵字呼叫類的建構函式來完成。我的問題是,什麼情況下這種方式就不適用了,就需要採用建造者模式來建立物件呢?你可以先思考一下,下面我通過一個例子來帶你看一下。
  2. 假設有這樣一道設計面試題:我們需要定義一個資源池配置類 ResourcePoolConfig。這裡的資源池,你可以簡單理解為執行緒池、連線池、物件池等。在這個資源池配置類中,有以下幾個成員變數,也就是可配置項。現在,請你編寫程式碼實現這個 ResourcePoolConfig 類。

只要你稍微有點開發經驗,那實現這樣一個類對你來說並不是件難事。最常見、最容易想到的實現思路如下程式碼所示。因為 maxTotal、maxIdle、minIdle 不是必填變數,所以在建立 ResourcePoolConfig 物件的時候,我們通過往建構函式中,給這幾個引數傳遞 null 值,來表示使用預設值。

public class ResourcePoolConfig {  private static final int DEFAULT_MAX_TOTAL = 8;  private static final int DEFAULT_MAX_IDLE = 8;  private static final int DEFAULT_MIN_IDLE = 0;  private String name;  private int maxTotal = DEFAULT_MAX_TOTAL;  private int maxIdle = DEFAULT_MAX_IDLE;  private int minIdle = DEFAULT_MIN_IDLE;  public ResourcePoolConfig(String name, Integer maxTotal, Integer maxIdle, Integer minIdle) {    if (StringUtils.isBlank(name)) {      throw new IllegalArgumentException("name should not be empty.");    }    this.name = name;    if (maxTotal != null) {      if (maxTotal <= 0) {        throw new IllegalArgumentException("maxTotal should be positive.");      }      this.maxTotal = maxTotal;    }    if (maxIdle != null) {      if (maxIdle < 0) {        throw new IllegalArgumentException("maxIdle should not be negative.");      }      this.maxIdle = maxIdle;    }    if (minIdle != null) {      if (minIdle < 0) {        throw new IllegalArgumentException("minIdle should not be negative.");      }      this.minIdle = minIdle;    }  }  //...省略getter方法...}

現在,ResourcePoolConfig 只有 4 個可配置項,對應到建構函式中,也只有 4 個引數,引數的個數不多。但是,如果可配置項逐漸增多,變成了 8 個、10 個,甚至更多,那繼續沿用現在的設計思路,建構函式的引數列表會變得很長,程式碼在可讀性和易用性上都會變差。在使用建構函式的時候,我們就容易搞錯各引數的順序,傳遞進錯誤的引數值,導致非常隱蔽的 bug。

// 引數太多,導致可讀性差、引數可能傳遞錯誤ResourcePoolConfig config = new ResourcePoolConfig("dbconnectionpool", 16, null, 8, null, false , true, 10, 20,false, true);

解決這個問題的辦法你應該也已經想到了,那就是用 set() 函式來給成員變數賦值,以替代冗長的建構函式。我們直接看程式碼,具體如下所示。其中,配置項 name 是必填的,所以我們把它放到建構函式中設定,強制建立類物件的時候就要填寫。其他配置項 maxTotal、maxIdle、minIdle 都不是必填的,所以我們通過 set() 函式來設定,讓使用者自主選擇填寫或者不填寫。

public class ResourcePoolConfig {  private static final int DEFAULT_MAX_TOTAL = 8;  private static final int DEFAULT_MAX_IDLE = 8;  private static final int DEFAULT_MIN_IDLE = 0;  private String name;  private int maxTotal = DEFAULT_MAX_TOTAL;  private int maxIdle = DEFAULT_MAX_IDLE;  private int minIdle = DEFAULT_MIN_IDLE;    public ResourcePoolConfig(String name) {    if (StringUtils.isBlank(name)) {      throw new IllegalArgumentException("name should not be empty.");    }    this.name = name;  }  public void setMaxTotal(int maxTotal) {    if (maxTotal <= 0) {      throw new IllegalArgumentException("maxTotal should be positive.");    }    this.maxTotal = maxTotal;  }  public void setMaxIdle(int maxIdle) {    if (maxIdle < 0) {      throw new IllegalArgumentException("maxIdle should not be negative.");    }    this.maxIdle = maxIdle;  }  public void setMinIdle(int minIdle) {    if (minIdle < 0) {      throw new IllegalArgumentException("minIdle should not be negative.");    }    this.minIdle = minIdle;  }  //...省略getter方法...}

接下來,我們來看新的 ResourcePoolConfig 類該如何使用。我寫了一個示例程式碼,如下所示。沒有了冗長的函式呼叫和引數列表,程式碼在可讀性和易用性上提高了很多。

// ResourcePoolConfig使用舉例ResourcePoolConfig config = new ResourcePoolConfig("dbconnectionpool");config.setMaxTotal(16);config.setMaxIdle(8);

至此,我們仍然沒有用到建造者模式,通過建構函式設定必填項,通過 set() 方法設定可選配置項,就能實現我們的設計需求。如果我們把問題的難度再加大點,比如,還需要解決下面這三個問題,那現在的設計思路就不能滿足了。

  • 我們剛剛講到,name 是必填的,所以,我們把它放到建構函式中,強制建立物件的時候就設定。如果必填的配置項有很多,把這些必填配置項都放到建構函式中設定,那建構函式就又會出現引數列表很長的問題。如果我們把必填項也通過 set() 方法設定,那校驗這些必填項是否已經填寫的邏輯就無處安放了。
  • 除此之外,假設配置項之間有一定的依賴關係,比如,如果使用者設定了 maxTotal、maxIdle、minIdle 其中一個,就必須顯式地設定另外兩個;或者配置項之間有一定的約束條件,比如,maxIdle 和 minIdle 要小於等於 maxTotal。如果我們繼續使用現在的設計思路,那這些配置項之間的依賴關係或者約束條件的校驗邏輯就無處安放了。
  • 如果我們希望 ResourcePoolConfig 類物件是不可變物件,也就是說,物件在建立好之後,就不能再修改內部的屬性值。要實現這個功能,我們就不能在 ResourcePoolConfig 類中暴露 set() 方法。

為了解決這些問題,建造者模式就派上用場了。我們可以把校驗邏輯放置到 Builder 類中,先建立建造者,並且通過 set() 方法設定建造者的變數值,然後在使用 build() 方法真正建立物件之前,做集中的校驗,校驗通過之後才會建立物件。除此之外,我們把 ResourcePoolConfig 的建構函式改為 private 私有許可權。這樣我們就只能通過建造者來建立 ResourcePoolConfig 類物件。並且,ResourcePoolConfig 沒有提供任何 set() 方法,這樣我們創建出來的物件就是不可變物件了。我們用建造者模式重新實現了上面的需求,具體的程式碼如下所示:

public class ResourcePoolConfig {  private String name;  private int maxTotal;  private int maxIdle;  private int minIdle;  private ResourcePoolConfig(Builder builder) {    this.name = builder.name;    this.maxTotal = builder.maxTotal;    this.maxIdle = builder.maxIdle;    this.minIdle = builder.minIdle;  }  //...省略getter方法...  //我們將Builder類設計成了ResourcePoolConfig的內部類。  //我們也可以將Builder類設計成獨立的非內部類ResourcePoolConfigBuilder。  public static class Builder {    private static final int DEFAULT_MAX_TOTAL = 8;    private static final int DEFAULT_MAX_IDLE = 8;    private static final int DEFAULT_MIN_IDLE = 0;    private String name;    private int maxTotal = DEFAULT_MAX_TOTAL;    private int maxIdle = DEFAULT_MAX_IDLE;    private int minIdle = DEFAULT_MIN_IDLE;    public ResourcePoolConfig build() {      // 校驗邏輯放到這裡來做,包括必填項校驗、依賴關係校驗、約束條件校驗等      if (StringUtils.isBlank(name)) {        throw new IllegalArgumentException("...");      }      if (maxIdle > maxTotal) {        throw new IllegalArgumentException("...");      }      if (minIdle > maxTotal || minIdle > maxIdle) {        throw new IllegalArgumentException("...");      }      return new ResourcePoolConfig(this);    }    public Builder setName(String name) {      if (StringUtils.isBlank(name)) {        throw new IllegalArgumentException("...");      }      this.name = name;      return this;    }    public Builder setMaxTotal(int maxTotal) {      if (maxTotal <= 0) {        throw new IllegalArgumentException("...");      }      this.maxTotal = maxTotal;      return this;    }    public Builder setMaxIdle(int maxIdle) {      if (maxIdle < 0) {        throw new IllegalArgumentException("...");      }      this.maxIdle = maxIdle;      return this;    }    public Builder setMinIdle(int minIdle) {      if (minIdle < 0) {        throw new IllegalArgumentException("...");      }      this.minIdle = minIdle;      return this;    }  }}// 這段程式碼會丟擲IllegalArgumentException,因為minIdle>maxIdleResourcePoolConfig config = new ResourcePoolConfig.Builder()        .setName("dbconnectionpool")        .setMaxTotal(16)        .setMaxIdle(10)        .setMinIdle(12)        .build();

實際上,使用建造者模式建立物件,還能避免物件存在無效狀態。我再舉個例子解釋一下。比如我們定義了一個長方形類,如果不使用建造者模式,採用先建立後 set 的方式,那就會導致在第一個 set 之後,物件處於無效狀態。具體程式碼如下所示

Rectangle r = new Rectange(); // r is invalidr.setWidth(2); // r is invalidr.setHeight(3); // r is valid

這裡是說,長方形必須同時具備寬、高兩個屬性才是一個有效的長方形。只有其中一個屬性,這個長方形物件就沒有意義,是無效的。

  1. 為了避免這種無效狀態的存在,我們就需要使用建構函式一次性初始化好所有的成員變數。如果建構函式引數過多,我們就需要考慮使用建造者模式,先設定建造者的變數,然後再一次性地建立物件,讓物件一直處於有效狀態。(建造者主要解決引數過多、引數檢驗、控制物件建立後不可變的問題)
  2. 實際上,如果我們並不是很關心物件是否有短暫的無效狀態,也不是太在意物件是否是可變的。比如,物件只是用來對映資料庫讀出來的資料,那我們直接暴露 set() 方法來設定類的成員變數值是完全沒問題的。而且,使用建造者模式來構建物件,程式碼實際上是有點重複的,ResourcePoolConfig 類中的成員變數,要在 Builder 類中重新再定義一遍。

與工廠模式有何區別?

  1. 從上面的講解中,我們可以看出,建造者模式是讓建造者類來負責物件的建立工作。上面講到的工廠模式,是由工廠類來負責物件建立的工作。那它們之間有什麼區別呢?
  2. 實際上,工廠模式是用來建立不同但是相關型別的物件(繼承同一父類或者介面的一組子類),由給定的引數來決定建立哪種型別的物件。建造者模式是用來建立一種型別的複雜物件,通過設定不同的可選引數,“定製化”地建立不同的物件。

網上有一個經典的例子很好地解釋了兩者的區別:

顧客走進一家餐館點餐,我們利用工廠模式,根據使用者不同的選擇,來製作不同的食物,比如披薩、漢堡、沙拉。對於披薩來說,使用者又有各種配料可以定製,比如乳酪、西紅柿、起司,我們通過建造者模式根據使用者選擇的不同配料來製作披薩。

實際上,我們也不要太學院派,非得把工廠模式、建造者模式分得那麼清楚,我們需要知道的是,每個模式為什麼這麼設計,能解決什麼問題。只有瞭解了這些最本質的東西,我們才能不生搬硬套,才能靈活應用,甚至可以混用各種模式創造出新的模式,來解決特定場景的問題。

原型模式【不常用】

今天的講解跟具體某一語言的語法機制無關,而是通過一個 clone 散列表的例子帶你搞清楚:原型模式的應用場景,以及它的兩種實現方式:深拷貝和淺拷貝。雖然原型模式的原理和程式碼實現非常簡單,但今天舉的例子還是稍微有點複雜的

原型模式的原理與應用

如果物件的建立成本比較大,而同一個類的不同物件之間差別不大(大部分欄位都相同),在這種情況下,我們可以利用對已有物件(原型)進行復制(或者叫拷貝)的方式來建立新物件,以達到節省建立時間的目的。這種基於原型來建立物件的方式就叫作原型設計模式(Prototype Design Pattern),簡稱原型模式。

那何為“物件的建立成本比較大”?

  1. 實際上,建立物件包含的申請記憶體、給成員變數賦值這一過程,本身並不會花費太多時間,或者說對於大部分業務系統來說,這點時間完全是可以忽略的。應用一個複雜的模式,只得到一點點的效能提升,這就是所謂的過度設計,得不償失。
  2. 但是,如果物件中的資料需要經過複雜的計算才能得到(比如排序、計算雜湊值),或者需要從 RPC、網路、資料庫、檔案系統等非常慢速的 IO 中讀取,這種情況下,我們就可以利用原型模式,從其他已有物件中直接拷貝得到,而不用每次在建立新物件的時候,都重複執行這些耗時的操作。

這麼說還是比較理論,接下來,我們通過一個例子來解釋一下剛剛這段話。

  1. 假設資料庫中儲存了大約 10 萬條“搜尋關鍵詞”資訊,每條資訊包含關鍵詞、關鍵詞被搜尋的次數、資訊最近被更新的時間等。系統 A 在啟動的時候會載入這份資料到記憶體中,用於處理某些其他的業務需求。為了方便快速地查詢某個關鍵詞對應的資訊,我們給關鍵詞建立一個散列表索引。
  2. 如果你熟悉的是 Java 語言,可以直接使用語言中提供的 HashMap 容器來實現。其中,HashMap 的 key 為搜尋關鍵詞,value 為關鍵詞詳細資訊(比如搜尋次數)。我們只需要將資料從資料庫中讀取出來,放入 HashMap 就可以了。
  3. 不過,我們還有另外一個系統 B,專門用來分析搜尋日誌,定期(比如間隔 10 分鐘)批量地更新資料庫中的資料,並且標記為新的資料版本。比如,在下面的示例圖中,我們對 v2 版本的資料進行更新,得到 v3 版本的資料。這裡我們假設只有更新和新添關鍵詞,沒有刪除關鍵詞的行為。

  1. 為了保證系統 A 中資料的實時性(不一定非常實時,但資料也不能太舊),系統 A 需要定期根據資料庫中的資料,更新記憶體中的索引和資料。
  2. 我們該如何實現這個需求呢?實際上,也不難。我們只需要在系統 A 中,記錄當前資料的版本 Va 對應的更新時間 Ta,從資料庫中撈出更新時間大於 Ta 的所有搜尋關鍵詞,也就是找出 Va 版本與最新版本資料的“差集”,然後針對差集中的每個關鍵詞進行處理。如果它已經在散列表中存在了,我們就更新相應的搜尋次數、更新時間等資訊;如果它在散列表中不存在,我們就將它插入到散列表中。
  3. 按照這個設計思路,我給出的示例程式碼如下所示:
public class Demo {  private ConcurrentHashMap<String, SearchWord> currentKeywords = new ConcurrentHashMap<>();  private long lastUpdateTime = -1;  public void refresh() {    // 從資料庫中取出更新時間>lastUpdateTime的資料,放入到currentKeywords中    List<SearchWord> toBeUpdatedSearchWords = getSearchWords(lastUpdateTime);    long maxNewUpdatedTime = lastUpdateTime;    for (SearchWord searchWord : toBeUpdatedSearchWords) {      if (searchWord.getLastUpdateTime() > maxNewUpdatedTime) {        maxNewUpdatedTime = searchWord.getLastUpdateTime();      }      if (currentKeywords.containsKey(searchWord.getKeyword())) {        currentKeywords.replace(searchWord.getKeyword(), searchWord);      } else {        currentKeywords.put(searchWord.getKeyword(), searchWord);      }    }    lastUpdateTime = maxNewUpdatedTime;  }  private List<SearchWord> getSearchWords(long lastUpdateTime) {    // TODO: 從資料庫中取出更新時間>lastUpdateTime的資料    return null;  }}

不過,現在,我們有一個特殊的要求:任何時刻,系統 A 中的所有資料都必須是同一個版本的,要麼都是版本 a,要麼都是版本 b,不能有的是版本 a,有的是版本 b。那剛剛的更新方式就不能滿足這個要求了(因為資料很多,一個一個的for迴圈肯定會出現不同資料版本的問題)。除此之外,我們還要求:在更新記憶體資料的時候,系統 A 不能處於不可用狀態,也就是不能停機更新資料。

  1. 那我們該如何實現現在這個需求呢?
  2. 實際上,也不難。我們把正在使用的資料的版本定義為“服務版本”,當我們要更新記憶體中的資料的時候,我們並不是直接在服務版本(假設是版本 a 資料)上更新,而是重新建立另一個版本資料(假設是版本 b 資料),等新的版本資料建好之後,再一次性地將服務版本從版本 a 切換到版本 b。這樣既保證了資料一直可用,又避免了中間狀態的存在。
  3. 按照這個設計思路,我給出的示例程式碼如下所示:
public class Demo {  private HashMap<String, SearchWord> currentKeywords=new HashMap<>();  public void refresh() {    HashMap<String, SearchWord> newKeywords = new LinkedHashMap<>();    // 從資料庫中取出所有的資料,放入到newKeywords中    List<SearchWord> toBeUpdatedSearchWords = getSearchWords();    for (SearchWord searchWord : toBeUpdatedSearchWords) {      newKeywords.put(searchWord.getKeyword(), searchWord);    }    currentKeywords = newKeywords;  }  private List<SearchWord> getSearchWords() {    // TODO: 從資料庫中取出所有的資料    return null;  }}
  1. 不過,在上面的程式碼實現中,newKeywords 構建的成本比較高。我們需要將這 10 萬條資料從資料庫中讀出,然後計算雜湊值,構建 newKeywords。這個過程顯然是比較耗時。為了提高效率,原型模式就派上用場了。
  2. 我們拷貝 currentKeywords 資料到 newKeywords 中,然後從資料庫中只撈出新增或者有更新的關鍵詞,更新到 newKeywords 中。而相對於 10 萬條資料來說,每次新增或者更新的關鍵詞個數是比較少的,所以,這種策略大大提高了資料更新的效率。
  3. 按照這個設計思路,我給出的示例程式碼如下所示:
public class Demo {  private HashMap<String, SearchWord> currentKeywords=new HashMap<>();  private long lastUpdateTime = -1;  public void refresh() {    // 原型模式就這麼簡單,拷貝已有物件的資料,更新少量差值    HashMap<String, SearchWord> newKeywords = (HashMap<String, SearchWord>) currentKeywords.clone();    // 從資料庫中取出更新時間>lastUpdateTime的資料,放入到newKeywords中    List<SearchWord> toBeUpdatedSearchWords = getSearchWords(lastUpdateTime);    long maxNewUpdatedTime = lastUpdateTime;    for (SearchWord searchWord : toBeUpdatedSearchWords) {      if (searchWord.getLastUpdateTime() > maxNewUpdatedTime) {        maxNewUpdatedTime = searchWord.getLastUpdateTime();      }      if (newKeywords.containsKey(searchWord.getKeyword())) {        SearchWord oldSearchWord = newKeywords.get(searchWord.getKeyword());        oldSearchWord.setCount(searchWord.getCount());        oldSearchWord.setLastUpdateTime(searchWord.getLastUpdateTime());      } else {        newKeywords.put(searchWord.getKeyword(), searchWord);      }    }    lastUpdateTime = maxNewUpdatedTime;    currentKeywords = newKeywords;  }  private List<SearchWord> getSearchWords(long lastUpdateTime) {    // TODO: 從資料庫中取出更新時間>lastUpdateTime的資料    return null;  }}
  1. 這裡我們利用了 Java 中的 clone() 語法來複制一個物件。如果你熟悉的語言沒有這個語法,那把資料從 currentKeywords 中一個個取出來,然後再重新計算雜湊值,放入到 newKeywords 中也是可以接受的。畢竟,最耗時的還是從資料庫中取資料的操作。相對於資料庫的 IO 操作來說,記憶體操作和 CPU 計算的耗時都是可以忽略的。
  2. 不過,不知道你有沒有發現,實際上,剛剛的程式碼實現是有問題的。要弄明白到底有什麼問題,我們需要先了解另外兩個概念:深拷貝(Deep Copy)和淺拷貝(Shallow Copy)。

原型模式的實現方式:深拷貝和淺拷貝

1、關於深拷貝淺拷貝,我覺得這篇文章講的不錯:https://blog.csdn.net/baiye_xing/article/details/71788741

我自己的總結:

淺拷貝:對一個物件進行拷貝時,這個物件對應的類裡的成員變數。

  • 對於資料型別是基本資料型別的成員變數,淺拷貝會直接進行值拷貝,也就是將該屬性值複製一份給新的物件。因為是兩份不同的資料,所以對其中一個物件的該成員變數值進行修改,不會影響另一個物件拷貝得到的資料
  • 對於資料型別是引用資料型別的成員變數(也就是子物件,或者陣列啥的),也就是隻是將該成員變數的引用值(引用拷貝【併發引用傳遞,Java本質還是值傳遞】)複製一份給新的物件。因為實際上兩個物件的該成員變數都指向同一個例項。在這種情況下,在一個物件中修改該成員變數會影響到另一個物件的該成員變數值。
  1. 深拷貝:對基本資料型別進行值傳遞,對引用資料型別,建立一個新的物件,並複製其內容,此為深拷貝。
  2. 也就是說淺拷貝對於子物件只是拷貝了引用值,並沒有真正的拷貝整個物件。

深拷貝實現思路:

  1. 對於每個子物件都實現Cloneable 介面,並重寫clone方法。最後在最頂層的類的重寫的 clone 方法中呼叫所有子物件的 clone 方法即可實現深拷貝。【簡單的說就是:每一層的每個子物件都進行淺拷貝=深拷貝】
  2. 利用序列化。【先對物件進行序列化,緊接著馬上反序列化出 】

我們來看,在記憶體中,用散列表組織的搜尋關鍵詞資訊是如何儲存的。我畫了一張示意圖,大致結構如下所示。從圖中我們可以發現,散列表索引中,每個結點儲存的 key 是搜尋關鍵詞,value 是 SearchWord 物件的記憶體地址。SearchWord 物件本身儲存在散列表之外的記憶體空間中。

淺拷貝和深拷貝的區別在於,淺拷貝只會複製圖中的索引(散列表),不會複製資料(SearchWord 物件)本身。相反,深拷貝不僅僅會複製索引,還會複製資料本身。淺拷貝得到的物件(newKeywords)跟原始物件(currentKeywords)共享資料(SearchWord 物件),而深拷貝得到的是一份完完全全獨立的物件。具體的對比如下圖所示:

  1. 在 Java 語言中,Object 類的 clone() 方法執行的就是我們剛剛說的淺拷貝。它只會拷貝物件中的基本資料型別的資料(比如,int、long),以及引用物件(SearchWord)的記憶體地址,不會遞迴地拷貝引用物件本身。
  2. 在上面的程式碼中,我們通過呼叫 HashMap 上的 clone() 淺拷貝方法來實現原型模式。當我們通過 newKeywords 更新 SearchWord 物件的時候(比如,更新“設計模式”這個搜尋關鍵詞的訪問次數),newKeywords 和 currentKeywords 因為指向相同的一組 SearchWord 物件,就會導致 currentKeywords 中指向的 SearchWord,有的是老版本的,有的是新版本的,就沒法滿足我們之前的需求:currentKeywords 中的資料在任何時刻都是同一個版本的,不存在介於老版本與新版本之間的中間狀態。
  3. 現在,我們又該如何來解決這個問題呢?
  4. 我們可以將淺拷貝替換為深拷貝。newKeywords 不僅僅複製 currentKeywords 的索引,還把 SearchWord 物件也複製一份出來,這樣 newKeywords 和 currentKeywords 就指向不同的 SearchWord 物件,也就不存在更新 newKeywords 的資料會導致 currentKeywords 的資料也被更新的問題了。
  5. 那如何實現深拷貝呢?總結一下的話,有下面兩種方法。

第一種方法:遞迴拷貝物件、物件的引用物件以及引用物件的引用物件……直到要拷貝的物件只包含基本資料型別資料,沒有引用物件為止。根據這個思路對之前的程式碼進行重構。重構之後的程式碼如下所示:

public class Demo {  private HashMap<String, SearchWord> currentKeywords=new HashMap<>();  private long lastUpdateTime = -1;  public void refresh() {    // Deep copy    HashMap<String, SearchWord> newKeywords = new HashMap<>();    for (HashMap.Entry<String, SearchWord> e : currentKeywords.entrySet()) {      SearchWord searchWord = e.getValue();      SearchWord newSearchWord = new SearchWord(              searchWord.getKeyword(), searchWord.getCount(), searchWord.getLastUpdateTime());      newKeywords.put(e.getKey(), newSearchWord);    }    // 從資料庫中取出更新時間>lastUpdateTime的資料,放入到newKeywords中    List<SearchWord> toBeUpdatedSearchWords = getSearchWords(lastUpdateTime);    long maxNewUpdatedTime = lastUpdateTime;    for (SearchWord searchWord : toBeUpdatedSearchWords) {      if (searchWord.getLastUpdateTime() > maxNewUpdatedTime) {        maxNewUpdatedTime = searchWord.getLastUpdateTime();      }      if (newKeywords.containsKey(searchWord.getKeyword())) {        SearchWord oldSearchWord = newKeywords.get(searchWord.getKeyword());        oldSearchWord.setCount(searchWord.getCount());        oldSearchWord.setLastUpdateTime(searchWord.getLastUpdateTime());      } else {        newKeywords.put(searchWord.getKeyword(), searchWord);      }    }    lastUpdateTime = maxNewUpdatedTime;    currentKeywords = newKeywords;  }  private List<SearchWord> getSearchWords(long lastUpdateTime) {    // TODO: 從資料庫中取出更新時間>lastUpdateTime的資料    return null;  }}

第二種方法:先將物件序列化,然後再反序列化成新的物件。具體的示例程式碼如下所示:

public Object deepCopy(Object object) {
  ByteArrayOutputStream bo = new ByteArrayOutputStream();
  ObjectOutputStream oo = new ObjectOutputStream(bo);
  oo.writeObject(object);
  
  ByteArrayInputStream bi = new ByteArrayInputStream(bo.toByteArray());
  ObjectInputStream oi = new ObjectInputStream(bi);
  
  return oi.readObject();
}
  1. 剛剛的兩種實現方法,不管採用哪種,深拷貝都要比淺拷貝耗時、耗記憶體空間。針對我們這個應用場景,有沒有更快、更省記憶體的實現方式呢?
  2. 我們可以先採用淺拷貝的方式建立 newKeywords。對於需要更新的 SearchWord 物件,我們再使用深度拷貝的方式建立一份新的物件,替換 newKeywords 中的老物件。畢竟需要更新的資料是很少的。這種方式即利用了淺拷貝節省時間、空間的優點,又能保證 currentKeywords 中的中資料都是老版本的資料。具體的程式碼實現如下所示。這也是標題中講到的,在我們這個應用場景下,最快速 clone 散列表的方式。
public class Demo {
  private HashMap<String, SearchWord> currentKeywords=new HashMap<>();
  private long lastUpdateTime = -1;

  public void refresh() {
    // Shallow copy
    HashMap<String, SearchWord> newKeywords = (HashMap<String, SearchWord>) currentKeywords.clone();

    // 從資料庫中取出更新時間>lastUpdateTime的資料,放入到newKeywords中
    List<SearchWord> toBeUpdatedSearchWords = getSearchWords(lastUpdateTime);
    long maxNewUpdatedTime = lastUpdateTime;
    for (SearchWord searchWord : toBeUpdatedSearchWords) {
      if (searchWord.getLastUpdateTime() > maxNewUpdatedTime) {
        maxNewUpdatedTime = searchWord.getLastUpdateTime();
      }
      if (newKeywords.containsKey(searchWord.getKeyword())) {
        newKeywords.remove(searchWord.getKeyword());
      }
      newKeywords.put(searchWord.getKeyword(), searchWord);
    }

    lastUpdateTime = maxNewUpdatedTime;
    currentKeywords = newKeywords;
  }

  private List<SearchWord> getSearchWords(long lastUpdateTime) {
    // TODO: 從資料庫中取出更新時間>lastUpdateTime的資料
    return null;
  }
}