舒服了,學習了,踩到一個 Lombok 的坑!

why技术發表於2024-03-25

你好呀,我是歪歪。

踩坑了啊,最近踩了一個 lombok 的坑,有點意思,給你分享一波。

我之前寫過一個公共的服務介面,這個介面已經有好幾個系統對接並穩定執行了很長一段時間了,長到這個介面都已經交接給別的同事一年多了。

因為是基礎服務嘛,相對穩定,所以交出去之後他也一直沒有動過這部分程式碼。

但是有一天有新服務要對接這個介面,同事反饋說遇到一個詭異的問題,這個新服務呼叫的時候,介面裡面報了一個空指標異常。

根據日誌來看,那一行程式碼大概是這樣的:

//為了脫敏我用field1、2、3來代替了
if(reqDto.getField1()
&& reqDto.getField2()!=null
&& reqDto.getField3()!=null){
//滿足條件則執行對應業務邏輯
}

reqDto 是介面入參物件,有好多欄位。具體到 field1、2、3 大概是這樣的:

@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class ReqDto {
private Boolean field1 = true;
private String field2;
private String field3;
}

所以看到這一行丟擲了空指標異常,我直接就給出了一個結論:首先排除 field1 為 null,因為有預設值。那隻可能 reqDto 傳進來的就是 null,導致在 get 欄位的時候出現了空指標異常。

但是很不幸,這個結論一秒就被推翻了。

因為 reqDto 是請求入參,在方法入口處選了幾個關鍵欄位進行列印。

如果 reqDto 是 null 的話,那麼日誌列印的時候就會先丟擲空指標異常了。

然後我又開始懷疑是部署的程式碼版本和我們看的版本不一致,可能並不是這一行報錯。

和測試同學確認之後,也排除了這個方向。

盯著報錯的那一行程式碼又看了幾秒,排除所有不可能之後,我又下了一個結論:呼叫的時候,傳遞進來的 field1 主動設值為了 null。

也就是說呼叫方有這樣的程式碼:

ReqDto reqDto = new ReqDto();
reqDto.setField1(null);

我知道,這樣的程式碼看起來很傻,但是確實只剩下這一種可能了。

於是我去看了呼叫方構建引數的寫法,準備吐槽一波為什麼要寫設定為 null 這樣的坑爹程式碼。

然而,當時我就被打臉了,呼叫方的程式碼是這樣的:

ReqDto reqDto = ReqDto.builder()
.field2("why")
.field3("max")
.build();

用的是 builder 模式構建的物件,並不是直接 new 出來的物件。

我一眼看著這個程式碼也沒有發現毛病,雖然沒有對 Boolean 型別的 field1 進行設值,但是我有預設值啊。

問呼叫方為什麼不設值,對方的回答也是一句話:我看你有預設值,我本來也是想傳 true,但是一看你的預設值就是 true,所以就沒有給值了。

對啊,這邏輯無懈可擊啊,難道......

是 builder 在裡面搞事情了?

於是我裡面寫了一個程式碼進行了驗證:

好你個濃眉大眼的 @Builder,果然是你在搞事情。

問題現象基本上就算是定位到了,用 @Builder 註解的時候,丟失預設值了。

所以拿著 “@Builder 預設值” 這樣的關鍵詞一搜:

立馬就能找到這樣的一個註解:@Builder.Default

對應到我的案例應該是這樣的:

@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class ReqDto {
@Builder.Default
private Boolean field1 = true;
private String field2;
private String field3;
}

這樣,再次執行 Demo 就會發現有預設值了:

同時我們從兩個寫法生成的 class 檔案中也可以看出一些端倪。

沒有@Builder.Default 註解的時候,class 檔案中 ReqDtoBuilder 類中關於 field1 欄位是這樣的:

但是有 @Builder.Default 註解的時候,是這樣的:

明顯是不同的處理方式。

反正,網上一搜尋,加上 @Builder.Default 註解,問題就算是解決了。

但是緊接著我想到了另外一個問題:為什麼?

為什麼我明明給了預設值,@Builder 不使用,非得給再顯示的標記一下呢?

於是我帶著這個問題在網上衝了一大圈,不說沒有找到權威的回答了,甚至沒有找到來自“民間”的回答。

所以我也只能個人猜測一下,我覺得可能是 Lombok 覺得這樣的賦預設值的寫法是 Java 語言的規範:

private Boolean field1 = true;

規範我 Lombok 肯定遵守,但是我怎麼知道你這個欄位有沒有預設值呢?

我肯定是有手段去檢查的,但是我必須要每個欄位都盲目的去瞅一眼,這個方案對我不友好啊。

這樣,我給使用者定一個規範:你給我打個標,主動告訴我那些欄位是有預設值的。對於打了標的欄位,我才去解析對應的預設值,否則我就不管了。

如果你直接 new 物件,那是 Java 的規範,我管不了。

但是如果你使用 Builder 模式,你就得遵守我的規範。不然出了問題也別賴我,誰叫你不準守我的規範。

打個標,就是 @Builder.Default。

必須要強調的是,這個觀點是歪師傅純粹的個人想法,不保真。如果你有其他的看法也可以提出來一起交流,學習一波。

吃個瓜

雖然我沒有找到關於 @Builder.Default 註解存在的意義的官方說明,但是我在 github 上找到了這個一個連結:

https://github.com/projectlombok/lombok/issues/1347

裡面的討論的問題和我們這個註解有點關係,而且我認為這是一個非常明確的 bug,但是官方卻當做 feature 給處理了。

簡單的一起吃個瓜。

2017 年 3 月 29 日的時候,一個老哥丟擲了一個問題。

首先我們看一下提出問題的老哥給的程式碼:

就上面這個程式碼,如果我們這樣去建立物件:

MyClass myClass = new MyClass();

按照 Java 規範來說,我們附了預設值的,呼叫 myClass.getEntitlements() 方法返回的肯定是一個空集合嘛。

但是,這個老哥說當 new MyClass 物件的時候,這個欄位變成了 null:

他就覺得很奇怪,於是丟擲了這個問題。

然後另外有人立馬補充了一下。說不僅是 list/set/map,任何其他 non-primitive 型別都會出現這個問題:

啥意思呢,拿我們前面的案例來說就是,你用 1.16.16 這個版本,不加 @Builder.Default 註解,執行結果是符合預期的:

但是加上 @Builder.Default 註解,執行結果會變成這樣:

build 倒是正確了,但是 new 物件的時候,你把預設值直接給乾沒了。

看到這個執行結果的第一個感覺是很奇怪,第二個感覺是這肯定是 lombok 的 BUG。

問題丟擲來之後,緊接著就有老哥來討論了:

這個哥們直接喊話官方:造孽啊,這麼大個 BUG 還有沒有人管啦?

同時他還丟擲了一個觀點:老實說,為欄位生成預設值的最直觀方法就是從欄位初始化中獲取值,而不是需要額外的 Builder.Default 註解來標記。

這個觀點,和我前面的想法倒是不謀而合。但是還是那句話:一切解釋權歸官方所有,你要用,就得遵守我制定的規範。

那麼到底是改了啥導致產生了這麼一個奇怪的 BUG 呢?

注意 omega09 這個老哥的發言的後半句:field it will be initialized twice.

initialized twice,初始化兩次,哪來的兩次?

我們把目光放到這裡來:

@NoArgsConstructor,這是個啥東西?

這不就是讓 lombok 給我們搞一個無參建構函式嗎?

搞無參建構函式的時候,不是得針對有預設值的欄位,進行一波預設值的初始化嗎?

這個算一次了。

前面我們分析了 @Builder.Default 也要對有預設值的欄位初始化一次。

所以是 twice,而且這兩次幹得都是同一個活。

開發者一看,這不行啊,得最佳化啊。

於是把 @NoArgsConstructor 的初始化延遲到了 @Builder.Default 裡面去,讓兩次合併為一次了。

這樣一看,用 Builder 模式的時候確實沒問題了,但是用 new 的時候,預設值就沒了。

這是一種經典的顧頭不顧尾的解決問題的方式。

作者可能也沒想到,大家在使用的時候會把 @Builder 和 @NoArgsConstructor 兩個註解放在一起用。

作者可能還覺得委屈呢:這明明就是兩種不同的物件構建方式啊,二選一就行了,你要放在一起?哎喲,你幹嘛~

接著一個叫做 davidje13 的老哥接過了話茬,順著 omega09 老哥的話往下說,他除了解釋兩個註解放在一起使用的場景外,還提到了一個詞:least-surprise。

least-surprise,是一個軟體設計方面的詞彙,翻譯過來就是最小驚嚇原則。

簡單來說就是我們的程式所表現出的行為,應該儘量滿足在其領域內具有一致性、顯而易見、可預測、遵循慣例。

比如我們認為的慣例是 new 物件的時候,如果有預設值會附上預設值。

結果你這個就搞沒了,就不遵循慣例了。

當然,你還是可以拿出那句萬金油的話:一切解釋權歸官方所有,你要用,就得遵守我制定的規範。我的規範就是不讓你們混用。

這就是純純的耍無賴了,相當於是做了一個違背祖宗的決定。

然而這個問題似乎並沒有官方人員參與討論,直到這個時候,2018 年 3 月 27 日:

rspiller 就是官方人員,他說:我們正在調查此事。

此時,距離這個問題提出的時間已經過去了一年。

我是比較吃驚的,因為我認為這是一個比較嚴重的 BUG 了,程式設計師在使用的時候會遇到一些就類似於我認為這個欄位一定是有預設值的,但是實際上卻變成了 null 這種莫名其妙的問題。

在官方人員介入之後,這個問題再次活躍起來。

一位 xak2000 老哥也發表了自己的看法,並艾特了官方人員:

他的觀點我是非常認同的,給你翻譯一波。

他說,導致這個問題的原因是為了消除可能出現的重複初始化。但實際上,與修改 POJO 欄位的預設初始化這種完全出乎意料的行為相比,重複初始化的問題要小得多。

當然,解決這個問題的最佳方法是以某種方式擺脫雙重初始化,同時又不破壞欄位初始化器。

但如果這不可能,或者太難,或者時間太長,那麼,就讓重複初始化發生吧!

然後把“重複初始化”寫到 @Builder.Default javadocs 中,大不了再給這幾個字加個粗。

如果有人確實寫了一些欄位初始化比較複雜的程式,這可能會導致一些問題,但比起該初始化卻沒有初始化帶來的問題要少得多。

在當前的這個情況下,當突然丟擲一個空指標異常的時候,我真的很矇蔽啊。

當然了,也有人提出了不一樣的看法:

這個哥們的核心思路剛剛相反,就是呼籲大家不要把 @Builder 和 @NoArgsConstructor 混著用。

從“點贊數”你也能看出來,大家都不喜歡這個方案。

而這個 BUG 是在 2018 年 7 月 26 日,1.18.2 版本中才最終解決的:

https://projectlombok.org/changelog

此時,距離這個問題提出,已經過去了一年又四個月。

值得注意的是,在官方的描述裡面,用的是 FEATURE 而不是 BUGFIX。

箇中差異,你可以自己去品一品。

但是現在 Lombok 都已經發展到 1.18.32 版本了,1.16.x 版本應該沒有人會去使用了。

所以,大家大機率是不會踩到這個坑的。

我覺得這個事情,瞭解“坑”具體是啥不重要,而是稍微走進一下開源專案維護者的內心世界。

開源不易,有時候真的就挺崩潰的。

編譯時註解

既然聊到 Lombok 了,順便也簡單聊聊它的工作原理。

Lombok 的核心工作原理就是編譯時註解,這個你知道吧?

不知道其實也很正常,因為我們寫業務程式碼的時候很少自定義編譯時註解,頂天了搞個執行時註解就差不多了。

其實我瞭解的也不算深入,只是大概知道它的工作原理是什麼樣的,對於原始碼沒有深入研究。

但是我可以給你分享一下兩個需要注意的地方和可以去哪裡瞭解這個玩意。

以 Lombok 的日誌相關的註解為例。

首先第一個需要注意的地方是這裡:

log 相關注解的原始碼位於這個部分,可以看到很奇怪啊,這些檔案是以 SCL.lombok 結尾的,這是什麼玩意?

這是 lombok 的小心思,其實這些都是 class 檔案,但是為了避免汙染使用者專案,它做了特殊處理。

所以你開啟這類檔案的時候選擇以 class 檔案的形式開啟就行了,就可以看到裡面的具體內容。

比如你可以看看這個檔案:

lombok.core.handlers.LoggingFramework

你會發現你們就像是列舉似的,寫了很多日誌的實現:

這個裡面把每個註解需要生成的 log 都硬編碼好了。正是因為這樣,Lombok 才知道你用什麼日誌註解,應該給你生成什麼樣的 log。

比如 log4j 是這樣的:

private static final org.apache.logging.log4j.Logger log = org.apache.logging.log4j.LogManager.getLogger(TargetType.class);

而 SLF4J 是這樣的:

private static final org.slf4j.Logger log = org.slf4j.LoggerFactory.getLogger(TargetType.class);

第二個需要注意的地方是找到入口:

這些 class 檔案載入的入口在於這個地方,是基於 Java 的 SPI 機制:

AnnotationProcessorHider 這個類裡面有兩行靜態內部類,我們看其中一個, AnnotationProcessor ,它是繼承自 AbstractProcessor 抽象類:

javax.annotation.processing.AbstractProcessor

這個抽象類,就是入口中的入口,核心中的核心。

在這個入口裡面,初始化了一個類載入器,叫做 ShadowClassLoader:

它乾的事兒就是載入那些被標記為 SCL.lombok 的 class 檔案。

然後我是怎麼知道 Lombok 是基於編譯時註解的呢?

其實這玩意在我看過的兩本書裡面都有寫,有點模糊的印象,寫文章的時候我又翻出來讀了一遍。

首先是《深入理解 Java 虛擬機器(第三版)》的第四部分程式編譯與程式碼最佳化的第 10 章:前端編譯與最佳化一節。

裡面專門有一小節,說插入式註解的:

Lombok 的主要工作地盤,就在 javac 編譯的過程中。

在書中的 361 頁,提到了編譯過程的幾個階段。

從 Java 程式碼的總體結構來看,編譯過程大致可以分為一個準備過程和三個處理過程:

  • 1.準備過程:初始化插入式註解處理器。
  • 2.解析與填充符號表過程,包括:
    • 詞法、語法分析。將原始碼的字元流轉變為標記集合,構造出抽象語法樹。
    • 填充符號表。產生符號地址和符號資訊。
  • 3.插入式註解處理器的註解處理過程:插入式註解處理器的執行階段,本章的實戰部分會設計一個插入式註解處理器來影響Javac的編譯行為。
  • 4.分析與位元組碼生成過程,包括:
    • 標註檢查。對語法的靜態資訊進行檢查。
    • 資料流及控制流分析。對程式動態執行過程進行檢查。
    • 解語法糖。將簡化程式碼編寫的語法糖還原為原有的形式。(java中的語法糖包括泛型、變長引數、自動裝拆箱、遍歷迴圈foreach等,JVM執行時並不支援這些語法,所以在編譯階段需要還原。)
    • 位元組碼生成。將前面各個步驟所生成的資訊轉換成位元組碼。

如果說 javac 編譯的過程就是 Lombok 的工作地盤,那麼其中的“插入式註解處理器的註解處理過程”就是它的工位了。

書中也提到了 Lombok 的工作原理:

第二本書是《深入理解 JVM 位元組碼》,在它的第 8 章,也詳細的描述了外掛化註解的處理原理,其中也提到了 Lombok:

最後畫了一個示意圖,是這樣的:

如果你看懂了書中的前面的十幾頁的描述,那麼看這個圖就會比較清晰了。

總之,Lombok 的核心原理就是在編譯期對於 class 檔案的魔改,幫你生成了很多程式碼。

如果你有興趣深入瞭解它的原理的話,可以去看看我前面提到的這兩本書,裡面都有手把手的實踐開發。

我就不寫了,一個原因是因為確實門檻較高,寫出來生澀難懂,對我們日常業務開發幫助也不大。

另外一個原因那不是因為我懶嘛。

相關文章