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

你好呀,我是歪歪。

踩坑了啊,最近踩了一個 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 文件的魔改,幫你生成了很多代碼。

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

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

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

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章