避免重复的代码
1. Lombok的介绍
Java是一种很棒的编程语言,但有时候在我们处理常见任务或遵循某些框架实践时,可能会变得过于冗长。这往往对我们程序的业务部分没有任何实际价值,这就是Lombok发挥作用的地方,它可以提高我们的生产效率。
它的工作方式是通过插入到我们的构建过程中,并根据我们在代码中引入的一系列项目注解,自动生成Java字节码到我们的.class文件中。
将其包含在我们使用的任何系统的构建中非常简单。Project Lombok的项目页面上有关于具体操作的详细说明。我的大多数项目都是基于Maven的,所以我通常只需在提供的范围中添加它们的依赖项即可开始使用:
...
org.projectlombok
lombok
1.18.20
provided
...
我们可以在这里查找最新可用的版本。
请注意,依赖于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 // {
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 transactions = new AtomicReference();
public GetterLazy() {
}
//other methods
public Map getTransactions() {
Object value = this.transactions.get();
if (value == null) {
synchronized(this.transactions) {
value = this.transactions.get();
if (value == null) {
Map 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);