Spring REST 与枚举一起使用_JPA

概述

枚举是 java 语言的一个强大功能。它们是在 Java 5 中引入的。它们是一种特殊类型,允许我们声明一组预定义常量。它们提高了可读性,提供编译时检查并且类型安全。
在本文中,我们将扩展之前文章中客户端端点的功能。我们要做的就是向 Customer 类添加一个新的状态字段。然后,我们就可以根据状态进行持久化和搜索。

本文分为两个主要部分:

  1. 使用 JPA 映射枚举。
  2. Spring 中的请求参数和枚举。

事不宜迟,让我们进入下一部分并编写一些代码。

使用 JPA 映射枚举

将枚举与数据库列映射的最简单方法是使用 jakarta.persistence 包中的 @Enumerated 注释。该注释接受两种类型的映射。默认值为 ORDINAL,这意味着枚举将存储为整数。另一种类型 STRING 将枚举存储为枚举值/常量。

让我们看看这是行动。步骤是:

  1. 创建枚举。最好使用大写字母表示值/常量。
  2. 向客户实体添加新字段。
  3. 使用 @Enumerated 注释 ne 字段。
@Entity
public class Customer {
    public enum Status { ACTIVATED, DEACTIVATED, SUSPENDED };
...
    @Enumerated(EnumType.ORDINAL)
    private Status status;
...
}


下图显示了数据库中为客户实体保存的记录

Spring REST 与枚举一起使用_字段_02

ORDINAL 值从 0 开始。枚举中每个值的表示形式为

0 -> 激活
1 -> 停用
2 -> 暂停

让我们看看在我们的实体中使用 STRING 类型映射会发生什么

@Enumerated(EnumType.STRING)
    private Status status;


现在customer表中的数据如下图所示

Spring REST 与枚举一起使用_字段_03

现在该专栏更容易解读。另一方面,存在重复。如果数据量很大,这可能会成为问题。

这两种开箱即用的解决方案效果很好,但也有局限性。如果我们想在表中使用不同的值,甚至不同的数据库类型(除 INT 或 VARCHAR 之外)怎么办?

使用 @Convert 进行自定义映射

@Convert 注解将实体字段转换为数据库中的特定值。它需要一个 Converter 类来执行从实体属性状态到数据库列表示形式的转换,然后再返回。

对于我们的例子,该列中的值将如下所示:ACTIVATED -> 1、DEACTIVATED -> 2 和 SUSPENDED -> 3。

此处描述了实现此目的的步骤:

  1. 将 code 属性添加到 Status 枚举。这将是表列中的值。
  2. 创建一个转换器类来进行对话。
  3. 注释实体字段。

枚举中的更改很简单。

public enum Status {
    ACTIVATED(1), DEACTIVATED(2), SUSPENDED(3);

    int statusId;

    private Status(int statusId) {
        this.statusId = statusId;
    }

    public int getStatusId() {
        return statusId;
    }
};


转换器类必须实现 AttributeConverter 接口。它是一个通用接口,采用两个参数并声明两个方法。第一个类型是实体属性类型,第二个类型是将属性转换为的数据类型。

这里我们为 Status 枚举创建转换器

@Converter
public class CustomerStatusConverter implements AttributeConverter<Customer.Status, Integer> {
    @Override
    public Integer convertToDatabaseColumn(Customer.Status status) 
   { 
        return status.getStatusId();
   }

    @Override
    public Customer.Status convertToEntityAttribute(Integer 
        statusId) {
        return Arrays.stream(Customer.Status.values())
                .filter(s -> s.getStatusId() == statusId)
                .findFirst()
                .orElseThrow(IllegalArgumentException::new);
    }
}


最后,实体属性用@Convert注释,表示转换器类。

@Convert(converter = CustomerStatusConverter.class)
    private Status status;


或者,注释 @Converter 可以采用属性 autoApply。它的默认值为 false,因此它被禁用。当它设置为 true 时,Spring 将自动将转换器应用于第一个参数中指定类型的实体属性。

这就是所需要的一切。为了测试它,我们将使用 DataInitalizer 保留一些客户。

初始化客户表

我们将使用 CommandLineRunner 用一些随机记录填充客户表。这是一个声明 run 方法的函数式接口。Spring将在所有bean实例化并且应用程序上下文完全加载之后但在应用程序启动之前执行run方法。

@Configuration
public class DataInitializer implements CommandLineRunner {
    private CustomerRepo customerRepo;

    public DataInitializer(CustomerRepo customerRepo) {
        this.customerRepo = customerRepo;
    }

    @Override
    public void run(String... args) {       
        IntStream.rangeClosed(1,9)
            .forEach(this::createRandomCustomer);
    }

    public void createRandomCustomer(int id) {
        Customer customer = new Customer();
        customer.setId((long)id);
        customer.setName("name "+id+" surname "+id);
        customer.setEmail("organisation"+id+"@email.com");
        customer.setDateOfBirth(LocalDate.of(1980 + 2*id,id % 12, 
            (3*id) % 28));
        customer.setStatus(switch (id % 3) {
            case 0 -> Customer.Status.ACTIVATED;
            case 1 -> Customer.Status.DEACTIVATED;
            case 2 -> Customer.Status.SUSPENDED;
            default -> throw new IllegalStateException("Unexpected 
                       value: " + id % 3);
        });

        customerRepo.save(customer);
    }
}


现在表中有一些可用记录,其中 STATUS 列中包含我们想要的值。

Spring REST 与枚举一起使用_JPA_04

将 find 方法添加到存储库

移至 Web 层之前的最后一步是创建一个新方法,以在 CustomerRepo 类中按名称和状态进行过滤。Spring Data JPA 提供 @Query 注解来执行 JPQL 或本机 SQL。当 Spring Data JPA 查询方法不提供搜索条件的解决方案时,这会派上用场。

我们希望在单个方法中按状态和名称进行搜索并忽略空值。然后,借助@Query我们就可以编写代码了

public interface CustomerRepo extends JpaRepository<Customer, Long> {
    @Query("""
        SELECT c FROM Customer c 
        WHERE (c.status = :status or :status is null) 
          and (c.name like :name or :name is null) """)
    public List<Customer> findCustomerByStatusAndName(
            @Param("status") Customer.Status status,
            @Param("name") String name);
}


现在一切都已准备就绪,可以进入 Web 层了。

Spring 中的请求参数和枚举

这个想法是添加过滤器,以便 API 的客户端可以按姓名和状态搜索客户。过滤器将作为 GET HTTP 请求中的请求参数进行传递。名称可以接受任何字符串数据。相反,状态将仅限于枚举预定义值。

Spring MVC 支持枚举作为参数以及路径变量。只需将参数放入控制器的方法签名中就足够了

@RestController
@RequestMapping("api/v1/customers")
public class CustomerController {
...
    @GetMapping
    public List<CustomerResponse> findCustomers(
        @RequestParam(name="name", required=false) String name,
        @RequestParam(name="status", required=false) 
            Customer.Status status) {
        return customerRepo
            .findCustomerByStatusAndNameNamedParams(status, name)
            .stream()
            .map(CustomerUtils::convertToCustomerResponse)
            .collect(Collectors.toList());
   }
...
}


控制器已准备好接收参数。假设调用URL http://localhost:8080/api/v1/customers?status=ACTIVATED 。Spring 将尝试将来自 http 参数 status 的值转换为有效的状态枚举。

在这种情况下,两个值都匹配:http 参数和状态枚举值。因此,API 返回的响应是激活客户的列表

[
    {
        "id": 3,
        "name": "name 3 surname 3",
        "email": "organisation3@email.com",
        "dateOfBirth": "09-03-1986",
        "status": "activated"
    },
    {
        "id": 6,
        "name": "name 6 surname 6",
        "email": "organisation6@email.com",
        "dateOfBirth": "18-06-1992",
        "status": "activated"
    },
    {
        "id": 9,
        "name": "name 9 surname 9",
        "email": "organisation9@email.com",
        "dateOfBirth": "27-09-1998",
        "status": "activated"
    }
]


如果参数与任何枚举值不匹配或者无效,会发生什么情况?那么,如果它们不匹配或者无效,Spring 将抛出 ConversionFailedException。

让我们通过以下调用来演示它。首先,URL 是http://localhost:8080/api/v1/customers?status=activated

反应并不是我们所期望的

{
    "status": 400,
    "message": "There was a error converting the value activated 
                to type Status. ",
    "timestamp": "2023-06-24T19:33:26.6583667"
}


第二个 URL 为http://localhost:8080/api/v1/customers?status=DELETED

在这种情况下,可以接受来自服务器的响应,因为状态不存在。

{
    "status": 400,
    "message": "There was a error converting the value DELETED to 
                type Status.",
    "timestamp": "2023-06-24T19:08:49.6261182"
}


出现此行为的原因是 Spring 使用 StringToEnumConverterFactory 类。Convert 方法委托给 Enum valueOf 方法,如果未找到枚举常量,该方法将抛出 IllegalArgumentException。

@Nullable
public T convert(String source) {
    return source.isEmpty() ? null : Enum.valueOf(this.enumType, 
       source.trim());
}


我们希望改变这种行为并允许使用小写字母进行过滤。与 JPA 部分中所做的类似,我们必须编写一个自定义转换器。

创建 Spring 转换器

添加自定义转换器非常简单。只需实现 Converter 接口并重写其转换方法即可。Convert 方法采用两种泛型类型:源类型和结果类型。

@Component
public class StringIgnoreCaseToEnumConverter implements 
   Converter<String, Customer.Status> {

    @Override
    public Customer.Status convert(String source) {
        return Customer.Status.valueOf(source.toUpperCase());        
    }
}


上面的方法接受传入的 String。在通过 Enum.valueOf 返回枚举常量之前,它会被转换为大写。因此,字符串“activated”将变为“ACTIVATED”。因此,它将返回枚举常量 Status.ACTIVATED。

现在让我们重新测试之前的 URL 并检查结果。调用 URL http://localhost:8080/api/v1/customers?status=activated会生成我们想要的输出

[
    {
        "id": 3,
        "name": "name 3 surname 3",
        "email": "organisation3@email.com",
        "dateOfBirth": "09-03-1986",
        "status": "activated"
    },
 ...
]


结论

本文详细讲解了如何在持久层和Web层使用枚举。这里总结一下它的要点。

对于持久层,JPA 提供了开箱即用的 ORDINAL 和 STRING 选项。如果这不能满足应用程序的要求,自定义转换器可以完全控制映射的完成方式。

对于 Web 层,Spring MVC 为请求参数和路径变量提供了枚举支持。如果存在精确匹配,它会自动将 String 转换为枚举。可以通过实现转换器来更改此行为。