使用 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 的两个原因:
- Lombok 有助于保持我的代码干净、简洁和中肯。 我发现我的 Lombok 带注释的类非常有表现力,而且我通常发现带注释的代码非常能揭示意图,尽管并非互联网上的每个人都一定同意。
- 当我开始一个项目并考虑一个领域模型时,我倾向于首先编写一些正在进行中的类,并且随着我进一步思考和改进它们,我会反复更改它们。 在这些早期阶段,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 屏幕截图)。
正如您所注意到的,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 示例,如果我们想创建一个新实例,我们需要使用一个最多包含六个参数的构造函数。 这已经是一个相当大的数字,如果我们进一步为类添加属性,它会变得更糟。 还假设我们想为lastName和payingCustomer字段设置一些默认值。
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类,它使用构造函数注入来获取对UserRepository和UserApiClient的引用。
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 是一个很好的工具,可以帮助您的程序更加简洁、富有表现力和可维护性。
