避免重复的代码
1. Lombok的介绍
Java是一种很棒的编程语言,但有时候在我们处理常见任务或遵循某些框架实践时,可能会变得过于冗长。这往往对我们程序的业务部分没有任何实际价值,这就是Lombok发挥作用的地方,它可以提高我们的生产效率。
它的工作方式是通过插入到我们的构建过程中,并根据我们在代码中引入的一系列项目注解,自动生成Java字节码到我们的.class文件中。
将其包含在我们使用的任何系统的构建中非常简单。Project Lombok的项目页面上有关于具体操作的详细说明。我的大多数项目都是基于Maven的,所以我通常只需在提供的范围中添加它们的依赖项即可开始使用:
<dependencies>
...
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
<version>1.18.20</version>
<scope>provided</scope>
</dependency>
...
</dependencies>
我们可以在这里查找最新可用的版本。
请注意,依赖于Lombok不会使我们的.jars文件的用户也依赖于它,因为它是纯粹的构建依赖关系,而不是运行时依赖关系。
2. Getter/Setter和构造函数 - 多么重复
通过公共的getter和setter方法封装对象属性在Java世界中是一种常见的做法,许多框架广泛依赖这种“Java Bean”模式(一个具有空构造函数和用于“属性”的get/set方法的类)。
这是如此常见,以至于大多数IDE都支持自动生成这些模式(以及更多)。然而,这些代码需要存在于我们的源代码中,并在添加新属性或重命名字段时进行维护。
让我们考虑这个作为JPA实体类使用的类:
@Entity
public class User implements Serializable {
private @Id Long id; // 在持久化时设置
private String firstName;
private String lastName;
private int age;
public User() {
}
public User(String firstName, String lastName, int age) {
this.firstName = firstName;
this.lastName = lastName;
this.age = age;
}
// getters and setters: ~30 extra lines of code
}
这是一个相当简单的类,但想象一下如果我们为getter和setter添加了额外的代码。我们最终会得到一个定义,在其中模板代码的数量将超过相关的业务信息:“一个用户有名和姓,以及年龄”。
现在让我们使用Lombok对这个类进行简化:
@Entity
@Getter @Setter @NoArgsConstructor // <--- 这就是它
public class User implements Serializable {
private @Id Long id; // 在持久化时设置
private String firstName;
private String lastName;
private int age;
public User(String firstName, String lastName, int age) {
this.firstName = firstName;
this.lastName = lastName;
this.age = age;
}
}
通过添加@Getter和@Setter注解,我们告诉Lombok为类的所有字段生成这些方法。@NoArgsConstructor会生成一个空构造函数。
请注意,这是整个类的代码,与上面的带有// getters and setters注释的版本不同,我们没有省略任何内容。对于一个只有三个相关属性的类来说,这在代码上是非常大的节约!
如果我们进一步向User类添加属性(属性),同样的情况也会发生;我们将注解应用于类型本身,因此它们将默认关注所有字段。
如果我们想要调整某些属性的可见性怎么办?例如,如果我们想要保持实体的id字段的修饰符为包或受保护的可见性,因为预期它们将被读取,但不是由应用程序代码显式设置,我们可以只对这个特定字段使用更精细的@Setter注解:
private @Id @Setter(AccessLevel.PROTECTED) Long id;
3. 延迟获取器
应用程序经常需要执行昂贵的操作并保存结果以供后续使用。
例如,假设我们需要从文件或数据库中读取静态数据。通常的做法是只获取一次数据,然后缓存在应用程序中以供内存中读取。这样可以避免应用程序重复执行昂贵的操作。
另一种常见的模式是仅在首次需要时获取数据。换句话说,只有在首次调用相应的getter方法时才获取数据。我们将此称为延迟加载。
假设这些数据作为一个字段缓存在类中。现在,类必须确保对该字段的任何访问都返回缓存的数据。一种可能的实现方式是使getter方法只在字段为null时检索数据。我们将其称为延迟获取器。
Lombok通过上面介绍的@Getter注解中的lazy参数实现了这一点。
例如,考虑下面这个简单的类:
public class GetterLazy {
@Getter(lazy = true)
private final Map<String, Long> transactions = getTransactions();
private Map<String, Long> getTransactions() {
final Map<String, Long> cache = new HashMap<>();
List<String> txnRows = readTxnListFromFile();
txnRows.forEach(s -> {
String[] txnIdValueTuple = s.split(DELIMETER);
cache.put(txnIdValueTuple[0], Long.parseLong(txnIdValueTuple[1]));
});
return cache;
}
}
它从文件中读取一些交易数据并存储在一个Map中。由于文件中的数据不会改变,我们将其缓存在内存中,并通过getter方法进行访问。
如果我们现在查看这个类的编译代码,我们会看到一个getter方法,在字段为null时更新缓存并返回缓存的数据:
public class GetterLazy {
private final AtomicReference<Object> transactions = new AtomicReference();
public GetterLazy() {
}
//other methods
public Map<String, Long> getTransactions() {
Object value = this.transactions.get();
if (value == null) {
synchronized(this.transactions) {
value = this.transactions.get();
if (value == null) {
Map<String, Long> actualValue = this.readTxnsFromFile();
value = actualValue == null ? this.transactions : actualValue;
this.transactions.set(value);
}
}
}
return (Map)((Map)(value == this.transactions ? null : value));
}
}
值得注意的是,Lombok将数据字段包装在AtomicReference中。这确保对transactions字段的原子更新。getTransactions()方法还确保在transactions为null时读取文件。
我们不建议在类内部直接使用AtomicReference transactions字段。我们建议使用getTransactions()方法来访问该字段。
因此,如果我们在同一个类中使用另一个Lombok注解,比如ToString,它将使用getTransactions()而不是直接访问字段。
4. 值类/数据传输对象(DTO)
有许多情况下,我们希望定义一种数据类型,其唯一目的是将复杂的“值”表示为“数据传输对象”,大多数情况下以不可变的数据结构的形式存在,我们只构建一次并且不希望更改。
我们设计一个代表成功登录操作的类。我们希望所有字段都不能为空,并且对象是不可变的,以便我们可以线程安全地访问其属性:
public class LoginResult {
private final Instant loginTs;
private final String authToken;
private final Duration tokenValidity;
private final URL tokenRefreshUrl;
// constructor taking every field and checking nulls
// read-only accessor, not necessarily as get*() form
}
同样,我们需要编写的代码量要比我们要封装的信息量大得多。我们可以使用Lombok来改进这一点:
@RequiredArgsConstructor
@Accessors(fluent = true) @Getter
public class LoginResult {
private final @NonNull Instant loginTs;
private final @NonNull String authToken;
private final @NonNull Duration tokenValidity;
private final @NonNull URL tokenRefreshUrl;
}
一旦我们添加了@RequiredArgsConstructor注解,我们将获得一个构造函数,用于类中的所有final字段,就像我们声明的那样。将@NonNull添加到属性中会使我们的构造函数检查是否为空,并相应地抛出NullPointerException。如果字段是非final的,并且我们为它们添加了@Setter注解,也会发生这种情况。
我们是否希望为属性使用传统的get*()形式?由于我们在本例中添加了@Accessors(fluent=true),因此“getter”方法将具有与属性相同的方法名;getAuthToken()只需变为authToken()。
这种“fluent”形式也适用于非final字段的属性设置器,并允许进行链式调用:
// 假设字段现在不再是final的 return new LoginResult() .loginTs(Instant.now()) .authToken("asdasd") . // 等等
5. 核心Java样板代码
另一个我们需要编写并维护的代码是生成toString()、equals()和hashCode()方法的情况。IDE会尝试通过模板自动生成这些方法,根据我们的类属性来生成。
我们可以通过其他Lombok类级别的注解来自动化这个过程:
- @ToString: 会生成包含所有类属性的toString()方法。我们不需要自己编写和维护它,当我们丰富我们的数据模型时。
- @EqualsAndHashCode: 默认情况下,根据所有相关字段生成equals()和hashCode()方法,并根据非常完善的语义进行生成。
这些生成器提供了非常方便的配置选项。例如,如果我们的注解类是层级结构的一部分,
默认情况下,@EqualsAndHashCode会包括实体类的所有非final属性。我们可以尝试使用@EqualsAndHashCode的onlyExplicitlyIncluded属性来“修复”这个问题,使Lombok仅使用实体的主键。然而,生成的equals()方法可能会引发一些问题。Thorben Janssen在他的博客文章中更详细地解释了这种情况。
一般来说,我们应该避免使用Lombok为我们的JPA实体生成equals()和hashCode()方法。
6. 构建器模式
下面是一个用于REST API客户端的示例配置类:
public class ApiClientConfiguration {
private String host;
private int port;
private boolean useHttps;
private long connectTimeout;
private long readTimeout;
private String username;
private String password;
// 其他选项
// 空构造函数?所有组合?
// getter... 和 setter?
}
我们可以采用初始方法,使用类的默认空构造函数并为每个字段提供setter方法;然而,我们理想情况下希望配置在被构建(实例化)后不可重新设置,从而使其成为不可变对象。因此,我们希望避免使用setter,但是编写这样一个可能很长的args构造函数是一种反模式。
相反,我们可以使用@Builder注解告诉工具生成一个构建器模式,这样我们就不需要编写额外的Builder类和相关的类似setter的方法,只需将@Builder注解添加到我们的ApiClientConfiguration类中:
@Builder
public class ApiClientConfiguration {
// ... 其他部分保持不变
}
将类定义保持如上所示(不声明构造函数或setter + @Builder),我们可以像下面这样使用它:
ApiClientConfiguration config =
ApiClientConfiguration.builder()
.host("api.server.com")
.port(443)
.useHttps(true)
.connectTimeout(15_000L)
.readTimeout(5_000L)
.username("myusername")
.password("secret")
.build();
7. 受检异常负担
许多Java API设计为可以抛出多个受检异常;客户端代码被要求要么捕获这些异常,要么声明throws。我们有多少次将我们知道不会发生的异常转化为如下形式?
public String resourceAsString() {
try (InputStream is = this.getClass().getResourceAsStream("sure_in_my_jar.txt")) {
BufferedReader br = new BufferedReader(new InputStreamReader(is, "UTF-8"));
return br.lines().collect(Collectors.joining("\n"));
} catch (IOException | UnsupportedCharsetException ex) {
// 如果发生这种情况,那么这是一个bug。
throw new RuntimeException(ex); <--- 将其封装为运行时异常
}
}
如果我们想要避免这种代码模式,因为编译器不会满意(并且我们知道这些受检错误不会发生),可以使用名为@SneakyThrows的注解:
@SneakyThrows
public String resourceAsString() {
try (InputStream is = this.getClass().getResourceAsStream("sure_in_my_jar.txt")) {
BufferedReader br = new BufferedReader(new InputStreamReader(is, "UTF-8"));
return br.lines().collect(Collectors.joining("\n"));
}
}
8. 确保资源被释放
Java 7引入了try-with-resources块,以确保在退出时释放由实现java.lang.AutoCloseable接口的实例持有的资源。
Lombok通过@Cleanup提供了一种替代且更灵活的方式来实现这一点。我们可以将其用于我们想要确保释放的任何本地变量的资源。它们不需要实现任何特定的接口,我们只需调用close()方法即可:
@Cleanup InputStream is = this.getClass().getResourceAsStream("res.txt");
如果我们的释放方法有不同的名称?没问题,我们只需自定义注解:
@Cleanup("dispose") JFrame mainFrame = new JFrame("Main Window");
9. 为我们的类添加日志记录器注解
许多人通过从所选择的框架中创建一个Logger实例来稀缺地向我们的代码添加日志记录语句。比如SLF4J:
public class ApiClientConfiguration {
private static Logger LOG = LoggerFactory.getLogger(ApiClientConfiguration.class);
// LOG.debug(), LOG.info(), ...
}
这是一种非常常见的模式,Lombok开发人员为我们简化了这个过程:
@Slf4j // 或者:@Log @CommonsLog @Log4j @Log4j2 @XSlf4j
public class ApiClientConfiguration {
// log.debug(), log.info(), ...
}
支持许多日志记录框架,当然我们可以自定义实例名称、主题等。
10. 编写线程安全的方法
在Java中,我们可以使用synchronized关键字来实现临界区,但这并不是一种100%安全的方法。其他客户端代码最终也可以对我们的实例进行同步,可能导致意外的死锁。
这就是@Synchronized发挥作用的地方。我们可以使用它对我们的方法(包括实例方法和静态方法)进行注解,然后我们将得到一个自动生成的、私有的、不可见的字段,我们的实现将使用该字段进行锁定:
@Synchronized
public /* better than: synchronized */ void putValueInCache(String key, Object value) {
// 这里的代码将是线程安全的代码
}
11. 自动化对象组合
Java没有语言级别的构造来平滑“优先使用组合而不是继承”的方法。其他语言具有内置的概念,如Traits或Mixins,可以实现这一点。
当我们希望使用这种编程模式时,Lombok的@Delegate非常有用。让我们考虑一个例子:
我们希望用户(User)和客户(Customer)共享一些常见的属性,如姓名和电话号码。 我们为这些字段定义了一个接口和一个适配器类。 我们的模型类实现该接口并使用@Delegate注解到其适配器上,有效地将它们与我们的联系信息组合在一起。 首先,让我们定义一个接口:
public interface HasContactInformation {
String getFirstName();
void setFirstName(String firstName);
String getFullName();
String getLastName();
void setLastName(String lastName);
String getPhoneNr();
void setPhoneNr(String phoneNr);
}
现在,一个作为支持类的适配器:
@Data
public class ContactInformationSupport implements HasContactInformation {
private String firstName;
private String lastName;
private String phoneNr;
@Override
public String getFullName() {
return getFirstName() + " " + getLastName();
}
}
现在是有趣的部分;看看将联系信息组合到两个模型类中是多么简单:
public class User implements HasContactInformation {
// 其他User特定的属性
@Delegate(types = {HasContactInformation.class})
private final ContactInformationSupport contactInformation =
new ContactInformationSupport();
// User本身通过委托实现所有联系信息
}
对于Customer来说,情况非常相似,为了简洁起见,我们可以省略示例。
12. 是否可以回滚Lombok?
简短回答:实际上不行。
可能会担心,如果我们在项目中使用了Lombok,以后可能会想要撤销这个决定。潜在的问题可能是有大量的类使用了Lombok的注解。在这种情况下,我们可以使用同一项目中的delombok工具来解决这个问题。
通过对我们的代码进行delombok处理,我们可以获得自动生成的Java源代码,这些代码与Lombok构建的字节码具有完全相同的功能。然后,我们可以简单地用这些新的delomboked文件替换原始的带有注解的代码,并不再依赖于Lombok。
这是我们可以集成到构建中的内容。
13. 总结
在本文中,我们还没有介绍其他一些功能。我们可以深入研究功能概述,了解更多细节和用例。
此外,我们展示的大多数功能都有一些自定义选项,可能会很方便。可用的内置配置系统也可以帮助我们实现这一点。
现在,我们可以给Lombok一个机会,让它成为我们的Java开发工具集,从而提高我们的生产力。
示例代码可以在GitHub项目中找到。