Builder(Effective Java)

December 24, 2021

為了便於溝通,開發者會為模式取名稱,然後就可以「唉…你這可以用 XX 模式來解決…」,接著認為對方應該就懂怎麼寫了,真的是這樣嗎?對方真的知道 XX 模式嗎?他對 XX 模式的認識是正確的嗎?你的 XX 模式跟他的模式一樣嗎?

別的不說,你知道《Effective Java》也談過 Builder 模式,跟 Gof 說的不太一樣嗎?你知道 Java 標準 API 定義了一些 Builder,這個 Builder 跟那個 Builder 實現的是不同的模式嗎?

建立 HTTP 請求

來看實際一點的例子好了,如果你想建立 HTTP 請求,目前定義了 Request 類別,用來封裝請求的相關資訊,像是請求方法、標頭、查詢字串、請求本體等:

class Request {
	private final String method;
	private final String url;
	private final String contentType;
	private final String queryString;
	private final String body;
	
	Request(String method, String url, String contentType, String queryString, String body) {
		this.method = method;
		this.url = url;
		this.contentType = contentType;
		this.queryString = queryString;
		this.body = body;
	}
	
	...
}

HTTP 請求在建立時的選項很多,為了簡化範例,以上先用五個參數代表很多選項就好了,重點在於參數冗長時,使用 API 時會很麻煩,要確認每個參數代表的意義,就 Java 而言還得依順序指定,撰寫上非常麻煩。

另一方面,有些參數可以有預設值吧!Java 沒有指定預設值的語法,不過倒是可以用重構意思意思解決一下:

class Request {
	private final String url;
	private final String method;
	private final String contentType;
	private final String queryString;
	private final String body;
	
	Request(String url, String method, String contentType, String queryString, String body) {
		this.url = url;
		this.method = method;
		this.contentType = contentType;
		this.queryString = queryString;
		this.body = body;
	}
	
	Request(String url, String method) {
		this(url, method, "application/x-www-form-urlencoded", "", "");
	}
	
	Request(String url) {
		this(url, "GET");
	}

	...
}

這確實是可以讓客戶端在建立 Request 實例時輕鬆一些,在提供預設引數的程式語言中,不用以重載來實現預設值效果,確實也可以讓定義 Request 時簡單一些,不過本質上沒有解決長參數的問題,例如在查詢文件時,使用者還是會看到一長串參數,例如 Python 的 open 函式:

open(file, mode='r', buffering=- 1, encoding=None, errors=None, newline=None, closefd=True, opener=None)

當然,Python 的 open 函式會這麼設計,還有一些其他的考量在,這邊只是拿它來呈現一下冗長參數加上預設引數時,在 API 文件上呈現的外觀。

冗長參數

如果你的 API 具有冗長參數,程式碼可能散發著餿味(bad smell),例如會有方才談到的一些缺點,或許有人會採用 JavaBean 風格來解決:

class Request {
	private String url;
	private String method = "GET";
	private String contentType = "application/x-www-form-urlencoded";
	private String queryString = "";
	private String body = "";

	public void setUrl(String url) {
		this.url = url;
	}

	public void setMethod(String method) {
		this.method = method;
	}

	public void setContentType(String contentType) {
		this.contentType = contentType;
	}

	public void setQueryString(String queryString) {
		this.queryString = queryString;
	}

	public void setBody(String body) {
		this.body = body;
	}
	
    ...    
}

這麼一來,就可以先建立 Request,然後再透過 setter 設定相關的選項,不過 JavaBean 風格會讓將物件成為不可變動(immutable)的可能性消失,怎麼辦呢?

有人可能會想,如果一開始有個 JavaBean 用來收集選項,最後才建立 Request 呢?那就來定義一個 Builder 好了:

class Request {
	private final String url;
	private final String method;
	private final String contentType;
	private final String queryString;
	private final String body;
	
	private Request(Builder builder) {
		this.url = builder.url;
		this.method = builder.method;
		this.contentType = builder.contentType;
		this.queryString = builder.queryString;
		this.body = builder.body;
	}

    static Builder newBuilder() {
		return new Builder();
	}

	static class Builder {
		private String url;
		private String method = "GET";
		private String contentType = "application/x-www-form-urlencoded";
		private String queryString = "";
		private String body = "";

		public Builder url(String url) {
			this.url = url;
			return this;
		}

		public Builder method(String method) {
			this.method = method;
			return this;
		}

		public Builder contentType(String contentType) {
			this.contentType = contentType;
			return this;
		}

		public Builder queryString(String queryString) {
			this.queryString = queryString;
			return this;
		}

		public Builder body(String body) {
			this.body = body;
			return this;
		}
		
		public Request build() {
			return new Request(this));
		}
	}
	...
}

public class Main {
	public static void main(String[] args) {
		var reqeust = 
		        Request.newBuilder()
			           .url("https://openhome.cc")
				       .method("GET")
				       .contentType("application/x-www-form-urlencoded")
				       .queryString("keyword=java")
				       .build();
		...
	}
}

在範例中可以看到,Request 的建構式設為 private,這麼一來客戶端就不能直接 new 建構 Request 實例,Builder 是作為 Requeststatic 成員,因此可以使用 privateRequest 建構式。

為了在使用上更為簡單明瞭,Builder 不使用 setter 命名風格,傳回 this,這麼一來就可以使用以流暢 API 風格來設定選項,在收集必要選項的過程,方法的呼叫順序其實可以隨意,如果有些參數是必要的,或者要檢查格式等,可以在最後 build 時驗證選項的完整性,因為 Builder 怎麼建立最後的物件,主要是看實作,也就是說 Builder 還能用來封裝更複雜的流程,例如,或許你可以有個 header 方法,過程中可以多次呼叫,設定多個 HTTP header。

都是 Builder!

在《Effective Java》談到的 Builder 模式,就是這個範例實現的概念,若要建立物件時,可指定的選項很多,想讓選項的指定簡化或易理解,Builder 模式是一個思考的方向。

Java 11 以後的 HttpClient API,就是以 Builder 概念的具體實現。例如:

var request = HttpRequest
					.newBuilder()
					.uri(URI.create("https://openhome.cc"))
					.header("Content-Type", "application/x-www-form-urlencoded")
					.build();

就外觀而言,是有點像 Gof 的 Builder 模式,只不過 Gof 的 Builder,關注點是在 Builder 的可替換性,而《Effective Java》的 Builder 是關注選項的收集。

當然,如果用來收集選項的 Builder,真有各種實現的可能性,用介面來定義 Builder 的行為,再提供實作也不是不可以,這麼一來,也就接近 Gof 的 Builder 概念了。

也就是說,別太執著於名稱,開發者很容易為了名詞吵架,明明被當成吵架對象的名詞,根本沒有正式定義,不同領域、不同需求下產生的 XX 名詞,可能是各自解釋,或者被某個大神隨意、漫不經心地順口一提,只不過剛好都叫 XX 名詞,後續都會有一群人爭破頭地想要「選我正解」,這真是件怪事…

就目的而言,模式的名稱是便於溝通用的,如果發生了你的 XX 模式好像跟我的 XX 模式不同,也不用吵架什麼的,直接就需求來討論比較實際,畢竟 XX 這個名詞已經造成誤會,對溝通沒有幫助了。

另外就是,語言實作模式的方式,會因語言本身語法特性而變化,因而不要太執著於一定得有 classinterface、存取修飾、static 有的沒的,例如,動態定型語言就不需要 interface 這種東西,只要看有無具備某行為就可以了,也就是結構化定型(structural typing)或鴨子定型(duck typing)。

謹記,模式只是一個思考方向,不是最後目標!甚至在某些語言的特性下,不需要某些模式,或者說使用該語言,開發者們在設計上,不會出現另一門語言常見的某種撰寫方式,因而不會產生該語言中常討論的模式,這下篇再來討論吧!