为了账号安全,请及时绑定邮箱和手机立即绑定

如何在Spring JPA中避免使用DTO

标签:
Java Spring

在进行 Spring Boot 开发时,我们常常发现自己陷入创建数据传输对象(DTO)并手动进行映射的繁琐循环。这个过程虽然很常见,但可能会导致代码库膨胀、增加维护负担以及潜在的一致性问题。

传统的DTO弊端

传统上,当我们希望通过 API 公开实体的某些字段时,我们会创建一个 DTO 类,然后手动将实体映射到这个 DTO。这通常会这样做,我们需要创建一个将实体映射到 DTO 的方法。


@Entity  
class User(  
    @Id @GeneratedValue  
    val id: Long = 0,  
    val username: String,   
    val email: String  
)  

data class UserDTO(val username: String, val email: String)  

fun User.toDTO() = UserDTO(username, email)

让我们通过一个更复杂的例子来看一下DepartmentEmployee实体之间的一对多关系。我们将展示如何通过投影帮助解决N+1查询问题,并减少在获取数据时的内存消耗。

首先,我们定义一下实体吧

@Entity  
data class 部门(  
    @Id @GeneratedValue  
    val id: Long = 0,  
    val name: String,  

    @OneToMany(  
        mappedBy = "department",   
        cascade = [CascadeType.ALL],   
        orphanRemoval = true,   
        fetch = FetchType.LAZY  
    )  
    val employees: MutableList<Employee> = mutableListOf()  
)  

@Entity  
data class 员工(  
    @Id @GeneratedValue  
    val id: Long = 0,  
    val name: String,  
    val email: String,  

    @ManyToOne(fetch = FetchType.LAZY)  
    @JoinColumn(name = "department_id")  
    val department: 部门  
)

如果我们尝试从一个 REST API 控制器返回实体,API 会返回一个字符串(而不是 JSON),这是因为递归序列化问题导致的。Employee 实体有一个指向 Department 实体的指向,而 Department 实体又有一个 Employee 实体的列表,形成了一个循环引用关系。这将导致序列化过程陷入无限循环。我们需要在 department 上添加 @JsonBackReference 注释。这样做会使这些实体文件更加难以维护。

在我们处理 DTO 的世界里,我们这样做来避免在我们的 REST API 中返回实体对象:

    data class DepartmentDTO(  
        val id: Long,  
        val name: String,  
        val employeeCount: Int,  
        val averageSalary: Double  
    )  

    @Repository  
    interface DepartmentRepository : JpaRepository<Department, Long>  

    @Service  
    class DepartmentService(private val departmentRepository: DepartmentRepository) {  
        // 获取所有部门的简要信息
        fun getAllDepartmentSummaries(): List<DepartmentDTO> {  
            return departmentRepository.findAll().map { department ->  
                DepartmentDTO(  
                    id = department.id,  
                    name = department.name,  
                    employeeCount = department.employees.size,  
                    averageSalary = department.employees.map { it.salary }.average()  
                )  
            }  
        }  
    }

传统方法存在的问题,

  1. 手动映射工作 — 每个实体都需要手动映射到DTO,虽然这样做有其必要性,但会导致更多的代码需要测试和维护。
  2. N+1 查询问题 — 对于每个部门,会执行额外的查询来获取员工,这将导致N+1查询。可以通过在实体映射字段上使用FetchType.EAGER或在仓库函数上使用@EntityGraph(attributePaths = ["employees"])等方式来避免N+1问题。
  3. 内存使用 — 它会将每个部门的所有员工加载到内存中,尽管我们仅仅需要员工的数量和平均薪水。
  4. 性能 — 在内存中计算平均值不如在数据库中更高效。
  5. 代码可读性和可维护性 — DTO会增加更多需要维护和测试的代码,使得代码库比实际需求更复杂。
投影模型而不是DTO(DTO:数据传输对象)

现在来定义一个包含部门信息和员工概况的视图,如下所示:

    // 可以简单命名为 DepartmentSummary 或者更详细的 DepartmentSummaryView  
    // 因为 "Projection" 是一个较长的后缀  
    interface DepartmentSummaryProjection {  
        val id: Long  
        val name: String  
        val employeeCount: Int  
        val averageSalary: Double  
    }

接下来,创建一个使用该投影的代码库。

@Repository  
interface 部门投影仓库接口 : JpaRepository<Department, Long> {  
    @Query("""  
        SELECT d.id as id, d.name as name,   
        COUNT(e) as 员工数量,   
        AVG(e.salary) as 平均工资  
        FROM Department d   
        LEFT JOIN d.employees e   
        GROUP BY d.id, d.name  
    """)  
    fun 查询所有部门摘要(): List<DepartmentSummaryProjection>  
}

在这样的场景中,使用投影的好处有:

  1. 单一优化查询 — 投影使用一个优化过的SQL查询来获取所有需要的数据。
  2. 减少内存占用 — 仅传输必要的数据(id、名字、计数、平均值)从数据库到应用程序,而不是全部员工记录。
  3. 数据库级别计算 — 平均值和计数在数据库中计算,这通常更有效率。
  4. 无需手动映射实体到DTO — 我们避免手动将实体映射到DTO的需要。
  5. 类型安全 — 投影接口定义确保我们处理的是正确的字段。
明确划分实体与视图之间的界限

让我们设立一个清晰的界限,使得投影仓库仅展示投影,绝不会展示实体,就像我们给 JpaRepository 扩展功能时那样。这样,我们可以避免误用投影仓库来操作实体,而是专注于使用它们,并通过设计指导任何修改代码库的人创建新的投影。

import org.springframework.data.jpa.repository.Query  
import org.springframework.data.repository.NoRepositoryBean  
import org.springframework.data.repository.Repository  

@NoRepositoryBean  
interface ProjectionRepository <T, ID> : Repository<T, ID>  

interface DepartmentProjectionRepository : ProjectionRepository<Department, Long> {  
    @Query("""  
        SELECT d.id AS id, d.name AS name,  
        COUNT(e) AS employeeCount,  
        AVG(e.salary) AS averageSalary  
        FROM Department d  
        LEFT JOIN d.employees e  
        GROUP BY d.id, d.name  
    """)  
    fun 获取所有部门摘要(): List<DepartmentSummaryProjection>  
}

我们现在可以在REST API控制器中开始使用这些“投影”,也可以在任何不需要实体处理的地方使用它们。

权衡

“没有解决办法,只有权衡取舍。”
― 托马斯·索维尔(引用于原文)

DTOs
  • 优点:强类型安全性,各层间明确的接口约定
  • 缺点:更多的样板代码量,需要手动进行映射
未来预测
  • 优点:代码更简洁,数据库与API的直接关联,查询效率更高,对象创建开销更小
  • 缺点:类型安全较弱,可能存在运行时错误的问题
测试的影响

关键是理解这两种方案之间的权衡。选择最符合你项目需求的方法。

两种方法都需要进行彻底的测试,但有一些细微的差别:

  • 对于 DTO,单元测试可以覆盖实体与 DTO 之间的映射逻辑。需要注意的是,为了确保在仓库中实现自定义查询时也能覆盖到这些内容,可能需要额外进行一些集成测试工作。
  • 对于投影,也需要进行集成测试来确保查询能够正确执行并正确映射结果。

所以,无论我们选择哪种方法,都需要测试,但似乎投影模型需要的测试比DTO少一些。

这里有一些资源
点击查看更多内容
TA 点赞

若觉得本文不错,就分享一下吧!

评论

作者其他优质文章

正在加载中
  • 推荐
  • 评论
  • 收藏
  • 共同学习,写下你的评论
感谢您的支持,我会继续努力的~
扫码打赏,你说多少就多少
赞赏金额会直接到老师账户
支付方式
打开微信扫一扫,即可进行扫码打赏哦
今天注册有机会得

100积分直接送

付费专栏免费学

大额优惠券免费领

立即参与 放弃机会
意见反馈 帮助中心 APP下载
官方微信

举报

0/150
提交
取消