Java ♨️ 客製化 Lombok @Builder 的方法 (一):傳入 Null 採用預設值
筆者曾任職 Yahoo ,《軟體需求溝通 ─ 從外商公司學跨部門協作開發》線上課程講師,紛絲團《程式猿吃香蕉🍌》
Lombok 是 Java 開發時常用的函式庫,它提供簡單的 Annotation ,在編譯時自動產生程式碼,讓工程師可以少打很多字。其中, Lombok 有一個很常用的 Annotation 叫做 @Builder
,可以幫助我們快速採用 Builder Pattern 建立物件。
▍@Builder 的妙用
《Effective Java》書中提到,Builder Pattern 是當物件有多個參數 (parameters) 時,建立物件更好的方式。
The builder pattern is a good choice when designing classes whose constructors or static factories would have more than a handful of parameters.
— Effective Java, 2nd Edition
Builder Pattern 具體長什麼樣子呢?我們直接來看程式碼,假設我們要建立一個 Person 物件,裡面分別有 name 和 age 兩個欄位要透過參數設定,Builder Pattern 可以這樣建立物件:
從 Builder Pattern 建立物件的方式,我們可以發現,有別於傳統使用建構子 (Constructor) 建立物件的方式,Builder Pattern 的寫法可以讓呼叫方更靈活地設定欄位值。接下來,我們分別用 Lombok @Builder
和原生 Java 程式碼來實作 Builder Pattern 的細節,程式碼範例如下:
⊙ 採用 Lombok @Builder
程式碼實作
⊙ 原生 Java 程式碼實作
我們可以看到,在相同功能下,使用 Lombok 可以大幅減少程式碼撰寫的數量。
雖然 Lombok 很方便,但實際開發時,預設的 Builder 行為不一定完全符合我們的需要,對它做客製化是經常出現的需求。因此,我將整理系列文章,分享在不同情境下,幾種客製化 Lombok @Builder
的方法。
首先來看第一個情境:有關於「預設值」的難題。當我們建立 Person 物件時,裡面的欄位 name 和 age (如下圖),要怎麼給它們預設值呢?
▍情境:傳入 Null 時,怎麼給欄位預設值 ?
大多數情況,若要給某個物件內欄位預設值,只要在該欄位加上 @Builder.Default
上就可以了,但這不包含該欄位被傳入 null 的情況。我們通常希望欄位被傳入 null 的時候,會採用預設值,如果你是這樣期待的話,很遺憾地:
傳入 null 時,
@Builder.Default
的預設值會失效
舉個例子,假設我們在 Person 物件多加一個 bonusPoint 欄位,給它預設值 0 ,我們可以這樣寫,如下圖所示:
當我們建立物件的時候 (如下圖),若沒有調用 bonusPoint() ,bonusPoint 會是預設值 0。注意!即使我們 bonusPoint 設定成 final 也會生效,在編譯時 Lombok 會幫我們覆寫掉原本的 Person 類別 (Class)。
看起來很美好,那問題出在哪呢?
如果我們建立物件時 (如下圖),不小心傳了 null 到 bonusPoint() ,bonusPoint 就會被設定成 null,而不是預設值 0。
讀者可能會想說,我怎麼可能傳 null 進去? 其實還真不一定,因為:
開發時,傳入值通常是來自另一個變數 (variable),而那個值可能是 null。
舉個例子,我們以外部變數 userRequest 來設定 Person (如下圖),其中 userRequest.bonusPoint 是從前端傳過來的參數,有可能是 null。
這情形通常發生在 API 新增欄位的情況 (經常發生),後端需要作向後相容 (Backward Compatible),某些尚未升級的前端程式發來的請求欄位值可能是 null,面對這個問題,當然,我們可以多寫一段 if 判斷式,來決定預設值:
只是這樣寫的話, bonusPoint 的不變量 (invaraint) 條件就洩漏出去了,沒有封裝在 Person 物件裏面,不符合物件導向開發的精神,也讓程式碼變得很亂。
那該怎麼做呢?以下分享兩種做法。
▍來客製化 @Builder 吧!
(以下實做基於 Lombok 1.18.8)
❶ 從 Getter 下手,若 null 回傳預設值
這個解法的思路是:
我們既然無法在「放入時」設定預設值,就改在「取出時」返回預設值。
因此,我們實做一個 Getter 為 getBonusPoint(),判斷當 bonusPoint 被設定為 null 或是沒被設定時,就返回預設值,程式碼範例如下:
如果你習慣用 Lombok 的 @Getter
來做 Getter,可以這樣寫:
記得在欄位加上 @Getter(AccessLevel.NONE)
,讓我們可以自行實作 Getter。
這麼一來,當使用 Person 時,不管 userRequest.bonusPoint 是 null ,或 bonusPoint 沒設定,都可以返回預設值了,以下示範呼叫方的使用情境:
因為我們是從 Getter 下手,所以 bonusPoint 的值其實沒有「真真正正地」被設定,它本身可能是 null,只是呼叫方都是透過 getBonusPoint() 讀取值,所以可以有預設值返回。
「從 Getter 下手,若 null 回傳預設值」的做法有兩個重點:
- (1) 欄位要設定為 private 的,必須確保只有唯一的 getter (範例裡為 getBonusPoint) 可以取出來欄位。
- (2) 當物件內部有其他邏輯時,要記得統一使用 getter 來取欄位,以免讀到 null 值。
以下程式碼是錯誤實作的例子:
從上述程式碼我們可以看到,第 6 行如果 bonusPoint 不是 private 而是 public,外部直接讀取 bonusPoint 就會讀到 null 而非預設值。另外,在第12 行物件裏面邏輯 getDiscount() 用到 bonusPoint 時,也要記得改用 getBonusPoint() 做欄位讀取,否則也可能會讀到 null。
接下來我們來看第二種做法。
❷ 放棄 final 修飾字,在 Builder 裡初始化
我們已知 Lombok 會自動幫我們生成 Builder 類別,因此這個解法的思路是:
我們何不自行定義 Builder 類別,來做欄位的初始化呢?
Lombok 建立的 Builder 類別名稱,會依據 @Builder
它所在類別 (Class) 做決定,命名方式是加上 Builder 後綴。舉例來說,當 @Builder
放在 Person 類別,就會對應產生 PersonBuilder 類別 (加上了 Builder 後綴),因此我們就自行實做一個 PersonBuilder 類別,來初始化 bonusPoint 欄位,程式碼範例如下:
這個做法同樣有兩個重點:
- (1) 欄位不能是 final,因為我們先自行初始化了預設值,final 會讓欄位無法被二次設定。如果設定成 final,值就永遠是預設值了。
- (2) 要實作設定方法 bonusPoint() ,避免 null 被呼叫方設定進來。
雖然這個做法有個小缺點,就是不能使用 final ,但這個缺點需要看情況:
只有當物件內部要拿欄位值做操作時,不能使用 final 才會是問題。
否則一般情況下,採用 private 對欄位做保護就可以了。
此外,跟上個方法比起來,這個做法是「真真實實地」把值設定到 bonusPoint,當我們的物件內部邏輯有用到 bonusPoint 時,可以不用強制透過 getBonusPoint() 取用,誤用的情況會少很多。
▍小結
有時候函式庫很方便也很好用,但身為工程師不應該被函式庫束縛住。在需要客製化的時候,便考驗動手實作的能力。以上分享兩種方法:
- ❶ 從 Getter 下手,若 null 回傳預設值
- ❷ 放棄 final 修飾字,在 Builder 裡初始化
解題思路分別是從「輸出」和「輸入」兩端去掐頭捉尾,達成返回預設值的功能。不管未來是不是有更好用的函式庫,這樣的實作練習都是很有趣的。下篇文章也會繼續分享,在其他情境下,客製化 Lombok @Builder 的作法。
若是喜歡我分享的內容,可以訂閱我的粉絲團《程式猿吃香蕉🍌》,在軟體開發的路上,一起分享交流,一起成長。