编程

使用 Spring Boot 和 JPA 存储 PostgreSQL JSONB

288 2024-06-02 01:26:00

1. 概述

本教程将使我们全面了解在 PostgreSQL JSONB 列中存储 JSON 数据。
我们将快速回顾如何使用 JPA 处理存储在可变字符(VARCHAR)数据库列中的 JSON 值。之后,我们将比较 VARCHAR 类型和 JSONB 类型之间的差异,了解 JSONB 的附加功能。最后,我们将讨论 JPA 中的映射 JSONB 类型

2. VARCHAR 映射

本节中,我们将探讨如何使用 AttributeConverter 将 VARCHAR 类型的 JSON值转换为自定义 Java POJO。
它的目的是促进 Java 数据类型中实体属性值与其在数据库列中对应值之间的转换。

2.1. Maven 依赖

要创建 AttributeConverter,我们在 pom.xml 中引入 Spring Data JPA 依赖:

<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-data-jpa</artifactId>
    <version>2.7.18</version>
</dependency>

2.2. Table 定义

让我们使用以下数据库表定义,通过一个简单的例子来说明这个概念:

CREATE TABLE student (
    student_id VARCHAR(8) PRIMARY KEY,
    admit_year VARCHAR(4),
    address VARCHAR(500)
);

student 表有三个字段,我们期望 address 字段使用如下结构存储 JSON 值:

{
  "postCode": "TW9 2SF",
  "city": "London"
}

2.3. 实体(Entity)类

要处理这一情况,我们将创建对应的 POJO 类以在 Java 中表示地址数据:

public class Address {
    private String postCode;

    private String city;

    // constructor, getters and setters
}

接下来,我们将创建一个 entity 类,StudentEntity并将其映射到此前创建的 student 表中:

@Entity
@Table(name = "student")
public class StudentEntity {
    @Id
    @Column(name = "student_id", length = 8)
    private String id;

    @Column(name = "admit_year", length = 4)
    private String admitYear;

    @Convert(converter = AddressAttributeConverter.class)
    @Column(name = "address", length = 500)
    private Address address;

    // constructor, getters and setters
}

我们将使用 @Convert 注解 address 字段并应用 AddressAttributeConverter 以将 Address 实例转换成它的 JSON 描述。

2.4. AttributeConverter

我们将实体类中的 addess 字段映射到数据库中的 VARCHAR 类型。但是,JPA 无法自动实现自定义 Java 类型和 VARCHAR 类型之间的转换。AttributeConverter 通过提供一种处理转换过程的机制来弥补这一差距。
我们使用 AttributeConverter 将自定义 Java 数据类型持久化到数据库列中。必须为每个 AttributeConverter 实现定义两个转换方法。一个将 Java 数据类型转换为相应的数据库数据类型,另一个将数据库数据类型转换成 Java 数据类型:

@Converter
public class AddressAttributeConverter implements AttributeConverter<Address, String> {
    private static final ObjectMapper objectMapper = new ObjectMapper();

    @Override
    public String convertToDatabaseColumn(Address address) {
        try {
            return objectMapper.writeValueAsString(address);
        } catch (JsonProcessingException jpe) {
            log.warn("Cannot convert Address into JSON");
            return null;
        }
    }

    @Override
    public Address convertToEntityAttribute(String value) {
        try {
            return objectMapper.readValue(value, Address.class);
        } catch (JsonProcessingException e) {
            log.warn("Cannot convert JSON into Address");
            return null;
        }
    }
}

convertToDatabaseColumn() 负责将实体字段值转换为对应的数据库字段值,而 convertToEntityAttribute() 负责将数据库字段值转换成对应的实体字段值。

2.5. 测试用例

现在,让我们创建一个测试用例,将 Student 实例持久化到数据库中:

@Test
void whenSaveAnStudentEntityAndFindById_thenTheRecordPresentsInDb() {
    String studentId = "23876213";
    String postCode = "KT5 8LJ";

    Address address = new Address(postCode, "London");
    StudentEntity studentEntity = StudentEntity.builder()
      .id(studentId)
      .admitYear("2023")
      .address(address)
      .build();

    StudentEntity savedStudentEntity = studentRepository.save(studentEntity);

    Optional<StudentEntity> studentEntityOptional = studentRepository.findById(studentId);
    assertThat(studentEntityOptional.isPresent()).isTrue();

    studentEntity = studentEntityOptional.get();
    assertThat(studentEntity.getId()).isEqualTo(studentId);
    assertThat(studentEntity.getAddress().getPostCode()).isEqualTo(postCode);
}

运行测试时,JPA 触发以下的插入 SQL:

Hibernate: 
    insert 
    into
        "public"
        ."student_str" ("address", "admit_year", "student_id") 
    values
        (?, ?, ?)
binding parameter [1] as [VARCHAR] - [{"postCode":"KT6 7BB","city":"London"}]
binding parameter [2] as [VARCHAR] - [2023]
binding parameter [3] as [VARCHAR] - [23876371]

我们可以看到 AddressAttributeConverter 已成功从 Address 实例转换第一个参数,并绑定为 VARCHAR 类型数据库。

3. JSONB Over VARCHAR

我们已经研究了将 JSON 数据存储在 VARCHAR 列中的转换。现在,让我们将地址的列定义从 VARCHAR 更改为 JSONB:

CREATE TABLE student (
    student_id VARCHAR(8) PRIMARY KEY,
    admit_year VARCHAR(4),
    address jsonb
);

当我们探索 JSONB 数据类型时,经常会出现一个常见的问题:使用 JSONB 而非 VARCHAR 在 PostgreSQL 中存储 JSON 有什么意义,因为它本质上是一个字符串?
JSONB 是 PostgreSQL 中用于处理 JSON 数据的指定数据类型。这种类型以分解的二进制格式存储数据,由于额外的转换,在存储 JSON 时会有一些开销。
事实上,与 VARCHAR 相比,它提供了额外的特性,使 JSONB 成为在 PostgreSQL 中存储 JSON 数据的更有利的选择。

3.1. 验证

JSONB 类型对存储的值强制执行数据验证,以确保列值是有效的 JSON。PostgreSQL 拒绝任何使用无效 JSON 值插入或更新数据的尝试。
为了证明这一点,我们假设对 address 列使用无效 JSON 值进行 insert SQL 查询,其中 city 属性末尾缺少双引号:

INSERT INTO student(student_id, admit_year, address) 
VALUES ('23134572', '2022', '{"postCode": "E4 8ST, "city":"London}');

在 PostgreSQL 中执行此查询会导致一个验证错误,表明 JSON 无效:

SQL Error: ERROR: invalid input syntax for type json
  Detail: Token "city" is invalid.
  Position: 83
  Where: JSON data, line 1: {"postCode": "E4 8ST, "city...

3.2. 查询

PostgreSQL 支持在 SQL 查询中使用 JSON 列进行查询。JPA 支持使用原生查询来搜索数据库中的记录。在 Spring Data 中,我们可以定义一个自定义查询方法,用于查找 Student 列表:

@Repository
public interface StudentRepository extends CrudRepository<StudentEntity, String> {
    @Query(value = "SELECT * FROM student WHERE address->>'postCode' = :postCode", nativeQuery = true)
    List<StudentEntity> findByAddressPostCode(@Param("postCode") String postCode);
}

此查询是一个原生 SQL 查询,用于选择数据库中 address JSON 属性 postCode 等于所提供参数的所有 Student 实例。
3.3. 索引

JSONB 支持 JSON 数据索引。当我们必须通过 JSON 列中的键或属性查询数据时,这使 JSONB 具有显著优势。

JSON 列可以应用各种类型的索引,包括 GIN、HASH 和 BTREE。GIN 适用于索引复杂的数据结构,包括数组和 JSON。当我们只需要考虑等式运算符 = 时,HASH 是很重要的。当我们处理范围运算符(如<和>=)时,BTREE 允许高效查询。
例如,如果我们总是需要根据 address 列中的 postCode 属性检索数据,我们可以创建以下索引:

CREATE INDEX idx_postcode ON student USING HASH((address->'postCode'));

4. JSONB 映射

当数据库列定义为 JSONB 时,我们不能应用同一个的 AttributeConverter。如果尝试这样做,应用在启动时会抛出以下错误:

org.postgresql.util.PSQLException: ERROR: column "address" is of type jsonb but expression is of type character varying

即使我们将 AttributeConverter 类定义更改为使用 Object 而不是 String 作为转换后的列值,情况也是如此:

@Converter 
public class AddressAttributeConverter implements AttributeConverter<Address, Object> {
    // 2 conversion methods implementation
}

应用会抱怨不支持的类型:

org.postgresql.util.PSQLException: Unsupported Types value: 1,943,105,171

这表明 JPA 本机不支持 JSONB 类型。然而,我们的底层 JPA 实现(即 Hibernate)确实支持 JSON 自定义类型,允许我们将复杂类型映射到 Java 类。

4.1. Maven 依赖

实际上,我们必须为 JSONB 转换定义一个自定义类型。然而,我们不必因为现有的库  Hypersistence Utilities 而重新发明轮子。
Hyperpresence Utilities 是 Hibernate 的一个通用实用库。它的一个功能是为 PostgreSQL 和 Oracle 等不同数据库定义 JSON 列类型映射。因此,我们可以简单地将这个额外的依赖项包含在 pom.xml 中:

<dependency>
    <groupId>io.hypersistence</groupId>
    <artifactId>hypersistence-utils-hibernate-55</artifactId>
    <version>3.7.0</version>
</dependency>

4.2. 更新后的实体类

Hypersistence Utilities 定义了依赖于数据库的不同自定义类型。在 PostgreSQL 中,我们将使用 JsonBinaryType 类作为 JSONB 列类型。在实体类中,我们使用 Hibernate 的 @TypeDef 注解定义自定义类型,然后通过 @Type 将定义的类型应用于 address 字段:

@Entity
@Table(name = "student")
@TypeDef(name = "jsonb", typeClass = JsonBinaryType.class)
public class StudentEntity {
    @Id
    @Column(name = "student_id", length = 8)
    private String id;

    @Column(name = "admit_year", length = 4)
    private String admitYear;

    @Type(type = "jsonb")
    @Column(name = "address", columnDefinition = "jsonb")
    private Address address;

    // getters and setters
}

对于使用 @Type 的这种情况,我们不再需要将 AttributeConverter 应用于 address 字段。Hyperservation Utilities 的自定义类型为我们处理转换任务,使我们的代码更加整洁。但请注意,@TypeDef@Type 注解在 Hibernate 6 中已被弃用。

4.3. 测试用例

做完这些修改后,我们再次运行 Student 持久化测试用例:

Hibernate: 
    insert 
    into
        "public"
        ."student" ("address", "admit_year", "student_id") 
    values
        (?, ?, ?)
binding parameter [1] as [OTHER] - [Address(postCode=KT6 7BB, city=London)]
binding parameter [2] as [VARCHAR] - [2023]
binding parameter [3] as [VARCHAR] - [23876371]

我们将看到 JPA 触发与以前相同的 insert SQL,只是第一个参数绑定为 OTHER 而不是 VARCHAR。这表示 Hibernate 这次将参数绑定为 JSONB 类型。

5. 结论

这个全面的指南使我们掌握了使用 Spring Boot 和 JPA 在 PostgreSQL 中熟练存储和管理 JSON 数据的知识。