mcpField
K
kotlin-backend-jpa-entity-mapping

kotlin-backend-jpa-entity-mapping

github.com/kotlin开发1.1k1.6k 次安装7 周前更新
代码仓库

关于 kotlin-backend-jpa-entity-mapping

面向 Kotlin + Spring Data JPA 项目的开发者,提供实体建模、身份相等性和唯一性约束方面的指导,重点说明 Hibernate 常见陷阱及安全做法。

主要功能

  1. 01避免用 data class 定义实体
  2. 02采用稳定的 ID 相等性策略
  3. 03结合数据库与应用层实施唯一性约束
  4. 04诊断 N+1 并选择针对性抓取方案
  5. 05处理延迟加载与关联生命周期

适用场景

  1. 01审查 Kotlin JPA 实体的 equals 与 hashCode
  2. 02为幂等操作设计重复数据防护
  3. 03分析 N+1 查询与抓取计划
  4. 04避免序列化或日志触发延迟加载

安装

复制下面的命令,在终端中运行。

$npx skills add https://github.com/kotlin/kotlin-agent-skills --skill kotlin-backend-jpa-entity-mapping

安装后在 Claude Code 中输入/kotlin-backend-jpa-entity-mapping确认已加载。

详细介绍

Kotlin 的 JPA 实体映射

Kotlin 的 data class 很适合用作 DTO,但用于 JPA 实体则有风险。Hibernate 依赖 data class 会破坏的身份语义:基于所有字段实现的 equals/hashCode 会在状态变化后破坏 Set/Map 成员关系,而自动生成的 copy() 会创建受管实体的游离副本。

本技能将介绍 Kotlin + Spring Data JPA 项目的正确实体设计、身份策略和唯一性约束。

实体设计规则

  • 切勿将 data class 用作 JPA 实体。 使用普通 class。将 data class 用于 DTO。
  • 除非项目明确使用共享模型,否则应将传输 DTO 与持久化实体分开。
  • 只有在对象构造和持久化生命周期足够安全时,才将必填列建模为非空。
  • 只有当项目已接受这种权衡且生命周期安全时,才使用 lateinit。
  • 存在 JPA 实体时,确认已启用 kotlin("plugin.jpa") 或同等的无参构造函数支持。
  • 在需要时,确认类和成员与代理机制兼容。

身份与相等性

  • 切勿接受 data class 在实体上生成的基于所有字段的 equals/hashCode。
  • 如果项目已有身份策略约定,请遵循该约定。
  • 如果没有约定,请使用基于 ID 的相等性,并采用稳定的 hashCode。
  • 对于由数据库生成的 ID,使用可空的 var id: Long? = null 表示未保存状态,并设置 protected set;不要用 0L 作为哨兵值。
  • 讨论相等性时,明确说明可变字段和延迟加载关联的影响。
错误示例:data class 实体
// WRONG: data class generates equals/hashCode from ALL fields,
// and the generated ID uses a 0 sentinel instead of null
data class Order(
    @Id @GeneratedValue val id: Long = 0,
    var status: String,
    var total: BigDecimal
)
// BUG: order.status = "SHIPPED"; set.contains(order) → false (hash changed)
// BUG: Hibernate proxy.equals(entity) → false (proxy has lazy fields uninitialized)
正确示例:使用基于 ID 的身份标识的普通类
@Entity
@Table(name = "orders")
class Order(
    @Column(nullable = false)
    var status: String,

    @Column(nullable = false)
    var total: BigDecimal
) {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    var id: Long? = null
        protected set

    override fun equals(other: Any?): Boolean {
        if (this === other) return true
        if (other !is Order) return false
        return id != null && id == other.id
    }

    override fun hashCode(): Int = javaClass.hashCode()

    // toString must NOT reference lazy collections
    override fun toString(): String = "Order(id=$id, status=$status)"
}

关键规则:

  • equals 仅按 ID 进行比较——在脏跟踪和代理解包期间保持稳定
  • hashCode 返回基于类的常量——避免持久化后破坏 Set/Map
  • toString 排除延迟加载的关联——避免触发 LazyInitializationException
  • 构造函数参数是可变的实体字段;由数据库生成的 id 可空,并带有受保护的 setter

唯一性约束

当 API 必须具备幂等性(例如“为订单 X 预留库存”)时,应在两个层面强制唯一性:数据库约束确保正确性,应用层检查则提供清晰的错误信息。

错误示例:没有重复项保护
@Service
class ReservationService(private val repo: ReservationRepository) {
    @Transactional
    fun createReservation(variantId: Long, orderId: String, qty: Int): Reservation {
        // BUG: no check — duplicates silently accumulate
        return repo.save(Reservation(variantId = variantId, orderId = orderId, quantity = qty))
    }
}
正确示例:数据库约束 + 应用层保护
@Entity
@Table(
    name = "reservations",
    uniqueConstraints = [
        UniqueConstraint(columnNames = ["variant_id", "order_id"])
    ]
)
class Reservation(
    @Column(name = "variant_id", nullable = false)
    val variantId: Long,

    @Column(name = "order_id", nullable = false)
    val orderId: String,

    @Column(nullable = false)
    var quantity: Int
) {
    @Id @GeneratedValue(strategy = GenerationType.IDENTITY)
    var id: Long? = null
        protected set
}

interface ReservationRepository : JpaRepository<Reservation, Long> {
    fun findByVariantIdAndOrderId(variantId: Long, orderId: String): Reservation?
}

@Service
class ReservationService(private val repo: ReservationRepository) {
    @Transactional
    fun createReservation(variantId: Long, orderId: String, qty: Int): Reservation {
        repo.findByVariantIdAndOrderId(variantId, orderId)?.let {
            throw IllegalStateException(
                "Reservation already exists for variant=$variantId, order=$orderId"
            )
        }
        return repo.save(Reservation(variantId = variantId, orderId = orderId, quantity = qty))
    }
}

关键规则:

  • 数据库约束必不可少——仅靠应用层检查会产生竞态条件
  • 应用层检查可提供清晰的错误信息——否则用户会看到原始的 DataIntegrityViolationException
  • 两个层面配合:应用层处理常见情况,数据库负责捕获竞态
  • Spring Data 会自动派生 findByXAndY 查询

查询与抓取规则

  • 通过实际查询数量或 SQL 日志诊断 N+1 问题,不要根据注解猜测。
  • 优先选择有针对性的抓取方案:@EntityGraph、JOIN FETCH、批量抓取或 DTO 投影。
  • 使用集合抓取连接时要谨慎处理分页——应说明其中的权衡。
  • 使用索引和唯一性约束来支持实际查询模式。

ORM 常见陷阱

  • 双向关联: 在领域方法中维护两端。只更新一端会导致微妙的错误。
  • orphanRemoval 与级联删除: 两者不可互换。选择前应说明其生命周期语义。
  • 触发延迟加载: toString、调试日志、JSON 序列化和 IDE 检查都可能触发延迟加载。
  • 批量更新/删除: 会绕过持久化上下文和生命周期回调。后续读取可能得到过期数据。
  • 多个 bag 抓取: 可能导致笛卡尔积爆炸。应验证 ORM 能否安全执行包含大量集合的抓取计划。
  • Set + 可变相等性: 实体状态变化后,集合成员关系可能失效。
  • @Version: 在并发更新很重要时,这是最清晰的乐观并发控制机制。
  • 禁用 open-in-view: 在 DTO 映射过程中访问延迟加载字段时,必须处于事务边界内。

防护措施

  • 不要将 data class 用于 JPA 实体。
  • 不要为了消除延迟加载问题而到处推荐 FetchType.EAGER。
  • 默认不要在 API 响应中直接暴露实体。
  • 声称解决了 N+1 问题时,必须说明获取计划如何改变查询行为。

评论

支持 Markdown