超越 DTO:探索 Java Record

2023-09-08 09:02:10 浏览数 (2)

作者 | Otavio Santana

译者 | 明知山

策划 | Tina

关键要点

  • Record 作为一种不可变数据的透明载体类,可以被认为是名义上的元组。
  • Record 可以帮你写出更可预测的代码,降低复杂性,并提高 Java 应用程序的总体质量。
  • Record 可以结合领域驱动设计(DDD)原则,编写不可变类,让代码变得更加健壮和可维护。
  • Jakarta Persistence 规范不支持关系数据库的不可变性,但可以在 NoSQL 数据库上实现不可变性。
  • 你可以在并发、CQRS、事件驱动架构等场景中利用不可变类。

如果你跟得上 Java 的发布节奏并且知道最新的 LTS 版本 Java 17,那么你可以了解一下支持不可变类的 Record 特性。

那么问题来了:如何在项目中使用这个新特性?如何利用它做出干净的、更好的设计?本教程将提供一些超越经典的数据传输对象(DTO)的示例。

Record 是什么?为什么要有它?

首先,什么是 Record?你可以将 Record 视为一种类,它充当不可变数据的透明载体。Record 是作为一种预览特性引入到 Java 14(JEP 359)的。

在 Java 15 中发布了第二个预览版(JEP 384)之后,在 Java 16 中发布了最终版(JEP 395)。Record 也可以被认为是名义上的元组。

正如前面提到的,有了它,我们可以用更少的代码创建出不可变类。假设我们有一个 Person 类,它包含了三个字段——姓名(name)、生日(birthday)和此人出生的城市(city)——前提条件是我们不能修改这个类的数据。

因此,我们需要创建一个不可变类。我们将遵循相同的 Java Bean 规范,并将相关的字段定义为 final:

代码语言:javascript复制
public final class Person {

    private final String name;

    private final LocalDate birthday;

    private final String city;

    public Person(String name, LocalDate birthday, String city) {
        this.name = name;
        this.birthday = birthday;
        this.city = city;
    }


    public String name() {
        return name;
    }

    public LocalDate birthday() {
        return birthday;
    }

    public String city() {
        return city;
    }

    @Override
    public boolean equals(Object o) {
        if (this == o) {
            return true;
        }
        if (o == null || getClass() != o.getClass()) {
            return false;
        }
        OldPerson person = (OldPerson) o;
        return Objects.equals(name, person.name)
                && Objects.equals(birthday, person.birthday)
                && Objects.equals(city, person.city);
    }

    @Override
    public int hashCode() {
        return Objects.hash(name, birthday, city);
    }

    @Override
    public String toString() {
        return "OldPerson{"  
                "name='"   name   '''  
                ", birthday="   birthday  
                ", city='"   city   '''  
                '}';
    }
}

在上面的示例中,我们创建了带有 final 字段和 getter 方法的类,但请注意,我们并没有完全遵循 Java Bean 规范——在方法前面加上 get。

现在,我们按照相同的方式创建一个不可变类:将类定义为 final,然后定义字段,然后再定义构造函数。既然这些步骤是可重复的,我们可以减少这些样板代码吗?答案是可以的。这要归功于 Record:

代码语言:javascript复制
public record Person(String name, LocalDate birthday, String city) {

}

正如你所看到的,我们可以用一行代码替代好几行代码。我们将 class 关键字替换为 record 关键字,就这样简单地让魔法生效。

需要注意的是,record 其实是一个类。因此,它也可以有 Java 类的功能,例如方法和实现。我们将进入下一个话题,看看如何使用 Record。

数据传输对象(DTO)

这是网上第一个也是最常见的用例。因此,我们不需要过多地关注这一点,但不管怎样,这是 Record 的一个很好的用例示例,当然不是唯一的用例。

无论你用的是 Spring、MicroProfile 还是 Jakarta EE 都没关系。目前,我们有几个示例用例,如下所示:

  • MapStruct
  • Jakarta JSONB
  • Spring

值对象或不可变类型

在领域驱动设计(DDD)中,值对象用于表示来自问题领域或上下文的概念。这些类是不可变的,比如 Money 或 Email 类型。

在我们的第一个示例中,我们将创建 Email:

代码语言:javascript复制
public record Email (String value) {
}

与其他值对象一样,我们可以为其添加方法和行为,但它们返回的结果应该是不同的实例。假设我们要创建 Money 类型,并添加 add 操作。也就是说,我们将添加方法来检查它们是不是相同的货币,然后创建一个新的实例:

代码语言:javascript复制
public record Money(Currency currency, BigDecimal value) {

    Money add(Money money) {
        Objects.requireNonNull(money, "Money is required");
        if (currency.equals(money.currency)) {
            BigDecimal result = this.value.add(money.value);
            return new Money(currency, result);
        }
        throw new IllegalStateException("You cannot sum money with different currencies");
    }
}

Money 只是一个例子,主要是因为开发人员可以使用著名的 Joda-Money 库。关键在于,当你需要创建一个值对象或不可变类型时,可以使用 Record。

不可变实体

等等,你是说不可变实体吗?这可能吗?这可能不太常见,但确实是可以的,比如当一个实体持有历史转变点数据。

实体可以是不可变的吗?Eric Evans 在《领域驱动设计:软件核心复杂性应对之道》一书中对实体进行了定义:

实体是在整个生命周期中具有连续性且拥有独立于应用程序用户的基本属性的任何东西。

实体与不可变或不可变无关,而是与领域相关,因此,我们可以有不可变的实体,只是可能不太常见。在 Stackoverflow 上有一个关于这个问题的讨论。

我们来创建一个叫作 Book 的实体,它有 ID、标题(title)和出版年份(release)。如果我们想要修改一个 Book 实体该怎么办?实际上我们不会这么做,相反,我们会创建一个新的版本。因此,我们还需要添加版本(edition)字段。

代码语言:javascript复制
public record Book(String id, String title, Year release, int edition) {}

我们还需要加入验证逻辑,否则这本书将会出现不一致的数据。id、title、release 为空、edition 为负数是没有意义的。我们可以使用 Record 的构造函数并在其中放置验证逻辑:

代码语言:javascript复制
public Book {
        Objects.requireNonNull(id, "id is required");
        Objects.requireNonNull(title, "title is required");
        Objects.requireNonNull(release, "release is required");
        if (edition < 1) {
            throw new IllegalArgumentException("Edition cannot be negative");
        }
    }

如果有必要,我们可以重写 equals()、hashCode() 和 toString() 方法。实际上,我们确实为 id 字段重写了 equals() 和 hashCode() 方法:

代码语言:javascript复制
@Override
    public boolean equals(Object o) {
        if (this == o) {
            return true;
        }
        if (o == null || getClass() != o.getClass()) {
            return false;
        }
        Book book = (Book) o;
        return Objects.equals(id, book.id);
    }

    @Override
    public int hashCode() {
        return Objects.hashCode(id);
    }

为了方便创建对象,或者当你需要创建更复杂的对象时,可以使用方法工厂或构建器。下面的代码演示了如何使用构建器创建 Book 对象:

代码语言:javascript复制
 Book book = Book.builder().id("id").title("Effective Java").release(Year.of(2001)).builder();

最后,我们还将提供将书修改为新版本的方法。在接下来的步骤中,我们将看到如何创建 Joshua Bloch 的第二版《Effective Java》。因此,我们无法改变这本书曾经有过第一版的事实,这是图书出版历史的组成部分。

代码语言:javascript复制
Book first = Book.builder().id("id").title("Effective Java").release(Year.of(2001)).builder();
 Book second = first.newEdition("id-2", Year.of(2009));

目前,由于兼容性原因,Jakarta Persistence 规范不支持不可变性,但我们可以在 NoSQL API(如 Eclipse JNoSQL 和 Spring Data MongoDB)上探索实现不可变性。

我们已经介绍了许多种模式,接下来,我们将进入另一种代码设计模式。

状态的实现

在某些情况下,我们需要在代码中实现流或状态。状态设计模式在电子商务中有一个应用场景,即对于订单,我们需要维护其时序流。我们想要知道订单是什么时候被发起、交付和被用户接收的。

第一步是创建一个接口。为简单起见,我们使用一个字符串来表示产品,但还是需要一个完整的对象:

代码语言:javascript复制
public interface Order {

    Order next();
    List<String> products();
}

定义好这个接口后,我们将创建一个可以遵循其状态流并返回产品的实现。我们希望避免对产品做任何改动。因此,我们将覆盖 products() 方法来生成一个只读清单。

代码语言:javascript复制
public record Ordered(List<String> products) implements Order {

    public Ordered {
        Objects.requireNonNull(products, "products is required");
    }
    @Override
    public Order next() {
        return new Delivered(products);
    }

    @Override
    public List<String> products() {
        return Collections.unmodifiableList(products);
    }
}

public record Delivered(List<String> products) implements Order {

    public Delivered {
        Objects.requireNonNull(products, "products is required");
    }
    @Override
    public Order next() {
        return new Received(products);
    }

    @Override
    public List<String> products() {
        return Collections.unmodifiableList(products);
    }
}


public record Received(List<String> products) implements Order {

    public Received {
        Objects.requireNonNull(products, "products is required");
    }

    @Override
    public Order next() {
        throw new IllegalStateException("We finished our journey here");
    }

    @Override
    public List<String> products() {
        return Collections.unmodifiableList(products);
    }

}

我们已经有了状态的实现,接下来我们将修改 Order 接口。首先,我们将创建一个静态方法来开始一个订单。然后,为了确保不会有新的异常状态,我们将阻止新的订单状态实现,只允许已有的状态。因此,我们将使用封印(sealed)接口特性。

代码语言:javascript复制
public sealed interface Order permits Ordered, Delivered, Received {

    static Order newOrder(List<String> products) {
        return new Ordered(products);
    }

    Order next();
    List<String> products();
}

我们成功了!现在我们将使用一个产品列表来测试代码。

代码语言:javascript复制
List<String> products = List.of("Banana");
Order order = Order.newOrder(products);
Order delivered = order.next();
Order received = delivered.next();
Assertions.assertThrows(IllegalStateException.class, () -> received.next());

状态和不可变类或许会让你想到事务,例如实体,或者在事件驱动架构中生成事件。

结 论

就是这样!在本文中,我们讨论了 Record 的强大功能。它是一种 Java 类,优势在于它提供了构造方法,构造函数中的验证逻辑,getter、hashCode()、toString() 方法的覆盖,等等。

Record 不只是 DTO 那么简单。在本文中,我们探讨了一些用例,如值对象、不可变实体和状态的实现。

我们可以在并发场景、CQRS、事件驱动架构中利用不可变类。Record 将为你的代码带来无限的可能性!

英文原文

https://www.infoq.com/articles/exploring-java-records/

声明:本文由 InfoQ 翻译整理,未经许可禁止转载。

今日好文推荐

我的20年职业生涯:全是技术债

中国最大公有云服务商,如何从零开始构建一支云效团队

工信部要求所有 App、小程序备案;某国产电商被提名 Pwnie Awards “最差厂商奖”;阿里财报超预期 | Q资讯

谷歌的反“背锅”文化

0 人点赞