获取Spring Data JPA DTO投影的最佳方式

1,620 阅读6分钟

简介

在这篇文章中,我将向你展示什么是获取Spring Data JPA DTO投影的最佳方式。

除了基本的用例之外,我们将看到如何使用Spring Data JPA获取甚至是分层的DTO结构。

为什么使用DTO投射

正如我在这篇文章中所解释的,DTO投影允许你只选择你在特定用例中需要的列。

在我的咨询培训工作中,我看到,很多时候,开发人员获取的数据比需要的多,而解决这个问题的最好方法是使用分页和投影。

分页允许你减少结果集中的记录数量,而投影允许你减少你所获取的列的数量,这可以帮助你减少查询响应时间。

什么是DTO投影

一个SQL投影基本上是一个查询,它在SELECT子句中提供了一个列的列表。

DTO投影是一个Java对象,它包含了由一个给定的SQL投影查询获取的列值。

DTO投影可以是一个POJO(Plain Old Java Object),一个JPA Tuple,或者是一个Java Record,我们可以使用Spring Data JPA获取所有这些DTO投影类型。

用Spring Data JPA获取Tuple投影

你的第一个选择是将SQL投影记录在JPATuple 容器中,像这样。

@Query("""
    select 
        p.id as id, 
        p.title as title, 
        c.review as review
    from PostComment c
    join c.post p
    where p.title like :postTitle
    order by c.id
    """)
List<Tuple> findCommentTupleByTitle(@Param("postTitle") String postTitle);

而我们可以像这样调用findCommentTupleByTitle 方法。

List<Tuple> commentTuples = postRepository.findCommentTupleByTitle(titleToken);


Tuple commentTuple = commentTuples.get(0);

assertEquals(
    Long.valueOf(1), 
    ((Number) commentTuple.get("id")).longValue()
);

assertTrue(
    ((String) commentTuple.get("title"))
        .contains("Chapter nr. 1")
);

然而,虽然Tuple 允许我们通过列的别名来检索列的值,但我们仍然需要进行类型转换,这是一个主要的限制,因为客户必须预先知道要转换的实际类型。

如果投影容器是类型安全的,那会好很多。

使用Spring Data JPA获取基于接口的代理投影

通过Spring Data JPA,我们可以将SQL投影映射到一个实现了接口的DTO上,比如下面这个。

public interface PostCommentSummary {

    Long getId();

    String getTitle();

    String getReview();
}

PostSummary 接口方法定义了相关投影列别名的名称以及需要用来投射投影列值的类型。

在幕后,Spring Data JPA在返回PostCommentSummary 对象引用时,将使用一个实现该接口的Proxy。

在我们的案例中,我们可以这样定义findCommentSummaryByTitle 存储库方法来使用前述的PostCommentSummary 接口。

@Query("""
    select 
        p.id as id, 
        p.title as title, 
        c.review as review
    from PostComment c
    join c.post p
    where p.title like :postTitle
    order by c.id
    """)
List<PostCommentSummary> findCommentSummaryByTitle(@Param("postTitle") String postTitle);

而在调用findCommentSummaryByTitle 方法时,我们不再需要对投影值进行铸造。

List<PostCommentSummary> commentSummaries = postRepository.findCommentSummaryByTitle(titleToken);

PostCommentSummary commentSummary = commentSummaries.get(0);

assertEquals(
    Long.valueOf(1), 
    commentSummary.getId().longValue()
);

assertTrue(
    commentSummary.getTitle()
        .contains("Chapter nr. 1")
);

好多了,对吗?

然而,使用Proxy投影也有一个坏处。我们不能为equalshashCode 提供一个具体的实现,这就限制了它的可用性。

使用Spring Data JPA获取记录DTO投影

虽然Proxy投影是一个很好的解决方案,但在现实中,使用一个Java记录和下面的记录一样简单。

public record PostCommentRecord(
    Long id,
    String title,
    String review
) {}

PostCommentRecord 有一个非常紧凑的定义,但提供了对equals,hashCode, 和toString 方法的支持。

为了在我们的投影中使用PostCommentRecord ,我们需要改变JPQL查询以使用构造函数表达式,正如下面的例子所说明的。

@Query("""
    select new PostCommentRecord(
        p.id as id, 
        p.title as title, 
        c.review as review
    )
    from PostComment c
    join c.post p
    where p.title like :postTitle
    order by c.id
    """)
List<PostCommentRecord> findCommentRecordByTitle(@Param("postTitle") String postTitle);

通常情况下,我们必须使用PostCommentRecord Java类的全名,但由于 ClassImportIntegratorHibernate Types项目所提供的,我们可以在构造函数表达式JPQL查询中使用简单的Class名称。

为了从这个功能中获益,你只需要通过hibernate.integrator_provider Hibernate设置,在你基于Java的Spring配置bean中提供注册所有DTO和Record类的ClassImportIntegrator

@Bean
public LocalContainerEntityManagerFactoryBean entityManagerFactory() {
    LocalContainerEntityManagerFactoryBean entityManagerFactoryBean = 
        new LocalContainerEntityManagerFactoryBean();
    
    ...
    
    Properties properties = new Properties();
    properties.put(
        "hibernate.integrator_provider",
        (IntegratorProvider) () -> Collections.singletonList(
            new ClassImportIntegrator(
                List.of(
                    PostCommentDTO.class,
                    PostCommentRecord.class
                )
            )
        )
    );
    entityManagerFactoryBean.setJpaProperties(properties);
    
    return entityManagerFactoryBean;
}

而且,在调用findCommentRecordByTitle 方法时,我们可以看到,我们得到了预期的结果回来。

List<PostCommentRecord> commentRecords = postRepository.findCommentRecordByTitle(titleToken);

PostCommentRecord commentRecord = commentRecords.get(0);

assertEquals(
    Long.valueOf(1), 
    commentRecord.id()
);

assertTrue(
    commentRecord.title()
        .contains("Chapter nr. 1")
);

而且,与基于接口的Proxy不同,现在的平等工作是预期的。

assertEquals(
    commentRecord,
    new PostCommentRecord(
        commentRecord.id(),
        commentRecord.title(),
        commentRecord.review()
    )
);

所以,Java Record的解决方案要比基于接口的Proxy好很多。

用Spring Data JPA获取一个POJO DTO的投影

尽管如此,如果你想对你的DTO类有绝对的控制,那么POJO是最灵活的解决方案。

在我们的案例中,我们可以定义以下PostCommentDTO 类。

public class PostCommentDTO {

    private final Long id;

    private final String title;

    private final String review;

    public PostCommentDTO(Long id, String title, String review) {
        this.id = id;
        this.title = title;
        this.review = review;
    }

    public Long getId() {
        return id;
    }

    public String getTitle() {
        return title;
    }

    public String getReview() {
        return review;
    }

    @Override
    public boolean equals(Object o) {
        if (this == o) return true;
        if (!(o instanceof PostCommentDTO)) return false;
        PostCommentDTO that = (PostCommentDTO) o;
        return Objects.equals(getId(), that.getId()) &&
               Objects.equals(getTitle(), that.getTitle()) &&
               Objects.equals(getReview(), that.getReview());
    }

    @Override
    public int hashCode() {
        return Objects.hash(
            getId(),
            getTitle(),
            getReview()
        );
    }
}

而且,就像Java记录投影一样,我们可以使用JPQL构造器表达式功能来获取PostCommentDTO

@Query("""
    select new PostCommentDTO(
        p.id as id, 
        p.title as title, 
        c.review as review
    )
    from PostComment c
    join c.post p
    where p.title like :postTitle
    order by c.id
    """)
List<PostCommentDTO> findCommentDTOByTitle(@Param("postTitle") String postTitle);

而且,当调用findCommentDTOByTitle 方法时,底层的JPQL查询要将SQL记录投影映射到PostCommentDTO Java对象。

List<PostCommentDTO> commentDTOs = postRepository.findCommentDTOByTitle(titleToken);

PostCommentDTO commentDTO = commentDTOs.get(0);

assertEquals(
    Long.valueOf(1), 
    commentDTO.getId()
);

assertTrue(
    commentDTO.getTitle()
        .contains("Chapter nr. 1")
);

assertEquals(
    commentDTO,
    new PostCommentDTO(
        commentDTO.getId(),
        commentDTO.getTitle(),
        commentDTO.getReview()
    )
);

使用Spring Data JPA获取一个分层的DTO投影

而这还不是全部。你实际上可以用Spring Data JPA来获取分层的DTO投影。

假设我们有以下PostDTOPostCommentDTO 类,它们形成了双向关联。

The PostDTO and PostCommentDTO used for DTO projectionThe PostDTO and PostCommentDTO used for DTO projection

我们实际上可以使用Hibernate的DTO结构来获取这个分层的DTO。 ResultTransformer.

为了能够做到这一点,我们需要一个自定义的Spring Data JPA Repository,正如本文所解释的。

Custom Spring Data RepositoryCustom Spring Data Repository

findPostDTOByTitle 是在自定义Spring Data JPA仓库中实现的,这样它就可以利用HibernateResultTransformer 的功能。

public class CustomPostRepositoryImpl implements CustomPostRepository {
    
    @PersistenceContext
    private EntityManager entityManager;

    @Override
    public List<PostDTO> findPostDTOByTitle(@Param("postTitle") String postTitle) {
        return entityManager.createNativeQuery("""
            SELECT p.id AS p_id, 
                   p.title AS p_title,
                   pc.id AS pc_id, 
                   pc.review AS pc_review
            FROM post p
            JOIN post_comment pc ON p.id = pc.post_id
            WHERE p.title LIKE :postTitle
            ORDER BY pc.id
            """)
        .setParameter("postTitle", postTitle)
        .unwrap(org.hibernate.query.Query.class)
        .setResultTransformer(new PostDTOResultTransformer())
        .getResultList();
    }
}

PostDTOResultTransformer 允许我们将默认的Object[] JPA查询投影映射到PostDTO ,同时也将PostCommentDTO 子元素添加到父PostDTO 类的comments 集合中。

public class PostDTOResultTransformer implements ResultTransformer {

    private Map<Long, PostDTO> postDTOMap = new LinkedHashMap<>();

    @Override
    public PostDTO transformTuple(Object[] tuple, String[] aliases) {
        Map<String, Integer> aliasToIndexMap = aliasToIndexMap(aliases);
        
        Long postId = AbstractTest.longValue(
            tuple[aliasToIndexMap.get(PostDTO.ID_ALIAS)]
        );

        PostDTO postDTO = postDTOMap.computeIfAbsent(
            postId,
            id -> new PostDTO(tuple, aliasToIndexMap)
        );
        postDTO.getComments().add(
            new PostCommentDTO(tuple, aliasToIndexMap)
        );

        return postDTO;
    }

    @Override
    public List<PostDTO> transformList(List collection) {
        return new ArrayList<>(postDTOMap.values());
    }

    private Map<String, Integer> aliasToIndexMap(String[] aliases) {
        Map<String, Integer> aliasToIndexMap = new LinkedHashMap<>();
        
        for (int i = 0; i < aliases.length; i++) {
            aliasToIndexMap.put(
                aliases[i].toLowerCase(Locale.ROOT), 
                i
            );
        }
        
        return aliasToIndexMap;
    }
}

而且,当调用findPostDTOByTitle 方法时,我们得到了预期的结果集。

List<PostDTO> postDTOs = postRepository.findPostDTOByTitle(titleToken);

assertEquals(POST_COUNT, postDTOs.size());

PostDTO postDTO = postDTOs.get(0);

assertEquals(
    Long.valueOf(1), 
    postDTO.getId()
);

assertTrue(
    postDTO.getTitle()
        .contains("Chapter nr. 1")
);

assertEquals(
    POST_COMMENT_COUNT, 
    postDTO.getComments().size()
);

结论

在开发一个非琐碎的应用程序时,知道如何使用Spring Data JPA的DTO投影是非常重要的。

虽然Spring文档向你展示了一些非常简单的用例,但在现实中,你可以使用Spring Data JPA获取分层的DTO结构或Java Record投影。