Lombok技术点

1,000 阅读13分钟

避免重复的代码

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项目中找到。