在进行 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)
让我们通过一个更复杂的例子来看一下Department
和Employee
实体之间的一对多关系。我们将展示如何通过投影帮助解决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()
)
}
}
}
传统方法存在的问题,
- 手动映射工作 — 每个实体都需要手动映射到DTO,虽然这样做有其必要性,但会导致更多的代码需要测试和维护。
- N+1 查询问题 — 对于每个部门,会执行额外的查询来获取员工,这将导致N+1查询。可以通过在实体映射字段上使用
FetchType.EAGER
或在仓库函数上使用@EntityGraph(attributePaths = ["employees"])
等方式来避免N+1问题。 - 内存使用 — 它会将每个部门的所有员工加载到内存中,尽管我们仅仅需要员工的数量和平均薪水。
- 性能 — 在内存中计算平均值不如在数据库中更高效。
- 代码可读性和可维护性 — 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>
}
在这样的场景中,使用投影的好处有:
- 单一优化查询 — 投影使用一个优化过的SQL查询来获取所有需要的数据。
- 减少内存占用 — 仅传输必要的数据(id、名字、计数、平均值)从数据库到应用程序,而不是全部员工记录。
- 数据库级别计算 — 平均值和计数在数据库中计算,这通常更有效率。
- 无需手动映射实体到DTO — 我们避免手动将实体映射到DTO的需要。
- 类型安全 — 投影接口定义确保我们处理的是正确的字段。
让我们设立一个清晰的界限,使得投影仓库仅展示投影,绝不会展示实体,就像我们给 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少一些。
这里有一些资源-
https://docs.spring.io/spring-data/jpa/reference/repositories/projections.html (Spring JPA 仓库投影的参考文档)
- https://start.spring.io/#!type=gradle-project-kotlin&language=kotlin&platformVersion=3.3.4&packaging=jar&jvmVersion=21&groupId=com.example&artifactId=jpa-projections&name=jpa-projections&description=&packageName=com.example.jpa-projections&dependencies=web,h2,data-jpa (使用 Kotlin 创建一个基于 Gradle 的 Spring 项目,平台版本为 3.3.4,打包格式为 jar,JVM 版本为 21,项目组 ID 为 com.example,项目 ID 为 jpa-projections,项目名称为 jpa-projections,描述为空,包名路径为 com.example.jpa-projections,依赖项包括 web、h2 和 data-jpa。)
共同学习,写下你的评论
评论加载中...
作者其他优质文章