設計模式之美 - 46 | 建造者模式:詳解構造函數、set方法、建造者模式三種對象創建方式

這系列相關博客,參考 設計模式之美

設計模式之美 - 46 | 建造者模式:詳解構造函數、set方法、建造者模式三種對象創建方式

上兩節課中,我們學習了工廠模式,講了工廠模式的應用場景,並帶你實現了一個簡單的 DI 容器。今天,我們再來學習另外一個比較常用的創建型設計模式,Builder 模式,中文翻譯爲建造者模式或者構建者模式,也有人叫它生成器模式。

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

話不多說,帶着上面兩個問題,讓我們開始今天的學習吧!

爲什麼需要建造者模式?

在平時的開發中,創建一個對象最常用的方式是,使用 new 關鍵字調用類的構造函數來完成。我的問題是,什麼情況下這種方式就不適用了,就需要採用建造者模式來創建對象呢?你可以先思考一下,下面我通過一個例子來帶你看一下。

假設有這樣一道設計面試題:我們需要定義一個資源池配置類 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,
		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, n

解決這個問題的辦法你應該也已經想到了,那就是用 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>maxIdle
ResourcePoolConfig config = new ResourcePoolConfig.Builder()
		.setName("dbconnectionpool")
		.setMaxTotal(16)
		.setMaxIdle(10)
		.setMinIdle(12)
		.build();

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

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

爲了避免這種無效狀態的存在,我們就需要使用構造函數一次性初始化好所有的成員變量。如果構造函數參數過多,我們就需要考慮使用建造者模式,先設置建造者的變量,然後再一次性地創建對象,讓對象一直處於有效狀態。

實際上,如果我們並不是很關心對象是否有短暫的無效狀態,也不是太在意對象是否是可變的。比如,對象只是用來映射數據庫讀出來的數據,那我們直接暴露 set() 方法來設置類的成員變量值是完全沒問題的。而且,使用建造者模式來構建對象,代碼實際上是有點重複的,ResourcePoolConfig 類中的成員變量,要在 Builder 類中重新再定義一遍。

與工廠模式有何區別?

從上面的講解中,我們可以看出,建造者模式是讓建造者類來負責對象的創建工作。上一節課中講到的工廠模式,是由工廠類來負責對象創建的工作。那它們之間有什麼區別呢?

實際上,工廠模式是用來創建不同但是相關類型的對象(繼承同一父類或者接口的一組子類),由給定的參數來決定創建哪種類型的對象。建造者模式是用來創建一種類型的複雜對象,通過設置不同的可選參數,“定製化”地創建不同的對象。

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

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

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

重點回顧

好了,今天的內容到此就講完了。我們一塊來總結回顧一下,你需要重點掌握的內容。

建造者模式的原理和實現比較簡單,重點是掌握應用場景,避免過度使用。

如果一個類中有很多屬性,爲了避免構造函數的參數列表過長,影響代碼的可讀性和易用性,我們可以通過構造函數配合 set() 方法來解決。但是,如果存在下面情況中的任意一種,我們就要考慮使用建造者模式了。

  • 我們把類的必填屬性放到構造函數中,強制創建對象的時候就設置。如果必填的屬性有很多,把這些必填屬性都放到構造函數中設置,那構造函數就又會出現參數列表很長的問題。如果我們把必填屬性通過 set() 方法設置,那校驗這些必填屬性是否已經填寫的邏輯就無處安放了。

  • 如果類的屬性之間有一定的依賴關係或者約束條件,我們繼續使用構造函數配合 set()方法的設計思路,那這些依賴關係或約束條件的校驗邏輯就無處安放了。

  • 如果我們希望創建不可變對象,也就是說,對象在創建好之後,就不能再修改內部的屬性值,要實現這個功能,我們就不能在類中暴露 set() 方法。構造函數配合 set() 方法來設置屬性值的方式就不適用了。

除此之外,在今天的講解中,我們還對比了工廠模式和建造者模式的區別。工廠模式是用來創建不同但是相關類型的對象(繼承同一父類或者接口的一組子類),由給定的參數來決定創建哪種類型的對象。建造者模式是用來創建一種類型的複雜對象,可以通過設置不同的可選參數,“定製化”地創建不同的對象。

課堂討論

在下面的 ConstructorArg 類中,當 isRef 爲 true 的時候,arg 表示 String 類型的 refBeanId,type 不需要設置;當 isRef 爲 false 的時候,arg、type 都需要設置。請根據這個需求,完善 ConstructorArg 類。

public class ConstructorArg {
	private boolean isRef;
	private Class type;
	private Object arg;
	// TODO: 待完善...
}
發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章