返回

Spring Data JPA 中 Record 与 findAll 的兼容性问题及解决方案

java

Spring Data JPA 中 Record 类与 findAll 方法的困境及对策

最近在用 SpringBoot 3.4.2 做项目,用 RESTful 控制器访问 Service,然后通过 Repository 从数据库获取数据,一切顺利。 我完全明白,大多数情况下,不应该将 Entity 直接返回给调用 RESTful API 的客户端。 有时候我们不希望某些数据被返回,有时候又需要聚合数据或者做一些计算,这时 DTO 就派上用场了。 我是完全支持 Entity 和 DTO 之间进行映射的。

最近发现 Record 这个东西,对于小型数据,可以把它当作 DTO 来用。 资料显示 Record 是不可变的。 我看了很多关于 DTO (用 Lombok 生成) 和 Record 不同的文章。 如果想保持 Repository 不变,可以自己写代码实现 Entity 到 Record 的映射,但有必要这么做吗? 从网上的例子看,可以用 Spring Data JPA 直接将数据加载到 Record 中,如果字段完全相同,映射非常简单。 如果数据有差异,也有办法解决,但我还没研究到那一步。 现在我有个小问题,可能是我遗漏了什么。 下面是我的代码:

@Entity
@Table(name = "company")
public class CompanyEntity implements Serializable
{
   @Id
   @GeneratedValue(strategy = GenerationType.IDENTITY)
   @Column(name = "company_id")
   private long companyId;
   @Column(name = "active")
   private boolean active;
   @Column(name = "code")
   private String companyCode;
   @Column(name = "name")
   private String companyName;
   @Column(name = "description")
   private String description;
   @Column(name = "address1")
   private String address1;
   @Column(name = "address2")
   private String address2;
   @Column(name = "city")
   private String city;
   @Column(name = "state")
   private String state;
   @Column(name = "zip")
   private String zip;
}

Record 类的字段和 Entity 完全一致:

public record CompanyRecord(long companyId, boolean active, 
String companyCode, 
String companyName, String description, String address1, 
String address2, String city, String state, String zip)
implements Serializable {}

Repository 代码如下:

@Repository("CompanyRepository")
public interface CompanyRepository extends JpaRepository<CompanyEntity, Long>
{
    // 工作正常
    public CompanyRecord findByCompanyId(long companyId);

    // 工作正常
    public List<CompanyEntity> findAll();

    // 这里不行!
    // IDE 报错:
    // The return type is incompatible with ListCrudRepository<CompanyEntity,Long>.findAll()
    public List<CompanyRecord> findAll();

   // 工作正常
   List<CompanyRecord> findByCompanyCode(String companyCode);
}

我不确定这是不是 JpaRepository 的问题,也不知道怎么解决。 更让人困惑的是 JUnit 测试:

@Test
public void testFindById_Entity()
{
    // 工作正常
    // 奇怪的是,Repository 需要的是 CompanyRecord,而不是 Entity
    long companyId = 1;
    CompanyEntity companyEntity = companyRepository.findById(companyId).orElse(null);
    assertNotNull(companyEntity);
}

@Disabled
@Test
public void testFindById_Record()
{
    long companyId = 1;
    // 不工作,IDE 报错
    // Type mismatch: cannot convert from CompanyEntity to CompanyRecord
    // 我以为能正常工作,因为接口明确要求返回 Record
    CompanyRecord companyRecord = companyRepository.findById(companyId).orElse(null);
    assertNotNull(companyRecord);
}

@Test
public void testFindByCode_Entity()
{
    String companyCode = "IBM";
    // 不工作,符合预期,因为接口需要 Record
    List<CompanyEntity> companyEntityList = companyRepository.findByCompanyCode(companyCode);
    assertNotNull(companyEntityList);
}

@Test
public void testFindByCode()
{
    String companyCode = "IBM";
    // 工作正常,接口需要 Record
    List<CompanyRecord> companyRecordList = companyRepository.findByCompanyCode(companyCode);
    assertNotNull(companyRecordList);
}

@Test
public void testFindAll_Entity()
{
    // 工作正常
    List<CompanyEntity> companyEntityList = companyRepository.findAll();
    assertNotNull(companyEntityList);
}

这样用下来,感觉很混乱。 有时候符合预期,有时候又不行。 感觉 Record 还没完全准备好。 或者,我可以退回到老办法,在 Repository 里只用 Entity,然后在 Service 里手动创建 Record,但这样用 Spring Data JPA 里的 Record 就能减少很多重复代码。

大家有没有类似的经验,或者对 Java Record 在 Spring Data JPA 中的应用有更深入的了解?

问题分析:为何 findAll 不能直接返回 List<CompanyRecord>

问题的核心在于 JpaRepositoryfindAll() 方法的定义。JpaRepository 继承自 ListCrudRepository,而 ListCrudRepositoryfindAll() 方法返回的是泛型 T 的列表,也就是 List<T>。 在你的例子里,T 被指定为 CompanyEntity,所以 findAll() 只能返回 List<CompanyEntity>

直接在接口里声明 List<CompanyRecord> findAll() 是不行的,因为这违反了 JpaRepository 的约定。

另外,在测试用例testFindById_Entity能够通过是因为findById方法返回的是Optional<CompanyEntity>类型。虽然CompanyRepository接口中的findByCompanyId指定的是CompanyRecord返回类型,但JpaRepositoryfindById实际返回的类型是Optional,其包含的对象是实体类。

解决方案

要让 findAll 返回 List<CompanyRecord>,有以下几种方法:

1. 使用 JPQL (Java Persistence Query Language)

在 Repository 接口中,用 @Query 注解自定义查询,将查询结果映射到 CompanyRecord

@Repository("CompanyRepository")
public interface CompanyRepository extends JpaRepository<CompanyEntity, Long>
{
    // ... 其他方法 ...
    @Query("SELECT new com.example.yourpackage.CompanyRecord(c.companyId, c.active, c.companyCode, c.companyName, c.description, c.address1, c.address2, c.city, c.state, c.zip) FROM CompanyEntity c")
    List<CompanyRecord> findAllCompanyRecords();
}

原理:

  • JPQL 允许你直接在查询中构造对象。
  • new com.example.yourpackage.CompanyRecord(...) 会调用 CompanyRecord 的构造函数,将查询结果的每一列映射到构造函数的参数。
  • 确保 com.example.yourpackage 替换成你的 CompanyRecord 所在的包名。

代码示例: (已经在上面提供)

注意点: 如果不采用别名c进行属性访问,则需要在CompanyRecord中定义匹配数据库字段名字的构造器,如: @Query("SELECT new com.example.yourpackage.CompanyRecord(c.company_id, ...)

进阶使用技巧:
可以用JPQL来做一些比较复杂的查询,比如关联查询,条件查询,等。这样你的查询会更加的灵活。

2. 使用原生 SQL (Native SQL)

与 JPQL 类似,但使用数据库的原生 SQL 语句。

@Repository("CompanyRepository")
public interface CompanyRepository extends JpaRepository<CompanyEntity, Long>
{
    // ... 其他方法 ...

    @Query(value = "SELECT company_id, active, code AS companyCode, name AS companyName, description, address1, address2, city, state, zip FROM company", nativeQuery = true)
    List<CompanyRecord> findAllCompanyRecords();
}

原理:

  • nativeQuery = true 告诉 Spring Data JPA 使用原生 SQL。
  • AS 用来给查询结果的列起别名,使其与 CompanyRecord 的字段名匹配。
  • 因为是使用原生SQL,可以无视数据库字段与实体类中字段的命名差异。

代码示例: (已经在上面提供)

安全性建议:

  • 使用原生 SQL 时,要小心 SQL 注入攻击。 尽量使用参数化查询。
  • 原生 SQL 可能导致代码与特定数据库绑定,降低可移植性。

进阶使用技巧
这种查询可以无视JPA和实体的要求和限制, 如果你需要非常高效或者数据库特定的查询,考虑这个方案。

3. 在 Service 层进行转换

这是最灵活的方式,也是最推荐的方式。

@Service
public class CompanyService {

    @Autowired
    private CompanyRepository companyRepository;

    public List<CompanyRecord> getAllCompanies() {
        List<CompanyEntity> entities = companyRepository.findAll();
        return entities.stream()
                .map(entity -> new CompanyRecord(entity.getCompanyId(), entity.isActive(), entity.getCompanyCode(),
                        entity.getCompanyName(), entity.getDescription(), entity.getAddress1(), entity.getAddress2(),
                        entity.getCity(), entity.getState(), entity.getZip()))
                .toList();
    }
}

原理:

  1. 在 Service 层调用 companyRepository.findAll() 获取 List<CompanyEntity>
  2. 使用 Java Stream API 将 List<CompanyEntity> 转换为 List<CompanyRecord>

代码示例: (已经在上面提供)

好处:

  • 将数据转换逻辑从 Repository 移到 Service,职责更清晰。
  • 可以灵活地处理各种映射需求,包括复杂的计算和数据转换。
  • 保持了Repository的简洁。

进阶使用技巧: 可以使用一些工具来处理映射过程, 比如 ModelMapper 和 MapStruct, 如果你有大量这种转换需求, 他们可以给你带来很多便利。

4. 使用自定义结果转换器 (ResultTransformer) (不推荐,更复杂)

可以实现org.hibernate.transform.ResultTransformer来完成这种转换.

原理:

可以在 Hibernate 级别拦截查询结果并进行转换. 通常这适用于更复杂的映射场景,需要自定义转换逻辑时。 你的场景里并不需要这样做.

因为这种做法比较进阶且容易产生不必要代码,所以代码实现略过。有需要的可以自行研究。

总结

总的来说,处理 Spring Data JPA 和 Record 之间的映射问题,推荐在 Service 层进行转换。这种方式既保持了Repository的简洁,也让代码拥有最好的灵活性和可维护性。 如果希望在 Repository 层解决,JPQL 是一个相对简洁的选择。 原生 SQL 提供了最大的灵活性,但要注意安全性和可移植性问题。