老司机带你看Java 编程问题——记录和记录模式

本章包含了 19 个问题,详细介绍了 JDK 16 引入的 Java 记录(JEP 395),以及作为预览特性在 JDK 19(JEP 405)中引入的记录模式,作为 JDK 20 中的第二个预览特性(JEP 432),以及作为 JDK 21 中的最终特性(JEP 440)。

我们首先定义一个简单的 Java 记录。接着我们分析记录的内部结构,它可以包含什么,以及不能包含什么,如何在流中使用记录,它们如何改进序列化等等。我们还对如何在 Spring Boot 应用程序中使用记录感兴趣,包括 JPA 和 jOOQ 技术。

接下来,我们将重点介绍用于 instanceof 和 switch 的记录模式。我们将讨论嵌套记录模式、带条件的记录模式、处理记录模式中的空值等等。

在本章结束时,您将掌握 Java 记录。这很棒,因为记录是任何希望采用最酷的 Java 特性的 Java 开发人员必备的。

Problems

使用以下问题来测试您在 Java 记录上的编程技能。我强烈建议您在查看解决方案和下载示例程序之前尝试每个问题:

  1. 声明一个 Java 记录:编写一个示例应用程序,演示如何创建一个 Java 记录。此外,提供一个简短的描述,解释编译器在幕后为记录生成的相关构件。
  2. 引入记录的规范和简洁构造函数:解释内置记录的规范和简洁构造函数的作用。提供在何时提供这样明确构造函数的示例。
  3. 在记录中添加更多构件:提供有意义的示例列表,说明如何在 Java 记录中添加明确的构件(例如,添加实例方法、静态构件等)。
  4. 总结记录中不能包含的内容:举例说明记录中不能包含的内容(例如,我们不能有明确的私有字段),并解释原因。
  5. 在记录中定义多个构造函数:举例说明在记录中声明多个构造函数的几种方法。
  6. 在记录中实现接口:编写一个程序,演示如何在记录中实现接口。
  7. 理解记录序列化:详细解释和举例说明记录序列化在幕后的工作原理。
  8. 通过反射调用规范构造函数:编写一个示例程序,演示如何通过反射调用记录的规范构造函数。
  9. 在流中使用记录:编写几个示例,以突出记录在简化依赖于 Stream API 的函数表达式中的用法。
  10. 引入 instanceof 的记录模式:编写一系列示例,介绍 instanceof 的记录模式,包括嵌套记录模式。
  11. 引入 switch 的记录模式:编写一系列示例,介绍 switch 的记录模式。
  12. 处理受保护的记录模式:编写几段代码片段,演示受保护的记录模式(基于绑定变量的受保护条件)。
  13. 在记录模式中使用泛型记录:编写一个应用程序,突出声明和使用泛型记录。
  14. 处理嵌套记录模式中的空值:解释并举例说明如何处理记录模式中的空值(还要解释嵌套记录模式中空值的边缘情况)。
  15. 通过记录模式简化表达式:假设您有一个表达式(算术、基于字符串、抽象语法树(AST)等)。编写一个程序,使用记录模式来简化用于评估/转换此表达式的代码。
  16. 连接无名模式和变量:解释并举例说明 JDK 21 预览特性,涵盖无名模式和变量。
  17. 在 Spring Boot 中处理记录:编写几个应用程序,以示范记录在 Spring Boot 中的不同用例(例如,在模板中使用记录,在配置中使用记录等)。
  18. 在 JPA 中处理记录:编写几个应用程序,以示范记录在 JPA 中的不同用例(例如,使用记录和构造函数表达式,在结果变换器中使用记录等)。
  19. 在 jOOQ 中处理记录:编写几个应用程序,以示范记录在 jOOQ 中的不同用例(例如,使用记录和 MULTISET 运算符)。

以下部分描述了上述问题的解决方案。请记住,通常没有单一正确的解决方法。此外,请记住,这里显示的解释仅包括解决问题所需的最有趣和重要的细节。下载示例解决方案以查看更多细节,并对程序进行实验:github.com/PacktPublis....

1. 声明一个Java记录

在深入研究Java记录之前,让我们思考一下我们通常在Java应用程序中如何保存数据。没错......我们定义简单的类,其中包含所需的实例字段,通过这些类的构造函数填充我们的数据。我们还公开了一些特定的getter方法,以及常见的equals()、hashCode()和toString()方法。此外,我们创建这些类的实例,将我们宝贵的数据封装在其中,并在整个应用程序中传递它们来解决我们的任务。例如,以下类携带了关于甜瓜的数据,如甜瓜类型和它们的重量:

arduino 复制代码
public class Melon {
  private final String type;
  private final float weight;
  public Melon(String type, float weight) {
    this.type = type;
    this.weight = weight;
  }
  public String getType() {
    return type;
  }
  public float getWeight() {
    return weight;
  }
  // hashCode(), equals(), and to String()
}

你应该对这种传统的Java类和这种繁琐的仪式非常熟悉,所以没有必要详细讨论这段代码。现在,让我们看看如何使用Java记录语法糖完成完全相同的事情,但可以大大减少以前的仪式:

arduino 复制代码
public record MelonRecord(String type, float weight) {}

Java记录是从JDK 14开始作为功能预览发布的,并在JDK 16作为JEP 395发布和关闭。这一行代码为我们提供了与前一个类Melon相同的行为。在幕后,编译器提供了所有的构件,包括两个私有final字段(type和weight)、一个构造函数、两个访问器方法,其名称与字段相同(type()和weight()),以及包含hashCode()、equals()和toString()的三部曲。我们可以通过在MelonRecord类上调用javap工具轻松查看编译器生成的代码:

请注意,这些访问器的名称不遵循Java Bean约定,因此没有getType()或getWeight()方法,而是type()和weight()方法。但是,您可以显式编写这些访问器,或者显式添加getType()/getWeight()方法,例如,用于暴露字段的防御性副本。

所有这些都是基于声明记录时给定的参数构建的(type和weight)。这些参数也被称为记录的组件,我们说一个记录是建立在给定组件上的。 编译器通过record关键字识别Java记录。这是一种特殊类型的类(就像枚举是Java类的一种特殊类型一样),声明为final,并自动扩展java.lang.Record。

实例化MelonRecord与实例化Melon类相同。以下代码创建了一个Melon实例和一个MelonRecord实例:

ini 复制代码
Melon melon = new Melon("Cantaloupe", 2600);
MelonRecord melonr = new MelonRecord("Cantaloupe", 2600);

Java记录并不是可变Java Bean类的替代品。此外,您可能会认为Java记录只是一种用于携带不可变数据或不可变状态的纯透明方法(我们说"透明",是因为它完全暴露了其状态,我们说"不可变",是因为类是final的,它只有私有final字段,没有setter)。在这种情况下,我们可能认为Java记录并不是很有用,因为它们只是重叠了通过Lombok或Kotlin可以获得的功能。但是正如您在本章中将看到的那样,Java记录不仅如此,并且提供了几个在Lombok或Kotlin中不可用的功能。此外,如果您进行基准测试,您会注意到使用记录在性能上具有显著的优势。

2. 记录的规范构造函数和紧凑构造函数简介

在上一个问题中,我们创建了 Java 记录类 MelonRecord,并通过以下代码实例化它:

ini 复制代码
MelonRecord melonr = new MelonRecord("Cantaloupe", 2600);

这怎么可能实现呢(因为我们没有在 MelonRecord 中编写任何带参数的构造函数)?编译器只是遵循了 Java 记录的内部协议,并基于我们在记录声明中提供的组件(在本例中是两个组件,类型和重量)创建了一个默认构造函数。

这个构造函数被称为规范构造函数,它总是与给定的组件保持一致。每个记录都有一个规范构造函数,它代表创建该记录实例的唯一方式。

但是,我们可以重新定义规范构造函数。下面是一个显式的规范构造函数,类似于默认的 - 正如你所看到的,规范构造函数简单地获取所有给定的组件并设置相应的实例字段(也由编译器作为私有的 final 字段生成):

arduino 复制代码
public MelonRecord(String type, float weight) {
  this.type = type;
  this.weight = weight;
}

一旦实例被创建,它就无法被改变(它是不可变的)。它只会用于在程序中携带这些数据。这种显式的规范构造函数有一个快捷方式,称为紧凑构造函数 - 这是 Java 记录特有的。由于编译器知道给定组件的列表,它可以从这个紧凑构造函数完成它的工作,它相当于前面的那个:

arduino 复制代码
public MelonRecord {}

需要注意不要将紧凑构造函数与没有参数的构造函数混淆。以下代码段不是等价的:

csharp 复制代码
public MelonRecord {}  // 紧凑构造函数
public MelonRecord() {} // 没有参数的构造函数

当然,编写一个显式的规范构造函数只是为了模仿默认的构造函数是没有意义的。因此,让我们来看看重新定义规范构造函数有意义的几种场景。

处理验证

目前,当我们创建 MelonRecord 时,可以将类型传递为 null 或将甜瓜重量传递为负数。这会导致记录损坏,包含无效数据。可以在显式规范构造函数中处理记录组件的验证,如下所示:

typescript 复制代码
public record MelonRecord(String type, float weight) {
  // 用于验证的显式规范构造函数
  public MelonRecord(String type, int weight) {
    if (type == null) {
      throw new IllegalArgumentException(
          "The melon's type cannot be null");
    }
    if (weight < 1000 || weight > 10000) {
      throw new IllegalArgumentException("The melon's weight  must be between 1000 and 10000 grams");
    }
    this.type = type;
    this.weight = weight;
  }
}

或者,通过紧凑的构造函数如下:

csharp 复制代码
public record MelonRecord(String type, float weight) {
  // 用于验证的显式紧凑构造函数
  public MelonRecord {
    if (type == null) {
      throw new IllegalArgumentException(
          "The melon's type cannot be null");
    }
    if (weight < 1000 || weight > 10000) {
      throw new IllegalArgumentException("The melon's weight  must be between 1000 and 10000 grams");
    }
  }
}

处理验证是显式规范/紧凑构造函数最常见的用例。接下来,让我们看另外两个鲜为人知的用例。

重新分配组件

通过显式规范/紧凑构造函数,我们可以重新分配组件。例如,当我们创建 MelonRecord 时,我们提供其类型(例如,Cantaloupe)和以克为单位的重量(例如,2600 克)。但是,如果我们想使用以千克为单位的重量(2600 克 = 2.6 千克),那么我们可以在显式规范构造函数中提供这种转换,如下所示:

ini 复制代码
// 用于重新分配组件的显式规范构造函数
public MelonRecord(String type, float weight) {
  weight = weight/1_000; // 覆盖组件 'weight'
  this.type = type;
  this.weight = weight;
}

如您所见,在使用新重新分配的值初始化 weight 字段之前,weight 组件可用并重新分配。最后,weight 组件和 weight 字段具有相同的值(2.6 kg)。

给定组件的防御性副本

我们知道 Java 记录是不可变的。但这并不意味着它的组件也是不可变的。考虑诸如数组、列表、映射、日期等组件。所有这些组件都是可变的。为了恢复完全不可变性,您倾向于处理这些组件的副本,而不是修改给定的组件。正如您可能已经猜到的,这可以通过显式规范构造函数来完成。

例如,让我们考虑以下记录,它获取一个表示一组商品零售价的单个组件作为 Map:

arduino 复制代码
public record MarketRecord(Map<String, Integer> retails) {}

此记录不应修改此 Map,因此它依赖于显式规范构造函数来创建防御性副本,该副本将在后续任务中使用,而不会出现任何修改风险(Map.copyOf() 返回给定 Map 的不可修改副本):

typescript 复制代码
public record MarketRecord(Map<String, Integer> retails) {
  public MarketRecord {
    retails = Map.copyOf(retails);
  }
}

此外,我们可以通过访问器方法返回防御性副本:

typescript 复制代码
public Map<String, Integer> retails() {
  return Map.copyOf(retails);
}

// 或 Java Bean 风格的 getter
public Map<String, Integer> getRetails() {
  return Map.copyOf(retails);
}

3. 在记录中添加更多成员

到目前为止,我们知道如何将显式规范/紧凑构造函数添加到Java记录中。还可以添加什么呢?例如,我们可以添加实例方法,就像在任何典型的类中一样。在以下代码中,我们添加了一个实例方法,该方法返回以克为单位转换的重量:

csharp 复制代码
public record MelonRecord(String type, float weight) {
  public float weightToKg() {
    return weight / 1_000;
  }
}

您可以像调用您的类的任何其他实例方法一样调用weightToKg():

ini 复制代码
MelonRecord melon = new MelonRecord("Cantaloupe", 2600);
// 2600.0 g = 2.6 Kg
System.out.println(melon.weight() + " g = " 
  + melon.weightToKg() + " Kg"); 

除了实例方法之外,我们还可以添加静态字段和方法。看看这段代码:

arduino 复制代码
public record MelonRecord(String type, float weight) {
  private static final String DEFAULT_MELON_TYPE = "Crenshaw";
  private static final float DEFAULT_MELON_WEIGHT = 1000;
  public static MelonRecord getDefaultMelon() {
    return new MelonRecord(
      DEFAULT_MELON_TYPE, DEFAULT_MELON_WEIGHT);
  }
}

调用getDefaultMelon()与通常通过类名进行:

ini 复制代码
MelonRecord defaultMelon = MelonRecord.getDefaultMelon();

添加嵌套类也是可能的。例如,这里我们添加了一个静态嵌套类:

csharp 复制代码
public record MelonRecord(String type, float weight) {
  public static class Slicer {
    public void slice(MelonRecord mr, int n) {
      start();
      System.out.println("Slicing a " + mr.type() + " of " 
        + mr.weightToKg() + " kg in " + n + " slices ...");
      stop();
    }
    private static void start() {
      System.out.println("Start slicer ...");
    }
    private static void stop() {
      System.out.println("Stop slicer ...");
    }
  }
}

调用Slicer可以像往常一样进行:

ini 复制代码
MelonRecord.Slicer slicer = new MelonRecord.Slicer();
slicer.slice(melon, 10);
slicer.slice(defaultMelon, 14);

但是,即使允许在Java记录中添加所有这些成员,我强烈建议您在这样做之前三思。主要原因是Java记录应该只涉及数据,所以在记录中添加涉及额外行为的成员有点奇怪。如果遇到这种情况,那么您可能需要一个Java类,而不是一个Java记录。 在下一个问题中,我们将看到不能将什么添加到Java记录中。

4. 迭代我们不能在记录中拥有的内容

有几个成员无法在Java记录中使用。让我们逐个解决前5个。

记录不能扩展另一个类

由于记录已经扩展了java.lang.Record,并且Java不支持多重继承,我们不能编写一个扩展另一个类的记录:

arduino 复制代码
public record MelonRecord(String type, float weight)
extends Cucurbitaceae {...}

这段代码无法编译。

记录不能被扩展

Java记录是final类,因此它们不能被扩展:

scala 复制代码
public class PumpkinClass extends MelonRecord {...}

这段代码无法编译。

记录不能用实例字段丰富

当我们声明一个记录时,我们还提供了成员,这些成员成为记录的实例字段。稍后,我们不能像在典型类中那样添加更多的实例字段:

arduino 复制代码
public record MelonRecord(String type, float weight) {
  private String color;
  private final String color;
}

将颜色作为单独的final或非final字段添加不会编译通过。

记录不能有私有规范构造函数

有时我们创建具有私有构造函数的类,为创建实例提供静态工厂。基本上,我们通过静态工厂方法间接调用构造函数。在Java记录中,这种做法不可用,因为不允许私有的规范/紧凑构造函数:

typescript 复制代码
public record MelonRecord(String type, float weight) {
  private MelonRecord(String type, float weight) {
    this.type = type;
    this.weight = weight;
  }
  public static MelonRecord newInstance(
      String type, float weight) {
    return new MelonRecord(type, weight);
  } 
}

这段代码无法编译。但是,您可以拥有公共规范构造函数和私有非规范构造函数,这些构造函数首先调用其中一个公共规范构造函数。

记录不能有设置器

正如您所见,Java记录为其每个成员暴露了一个getter(访问器方法)。这些getter的名称与成员名称相同(对于type我们有type(),而不是getType())。另一方面,由于与给定成员对应的字段是final的,因此我们不能有设置器:

typescript 复制代码
public record MelonRecord(String type, float weight) {
   public void setType(String type) {
     this.type = type;
   }
   public void setWeight(float weight) {
      this.weight = weight;
   }
}

这段代码无法编译。

嗯,不能添加到Java记录的成员的列表仍然是开放的,但这些是最常见的。

5. 在记录中定义多个构造函数

正如您所知,当我们声明一个Java记录时,编译器会使用给定的成员创建一个默认构造函数,称为规范构造函数。正如您在问题89中看到的,我们还可以提供一个显式的规范/紧凑构造函数。 但是,我们甚至可以进一步,声明更多带有不同参数列表的构造函数。例如,我们可以有一个没有参数的构造函数来返回默认实例:

arduino 复制代码
public record MelonRecord(String type, float weight) {
  private static final String DEFAULT_MELON_TYPE = "Crenshaw";
  private static final float DEFAULT_MELON_WEIGHT = 1000;
  MelonRecord() {
    this(DEFAULT_MELON_TYPE, DEFAULT_MELON_WEIGHT);
  } 
}

或者,我们可以编写一个仅接受甜瓜类型或甜瓜重量作为参数的构造函数:

arduino 复制代码
public record MelonRecord(String type, float weight) {
  private static final String DEFAULT_MELON_TYPE = "Crenshaw";
  private static final float DEFAULT_MELON_WEIGHT = 1000;
  MelonRecord(String type) {
    this(type, DEFAULT_MELON_WEIGHT);
  }
  MelonRecord(float weight) {
    this(DEFAULT_MELON_TYPE, weight);
  } 
}

此外,我们还可以添加不适用于任何组件的参数(在此例中,是国家):

typescript 复制代码
public record MelonRecord(String type, float weight) {
  private static Set<String> countries = new HashSet<>();
  MelonRecord(String type, int weight, String country) {
    this(type, weight);
    MelonRecord.countries.add(country);
  }  
}

所有这些构造函数有什么共同之处?它们都通过this关键字调用规范构造函数。请记住,实例化Java记录的唯一方法是通过其规范构造函数,可以直接调用它,也可以间接调用,就像您在前面的示例中看到的那样。因此,请记住,您添加到Java记录中的所有显式构造函数都必须首先调用规范构造函数。

6. 在记录中实现接口

Java记录不能扩展另一个类,但它们可以像典型类一样实现任何接口。让我们考虑以下接口:

csharp 复制代码
public interface PestInspector {
  public default boolean detectPest() {
    return Math.random() > 0.5d;
  }
  public void exterminatePest();
}

以下代码片段是对此接口的直接使用:

csharp 复制代码
public record MelonRecord(String type, float weight)
implements PestInspector {
  @Override
public void exterminatePest() {  
    if (detectPest()) {
      System.out.println("All pests have been exterminated");
    } else {
      System.out.println(
        "This melon is clean, no pests have been found");
    }
  }
}

请注意,该代码覆盖了抽象方法exterminatePest()并调用了默认方法detectPest()。

7. 理解记录的序列化/反序列化

为了理解Java记录如何序列化/反序列化,让我们将基于普通Java类的经典代码与相同的代码通过Java记录的语法糖表达进行对比。 因此,让我们考虑以下两个普通的Java类(我们必须显式实现Serializable接口,因为在这个问题的第二部分中,我们想要序列化/反序列化这些类):

arduino 复制代码
public class Melon implements Serializable {
  private final String type;
  private final float weight;
  public Melon(String type, float weight) {
    this.type = type;
    this.weight = weight;
  }
  // getters, hashCode(), equals(), and toString()
}

public class MelonContainer implements Serializable {
  private final LocalDate expiration;
  private final String batch;
  private final Melon melon;
  public MelonContainer(LocalDate expiration, 
      String batch, Melon melon) {
    ...
    if (!batch.startsWith("ML")) {
      throw new IllegalArgumentException(
        "The batch format should be: MLxxxxxxxx");
    }
    ...
    this.expiration = expiration;
    this.batch = batch;
    this.melon = melon;
  }
  // getters, hashCode(), equals(), and toString()
}

如果我们通过Java记录表达此代码,则有以下代码:

arduino 复制代码
public record MelonRecord(String type, float weight)
implements Serializable {}

public record MelonContainerRecord(
  LocalDate expiration, String batch, Melon melon)
implements Serializable {
  public MelonContainerRecord {
    ...
    if (!batch.startsWith("ML")) {
      throw new IllegalArgumentException(
        "The batch format should be: MLxxxxxxxx");
    } 
    ...
  }
}

请注意,我们已经显式实现了Serializable接口,因为默认情况下,Java记录不可序列化。 接下来,让我们创建一个MelonContainer实例:

java 复制代码
MelonContainer gacContainer = new MelonContainer(
  LocalDate.now().plusDays(15), "ML9000SQA0", 
    new Melon("Gac", 5000));

和一个MelonContainerRecord实例:

java 复制代码
MelonContainerRecord gacContainerR = new MelonContainerRecord(
  LocalDate.now().plusDays(15), "ML9000SQA0", 
    new Melon("Gac", 5000));

要对这些对象(gacContainer和gacContainerR)进行序列化,我们可以使用以下代码:

java 复制代码
try ( ObjectOutputStream oos = new ObjectOutputStream(
   new FileOutputStream("object.data"))) {
     oos.writeObject(gacContainer);
}
try ( ObjectOutputStream oos = new ObjectOutputStream(
   new FileOutputStream("object_record.data"))) {
     oos.writeObject(gacContainerR);
}

并且,反序列化可以通过以下代码完成:

ini 复制代码
MelonContainer desGacContainer;
try ( ObjectInputStream ios = new ObjectInputStream(
  new FileInputStream("object.data"))) {
  desGacContainer = (MelonContainer) ios.readObject();
}
MelonContainerRecord desGacContainerR;
try ( ObjectInputStream ios = new ObjectInputStream(
  new FileInputStream("object_record.data"))) {
  desGacContainerR = (MelonContainerRecord) ios.readObject();
}

在利用这些代码片段进行序列化/反序列化的实际检查之前,让我们尝试一种理论方法,以提供这些操作的一些提示。

序列化/反序列化的工作原理

序列化/反序列化操作如下图所示:

简而言之,序列化(或将对象序列化)是将对象的状态提取为字节流,并将其表示为持久格式(文件、数据库、内存、网络等)的操作。相反的操作称为反序列化(或将对象反序列化),它表示从持久格式重建对象状态的步骤。

在Java中,如果一个对象实现了Serializable接口,那么它就是可序列化的。这是一个空接口,没有状态或行为,它充当编译器的标记。如果缺少此接口,则编译器假定该对象不可序列化。

编译器使用其内部算法对对象进行序列化。此算法依赖于各种技巧,如特殊特权(忽略可访问性规则)以访问对象,恶意反射,构造函数绕过等。我们的目的不是揭示这种黑暗魔法,所以作为开发者,知道以下信息就足够了:

  • 如果对象的某个部分不可序列化,则会出现运行时错误
  • 您可以通过writeObject() / readObject() API来更改序列化/反序列化操作

好的,现在让我们看看当对象被序列化时发生了什么。

将gacContainer对象(一个典型的Java类)序列化/反序列化

gacContainer对象是MelonContainer的一个实例,它是一个普通的Java类:

java 复制代码
MelonContainer gacContainer = new MelonContainer(
  LocalDate.now().plusDays(15), "ML9000SQA0", 
    new Melon("Gac", 5000));

将其序列化到名为object.data的文件中后,我们获得了表示gacContainer状态的字节流。虽然您可以检查捆绑的代码中的这个文件(使用十六进制编辑器,比如hexed.it/),这里是其内容的人类可读解释:

反序列化操作是通过自上而下地构建对象图进行的。当类名已知时,编译器通过调用MelonContainer的第一个非可序列化的超类的无参数构造函数来创建对象。在这种情况下,这是java.lang.Object的无参数构造函数。因此,编译器不会调用MelonContainer的构造函数。

接下来,字段被创建并设置为默认值,因此创建的对象的expiration、batch和melon都为null。当然,这不是我们对象的正确状态,因此我们继续处理序列化流以提取并填充字段的正确值。这可以在以下图表中看到(左侧,创建的对象具有默认值;右侧,字段已填充为正确的状态):

当编译器遇到melon字段时,它必须执行相同的步骤来获取Melon实例。它将字段(type和weight分别设置为null和0.0f)。然后,它从流中读取真实值,并为melon对象设置正确的状态。

最后,在整个流被读取后,编译器将相应地链接对象。下图显示了此过程(1、2和3表示反序列化操作的步骤):

此时,反序列化操作已完成,我们可以使用生成的对象。

反序列化一个恶意流

提供恶意流意味着在反序列化之前修改对象状态。这可以通过多种方式实现。例如,我们可以在编辑器中手动修改object.data实例(这就像一个不受信任的来源),如下图所示,我们将有效的批次ML9000SQA0替换为无效的批次0000000000:

如果我们反序列化恶意流(在捆绑的代码中,您可以找到它作为object_malicious.data文件),那么您可以看到损坏的数据已经"成功"地进入了我们的对象(简单调用toString()方法就会显示批次为0000000000):

ini 复制代码
MelonContainer{expiration=2023-02-26, 
   batch=0000000000, melon=Melon{type=Gac, weight=5000.0}}

瓜类(Melon)/瓜容器(MelonContainer)构造函数中的保护条件是无用的,因为反序列化不会调用这些构造函数。 因此,如果我们总结一下序列化/反序列化Java类的缺点,我们必须强调在对象处于不正确状态时存在的时间窗口(等待编译器使用正确数据填充字段并将它们链接到最终图中)以及处理恶意状态的风险。现在,让我们将一个Java记录通过这个过程。

对gacContainerR(一个Java记录)进行序列化/反序列化

简而言之,声明Java记录及其语义约束的极简设计使得序列化/反序列化操作与典型的Java类不同。当我说"不同"时,实际上应该是更好、更健壮。为什么这样说呢?嗯,Java记录的序列化仅基于其组件的状态,而反序列化依赖于Java记录的唯一入口点,即其规范构造函数。还记得创建Java记录的唯一方法是直接/间接调用其规范构造函数吗?这也适用于反序列化,因此该操作不再能够绕过规范构造函数。 也就是说,gacContainerR对象是一个MelonContainerRecord实例:

java 复制代码
MelonContainerRecord gacContainerR = new MelonContainerRecord(
  LocalDate.now().plusDays(15), "ML9000SQA0", 
    new Melon("Gac", 5000));

将其序列化到名为object_record.data的文件中后,我们获得了表示gacContainerR状态的字节流。虽然您可以检查捆绑的代码中的这个文件(使用十六进制编辑器,比如hexed.it/),这里是其内容的人类可读解释:

是的,你说得对------除了类名(MelonContainerRecord)之外,其余都与图4.3中的内容相同。这支持了从普通/常规Java类迁移到Java记录的过程。这一次,编译器可以使用记录公开的访问器,因此不需要使用黑暗魔法。 好的,这里没有引起我们的注意,所以让我们来看一下反序列化操作。

请记住,对于常规Java类,反序列化是从上到下构建对象图。对于Java记录而言,此操作是从下到上进行的,也就是说是倒序的。换句话说,这一次,编译器首先从流中读取字段(原始类型和重构对象)并将它们存储在内存中。接下来,编译器拥有了所有字段后,它试图将这些字段(它们的名称和值)与记录的组件进行匹配。从流中读取的任何字段,如果不与记录组件(名称和值)匹配,将在反序列化操作中被丢弃。最后,在成功执行匹配后,编译器调用规范构造函数来重建记录对象的状态。

反序列化恶意流

在捆绑的代码中,您可以找到一个名为object_record_malicious.data的文件,我们在其中将有效的批次ML9000SQA0替换为无效的批次0000000000。这一次,反序列化这个恶意流将导致以下图表中的异常:

正如您已经知道的,此异常源自我们在Java记录的显式规范构造函数中添加的保护条件。 显然,Java记录显着改善了序列化/反序列化操作。这一次,重建的对象从未处于损坏状态,恶意流可以通过放置在规范/紧凑构造函数中的保护条件拦截。

换句话说,记录的语义约束、它们的极简设计、仅通过访问器方法访问状态以及仅通过规范构造函数创建对象,使得序列化/反序列化成为一个可信的过程。

重构遗留序列化

通过Java记录进行序列化/反序列化是很棒的,但在MelonContainer等遗留代码的情况下,我们该怎么办?我们无法将所有充当数据载体的遗留类重写为Java记录。这将消耗大量的工作和时间。

实际上,有一种解决方案支持序列化机制,它要求我们添加两个名为writeReplace()和readResolve()的方法。通过遵循这个合理的重构步骤,我们可以将遗留代码序列化为记录,并将其反序列化回遗留代码。 如果我们将这个重构步骤应用到MelonContainer上,那么我们首先要在这个类中添加writeReplace()方法,如下所示:

java 复制代码
@Serial
private Object writeReplace() throws ObjectStreamException {
  return new MelonContainerRecord(expiration, batch, melon);
}

writeReplace()方法必须抛出ObjectStreamException并返回一个MelonContainerRecord的实例。只要我们用@Serial注解标记它,编译器就会使用这个方法来序列化MelonContainer实例。现在,MelonContainer实例的序列化将产生包含与MelonContainerRecord实例相对应的字节流的object.data文件。 接下来,必须向MelonContainerRecord中添加readResolve()方法,如下所示:

java 复制代码
@Serial
private Object readResolve() throws ObjectStreamException {
  return new MelonContainer(expiration, batch, melon);
}

readResolve()方法必须抛出ObjectStreamException并返回一个MelonContainer的实例。同样,只要我们用@Serial注解标记它,编译器就会使用这个方法来反序列化MelonContainerRecord实例。

当编译器反序列化MelonContainerRecord实例时,它将调用该记录的规范构造函数,因此将通过我们的保护条件。这意味着恶意流将无法通过保护条件,因此我们避免了创建损坏的对象。如果流包含有效值,那么readResolve()方法将使用它们来重构遗留的MelonContainer。

嘿,Kotlin/Lombok,你们能做到这一点吗?不,你们不能! 在捆绑的代码中,您可以找到一个名为object_malicious.data的文件,您可以用它来练习前面的陈述。

8. 通过反射调用规范构造函数

通过反射调用Java记录的规范构造函数并不是一项日常任务。然而,从JDK 16开始,这可以相当容易地实现,因为它提供了java.lang.Class中的RecordComponent\[\] getRecordComponents()方法。正如其名称和签名所示,该方法返回一个表示当前Java记录组件的java.lang.reflect.RecordComponent数组。

有了这个组件数组,我们可以调用众所周知的getDeclaredConstructor()方法来识别正好以这个组件数组作为参数的构造函数。而这就是规范构造函数。

将这些语句付诸实践的代码由Java文档本身提供,因此无需重新发明。以下是代码示例:

ruby 复制代码
// 此方法来自JDK的官方文档
// https://docs.oracle.com/en/java/javase/19/docs/api/
// java.base/java/lang/Class.html#getRecordComponents()
public static <T extends Record> Constructor<T>
      getCanonicalConstructor(Class<T> cls)
throws NoSuchMethodException {
  Class<?>[] paramTypes
    = Arrays.stream(cls.getRecordComponents())
            .map(RecordComponent::getType)
            .toArray(Class<?>[]::new);
  return cls.getDeclaredConstructor(paramTypes);
}

考虑以下记录:

arduino 复制代码
public record MelonRecord(String type, float weight) {}
public record MelonMarketRecord(
  List<MelonRecord> melons, String country) {}

通过上述解决方案找到并调用这些记录的规范构造函数可以这样实现:

ini 复制代码
Constructor<MelonRecord> cmr = 
   Records.getCanonicalConstructor(MelonRecord.class);
MelonRecord m1 = cmr.newInstance("Gac", 5000f);
MelonRecord m2 = cmr.newInstance("Hemi", 1400f);
Constructor<MelonMarketRecord> cmmr = 
   Records.getCanonicalConstructor(MelonMarketRecord.class);
MelonMarketRecord mm = cmmr.newInstance(
   List.of(m1, m2), "China");

如果您需要深入了解Java反射原理,请参考《Java编程问题》第一版第7章。

9. 在流中使用记录

考虑我们之前使用过的 MelonRecord:

arduino 复制代码
public record MelonRecord(String type, float weight) {}

以及以下的一个甜瓜列表:

arduino 复制代码
List<MelonRecord> melons = Arrays.asList(
  new MelonRecord("Crenshaw", 1200),
  new MelonRecord("Gac", 3000), 
  new MelonRecord("Hemi", 2600),
  // 其他
);

我们的目标是遍历这个甜瓜列表并提取总重量和重量列表。这些数据可以由常规的 Java 类或另一个记录来承载,如下所示:

csharp 复制代码
public record WeightsAndTotalRecord(
 double totalWeight, List<Float> weights) {}

填充此记录的数据可以通过多种方式完成,但如果我们更喜欢使用 Stream API,那么很可能会选择 Collectors.teeing() 收集器。我们这里不会深入讨论,但我们会快速展示它对于合并两个下游收集器的结果非常有用。(如果您感兴趣,可以在《Java 编程问题》第一版第9章第192题中找到关于这个特殊收集器的更多详细信息。)

让我们来看看代码:

less 复制代码
WeightsAndTotalRecord weightsAndTotal = melons.stream()
  .collect(Collectors.teeing(
     summingDouble(MelonRecord::weight),
     mapping(MelonRecord::weight, toList()),
     WeightsAndTotalRecord::new
));

在这里,我们有 summingDouble() 收集器,用于计算总重量,以及 mapping() 收集器,用于将重量映射到一个列表中。这两个下游收集器的结果被合并到 WeightsAndTotalRecord 中。

正如您所看到的,Stream API 和记录是非常好的组合。让我们以另一个例子开始,从这个函数式代码:

scss 复制代码
Map<Double, Long> elevations = DoubleStream.of(
      22, -10, 100, -5, 100, 123, 22, 230, -1, 250, 22)
  .filter(e -> e > 0)
  .map(e -> e * 0.393701)   
  .mapToObj(e -> (double) e)
  .collect(Collectors.groupingBy(
     Function.identity(), counting()));

此代码从以厘米为单位给出的高度列表开始(基于海平面为0)。首先,我们只想保留正的高度(因此,我们应用 filter())。接下来,这些高度将被转换为英寸(通过 map()),并进行计数(通过 groupingBy() 和 counting() 收集器)。

结果数据由 Map<Double, Long> 承载,这并不是很具有表现力。如果我们将这个 Map 拿出上下文(例如,将它作为参数传递给一个方法),很难说 Double 和 Long 代表什么。将其更改为类似 Map<Elevation, ElevationCount> 这样的东西会更具表现力。

因此,Elevation 和 ElevationCount 可以是两个记录,如下所示:

arduino 复制代码
record Elevation(double value) { 
  Elevation(double value) { 
    this.value = value * 0.393701;
  } 
}
record ElevationCount(long count) {}

为了简化函数式代码,我们还将从厘米转换为英寸的操作移动到了 Elevation 记录中的显式规范构造函数中。这次,函数式代码可以重写为:

css 复制代码
Map<Elevation, ElevationCount> elevations = DoubleStream.of(
      22, -10, 100, -5, 100, 123, 22, 230, -1, 250, 22)
  .filter(e -> e > 0)                
  .mapToObj(Elevation::new)
  .collect(Collectors.groupingBy(Function.identity(), 
           Collectors.collectingAndThen(counting(), 
             ElevationCount::new)));

现在,将 Map<Elevation, ElevationCount> 传递给一个方法就能清楚地说明它的内容。任何团队成员都可以在眨眼间检查这些记录,而不必花时间阅读我们的函数式实现来推断 Double 和 Long 代表什么。我们甚至可以更具表现力,将 Elevation 记录重命名为 PositiveElevation。

10. 引入用于 instanceof 的记录模式

为了引入记录模式,我们需要一个比我们到目前为止使用的更复杂的记录,所以这里是一个:

arduino 复制代码
public record Doctor(String name, String specialty)
  implements Staff {}

这个记录实现了 Staff 接口,就像我们医院的其他员工一样。现在,我们可以通过 instanceof 以老式的方式识别某个医生,如下所示:

typescript 复制代码
public static String cabinet(Staff staff) {
  if (staff instanceof Doctor) {
    Doctor dr = (Doctor) staff;
    return "Cabinet of " + dr.specialty() 
      + ". Doctor: " + dr.name();
  }
  ...
}

但是,正如我们从第二章的问题58-67中所知,JDK 引入了可以用于 instanceof 和 switch 的类型模式。因此,在这种特殊情况下,我们可以通过类型模式重写以前的代码,如下所示:

typescript 复制代码
public static String cabinet(Staff staff) {
  if (staff instanceof Doctor dr) { // 类型模式匹配
    return "Cabinet of " + dr.specialty() 
       + ". Doctor: " + dr.name();
  }
  ...
}

到目前为止还没有什么新东西!绑定变量 dr 可以用于调用记录访问器的 specialty() 和 name(),以进行检查、计算等操作。但是,编译器非常清楚,Doctor 记录是基于两个组件(name 和 specialty)构建的,所以编译器应该能够解构这个对象并直接将这些组件作为绑定变量提供,而不是通过 dr 访问它们。

这正是记录模式匹配的全部内容。记录模式匹配作为 JDK 19(JEP 405)的预览功能出现,作为 JDK 20(JEP 432)的第二个预览功能出现,并作为 JDK 21(JEP 440)的最终版本发布。

记录模式匹配确切地是通过遵循与记录本身(或者与规范构造函数)相同的声明语法来声明 name 和 specialty 作为绑定变量。下面是通过记录模式编写的先前代码:

typescript 复制代码
public static String cabinet(Staff staff) { 
  // 记录模式匹配
  if (staff instanceof Doctor(String name, String specialty)){ 
    return "Cabinet of " + name + ". Doctor: " + specialty;
  }
  ...
}

非常简单,不是吗?

现在,name 和 specialty 是可以直接使用的绑定变量。我们只需将此语法放在类型模式的位置即可。换句话说,我们用记录模式替换了类型模式。

重要说明:

编译器通过相应的绑定变量公开记录的组件。这通过在模式匹配中解构记录来实现,这称为记录模式。换句话说,解构模式允许我们以一种非常方便、直观和可读的方式访问对象的组件。

在记录模式中,初始化绑定变量(如 name 和 specialty)是编译器的责任。为了实现这一点,编译器调用相应组件的访问器。这意味着如果你在这些访问器中有一些额外的代码(例如,返回防御性副本、执行验证或应用约束等),那么这些代码将被正确执行。

让我们进一步来处理一些嵌套记录。

嵌套记录和记录模式

假设除了 Doctor 记录之外,我们还有以下记录:

arduino 复制代码
public record Resident(String name, Doctor doctor)
  implements Staff {}

每个住院医生都有一个协调员,也就是一名医生,因此 Resident 嵌套了 Doctor。这次,我们必须相应地嵌套记录模式,就像下面的代码一样:

typescript 复制代码
public static String cabinet(Staff staff) { 
  if (staff instanceof Resident(String rsname, 
      Doctor(String drname, String specialty))) { 
    return "Cabinet of " + specialty + ". Doctor: " 
                         + drname + ", Resident: " + rsname;
  }  
  ...
}

住院医生和医生都有一个名字组件。但是在这种情况下,我们不能在同一个上下文中两次使用绑定变量 name,因为这会导致冲突。这就是为什么我们有 rsname 和 drname。请注意,绑定变量的名称不必与组件的名称相同。这是可能的,因为编译器通过位置而不是通过名称识别组件。但是,当可能时,使用名称镜像可以减少混淆并保持代码的可读性。

如果不需要解构 Doctor 记录,则可以这样写:

java 复制代码
if (staff instanceof Resident(String name, Doctor dr)) { 
  return "Cabinet of " + dr.specialty() + ". Doctor: " 
                       + dr.name() + ", Resident: " + name;
}

添加更多的嵌套记录遵循相同的原则。例如,让我们也添加 Patient 和 Appointment 记录:

csharp 复制代码
public record Appointment(LocalDate date, Doctor doctor) {}
public record Patient(
  String name, int npi, Appointment appointment) {}

现在,我们可以编写以下美丽的代码:

typescript 复制代码
public static String reception(Object o) {
  if (o instanceof Patient(var ptname, var npi, 
                  Appointment(var date, 
                  Doctor (var drname, var specialty)))) {
   return "Patient " + ptname + " (NPI: " + npi
          + ") has an appointment at "
          + date + " to the doctor " + drname
          + " (" + specialty + ").";
  }
  ...
}

或者,如果我们不想解构 Appointment 并使用 var:

java 复制代码
if (o instanceof Patient(
 var ptname, var npi, var ap)) {
  return "Patient " + ptname + " (NPI: " + npi
       + ") has an appointment at "
       + ap.date() + " to the doctor " + ap.doctor().name() 
       + " (" + ap.doctor().specialty() + ").";
}

请注意,这次我们使用了 var 而不是显式类型。在这种情况下使用 var 完全合适,所以请随意这样做。如果你不熟悉类型推断,请参考《Java编程问题 第一版》第4章,其中包含了详细的解释和最佳实践。有关记录模式中的参数类型推断的更多细节,稍后在本章的问题100中提供。我想你已经明白了这个想法!

11. 引入记录模式用于switch语句

你已经知道类型模式可以用于instanceof和switch表达式。对于记录模式也是如此。例如,让我们重申一下Doctor和Resident记录:

arduino 复制代码
public record Doctor(String name, String specialty)
implements Staff {}
public record Resident(String name, Doctor doctor)
implements Staff {}

我们可以轻松地在switch表达式中使用这两个记录,如下所示:

java 复制代码
public static String cabinet(Staff staff) {
 return switch(staff) {  
  case Doctor(var name, var specialty) 
    -> "Cabinet of " + specialty + ". Doctor: " + name;
  case Resident(var rsname, Doctor(var drname, var specialty)) 
    -> "Cabinet of " + specialty + ". Doctor: " 
                     + drname + ", Resident: " + rsname;
  default -> "Cabinet closed";
 }; 
}

添加更多的嵌套记录遵循相同的原则。例如,让我们也添加Patient和Appointment记录:

csharp 复制代码
public record Appointment(LocalDate date, Doctor doctor) {}
public record Patient(
  String name, int npi, Appointment appointment) {}

现在,我们可以编写以下美丽的代码:

typescript 复制代码
public static String reception(Object o) {
  return switch(o) {           
    case Patient(String ptname, int npi, 
         Appointment(LocalDate date, 
         Doctor (String drname, String specialty))) ->
           "Patient " + ptname + " (NPI: " + npi
              + ") has an appointment at "
              + date + " to the doctor " + drname + " (" 
              + specialty + ").";
    default -> "";
  };
}

或者,不解构Appointment并使用var:

java 复制代码
return switch(o) {
  case Patient(var ptname, var npi, var ap) ->
    "Patient " + ptname + " (NPI: " 
    + npi + ") has an appointment at "
    + ap.date() + " to the doctor " + ap.doctor().name() 
    + " (" + ap.doctor().specialty() + ").";
  default -> "";
};

请注意,第2章涵盖的主题,如优势性、完整性和无条件模式,在switch中的记录模式中仍然有效。实际上,关于无条件模式有一些重要的事情需要强调,但这将在问题14中详细介绍。

12. 处理带有保护条件的记录模式

与类型模式的情况一样,我们可以基于绑定变量添加保护条件。例如,以下代码使用带有instanceof的保护条件来确定"过敏"药柜是开放还是关闭(你应该熟悉前两个问题中的Doctor记录):

typescript 复制代码
public static String cabinet(Staff staff) {
  if (staff instanceof Doctor(String name, String specialty) 
       && (specialty.equals("Allergy") 
       && (name.equals("Kyle Ulm")))) { 
     return "The cabinet of " + specialty 
       + " is closed. The doctor " 
       + name + " is on holiday.";
  }                
  if (staff instanceof Doctor(String name, String specialty) 
      && (specialty.equals("Allergy") 
      && (name.equals("John Hora")))) { 
    return "The cabinet of " + specialty 
      + " is open. The doctor " 
      + name + " is ready to receive patients.";
  }
  return "Cabinet closed";
}

如果我们还加入Resident记录,那么我们可以写成这样:

vbnet 复制代码
if (staff instanceof Resident(String rsname, 
    Doctor(String drname, String specialty))
       && (specialty.equals("Dermatology") 
       && rsname.equals("Mark Oil"))) { 
  return "Cabinet of " + specialty + ". Doctor " 
    + drname + " and resident " + rsname
    + " are ready to receive patients.";
}

如果我们还添加了Patient和Appointment记录,那么我们可以检查某个患者是否有预约,如下所示:

typescript 复制代码
public static String reception(Object o) {
  if (o instanceof Patient(var ptname, var npi,
                  Appointment(var date,
                  Doctor (var drname, var specialty)))
     && (ptname.equals("Alicia Goy") && npi == 1234567890
     && LocalDate.now().equals(date))) {
    return "The doctor " + drname + " from " + specialty
                         + " is ready for you " + ptname;
  }
  return "";
}

当我们在switch表达式中使用带有保护条件的记录模式时,事情变得很简单。在模式标签和检查之间使用when关键字(而不是&&运算符),如下所示:

csharp 复制代码
public static String cabinet(Staff staff) {
  return switch(staff) {             
    case Doctor(var name, var specialty) 
      when specialty.equals("Dermatology") 
        -> "The cabinet of " + specialty 
              + " is currently under renovation";
    case Doctor(var name, var specialty) 
      when (specialty.equals("Allergy") 
      && (name.equals("Kyle Ulm"))) 
        -> "The cabinet of " + specialty 
              + " is closed. The doctor " + name 
              + " is on holiday.";
    case Doctor(var name, var specialty) 
      when (specialty.equals("Allergy") 
      && (name.equals("John Hora"))) 
        -> "The cabinet of " + specialty 
              + " is open. The doctor " + name 
              + " is ready to receive patients.";
    case Resident(var rsname, 
        Doctor(var drname, var specialty)) 
      when (specialty.equals("Dermatology") 
      && rsname.equals("Mark Oil")) 
        -> "Cabinet of " + specialty + ". Doctor " 
               + drname + " and resident " + rsname
               + " are ready to receive patients.";
    default -> "Cabinet closed";
  };                
}

如果我们还添加了Patient和Appointment记录,那么我们可以检查某个患者是否有预约,如下所示:

vbnet 复制代码
public static String reception(Object o) {
  return switch(o) {
    case Patient(String ptname, int npi, 
         Appointment(LocalDate date, 
         Doctor (String drname, String specialty)))
      when (ptname.equals("Alicia Goy") 
      && npi == 1234567890 && LocalDate.now().equals(date)) 
        -> "The doctor " + drname + " from " + specialty 
           + " is ready for you " + ptname;
    default -> "";
  };                
}    

JDK 19+上下文特定关键字when被添加到模式标签和检查之间(表示保护条件的布尔表达式),这避免了使用&&运算符时的混淆。

13. 使用泛型记录在记录模式中

声明水果数据的示例如下:

csharp 复制代码
public record FruitRecord<T>(T t, String country) {}

现在,假设有一个MelonRecord,它是一种水果(实际上,关于瓜是水果还是蔬菜存在一些争议,但让我们假设它是水果):

arduino 复制代码
public record MelonRecord(String type, float weight) {}

我们可以声明一个FruitRecord<MelonRecord>如下:

arduino 复制代码
FruitRecord<MelonRecord> fruit = 
  new FruitRecord<>(new MelonRecord("Hami", 1000), "China");

这个FruitRecord<MelonRecord>可以在instanceof的记录模式中使用:

csharp 复制代码
if (fruit instanceof FruitRecord<MelonRecord>(
    MelonRecord melon, String country)) {
  System.out.println(melon + " from " + country);
} 

或在switch语句/表达式中使用:

csharp 复制代码
switch(fruit) {
  case FruitRecord<MelonRecord>(
       MelonRecord melon, String country) :
    System.out.println(melon + " from " + country); break;
  default : break; 
};

接下来,让我们看看如何使用类型参数推断。

类型参数推断

Java支持对记录模式的类型参数进行推断,因此我们可以将上述示例重新编写如下:

csharp 复制代码
if (fruit instanceof FruitRecord<MelonRecord>(
    var melon, var country)) {
  System.out.println(melon + " from " + country);
}

或者,如果我们希望代码更加简洁,可以省略类型参数,如下所示:

csharp 复制代码
if (fruit instanceof FruitRecord(var melon, var country)) {
  System.out.println(melon + " from " + country);
}

对于switch也是一样的:

csharp 复制代码
switch (fruit) {
  case FruitRecord<MelonRecord>(var melon, var country) :
    System.out.println(melon + " from " + country); break;
  default : break;
}

或者更加简洁:

csharp 复制代码
switch (fruit) {
  case FruitRecord(var melon, var country) :
    System.out.println(melon + " from " + country); break;
  default : break;
}

在这里,melon的类型被推断为MelonRecord,country的类型被推断为String。 现在,假设有以下泛型记录:

csharp 复制代码
public record EngineRecord<X, Y, Z>(X x, Y y, Z z) {}

泛型X、Y和Z可以是任何类型。例如,我们可以按照其类型、马力和冷却系统定义引擎,如下所示:

vbnet 复制代码
EngineRecord<String, Integer, String> engine
  = new EngineRecord("TV1", 661, "Water cooled");

然后,我们可以使用engine变量和instanceof,如下所示:

typescript 复制代码
if (engine instanceof EngineRecord<String, Integer, String>
   (var type, var power, var cooling)) {
  System.out.println(type + " - " + power + " - " + cooling);
}
// 或者,更加简洁
if (engine instanceof EngineRecord(
 var type, var power, var cooling)) {
  System.out.println(type + " - " + power + " - " + cooling);
}

以及,使用switch,如下所示:

go 复制代码
switch (engine) {
  case EngineRecord<String, Integer, String>(
      var type, var power, var cooling) :
    System.out.println(type + " - "
                                + power + " - " + cooling);
  default : break;
}
// 或者,更加简洁
switch (engine) {
  case EngineRecord(var type, var power, var cooling) :
    System.out.println(type + " - "
                            + power + " - " + cooling);
  default : break;
}

在这两个示例中,我们依赖于推断出的参数类型。推断出的类型参数是String,马力是Integer,冷却系统是String。

类型参数推断和嵌套记录

假设有以下记录:

csharp 复制代码
public record ContainerRecord<C>(C c) {}

以及以下嵌套容器:

ini 复制代码
ContainerRecord<String> innerContainer
  = new ContainerRecord("Inner container");
ContainerRecord<ContainerRecord<String>> container
  = new ContainerRecord(innerContainer);

接下来,我们可以这样使用container:

csharp 复制代码
if (container instanceof
    ContainerRecord<ContainerRecord<String>>(
      ContainerRecord(var c))) {
  System.out.println(c);
}

在这里,嵌套模式ContainerRecord(var c)的类型参数被推断为String,因此模式本身被推断为ContainerRecord<String>(var c)。 如果我们在外部记录模式中省略类型参数,则可以获得更简洁的代码,如下所示:

csharp 复制代码
if (container instanceof ContainerRecord(
    ContainerRecord(var c))) {
      System.out.println(c);
}

在这里,编译器将推断整个instanceof模式为ContainerRecord<ContainerRecord<String>>(ContainerRecord<String>(var c))。 或者,如果我们想要外部容器,则编写以下记录模式:

csharp 复制代码
if (container instanceof
    ContainerRecord<ContainerRecord<String>>(var c)) {
  System.out.println(c);
}

在捆绑的代码中,您也可以找到这些示例适用于switch。

重要提示 请注意,类型模式不支持对类型参数的隐式推断(例如,类型模式List list总是被视为原始类型模式)。 因此,Java泛型可以像普通Java类一样在记录中使用。此外,我们可以将它们与记录模式、instanceof和switch结合使用。

14. 处理嵌套记录模式中的空值

根据第2章第54题《处理switch中的null情况》,我们知道从JDK 17开始(JEP 406),我们可以将switch中的null情况视为任何其他普通情况:

csharp 复制代码
case null -> throw new IllegalArgumentException(...);

此外,从第67题中我们知道,当涉及类型模式时,总模式会无条件地匹配包括null值在内的所有内容(称为无条件模式)。解决此问题可以通过显式添加null情况(与前面代码片段中的情况相同),或依赖于JDK 19+。从JDK 19开始,无条件模式仍然会匹配null值,只是不会执行该分支。switch表达式将直接抛出NullPointerException,甚至不会查看模式。 这个语句部分适用于记录模式。例如,让我们考虑以下记录:

csharp 复制代码
public interface Fruit {}
public record SeedRecord(String type, String country)
  implements Fruit {}
public record MelonRecord(SeedRecord seed, float weight)
  implements Fruit {}
public record EggplantRecord(SeedRecord seed, float weight)
  implements Fruit {}

以及以下switch:

typescript 复制代码
public static String buyFruit(Fruit fruit) {
  return switch(fruit) {
    case null -> "Ops!";
    case SeedRecord(String type, String country) 
      -> "This is a seed of " + type + " from " + country;
    case EggplantRecord(SeedRecord seed, float weight) 
      -> "This is a " + seed.type() + " eggplant";
    case MelonRecord(SeedRecord seed, float weight) 
      -> "This is a " + seed.type() + " melon";
    case Fruit v -> "This is an unknown fruit";
  };
}

如果我们调用buyFruit(null),我们将得到Ops!消息。编译器知道选择器表达式是null,并且有一个case null,因此它将执行该分支。如果我们移除case null,那么我们会立即得到一个NullPointerException。编译器不会评估记录模式;它只是简单地抛出NullPointerException。 接下来,让我们创建一个茄子:

ini 复制代码
SeedRecord seed = new SeedRecord("Fairytale", "India");
EggplantRecord eggplant = new EggplantRecord(seed, 300);

这次,如果我们调用buyFruit(seed),我们会得到消息This is a seed of Fairytale from India。调用匹配了case SeedRecord(String type, String country)分支。如果我们调用buyFruit(eggplant),然后我们得到消息This is a Fairytale eggplant。调用匹配了case EggplantRecord(SeedRecord seed, float weight)分支。到目前为止都没有什么意外! 现在,让我们考虑一个边缘情况。我们假设SeedRecord为空,并创建以下"坏"茄子:

ini 复制代码
EggplantRecord badEggplant = new EggplantRecord(null, 300);

buyFruit(badEggplant)调用将返回一个包含以下清晰消息的NullPointerException:java.lang.NullPointerException: Cannot invoke "modern.challenge.SeedRecord.type()" because "seed" is null。正如在switch示例中一样,嵌套的null无法被编译器拦截,而嵌套的null不会使代码短路,并且会击中我们的分支代码(case EggplantRecord(SeedRecord seed, float weight)),在那里我们调用seed.type()。由于seed为null,因此我们会得到一个NullPointerException。

我们无法通过以下代码片段来处理此边缘情况case EggplantRecord(null, float weight)。这将无法编译。显然,更深或更广的嵌套将使这些边缘情况变得更加复杂。但是,我们可以添加一个保护来防止出现问题,覆盖此情况如下:

csharp 复制代码
case EggplantRecord(SeedRecord seed, float weight) 
     when seed == null -> "Ops! What's this?!";

让我们看看在使用instanceof而不是switch时会发生什么。因此,代码变成了:

typescript 复制代码
public static String buyFruit(Fruit fruit) {
  if (fruit instanceof SeedRecord(
      String type, String country)) {
     return "This is a seed of " + type + " from " + country;
  }
  if (fruit instanceof EggplantRecord(
      SeedRecord seed, float weight)) {
    return "This is a " + seed.type() + " eggplant";
  } 
  if (fruit instanceof MelonRecord(
      SeedRecord seed, float weight)) {
    return "This is a " + seed.type() + " melon";
  } 
  return "This is an unknown fruit";
}

对于instanceof,不需要添加显式的空检查。例如,调用buyFruit(null)将返回消息This is an unknown fruit。这是因为没有if语句会匹配给定的null。 接下来,如果我们调用buyFruit(seed),我们会得到消息This is a seed of Fairytale from India。调用匹配了if (fruit instanceof SeedRecord(String type, String country))分支。如果我们调用buyFruit(eggplant),然后我们会得到消息This is a Fairytale eggplant。调用匹配了case if (fruit instanceof EggplantRecord(SeedRecord seed, float weight))分支。再次,到目前为止都没有什么意外! 最后,让我们通过buyFruit(badEggplant)调用处理badEggplant。与switch示例中的情况完全相同,结果将包含一个NPE:Cannot invoke "modern.challenge.SeedRecord.type()" because "seed" is null。同样,编译器无法拦截嵌套的null,if (fruit instanceof EggplantRecord(SeedRecord seed, float weight))分支被执行,导致NullPointerException,因为我们调用了seed.type()而seed为null。 尝试通过以下代码片段处理此边缘情况将无法编译:

java 复制代码
if (fruit instanceof EggplantRecord(null, float weight)) {
  return "Ops! What's this?!";
}

但是,我们可以添加一个保护来处理此情况,如下所示:

java 复制代码
if (fruit instanceof EggplantRecord(
    SeedRecord seed, float weight) && seed == null) {
  return "Ops! What's this?!";
}

因此,请注意,嵌套模式不利用case null或JDK 19+的行为,即不会检查模式就抛出NPE。这意味着null值可以通过case(或instanceof检查)并执行该分支,导致NPE。因此,尽可能避免空值或尽可能添加额外检查(保护)应该是顺利前进的方法。

15. 使用记录模式简化表达式

Java记录可以帮助我们大大简化用于处理/评估不同表达式(数学的、统计的、基于字符串的、抽象语法树(AST)等等)的代码片段。通常,评估这些表达式意味着需要通过if和/或switch语句实现许多条件和检查。 例如,让我们考虑以下旨在塑造可以连接的基于字符串的表达式的记录:

java 复制代码
interface Str {}
record Literal(String text) implements Str {}
record Variable(String name) implements Str {}
record Concat(Str first, Str second) implements Str {}

字符串表达式的某些部分是字面值(Literal),而其他部分是作为变量(Variable)提供的。为了简洁起见,我们只能通过连接操作(Concat)来评估这些表达式,但是可以自由添加更多操作。

在评估过程中,我们有一个中间步骤来通过移除/替换不相关部分来简化表达式。例如,我们可以认为表达式的项为空字符串时,可以安全地从连接过程中移除。换句话说,一个字符串表达式,比如 t + " ",可以简化为 t,因为我们表达式的第二项是一个空字符串。 用于执行这种简化的代码可以依赖于类型模式和instanceof,如下所示:

typescript 复制代码
public static Str shortener(Str str) {
  if (str instanceof Concat s) {
    if (s.first() instanceof Variable first 
       && s.second() instanceof Literal second 
       && second.text().isBlank()) {
          return first;
    } else if (s.first() instanceof Literal first 
       && s.second() instanceof Variable second 
       && first.text().isBlank()) {
          return second;
    } 
  }
  return str;
}

如果我们继续添加更多用于简化给定str的规则,这段代码将变得相当冗长。幸运的是,我们可以通过使用记录模式和switch来增加此代码的可读性。这样,代码变得更加简洁和表达力十足。看看这个:

scss 复制代码
public static Str shortener(Str str) {
  return switch (str) { 
    case Concat(Variable(var name), Literal(var text)) 
      when text.isBlank() -> new Variable(name); 
    case Concat(Literal(var text), Variable(var name)) 
      when text.isBlank() -> new Variable(name);
    default -> str;
  }; 
}

这是多么酷啊!

16. 使用未命名模式和变量简化表达式

JDK 21 中最引人注目的预览功能之一是 JEP 443,即未命名模式和变量。换句话说,通过未命名模式和变量,JDK 21 提供了对我们在代码中未使用(我们不关心它们)的记录组件和局部变量的支持,将它们表示为下划线字符(_)。

未命名模式

对记录进行解构允许我们表达记录模式,但我们并不总是使用所有结果组件。未命名模式对于指示我们不使用但必须声明的记录组件非常有用,以维护语法的完整性。例如,让我们来看下面的示例(Doctor、Resident、Patient 和 Appointment 记录在之前的问题中已经介绍过了,在问题 97 和 98 中,为了简洁起见,我将在此处跳过它们的声明):

javascript 复制代码
if (staff instanceof Doctor(String name, String specialty)) {
  return "The cabinet of " + specialty
       + " is currently under renovation";
}

在此示例中,Doctor 记录被解构为 Doctor(String name, String specialty),但我们仅使用了 specialty 组件,而不需要 name 组件。但是,我们不能写成 Doctor(String specialty),因为这不符合 Doctor 记录的签名。作为替代,我们可以简单地用下划线替换 String name,如下所示:

java 复制代码
if (staff instanceof Doctor(_, String specialty)) {
  return "The cabinet of " + specialty
        + " is currently under renovation";
}

未命名模式是类型模式 var _ 的简写形式,因此我们也可以写成 if (staff instanceof Doctor(var _, String specialty))。

让我们考虑另一个用例:

java 复制代码
if (staff instanceof Resident(String name, Doctor dr)) {
  return "The resident of this cabinet is : " + name;
}

在这种情况下,我们使用 Resident 的 name,但我们不关心 Doctor,因此我们可以简单地使用下划线,如下所示:

java 复制代码
if (staff instanceof Resident(String name, _)) {
  return "The resident of this cabinet is : " + name;
}

这里是另一个忽略医生 specialty 的示例:

javascript 复制代码
if (staff instanceof Resident(String rsname,
      Doctor(String drname, _))) {
    return "This is the cabinet of doctor " + drname
         + " and resident " + rsname;
}

接下来,让我们也添加 Patient 和 Appointment 记录:

java 复制代码
if (o instanceof Patient(var ptname, var npi,
                    Appointment(var date,
                    Doctor (var drname, var specialty)))) {
  return "Patient " + ptname
       + " has an appointment for the date of " + date;
}

在此示例中,我们不需要 npi 组件和 Doctor 组件,因此我们可以将它们替换为下划线:

java 复制代码
if (o instanceof Patient(var ptname, _,
                    Appointment(var date, _))) {
  return "Patient " + ptname
       + " has an appointment for the date of " + date;
}

这里是一个只需要患者姓名的情况:

java 复制代码
if (o instanceof Patient(var ptname, _, _)) {
  return "Patient " + ptname + " has an appointment";
}

当然,在这种情况下,您可能更倾向于依赖类型模式匹配,并将代码表示为以下方式:

java 复制代码
if (o instanceof Patient pt) {
  return "Patient " + pt.name() + " has an appointment";
}

我想你已经明白了!当您不需要记录组件并且希望在键入代码时明确传达这一点时,只需将该组件替换为下划线(_)。

未命名模式也可以与 switch 一起使用。以下是一个示例:

javascript 复制代码
// 没有未命名模式
return switch(staff) {
  case Doctor(String name, String specialty) ->
      "The cabinet of " + specialty
    + " is currently under renovation";
  case Resident(String name, Doctor dr) ->
      "The resident of this cabinet is : " + name;
  default -> "Cabinet closed";
};
// 使用未命名模式
return switch(staff) {
  case Doctor(_, String specialty) ->
      "The cabinet of " + specialty
    + " is currently under renovation";
  case Resident(String name, _) ->
      "The resident of this cabinet is : " + name;
  default -> "Cabinet closed";
};

嵌套记录和未命名模式可以显着减少代码长度。以下是一个示例:

arduino 复制代码
// 没有未命名模式
return switch(o) {
  case Patient(String ptname, int npi,
               Appointment(LocalDate date,
               Doctor (String drname, String specialty))) ->
      "Patient " + ptname + " has an appointment";
  default -> "";
};
// 使用未命名模式
return switch(o) {
  case Patient(String ptname, _, _) ->
      "Patient " + ptname + " has an appointment";
  default -> "";
};

现在,让我们专注于未命名变量的另一个用例。

未命名变量

除了未命名模式(专用于解构记录组件)之外,JDK 21 还引入了未命名变量。未命名变量也用下划线(_)表示,用于突出显示我们不需要或不使用的变量。这些变量可能出现在以下其中一个上下文中。

在 catch 块中

每当您不使用 catch 块的异常参数时,可以将其替换为下划线。例如,在以下代码片段中,我们捕获了一个 ArithmeticException,但我们记录了一个不使用异常参数的友好消息:

csharp 复制代码
int divisor = 0;
try {
  int result = 1 / divisor;
  // 使用 result
} catch (ArithmeticException _) {
  System.out.println("除数 " + divisor + " 不好");
}

相同的技术也可以应用于多个 catch 情况。

在 for 循环中

未命名变量可以在简单的 for 循环中使用。例如,在以下代码片段中,我们调用了 logLoopStart(),但我们不使用返回的结果:

css 复制代码
int[] arr = new int[]{1, 2, 3};
for (int i = 0, _ = logLoopStart(i); i < arr.length; i++) {
  // 使用 i
}

未命名变量也可以在增强型 for 循环中使用。在以下代码片段中,我们通过增强型 for 循环遍历了 cards 列表,但我们不使用 cards:

ini 复制代码
int score = 0;
List<String> cards = List.of(
  "12 spade", "6 diamond", "14 diamond");
for (String _ : cards) {
  if (score < 10) {
    score ++;
  } else {
    score --;
  }
}

因此,在这里,我们不关心卡片的值,因此我们不是写成 for (String card : cards) { ... },而是简单地写成 for (String _ : cards) { ... }。

在忽略结果的赋值中

让我们考虑以下代码:

less 复制代码
Files.deleteIfExists(Path.of("/file.txt"));

deleteIfExists() 方法返回一个布尔结果,指示给定文件是否成功删除。但是,在这段代码中,我们没有捕获该结果,因此不清楚我们是要忽略结果还是只是忘记了它。如果我们假设我们忘记了它,那么我们可能想要写成这样:

ini 复制代码
boolean success = Files.deleteIfExists(Path.of("/file.txt"));
if (success) { ... }

但是,如果我们只想忽略它,那么我们可以通过未命名变量明确传达这一点(这表明我们知道结果但不想根据其值采取进一步的操作):

ini 复制代码
boolean _ = Files.deleteIfExists(Path.of("/file.txt"));
var _ = Files.deleteIfExists(Path.of("/file.txt"));

相同的技术适用于每当您想要忽略右侧表达式的结果时。

在 try-with-resources 中

有时,我们不使用 try-with-resources 块中打开的资源。我们只需要此资源的上下文,并且希望从它是 AutoCloseable 的事实中受益。例如,当我们调用 Arena.ofConfined() 时,我们可能需要 Arena 上下文,而不显式使用它。在这种情况下,未命名变量可以帮助我们,如下面的示例所示:

java 复制代码
try (Arena _ = Arena.ofConfined()) {
  // 不使用 arena
}

或者,使用 var:

csharp 复制代码
try (var _ = Arena.ofConfined()) {
  // 不使用 arena
}

Arena API 是第 7 章介绍的 Foreign(Function)Memory API 的一部分。

在 lambda 表达式中

当 lambda 参数对我们的 lambda 表达式不相关时,我们可以简单地将其替换为下划线。以下是一个示例:

ini 复制代码
List<Melon> melons = Arrays.asList(...);
Map<String, Integer> resultToMap = melons.stream()
  .collect(Collectors.toMap(Melon::getType, Melon::getWeight,
    (oldValue, _) -> oldValue));

搞定!不要忘记这是 JDK 21 中的一个预览功能,因此请使用 --enable-preview。

17. 处理 Spring Boot 中的记录

Java 记录非常适用于 Spring Boot 应用程序。让我们看看几种情景,在这些情景中,Java 记录可以通过压缩相似的代码来帮助我们提高可读性和表达力。

在控制器中使用记录

通常,Spring Boot 控制器操作简单的 POJO 类,将数据返回到客户端。例如,看看这个简单的控制器端点,它返回一个作者列表,包括他们的书籍:

kotlin 复制代码
@GetMapping("/authors")
public List<Author> fetchAuthors() {
  return bookstoreService.fetchAuthors();
}

这里,Author(以及 Book)可以是简单的数据载体,写成 POJO 类。但是,它们也可以被记录所替代。这是它的实现:

arduino 复制代码
public record Book(String title, String isbn) {}
public record Author(
  String name,  String genre, List<Book> books) {}

就是这样!Jackson 库(它是 Spring Boot 中默认的 JSON 库)将自动将 Author/Book 类型的实例编组为 JSON。在捆绑的代码中,您可以通过 localhost:8080/authors 端点地址来练习完整的示例。

在模板中使用记录

Thymeleaf(www.thymeleaf.org/)可能是 Spring Boot 应用程序中使用最多的模板引擎。Thymeleaf 页面(HTML 页面)通常由 POJO 类携带的数据填充,这意味着 Java 记录也应该可以工作。 让我们考虑之前的 Author 和 Book 记录,以及以下控制器端点:

typescript 复制代码
@GetMapping("/bookstore")
public String bookstorePage(Model model) {
  model.addAttribute("authors", 
    bookstoreService.fetchAuthors());
  return "bookstore";
}

通过 fetchAuthors() 返回的 List<Author> 存储在模型中,使用名为 authors 的变量来填充 bookstore.html,如下所示:

ini 复制代码
...
<ul th:each="author : ${authors}">
  <li th:text="${author.name} + ' (' 
             + ${author.genre} + ')'" />
  <ul th:each="book : ${author.books}">
    <li th:text="${book.title}" />
  </ul>
</ul>
...

搞定!

使用记录进行配置

假设在 application.properties 中,我们有以下两个属性(它们也可以用 YAML 表示):

ini 复制代码
bookstore.bestseller.author=Joana Nimar
bookstore.bestseller.book=Prague history

Spring Boot 将这样的属性映射到 POJO 中通过 @ConfigurationProperties。但是,记录也可以使用。例如,这些属性可以映射到 BestSellerConfig 记录,如下所示:

arduino 复制代码
@ConfigurationProperties(prefix = "bookstore.bestseller")
public record BestSellerConfig(String author, String book) {}

接下来,在 BookstoreService(一个典型的 Spring Boot 服务)中,我们可以注入 BestSellerConfig,并调用其访问器:

kotlin 复制代码
@Service
public class BookstoreService {
  private final BestSellerConfig bestSeller;
  public BookstoreService(BestSellerConfig bestSeller) {
    this.bestSeller = bestSeller;
  }
  public String fetchBestSeller() {
    return bestSeller.author() + " | " + bestSeller.book();
  }
}

在捆绑的代码中,我们还添加了一个使用此服务的控制器。

记录和依赖注入

在前面的示例中,我们使用 SpringBoot 提供的典型机制 ------ 通过构造函数进行依赖注入 ------ 将 BookstoreService 服务注入到 BookstoreController 中(也可以使用 @Autowired):

kotlin 复制代码
@RestController
public class BookstoreController {
  private final BookstoreService bookstoreService;
  public BookstoreController(
       BookstoreService bookstoreService) {
    this.bookstoreService = bookstoreService;
  }
  @GetMapping("/authors")
public List<Author> fetchAuthors() {
    return bookstoreService.fetchAuthors();
  }
}

但是,我们可以通过将其重新编写为记录来压缩这个类,如下所示:

csharp 复制代码
@RestController
public record BookstoreController(
     BookstoreService bookstoreService) {
  @GetMapping("/authors")
public List<Author> fetchAuthors() {
    return bookstoreService.fetchAuthors();
  }
}

该记录的规范构造函数将与我们的显式构造函数相同。随时挑战自己,找出在 Spring Boot 应用程序中使用 Java 记录的更多用例。

18. 处理 JPA 中的记录

如果你是 JPA 的粉丝(我看不出为什么,但我又有什么资格去评判),那么你会更乐意发现 Java 记录在 JPA 中也很有用。通常情况下,Java 记录可以用作 DTO。接下来,让我们看看几种情况下记录和 JPA 结合起来是多么美妙。

通过记录构造函数创建 DTO

假设我们有一个典型的 JPA Author 实体,用于映射作者数据,比如 id、name、age 和 genre。 接下来,我们想要编写一个查询,用于获取某种类型的作者。但是,我们不需要将作者作为实体获取,因为我们不打算修改这些数据。这是一个只返回每个给定类型作者的姓名和年龄的只读查询。因此,我们需要一个 DTO,可以通过记录来表示,如下所示:

arduino 复制代码
public record AuthorDto(String name, int age) {}

接下来,一个典型的 Spring Data JPA,由 Spring Data 查询构建器机制支持的 AuthorRepository,可以利用这个记录,如下所示:

java 复制代码
@Repository
public interface AuthorRepository
extends JpaRepository<Author, Long> {
  @Transactional(readOnly = true)    
  List<AuthorDto> findByGenre(String genre);
}

现在,生成的查询将获取数据,并由 Spring Boot 进行相应的映射,以便由 AuthorDto 携带。

通过记录和 JPA 构造函数表达式创建 DTO

前面情景的另一种变体可以依赖于使用构造函数表达式的 JPA 查询,如下所示:

less 复制代码
@Repository
public interface AuthorRepository
extends JpaRepository<Author, Long> {
  @Transactional(readOnly = true)
  @Query(value = "SELECT new com.bookstore.dto.AuthorDto(a.name, a.age) FROM Author a")
  List<AuthorDto> fetchAuthors();
}

AuthorDto 是前面示例中列出的相同记录。

通过记录和结果转换器创建 DTO

如果使用 Hibernate 6.0+ 的结果转换器不在你的"待办事项"列表中,那么你可以直接跳到下一个主题。

让我们考虑以下两个记录:

csharp 复制代码
public record BookDto(Long id, String title) {}
public record AuthorDto(Long id, String name, int age, List<BookDto> books) {
  public void addBook(BookDto book) {
    books().add(book);
  }
}

这次,我们需要获取一个层次化的 DTO,由 AuthorDto 和 BookDto 表示。由于作者可能写了几本书,所以我们在 AuthorDto 中必须提供一个类型为 List<BookDto> 的组件,以及一个用于收集当前作者书籍的辅助方法。

为了填充这个层次化的 DTO,我们可以依赖于 TupleTransformer、ResultListTransformer 的实现,如下所示:

typescript 复制代码
public class AuthorBookTransformer implements TupleTransformer, ResultListTransformer {
  private final Map<Long, AuthorDto> authorsDtoMap = new HashMap<>();
  
  @Override
  public Object transformTuple(Object[] os, String[] strings){
    Long authorId = ((Number) os[0]).longValue();
    AuthorDto authorDto = authorsDtoMap.get(authorId);
    if (authorDto == null) {
      authorDto = new AuthorDto(((Number) os[0]).longValue(), (String) os[1], (int) os[2], new ArrayList<>());
    }
    BookDto bookDto = new BookDto(((Number) os[3]).longValue(), (String) os[4]);
    authorDto.addBook(bookDto);
    authorsDtoMap.putIfAbsent(authorDto.id(), authorDto);
    return authorDto;
  }
  
  @Override
  public List<AuthorDto> transformList(List list) { 
    return new ArrayList<>(authorsDtoMap.values());
  }
}

你可以在捆绑的代码中找到完整的应用程序。

通过记录和 JdbcTemplate 创建 DTO

如果使用 SpringBoot JdbcTemplate 不在你的"待办事项"列表中,那么你可以直接跳到下一个主题。

JdbcTemplate API 在那些喜欢使用 JDBC 的人中取得了巨大成功。因此,如果你熟悉这个 API,那么你会很高兴发现它可以与 Java 记录相结合得非常好。

例如,假设和前面情景中一样,我们有相同的 AuthorDto 和 BookDto,我们可以依赖于 JdbcTemplate 来填充这个层次化的 DTO,如下所示:

less 复制代码
@Repository
@Transactional(readOnly = true)
public class AuthorExtractor {
  private final JdbcTemplate jdbcTemplate;
  
  public AuthorExtractor(JdbcTemplate jdbcTemplate) {
    this.jdbcTemplate = jdbcTemplate;
  }
  
  public List<AuthorDto> extract() {
    String sql = "SELECT a.id, a.name, a.age, b.id, b.title " 
               + "FROM author a INNER JOIN book b ON a.id = b.author_id";
    List<AuthorDto> result = jdbcTemplate.query(sql, (ResultSet rs) -> {
      final Map<Long, AuthorDto> authorsMap = new HashMap<>();
      while (rs.next()) {
        Long authorId = (rs.getLong("id"));
        AuthorDto author = authorsMap.get(authorId);
        if (author == null) {
          author = new AuthorDto(rs.getLong("id"), rs.getString("name"), rs.getInt("age"), new ArrayList()); 
        }
        BookDto book = new BookDto(rs.getLong("id"), rs.getString("title")); 
        author.addBook(book);
        authorsMap.putIfAbsent(author.id(), author);
      }
      return new ArrayList<>(authorsMap.values());
    });
    return result;
  }
}

你可以在捆绑的代码中找到完整的应用程序。

让 Java 记录与 @Embeddable 配合使用

Hibernate 6.2+ 允许我们将 Java 记录定义为可嵌入的。实际上,我们可以从一个定义如下的可嵌入类开始:

arduino 复制代码
@Embeddable
public record Contact(String email, String twitter, String phone) {}

接下来,我们在我们的 Author 实体中使用这个可嵌入类,如下所示:

java 复制代码
@Entity
public class Author implements Serializable {
  private static final long serialVersionUID = 1L;
  
  @Id
  @GeneratedValue(strategy = GenerationType.IDENTITY)
  private Long id;
  
  @Embedded
  private Contact contact;
  
  private int age;
  private String name;
  private String genre;
  ...
}

在我们的 AuthorDto DTO 中也是如此:

arduino 复制代码
public record AuthorDto(String name, int age, Contact contact) {}

接下来,一个典型的 Spring Data JPA AuthorRepository,由 Spring Data 查询构建器机制支持,可以利用这个记录,如下所示:

java 复制代码
@Repository
public interface AuthorRepository extends JpaRepository<Author, Long> {
  @Transactional(readOnly = true)    
  List<AuthorDto> findByGenre(String genre);
}

现在,生成的查询将获取数据,并由 Spring Boot 进行相应的映射,以便由 AuthorDto 携带。如果我们在控制台打印其中一个获取到的作者,我们将会看到类似这样的输出:

java 复制代码
[AuthorDto[name=Mark Janel, age=23, 
   contact=Contact[email=mark.janel@yahoo.com, twitter=@markjanel, phone=+40198503]]

高亮部分表示我们的可嵌入类。

19. 处理 jOOQ 中的记录

你对 JPA 越了解,就越会喜欢 jOOQ。为什么呢?因为 jOOQ 是在 Java 中编写 SQL 的最佳方式。灵活性、多功能性、方言无关、坚实的 SQL 支持、小的学习曲线和高性能仅是 jOOQ 最吸引人的持久化技术中的一部分属性。

作为现代技术栈的一部分,jOOQ 是符合成熟、稳健和良好文档化技术的新型持久化趋势。

如果你对 jOOQ 不熟悉,那么请考虑阅读我的书《jOOQ 大师课》。

话虽如此,让我们假设我们有一个包含两个表 Productline 和 Product 的数据库模式。一个产品线包含多个产品,所以我们可以通过两个记录来形成这种一对多的关系,如下所示:

arduino 复制代码
public record RecordProduct(String productName, 
  String productVendor, Integer quantityInStock) {}
public record RecordProductLine(String productLine, 
  String textDescription, List<RecordProduct> products) {}

在 jOOQ 中,我们可以通过基于 MULTISET 运算符的简单查询来填充这个模型:

less 复制代码
List<RecordProductLine> resultRecord = ctx.select(
  PRODUCTLINE.PRODUCT_LINE, PRODUCTLINE.TEXT_DESCRIPTION,
    multiset(
      select(
          PRODUCT.PRODUCT_NAME, PRODUCT.PRODUCT_VENDOR, 
          PRODUCT.QUANTITY_IN_STOCK)
        .from(PRODUCT)
        .where(PRODUCTLINE.PRODUCT_LINE.eq(
               PRODUCT.PRODUCT_LINE))
        ).as("products").convertFrom(
           r -> r.map(mapping(RecordProduct::new))))
         .from(PRODUCTLINE)
         .orderBy(PRODUCTLINE.PRODUCT_LINE)
         .fetch(mapping(RecordProductLine::new));

这是多酷啊!jOOQ 可以以完全类型安全的方式生成任何 jOOQ 记录或 DTO(POJO/Java 记录)的嵌套集合值,零反射,没有 N+1 风险,没有去重,也没有意外的笛卡尔积。这使得数据库能够执行嵌套并优化查询执行计划。

在捆绑的代码中,你可以看到另一个在记录模型中获取多对多关系的示例。此外,在捆绑的代码中,你还可以找到一个依赖于 jOOQ MULTISET_AGG() 函数的示例。这是一个可以用作 MULTISET 替代方案的合成聚合函数。

相关推荐
维天说2 小时前
CLI-Switch 2026年3月版历史设计:Hook、TTY 隔离与 JSON 状态
java·服务器·json
Zane19942 小时前
并发 vs 并行:别再傻傻分不清了,一文讲透 Java 并发编程的第一课
java·后端
杨充3 小时前
11.DDD与战术建模
设计模式·开源·代码规范
杨充3 小时前
12.综合实战图片框架
设计模式·开源·代码规范
噢,我明白了3 小时前
java中Excel的导入和导出(EasyExcel)
java·开发语言·excel
吠品3 小时前
Zabbix Web界面误报Server未运行的排查与解决
java·服务器·数据库
啊湘3 小时前
天气查询API接口 按月Token鉴权 实时天气 物联网可用 文档齐全
java·后端·struts
Java内核笔记4 小时前
告别十亿美元的错误 : Spring Boot 4 空安全 (JSpecify) 实战
java·spring boot·后端
糖果店的幽灵4 小时前
langgraph的 MessagesState 解读
java·开发语言·人工智能·windows·langgraph
极光代码工作室5 小时前
基于SpringBoot的在线博客系统
java·springboot·web开发·后端开发