英文原文:Write Fat-free Java Code with Project Lombok
前言
没想到这次收到的是java的文章,更没想到的是内容是之前在北京实习时一家公司用到过的工具。当初看公司里的代码,发现里面没有直接创建getter/setter,后来对比发现是使用了一个叫Lombok的库,但当初没仔细看过,直接按葫芦画瓢用上了。今天顺便补一下之前错过的知识。
文章正文
现今之前我无法想象自己使用大量的工具( tools )和类库( libraries )编写java代码。 传统上,我最终在我的项目中投入大量时间在像 Google Guava 或 Joda Time (至少在java8之前的时代 )等的依赖关系上 。
Lombok 当然也应当在我的POMs(注:即maven中)和 Gradle builds(注: 一个项目自动化建构工具)中占有一席之地,尽管它不是一个典型的类库( library)/开发框架( framework )utility。 Lombok已经有了很长一段时间了(2009年首次发布),同时已经成熟了很多。 不管怎样,我一直觉得它应该的到更多的关注-这是处理java原始冗长度的一个惊人的方式。
在本文中,我们将探索到底是什么使 Lombok 成为一个如此方便的工具的。
Java有很多事情要做,除了JVM本身,这是一个非凡的软件。Java是成熟和高效的,周围的社区和生态环境非常活跃。
然而,作为一种编程语言,Java有一些自己的特性以及可能导致其相当冗长的设计选择。添加一些我们java开发者经常需要用到的constructs 和 class patterns,并且我们经常得到许多代码,这些代码除了遵守一些约束或框架约定之外,只有一点甚至根本没有真正的价值。
这里是Lombok起作用的地方。它使我们能够大大减少我们需要编写的“样板”代码的数量。Lombok的创作者是一对非常聪明的家伙,肯定有幽默的风格——你不应该错过这个他们在过去的会议上作出的介绍。
让我们一起看看Lombok怎样施展它的魔法以及一些使用示例。
Lombok如何运行的(How Lombok Works)
Lombok充当注解处理器,在编译时将代码“添加”到你的类中。注解处理器(Annotation processing)是是在版本5中添加到Java编译器中的一个功能。这个想法是用户可以将注解处理器(由自己编写,或通过第三方依赖,如Lombok)放入构建类路径( build classpath)。然后,随着编译过程的进行,每当编译器发现一个注解时,它会提出一个问题:“嘿, classpat中有人对@Annotation感兴趣不?”当那些处理器举起手应答时,编译器将控制权连同编译上下文的进程转交给它们进行处理。
对于注解处理器来说,可能最常见的情况是生成新的源文件或执行某种编译时检查。
Lombok不是真正属于这些类型:他的作用是修改那些使用象征代码注解的编译器数据结构;换句话说是,其abstract syntax tree (AST)。通过修改编译器的AST,Lombok间接地改变了本身最终的字节码生成。
这种不寻常的同时相当入侵式的方式导致Lombok在传统上被视为有点黑客。虽然我自己在某种程度上同意这种描述,但与其将其视为这些词汇的不好的含义上,我更愿意将Lombok视为一个“聪明的(clever),技术上有价值的(technically meritorious)最原始的选择(and original alternative)”。
仍旧有一些开发者认为Lombok是一个黑客并因此不使用它。这是可以理解的,但根据我的经验,Lombok的生产效益超过了这些担忧。我已经开心的在线上项目(production projects )上使用它好多年了。
在详细介绍之前,我想总结一下我特别重视在项目中使用Lombok的两个原因:
- Lombok有助于保持我的代码干净,简洁、扼要。我发现我的Lombok注解类非常传神,我通常发现注解代码是非常有意图的(注:即有明确含义可以让人了解是什么意思),尽管不是互联网上的每个人都一定同意。
- 当我开始一个项目并想到一个领域模型时,我倾向首先编写一个工作中正在进行的非常多的classes,同时如我所想进一步提炼它们从而进行迭代。在这些早期阶段,Lombok 帮助我更快地移动(注:应该是进行迭代时对相关文件或代码修改),不需要移动或转换为我生成的样板代码。
Bean模式和通用对象方法(Bean Pattern and Common Object Methods)
我们使用的许多Java工具和框架都依赖于Bean模式。Java Bean是可序列化的类,它们具有默认的零参数构造函数(也可能是其他版本),并通过getter和setter显示其状态,通常由私有字段支持。。我们写了很多这些类,,例如在使用JPA或者如JAXB或Jackson等序列化框架时。
考虑这个User bean最多可以包含五个属性(attributes/properties),我们希望为其所有属性,有意义的字符串表示以及根据其电子邮件字段( email field)定义equality/hashing的附加的构造函数:
代码语言:javascript复制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 ?
}
为了简洁起见,这里没有包含所有方法(methods)的具体实现,我仅列出了实际执行所用方法和方法具体实现所用的代码行数量的注释。该样板代码将占该class代码的90%以上!
此外,如果我之后想更改email为emailAddress,或者registrationTs的类型是Date而不是Instant,我需要花时间(某些情况会在我的IDE的帮助下进行)改变这些属性的get/set的名称或者类型,修改我的实用程序构造函数(utility constructor)等等。再次,无价值的时间为我的代码带来没有实际的商业价值(注,即在说花费的那些时间都是毫无价值的)。
让我们看看Lombok是如何在这方面帮助我们的:
代码语言:javascript复制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。这是因为我在类级别(class)使用了注解。如果我想选择性地指定要为哪些属性生成什么,我可以自己注解这些字段。 - 感谢
@NoArgsConstructor
和@AllArgsConstructor
让我得到了我的类创建默认空构造以及额外的一个用于所有属性的构造。 - 该
@ToString
注释自动生成一个方便的toString()
方法,默认情况下展示他们的名字前缀的类属性。 - 最后,要使用电子邮件字段定义的一对
equals()
和hashCode()
方法,我将@EqualsAndHashCode
其与相关字段列表(仅在本例中为电子邮件)进行了参数化。
定制Lombok注解(Customizing Lombok Annotations)
我们现在基于上面的例子使用一些Lombok自定义:
- 我想降低默认构造函数的可见性。因为我只需要它的bean兼容性的原因,我期望类的消费者只调用所有字段的构造函数。为了实现这一点,我用自定义生成的构造函数
AccessLevel.PACKAGE
。 - 我想确保我的字段永远不会被赋值为null值,既不通过构造函数也不通过setter方法。注解类属性
@NonNull
就足够了, Lombok将通过NullPointerException
在构造函数和setter方法中适当地生成null检查。 - 我会添加一个
password
属性,但是toString()
出于安全原因调用时不希望显示该属性。这是通过排除的参数来实现的@ToString
。 - 我可以通过getters暴露公开的声明(state publicly),但更愿意限制外部的可变性。所以,我要离开
@Getter
原样,再次在@Setter使用AccessLevel.PROTECTED。 - 也许我想强制一些对该
email
领域的约束,以便如果被修改,则会执行某种检查。为此,我只是setEmail()
自己实现这个方法。Lombok只会省略已经存在的方法。
这就是 User类现在的样子:
代码语言:javascript复制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;
}
}
请注意,对于某些注解,我们将类属性指定为纯字符串。没有问题,因为如果我们比如打错字或提到一个不存在的field时,Lombok会抛出一个编译异常。与Lombok在一起,我们是安全的。
另外,就像这个setEmail()
方法一样,Lombok将会乖乖的,并且不会为程序员已经实现的方法生成任何东西。这适用于所有的方法和构造函数。
不可变数据结构(Immutable Data Structures)
Lombok另一个使用场景是用来创建不可变的数据结构( immutable data structures)时。这些经常被称为“value types” 。一些语言内置了对这些的支持,甚至有一个建议将其纳入未来的Java版本。
假设我们要对用户登录操作的响应建模。这是那种我们想要实例化并返回应用程序其他层的对象(例如,将JSON序列化为HTTP响应的主体)。这样一个LoginResponse就不需要是可变的了,Lombok可以帮忙简洁的描述这个。当然,还有许多其他使用不可变数据结构的用例(它们是多线程和缓存友好型等),但让我们坚持这个简单的例子:
代码语言:javascript复制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
注解被引入。恰当的命名,它所做的是为尚未初始化的所有final字段生成一个构造函数。
在我们想要重用以前发布的LoginResonse的情况下(想象一下,例如“刷新令牌”操作),我们当然不想修改我们现有的实例,而是要根据它来生成一个新的实例。看看@Wither
注解如何在这里帮助我们:它告诉Lombok生成一个withTokenExpiryTs(Instant tokenExpiryTs)
方法,用来来创建一个新的LoginResponse实例的方法,其中除了我们指定的新的实例值外,包含所有之前被使用的实例值。你想为所有领域的这种行为?只需添加@Wither
到类声明即可(注:即class 上)。
@Data 和 @Value(@Data and @Value)
目前为止所讨论的两个用例是很常见的,以至于Lombok 有两个注解可以使它们更短:使用 @Data
注解一个class触发Lombok就像使用 @Getter
@Setter
@ToString
@EqualsAndHashCode
@RequiredArgsConstructor
.一样。同样的使用 @Value
注解,会把你的class变为一个 immutable( final)的,也就像它已经用上面的列表注解一样。
生成器模式(Builder Pattern)
回到我们User示例,如果我们要创建一个新的实例,我们需要使用最多六个参数的构造函数。这已经是一个相当大的数字,如果我们进一步添加属性到类,将会变得更糟。还假设我们想为这些lastName
和payingCustomer
字段设置一些默认值。
Lombok 实现了一个非常强大的@Builder
功能,允许我们使用Builder Pattern来创建新的实例。我们将它添加到我们的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;
}
现在我们能够流畅地创建这样的新用户:
代码语言:javascript复制User user = User
.builder()
.email("miguel.garcia@toptal.com")
.password("secret".getBytes(StandardCharsets.UTF_8))
.firstName("Miguel")
.registrationTs(Instant.now())
.build();
很容易想象,随着我们的classes发展,这个结构变得如何便利。
授权/组成(Delegation/Composition)
如果你想遵循 “favor composition over inheritance” 这个非常理智的规则,这对java来说是不是真正的帮助以及明智的。如果要组合对象,通常需要在整个地方编写委托方法调用( delegating method calls)。
Lombok为此提供了一个使用@Delegate
注解的解决方案。我们来看一个例子。
想象一下,我们想引入一个新的概念ContactInformation
。这是我们的一些信息User
,我们可能希望其他类也有。然后,我们可以通过这样的 interface建模:
public interface HasContactInformation {
String getEmail();
String getFirstName();
String getLastName();
}
然后我们引入一个使用Lombok的 ContactInformation
类(class):
import lombok.Data;
@Data
public class ContactInformation implements HasContactInformation {
private String email;
private String firstName;
private String lastName;
}
最后,我们可以使用ContactInformation
重构User
,并使用Lombok生成所有必需的委托调用( delegating calls)以匹配接口协议( interface contract):
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让其去做的事情,将调用委派(delegating calls )给我们的ContactInformation
实现类(instance)。
另外,我不希望从外部访问 delegated instance,所以我使用 @Getter(AccessLevel.NONE)
定制来有效地阻止它的getter方法的创建。
检查异常(Checked Exceptions)
众所周知,Java区分了被检查和未检查的异常。,因为异常处理有时会导致我们的方式太多,特别是在处理旨在抛出检查异常的API时,因此迫使我们的开发人员捕获它们或抛出它们。
考虑这个例子:
代码语言:javascript复制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
,这是一个very extended practice。如果我们需要处理的检查异常的数量随着代码的增加而增加,这会变得更糟。
所以这正是Lombok的@SneakyThrows
目的,它会将任何被检查的异常包装在我们的方法中被抛出一个未经检查的异常,并使我们免于麻烦:
import lombok.SneakyThrows;
public class UserService {
@SneakyThrows
public URL buildUsersApiUrl() {
return new URL("https://apiserver.com/users");
}
}
日志(Logging)
你添加像这样的logger到你的classes里频率如何?(SLF4J 示例)
代码语言:javascript复制private static final Logger LOG = LoggerFactory.getLogger(UserService.class);
我猜是相当多的。知道这一点,Lombok的创造者实现了一个注解,创建一个可定制的名称(默认为log)的记录器实例,支持Java平台上最常见的日志记录框架。就像这样(基于SLF4J):
代码语言:javascript复制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");
}
}
注解生成代码(Annotating Generated Code)
如果我们使用Lombok生成代码,看起来我们将失去注解这些方法的能力,因为我们并不是在写这些方法。但这不是真的。相反,Lombok允许我们告诉它如何让生成的代码被注解,尽管说实话是通过一些有些奇怪的符号实现的。
考虑这个例子,针对使用依赖注入框架:我们有一个 UserService
class,使用构造函数注入来获取一个 UserRepository
和一个serApiClient
.
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也允许我们为生成的方法和参数做同样的事情。
学习更多(Learning More)
这篇文章中解释的Lombok使用方法主要关注多年来我个人认为最有用的功能。但是,还有许多其他功能和自定义功能。
Lombok’s documentation 非常翔实和全面。它们为每个单独的功能(注解)提供专门的页面,具有非常详细的说明和示例。如果你发现这个帖子有趣,我鼓励你更深入地了解lombok及其文档,以了解更多信息。
项目网站记录了如何在几个不同的编程环境中使用Lombok。简而言之,支持最流行的IDE(Eclipse,NetBeans和IntelliJ)。我自己每个项目都经常从一个转换到另一个,并且完美地使用Lombok。
Delombok!
Delombok 是 “Lombok toolchain”的一部分,可以非常方便。它所做的是基本上生成您的Lombok注解代码的Java 源代码,执行与Lombok生成的字节码相同的操作。
对于考虑采用Lombok的人来说,这是一个很好的选择,但还不太确定。您可以自由地开始使用它,并且不会有“vendor lock-in”。如果您或您的团队后悔选择,您可以随时使用delombok生成相应的源代码,然后您可以使用它们,而不需要Lombok任何剩余的依赖关系。
Delombok也是一个很好的工具,可以了解Lombok将在做什么。有很简单的方式将其插入到构建过程中。
备选方案(Alternatives)
Java世界中有许多可以使用类似的注解处理器,以便在编译时丰富或修改代码的工具。例如Immutables或Google Auto Value。这些(当然还有其他的)与Lombok结合互补。我特别喜欢Immutables方法(the Immutables approach),并且也在一些项目中使用它。
还值得注意的是,还有其他很好的工具提供了类似的字符串增强功能,比如Byte Buddy或者Javassist。这些通常在运行时工作,而且包括一个超出本文讨论范围的自己的世界。
简洁的Java(Concise Java)
有一些现代的JVM目标语言提供更多的惯用语言或甚至语言水平设计方法来帮助解决一些相同的问题。当然,Groovy,Scala和Kotlin是很好的例子。但是,如果您正在开发一个仅限Java的项目,那么Lombok是一个很好的工具来帮助您的程序更简洁,更具表现力和可维护性。