使用 Project Lombok 編寫無脂肪的 Java 代碼

已發表: 2022-03-11

如果沒有這些天,我無法想像自己編寫 Java 代碼有許多工具和庫。 傳統上,像 Google Guava 或 Joda Time(至少在 Java 8 之前的時代)之類的東西是我大部分時間最終投入到我的項目中的依賴項,無論手頭的特定領域是什麼。

Lombok 當然也值得在我的 POM 或 Gradle 構建中佔有一席之地,儘管它不是典型的庫/框架實用程序。 Lombok 已經存在了很長一段時間(2009 年首次發布)並且從那時起已經成熟了很多。 然而,我一直覺得它值得更多關注——這是一種處理 Java 天生冗長的驚人方式。

在這篇文章中,我們將探討是什麼讓 Lombok 成為如此方便的工具。

龍目島計劃

除了 JVM 本身,Java 有很多東西可以用於它,這是一個了不起的軟件。 Java 成熟且高性能,其周圍的社區和生態系統龐大而活躍。

然而,作為一種編程語言,Java 有一些自己的特性以及可以使它相當冗長的設計選擇。 添加一些我們 Java 開發人員經常需要使用的構造和類模式,我們經常會得到很多代碼行,除了遵守一些約束或框架約定之外,這些代碼幾乎沒有帶來任何實際價值。

這就是龍目島發揮作用的地方。 它使我們能夠大大減少我們需要編寫的“樣板”代碼的數量。 Lombok 的創作者是一對非常聰明的人,當然也喜歡幽默——你不能錯過他們在過去的會議上做的這個介紹!

讓我們看看 Lombok 如何發揮它的魔力以及一些使用示例。

龍目島如何運作

Lombok 充當註釋處理器,在編譯時將代碼“添加”到您的類中。 註釋處理是 Java 編譯器在版本 5 中添加的一項功能。其想法是用戶可以將註釋處理器(自己編寫,或通過第三方依賴項,如 Lombok)放入構建類路徑中。 然後,隨著編譯過程的進行,每當編譯器找到一個註解時,它都會問:“嘿,類路徑中的任何人對這個@Annotation 感興趣嗎?”。 對於那些舉手的處理器,編譯器然後將控制權連同編譯上下文一起轉移給它們,以便它們……處理。

註釋處理器最常見的情況可能是生成新的源文件或執行某種編譯時檢查。

Lombok 並不真正屬於這些類別:它所做的是修改用於表示代碼的編譯器數據結構; 即,它的抽象語法樹(AST)。 通過修改編譯器的 AST,Lombok 間接地改變了最終的字節碼生成本身。

這種不尋常且頗具侵入性的方法傳統上導致 Lombok 被視為某種黑客行為。 雖然我自己在某種程度上同意這種描述,而不是從這個詞的壞義來看,我認為龍目島是一個“聰明的、技術上值得稱道的、原創的替代品”。

儘管如此,仍有一些開發人員認為它是一種 hack,因此不使用 Lombok。 這是可以理解的,但根據我的經驗,Lombok 的生產力優勢超過了這些擔憂。 多年來,我一直很高興地將它用於生產項目。

在詳細介紹之前,我想總結一下我特別重視在我的項目中使用 Lombok 的兩個原因:

  1. Lombok 有助於保持我的代碼乾淨、簡潔和中肯。 我發現我的 Lombok 帶註釋的類非常有表現力,而且我通常發現帶註釋的代碼非常能揭示意圖,儘管並非互聯網上的每個人都一定同意。
  2. 當我開始一個項目並考慮一個領域模型時,我傾向於首先編寫一些正在進行中的類,並且隨著我進一步思考和改進它們,我會反復更改它們。 在這些早期階段,Lombok 幫助我更快地移動,無需移動或轉換它為我生成的樣板代碼。

Bean 模式和通用對象方法

我們使用的許多 Java 工具和框架都依賴於 Bean 模式。 Java Bean 是可序列化的類,具有默認的零參數構造函數(可能還有其他版本),並通過 getter 和 setter 公開其狀態,通常由私有字段支持。 我們編寫了很多這樣的代碼,例如在使用 JPA 或 JAXB 或 Jackson 等序列化框架時。

考慮這個最多包含五個屬性(屬性)的 User bean,我們希望為所有屬性提供一個額外的構造函數,有意義的字符串表示,並根據其電子郵件字段定義相等/散列:

 public class User implements Serializable { private String email; private String firstName; private String lastName; private Instant registrationTs; private boolean payingCustomer; // Empty constructor implementation: ~3 lines. // Utility constructor for all attributes: ~7 lines. // Getters/setters: ~38 lines. // equals() and hashCode() as per email: ~23 lines. // toString() for all attributes: ~3 lines. // Relevant: 5 lines; Boilerplate: 74 lines => 93% meaningless code :( }

為了簡潔起見,我沒有包括所有方法的實際實現,而是提供了列出方法和實際實現所用代碼行數的註釋。 該樣板代碼將佔該類代碼的 90% 以上!

此外,如果我以後想將email更改為emailAddress或將registrationTs設置為Date而不是Instant那麼我需要花時間(在某些情況下在我的 IDE 的幫助下,誠然)來更改諸如 get /set 方法名稱和類型,修改我的實用程序構造函數,等等。 再一次,對於我的代碼沒有實際商業價值的東西來說,這是寶貴的時間。

讓我們看看 Lombok 如何在這裡提供幫助:

 import lombok.AllArgsConstructor; import lombok.EqualsAndHashCode; import lombok.Getter; import lombok.NoArgsConstructor; import lombok.Setter; import lombok.ToString; @Getter @Setter @NoArgsConstructor @AllArgsConstructor @ToString @EqualsAndHashCode(of = {"email"}) public class User { private String email; private String firstName; private String lastName; private Instant registrationTs; private boolean payingCustomer; }

瞧! 我剛剛添加了一堆lombok.*註釋並實現了我想要的。 上面的清單正是我需要為此編寫的所有代碼。 Lombok 連接到我的編譯器進程並為我生成了所有內容(請參見下面的 IDE 屏幕截圖)。

IDE 截圖

正如您所注意到的,NetBeans 檢查器(無論 IDE 為何都會發生這種情況)確實會檢測到已編譯的類字節碼,包括 Lombok 帶入進程的添加項。 這裡發生的事情非常簡單:

  • 我使用@Getter@Setter指示Lombok 為所有屬性生成getter 和setter。 這是因為我在班級級別使用了註釋。 如果我想有選擇地指定為哪些屬性生成什麼,我可以對字段本身進行註釋。
  • 感謝@NoArgsConstructor@AllArgsConstructor ,我為我的類獲得了一個默認的空構造函數,並為所有屬性添加了一個額外的構造函數。
  • @ToString註釋自動生成一個方便的toString()方法,默認情況下顯示所有以其名稱為前綴的類屬性。
  • 最後,為了根據電子郵件字段定義equals()hashCode()方法對,我使用@EqualsAndHashCode並使用相關字段列表(在本例中只是電子郵件)對其進行參數化。

自定義 Lombok 註釋

現在讓我們按照相同的示例使用一些 Lombok 自定義:

  • 我想降低默認構造函數的可見性。 因為我只是出於 bean 兼容的原因才需要它,所以我希望該類的使用者只調用接受所有字段的構造函數。 為了強制執行此操作,我使用AccessLevel.PACKAGE自定義生成的構造函數。
  • 我想確保我的字段永遠不會被分配空值,無論是通過構造函數還是通過 setter 方法。 用@NonNull註釋類屬性就足夠了; Lombok 將在適當的構造函數和 setter 方法中生成拋出NullPointerException的空檢查。
  • 我將添加一個password屬性,但出於安全原因,在調用toString()時不希望它顯示。 這是通過@ToString的 excludes 參數完成的。
  • 我可以通過 getter 公開公開狀態,但更願意限制外部可變性。 因此,我將按原樣離開@Getter ,但再次將AccessLevel.PROTECTED用於@Setter
  • 也許我想對email字段施加一些約束,這樣,如果它被修改,就會運行某種檢查。 為此,我自己實現了setEmail()方法。 Lombok 將忽略已經存在的方法的生成。

這就是 User 類的外觀:

 import lombok.AccessLevel; import lombok.AllArgsConstructor; import lombok.EqualsAndHashCode; import lombok.Getter; import lombok.NoArgsConstructor; import lombok.NonNull; import lombok.Setter; import lombok.ToString; @Getter @Setter(AccessLevel.PROTECTED) @NoArgsConstructor(access = AccessLevel.PACKAGE) @AllArgsConstructor @ToString(exclude = {"password"}) @EqualsAndHashCode(of = {"email"}) public class User { private @NonNull String email; private @NonNull byte[] password; private @NonNull String firstName; private @NonNull String lastName; private @NonNull Instant registrationTs; private boolean payingCustomer; protected void setEmail(String email) { // Check for null (=> NullPointerException) // and valid email code (=> IllegalArgumentException) this.email = email; } }

請注意,對於某些註釋,我們將類屬性指定為純字符串。 沒問題,因為如果我們輸入錯誤或引用不存在的字段,Lombok 將引發編譯錯誤。 有了龍目島,我們就安全了。

此外,就像setEmail()方法一樣,Lombok 不會為程序員已經實現的方法生成任何內容。 這適用於所有方法和構造函數。

不可變數據結構

Lombok 擅長的另一個用例是創建不可變數據結構時。 這些通常被稱為“值類型”。 一些語言內置了對這些的支持,甚至有人提議將其合併到未來的 Java 版本中。

假設我們想要對用戶登錄操作的響應進行建模。 這是我們想要實例化並返回到應用程序的其他層的對象(例如,作為 HTTP 響應的主體被序列化為 JSON)。 這樣的 LoginResponse 根本不需要是可變的,Lombok 可以幫助簡潔地描述這一點。 當然,不可變數據結構還有許多其他用例(它們是多線程和緩存友好的,以及其他特性),但讓我們堅持這個簡單的例子:

 import lombok.EqualsAndHashCode; import lombok.Getter; import lombok.NonNull; import lombok.RequiredArgsConstructor; import lombok.ToString; import lombok.experimental.Wither; @Getter @RequiredArgsConstructor @ToString @EqualsAndHashCode public final class LoginResponse { private final long userId; private final @NonNull String authToken; private final @NonNull Instant loginTs; @Wither private final @NonNull Instant tokenExpiryTs; }

這裡值得注意:

  • 引入了@RequiredArgsConstructor註釋。 恰當地命名,它的作用是為所有尚未初始化的最終字段生成一個構造函數。
  • 在我們想要重用之前發布的 LoginResonse 的情況下(例如,想像一個“刷新令牌”操作),我們當然不想修改我們現有的實例,而是想要基於它生成一個新實例. 在這裡查看@Wither註釋如何幫助我們:它告訴 Lombok 生成一個withTokenExpiryTs(Instant tokenExpiryTs)方法,該方法創建一個新的 LoginResponse 實例,除了我們指定的新實例值之外,該實例具有所有 with'ed 實例值。 您希望所有字段都具有這種行為嗎? 只需將@Wither添加到類聲明中即可。

@Data 和 @Value

到目前為止討論的兩個用例都很常見,以至於 Lombok 提供了幾個註釋以使其更短:使用@Data註釋類將觸發 Lombok 的行為就像它已使用 @Getter + @Setter + @Getter + 註釋@ToString @EqualsAndHashCode + @RequiredArgsConstructor 。 同樣,使用@Value會將你的類變成一個不可變的(和最終的)類,同樣就像它已經用上面的列表進行了註釋一樣。

建造者模式

回到我們的 User 示例,如果我們想創建一個新實例,我們需要使用一個最多包含六個參數的構造函數。 這已經是一個相當大的數字,如果我們進一步為類添加屬性,它會變得更糟。 還假設我們想為lastNamepayingCustomer字段設置一些默認值。

Lombok 實現了一個非常強大的@Builder功能,允許我們使用 Builder 模式來創建新實例。 讓我們將它添加到我們的 User 類中:

 import lombok.AccessLevel; import lombok.AllArgsConstructor; import lombok.Builder; import lombok.EqualsAndHashCode; import lombok.Getter; import lombok.NoArgsConstructor; import lombok.NonNull; import lombok.Setter; import lombok.ToString; @Getter @Setter(AccessLevel.PROTECTED) @NoArgsConstructor(access = AccessLevel.PACKAGE) @AllArgsConstructor @ToString(exclude = {"password"}) @EqualsAndHashCode(of = {"email"}) @Builder public class User { private @NonNull String email; private @NonNull byte[] password; private @NonNull String firstName; private @NonNull String lastName = ""; private @NonNull Instant registrationTs; private boolean payingCustomer = false; }

現在我們可以像這樣流暢地創建新用戶:

 User user = User .builder() .email("[email protected]") .password("secret".getBytes(StandardCharsets.UTF_8)) .firstName("Miguel") .registrationTs(Instant.now()) .build();

很容易想像隨著我們的類的增長,這個結構變得多麼方便。

委派/組成

如果您想遵循“優先組合優於繼承”的非常理智的規則,那麼 Java 並沒有真正幫助,冗長明智。 如果要組合對象,通常需要在各處編寫委託方法調用。

Lombok 通過@Delegate提出了一個解決方案。 讓我們看一個例子。

想像一下,我們要引入一個新的ContactInformation概念。 這是我們的User擁有的一些信息,我們可能希望其他類也擁有。 然後我們可以通過這樣的接口對此進行建模:

 public interface HasContactInformation { String getEmail(); String getFirstName(); String getLastName(); }

然後我們將使用 Lombok 引入一個新的ContactInformation類:

 import lombok.Data; @Data public class ContactInformation implements HasContactInformation { private String email; private String firstName; private String lastName; }

最後,我們可以重構User以與ContactInformation組合併使用 Lombok 生成所有必需的委託調用以匹配接口契約:

 import lombok.AccessLevel; import lombok.AllArgsConstructor; import lombok.Builder; import lombok.EqualsAndHashCode; import lombok.Getter; import lombok.NoArgsConstructor; import lombok.NonNull; import lombok.Setter; import lombok.ToString; import lombok.experimental.Delegate; @Getter @Setter(AccessLevel.PROTECTED) @NoArgsConstructor(access = AccessLevel.PACKAGE) @AllArgsConstructor @ToString(exclude = {"password"}) @EqualsAndHashCode(of = {"contactInformation"}) public class User implements HasContactInformation { @Getter(AccessLevel.NONE) @Delegate(types = {HasContactInformation.class}) private final ContactInformation contactInformation = new ContactInformation(); private @NonNull byte[] password; private @NonNull Instant registrationTs; private boolean payingCustomer = false; }

請注意我不需要為HasContactInformation的方法編寫實現:這是我們告訴 Lombok 做的事情,將調用委託給我們的ContactInformation實例。

另外,因為我不希望從外部訪問委託的實例,所以我使用@Getter(AccessLevel.NONE)對其進行自定義,從而有效地防止為它生成 getter。

檢查異常

眾所周知,Java 區分已檢查和未檢查的異常。 這是該語言的爭議和批評的傳統來源,因為異常處理有時會導致我們的方式過多,特別是在處理旨在拋出檢查異常的 API 時,因此迫使我們的開發人員要么捕獲它們,要么聲明我們的方法扔掉它們。

考慮這個例子:

 public class UserService { public URL buildUsersApiUrl() { try { return new URL("https://apiserver.com/users"); } catch (MalformedURLException ex) { // Malformed? Really? throw new RuntimeException(ex); } } }

這是一種常見的模式:我們當然知道我們的 URL 格式正確,但是——因為URL構造函數拋出了一個檢查異常——我們要么被迫捕獲它,要么聲明我們的方法來拋出它並在相同的情況下排除調用者。 將這些檢查的異常包裝在RuntimeException中是一種非常擴展的做法。 如果我們需要處理的檢查異常的數量隨著我們編碼的增加而增加,情況會變得更糟。

所以這正是 Lombok 的@SneakyThrows的用途,它會將任何要在我們的方法中拋出的已檢查異常包裝成未經檢查的異常,讓我們免於麻煩:

 import lombok.SneakyThrows; public class UserService { @SneakyThrows public URL buildUsersApiUrl() { return new URL("https://apiserver.com/users"); } }

日誌記錄

您多久將記錄器實例添加到您的類中? (SLF4J 樣本)

 private static final Logger LOG = LoggerFactory.getLogger(UserService.class);

我會猜測很多。 知道了這一點,Lombok 的創建者實現了一個註解,它創建了一個具有可自定義名稱(默認為 log)的記錄器實例,支持 Java 平台上最常見的日誌記錄框架。 就像這樣(同樣,基於 SLF4J):

 import lombok.SneakyThrows; import lombok.extern.slf4j.Slf4j; @Slf4j public class UserService { @SneakyThrows public URL buildUsersApiUrl() { log.debug("Building users API URL"); return new URL("https://apiserver.com/users"); } }

註釋生成的代碼

如果我們使用 Lombok 來生成代碼,我們可能會失去註釋這些方法的能力,因為我們實際上並沒有編寫它們。 但這不是真的。 相反,Lombok 允許我們告訴它我們希望如何對生成的代碼進行註釋,不過說實話,使用一種有點特殊的符號。

考慮這個例子,目標是使用依賴注入框架:我們有一個UserService類,它使用構造函數注入來獲取對UserRepositoryUserApiClient的引用。

 package com.mgl.toptal.lombok; import javax.inject.Inject; import lombok.RequiredArgsConstructor; @RequiredArgsConstructor(onConstructor = @__(@Inject)) public class UserService { private final UserRepository userRepository; private final UserApiClient userApiClient; // Instead of: // // @Inject // public UserService(UserRepository userRepository, // UserApiClient userApiClient) { // this.userRepository = userRepository; // this.userApiClient = userApiClient; // } }

上面的示例顯示瞭如何註釋生成的構造函數。 Lombok 也允許我們對生成的方法和參數做同樣的事情。

了解更多

這篇文章中解釋的 Lombok 用法側重於我個人多年來認為最有用的那些功能。 但是,還有許多其他功能和自定義可用。

Lombok 的文檔內容豐富且全面。 他們為每個功能(註釋)提供了專門的頁面,並提供了非常詳細的解釋和示例。 如果您覺得這篇文章很有趣,我鼓勵您深入了解 lombok 及其文檔以了解更多信息。

項目站點記錄瞭如何在幾種不同的編程環境中使用 Lombok。 簡而言之,支持大多數流行的 IDE(Eclipse、NetBeans 和 IntelliJ)。 我自己經常在每個項目的基礎上從一個切換到另一個,並完美地在所有項目上使用 Lombok。

德隆波克!

Delombok 是“Lombok 工具鏈”的一部分,非常方便。 它所做的基本上是為 Lombok 註釋代碼生成 Java源代碼,執行與 Lombok 生成的字節碼相同的操作。

對於考慮採用 Lombok 但還不太確定的人來說,這是一個很好的選擇。 您可以自由地開始使用它,並且不會有“供應商鎖定”。 如果您或您的團隊後來後悔選擇,您可以隨時使用 delombok 生成相應的源代碼,然後您可以使用這些源代碼,而無需對 Lombok 產生任何剩餘依賴。

Delombok 也是一個很好的工具,可以準確地了解 Lombok 將要做什麼。 有很簡單的方法可以將它插入到您的構建過程中。

備擇方案

Java 世界中有許多工具可以類似地使用註釋處理器來在編譯時豐富或修改您的代碼,例如 Immutables 或 Google Auto Value。 這些(當然還有其他!)在功能方面與 Lombok 重疊。 我特別喜歡 Immutables 方法,並且在一些項目中也使用了它。

還值得注意的是,還有其他出色的工具為“字節碼增強”提供了類似的功能,例如 Byte Buddy 或 Javassist。 不過,這些通常在運行時工作,並且構成了它們自己的世界,超出了本文的範圍。

簡潔的Java

有許多現代 JVM 目標語言提供了更慣用甚至語言級別的設計方法,有助於解決一些相同的問題。 Groovy、Scala 和 Kotlin 無疑是很好的例子。 但是,如果您正在處理一個純 Java 項目,那麼 Lombok 是一個很好的工具,可以幫助您的程序更加簡潔、富有表現力和可維護性。

相關: Dart 語言:當 Java 和 C# 不夠鋒利時