编程

Lombok 项目介绍

784 2024-06-15 17:29:00

1. 避免重复代码

Java 是一种很棒的语言,但对于我们在代码中必须执行的常见任务或遵守某些框架实践来说,它有时会变得过于冗长。这通常不会给我们项目的商业方面带来任何真正的价值,而这正是 Lombok 让我们更有效率的地方。

它的工作方式是嵌入到我们的构建过程,并根据我们在代码中引入的诸多项目注释,将 Java 字节码自动生成到 .class 文件中。

将它引入在我们的构建中,无论我们使用哪个系统,都是非常直接的。Lombok 项目的项目页面上有详细的细节说明。我的大多数项目都是基于maven 的,所以我通常只是在 provided  scope 中引入依赖,如下:

<dependencies>
    ...
    <dependency>
        <groupId>org.projectlombok</groupId>
        <artifactId>lombok</artifactId>
        <version>1.18.30</version>
        <scope>provided</scope>
    </dependency>
    ...
</dependencies>

我们可以在这里查看最新的可用版本。

请注意,依赖 Lombok 不会让我们的 .jar 用户也依赖它,因为它是一个纯粹的编译时依赖,而不是运行时。

2. Getters/Setters, Constructors – 不断重复

通过公共 getter 和 setter 方法封装对象属性在 Java 世界中是一种常见的做法,许多框架广泛依赖于这种 “Java Bean” 模式(一个具有空构造函数和 get/set 方法的类用于“属性”)。

这是如此常见,以至于大多数 IDE 都支持为这些模式自动生成代码。但是,此代码需要存在于我们的源代码中,并在添加新属性或重命名字段时进行维护。

假设我们想将这个类用作 JPA 实体:

@Entity
public class User implements Serializable {

    private @Id Long id; // will be set when persisting

    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 添加了额外的代码。我们最终会得到一个定义,其中零值代码的样板比相关的业务信息更多:“用户有名字(firstName)、姓氏(lastName)和年龄(age)。”

现在让我们将该类 Lombok 化:

@Entity
@Getter @Setter @NoArgsConstructor // <--- THIS is it
public class User implements Serializable {

    private @Id Long id; // will be set when persisting

    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 为该类的所有字段生成 getter 和 setter。@NoArgsConstructor 将导致生成空的构造函数。

请注意,这是整个类的代码,我们没有遗漏任何任何,不同于上述带有 //getters 和 setters 注释的版本。对于三个相关属性类来说,这是一个显著的代码节省!

如果我们进一步向 User 类添加属性(property),也会发生同样的情况;我们将注释应用于类型本身,因此默认情况下它们会考虑所有字段。
如果我们想改进某些属性的可见性,该怎么办?例如,如果我们想保持实体的 id 字段修饰符包内可见 或 protected 可见,使之它们可被读取,但不是由应用代码显式设置的,我们可以对这个特定字段使用细粒度的 @Setter

private @Id @Setter(AccessLevel.PROTECTED) Long id;

3. 惰性 Getter

应用程序通常需要执行昂贵的操作并保存结果以供后续使用。
例如,假设我们需要从文件或数据库中读取静态数据。通常情况下,最好检索一次这些数据,然后将其缓存以允许在应用中进行内存读取。这样可以避免应用重复昂贵的操作。

另一种常见模式是仅在首次需要时检索此数据。换句话说,我们只有在第一次调用相应的 getter 时才能获得数据。我们称之为懒加载。
让我们假设这些数据被缓存为类中的一个字段。该类现在必须确保对该字段的任何访问都返回缓存的数据。实现此类的一种可能方法是使 getter 方法仅在字段为 null 时检索数据。我们称之为惰性 getter

Lombok 通过我们上面看到的 @Getter 注释中的惰性参数实现了这一点。

例如,这个简单的类:

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。这确保了事务字段的原子更新。getTransactions() 方法还确保在事务为 null 时读取文件。

我们不鼓励直接在类中使用 AtomicReference 事务字段。我们建议使用 getTransactions() 方法来访问字段。

因此,如果我们在同一类中使用另一个 Lombok 注释,如 ToString,它将使用 getTransactions(),而不是直接访问字段。

4. 值类/DTO’s

在许多情况下,我们希望定义一种数据类型,其唯一目的是将复杂的“值”表示为“数据传输对象”,大多数情况下都是以我们只构建一次且永远不想更改的不可变数据结构的形式。

我们设计了一个类来表示成功的登录操作。我们希望所有字段都是非 null 的,对象是不可变的,这样我们就可以通过线程安全地访问其属性:

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 注释后,我们将获得类中所有最终字段的构造函数,就像我们声明它们一样。将 @NonNull 添加到属性中会使我们的构造函数检查是否可为 Null,并相应地抛出 NullPointerException。如果字段是非最终字段,并且我们为它们添加了@Setter,也会发生这种情况。

我们想要属性使用无聊的旧的 get*() 形式吗?由于我们在本例中添加了 @Accessors(fluent=true),“getters” 将具有与属性相同的方法名称;getAuthToken() 简单地变为 authToken()

这种 “fluent” 的形式将适用于属性设置程序的非最终字段,并允许链式调用:

// Imagine fields were no longer final now
return new LoginResult()
  .loginTs(Instant.now())
  .authToken("asdasd")
  . // and so on

5. 核心 Java 模板

我们最终编写需要维护的代码的另一种情况是在生成 toString()equals()hashCode() 方法时。IDE 试图帮助使用模板来根据类属性自动生成这些模板。

我们可以通过其他 Lombok 类级注释实现自动化:

  • @ToString: 将生成一个包含所有类属性的 toString() 方法。当我们添加数据模型时,不需要自己编写并维护它。
  • @EqualsAndHashCode: 将根据所有相关字段和语义默认下生成 equals()hashCode() 方法。

这些生成器随带了非常方便的配置选项。例如,如果我们的注释类属于层次结构的一部分,我们可以只使用 callSuper=true 参数,在生成方法的代码时将会考虑父类结果。

为了证明这一点,我们假设我们的 User JPA 实体示例包含对与该用户相关联的事件的引用:

@OneToMany(mappedBy = "user")
private List<UserEvent> events;

我们不希望因为使用了 @toString 注释,而在调用 UsertoString() 方法时转储整个事件列表。因此,我们可以这样参数化它,@ToString(xclude={“events”}),这便不会出现前面所述的情况。这也有助于避免循环引用,例如,如果 UserEvents 引用了一个 User

对于 LoginResult 示例,我们可能希望仅根据令牌本身而不是类中的其他最终属性来定义等式和哈希代码计算。然后我们可以简单地编写类似@EqualsAndHashCode(of={“authToken”) 的东西。

如果到目前为止我们审查过的注释中的功能感兴趣,我们可能也需要检查 @Data@Value  注释,因为其行为就像它们中的一组已经应用到我们的类中一样。毕竟,在许多情况下,这些讨论的用法通常被放在一起。

5.1. JPA 实体使不使用 @EqualsAndHashCode 

我们是应该使用默认的 equals()hashCode() 方法,还是为 JPA 实体创建自定义方法,是开发人员经常讨论的话题。我们可以采用多种方法,每种方法都有其优缺点。

默认情况下,@EqualsAndHashCode 包含实体类的所有非 final 属性。我们可以尝试通过使用 @EqualsAndHashCodeonlyExplicitlyIncluded 属性使 Lombok 仅使用实体的主键来“修复”此问题。不过,生成的 equals() 方法可能会导致一些问题。Thorben Janssen 在他的一篇博客文章中更详细地解释了这种情况。

通常,我们应该避免使用 Lombok 为我们的 JPA 实体生成 equals()hashCode() 方法。

6. Builder 模式

以下内容可以作为 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;

    // Whatever other options you may thing.

    // Empty constructor? All combinations?

    // getters... and setters?
}

我们可以有一个基于使用类默认空构造函数并为每个字段提供 setter 方法的初始方法;然而,理想情况下,我们希望配置在构建(实例化)后不重新设置,从而有效地使它们不可变。因此,我们希望避免使用 setter,但编写这样一个可能很长的参数构造函数是一种反模式。

相反,我们可以告诉该工具生成一个 builder 模式,通过简单地将 @builder注释添加到我们的 ApiClientConfiguration中,我们就不必编写额外的 Builder 类和相关的 fultent 类似 setter 的方法:

@Builder
public class ApiClientConfiguration {

    // ... everything else remains the same

}

通过上述的类定义(没有声明构造函数或 setters + @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) {
        // If this ever happens, then its a bug.
        throw new RuntimeException(ex); <--- encapsulate into a Runtime 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 的实例所持有的资源。

Lambok 通过 @Cleanup 提供了一种更灵活的替代方式。我们可以将其用于任何本地变量,确保其资源得到释放。它们不需要实现任何特定的接口,我们只需要调用 close() 方法:

@Cleanup InputStream is = this.getClass().getResourceAsStream("res.txt");

我们的发布方法使用了不同的名称?没问题,我们只是自定义注释:

@Cleanup("dispose") JFrame mainFrame = new JFrame("Main Window");

9.注释类以获取 Logger

我们经常通过从选择的框架中创建 Logger 的实例,将日志语句添加到代码中。比如 SLF4J:

public class ApiClientConfiguration {

    private static Logger LOG = LoggerFactory.getLogger(ApiClientConfiguration.class);

    // LOG.debug(), LOG.info(), ...

}

这是一种常见的模式,Lombok 开发者为我们进行了简化:

@Slf4j // or: @Log @CommonsLog @Log4j @Log4j2 @XSlf4j
public class ApiClientConfiguration {

    // log.debug(), log.info(), ...

}

它支持许多日志框架,当然我们也可以自定义实例名,主题等。

10. 编写线程安全方法

在 Java 中,我们可以使用 synchronized 关键字来实现关键部分;然而,这不是一个 100% 安全的方法。其他客户端代码最终也可以在我们的实例上同步,这可能会导致意外的死锁。

这就是  @Synchronized 的用武之地。我们可以用它来注释我们的方法(实例和静态),我们将获得一个自动生成的、私有的、未公开的字段,g该字段的实现将用于锁定:

@Synchronized
public /* better than: synchronized */ void putValueInCache(String key, Object value) {
    // whatever here will be thread-safe code
}

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);

}

现在定义一个适配器作为 support 类:

@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 {

    // Whichever other User-specific attributes

    @Delegate(types = {HasContactInformation.class})
    private final ContactInformationSupport contactInformation =
            new ContactInformationSupport();

    // User itself will implement all contact information by delegation
    
}

Customer 类的情况非常相似,为了简洁起见,我们省略示例。

12. Lombok 回滚?

可能有人担心,如果我们在其中一个项目中使用 Lombok,我们可能会在以后想要撤销该决定。潜在的问题可能是我们已经为大量的类进行了注释。这种情况下,我们可以通过 delombok 工具得以解决。

通过 delombok 剥离我们的代码,我们可以获得自动生成的 Java 源代码,这些代码具有与 Lombok 构建的字节码完全相同的功能。然后,我们可以简单地用这些新的去标记文件替换原始的注释代码,而不再依赖它。

这是我们可以在构建中集成的东西。

13. 结论

还有一些其他功能我们还没有在本文中介绍。我们可以更深入地了解功能概述,以了解更多细节和用例。

此外,我们展示的大多数函数都有许多自定义选项,我们可能会发现这些选项很方便。内置配置系统也可以帮助我们做到这一点。

现在我们可以给 Lombok 一个进入 Java 开发工具集的机会,提高生产力。