PAYATHON 2026

jOOQ 3.20 与 Kotlin Spring Boot 的 DTO 映射实践

技术老齐

结论

复杂 join 查询中,不要把整个扁平 Record 直接交给 into(MyDto::class.java)。可以根据查询结构选择更合适的映射方式:

  • 按页面或查询用例定义只读 DTO,无须机械地为每张表定义 DTO。
  • 一对一和可选的一对一关系,可以用 ROW 组成独立的结构化字段。
  • 一对多关系优先考虑 MULTISET,以免 join 造成行膨胀,还要在 Kotlin 端分组。
  • 结构不超过 22 个字段时,优先使用 row(...).mapping(::Dto) 或 Records.mapping(::Dto),让字段类型与构造函数在编译期对齐。
  • 超过 22 个字段时,先按业务含义拆成几个子 DTO。无法合理拆分时,再使用显式 mapper。
  • into() 适合单表记录,或者列别名完全受控的简单 projection,不宜默认用于复杂 join。

row(*fields).convertFrom { it.into(...) } 会将名称匹配限制在一个 ROW 内,因此比对整个结果调用 into() 更安全。不过,这种写法仍依赖反射和字段名称,也没有完整的编译期类型检查。

DTO 应按查询用途拆分

Admin dashboard 中的 DTO 通常属于查询模型,无须与数据库表一一对应。可以根据页面结构拆成几个相对稳定的部分:

data class OrderSummaryDto(
    val orderId: Long?,
    val orderNo: String?,
    val status: String?
)

data class ReceiptDto(
    val id: Long?,
    val baseAmount: Int?,
    val timeAmount: Int?
)

data class OrderLineDto(
    val id: Long?,
    val sku: String?,
    val quantity: Int?
)

data class OrderSummaryData(
    val summary: OrderSummaryDto,
    val receipt: ReceiptDto?,
    val lines: List<OrderLineDto>
)

各字段对应的关系很明确:

  • summary 保存订单自身的数据。
  • receipt 表示可选的一对一数据。
  • lines 表示一对多集合。

DTO 放在 Web 层、Application 层还是 Repository 附近,可以根据项目规模决定。projection 定义最好靠近查询代码,避免 DTO 反向依赖 jOOQ 生成的表、字段和别名,影响后续复用。

如果 DTO 原本就是 data access layer 的内部类型,也可以把 projection 放进 companion object。一个 DTO 最好只承担一种职责,避免同时用作数据库 projection、业务模型和 HTTP response。

优先定义类型化的结构字段

DTO 字段不多时,可以直接绑定字段列表与构造函数:

private val orderSummaryField =
    DSL.row(
        ORDERS.ID,
        ORDERS.ORDER_NO,
        ORDERS.STATUS
    )
    .mapping(::OrderSummaryDto)
    .`as`("order_summary")

字段类型必须与 OrderSummaryDto 的构造函数参数兼容。增删字段后,数量或类型不匹配的问题通常能在编译期发现。

这种映射仍然依赖位置:

DSL.row(
    ORDERS.ID,
    ORDERS.ORDER_NO,
    ORDERS.STATUS
).mapping(::OrderSummaryDto)

字段顺序需要对应:

OrderSummaryDto(
    orderId = ...,
    orderNo = ...,
    status = ...
)

如果交换两个类型相同的字段,例如两个 String?,编译器无法识别其中的语义错误。因此,projection 和 DTO 不要相隔太远,重要查询也应配套集成测试。

处理 LEFT JOIN 产生的空对象

对可选的一对一关系直接使用下面的映射:

DSL.row(
    RECEIPT.ID,
    RECEIPT.BASE_AMOUNT,
    RECEIPT.TIME_AMOUNT
).mapping(::ReceiptDto)

当 LEFT JOIN 没有匹配行时,结果可能是 ReceiptDto(null, null, null),而非 null。这种结果通常不符合预期的领域语义。

可以根据主键判断关联记录是否存在:

private val receiptField =
    DSL.row(
        RECEIPT.ID,
        RECEIPT.BASE_AMOUNT,
        RECEIPT.TIME_AMOUNT
    )
    .convertFrom { record ->
        val receiptId = record.value1()

        if (receiptId == null) {
            null
        } else {
            ReceiptDto(
                id = receiptId,
                baseAmount = record.value2(),
                timeAmount = record.value3()
            )
        }
    }
    .`as`("receipt")

这里仍有少量位置映射,但范围已经缩小到单个结构,不会受外层其他表中 id、status 等同名字段干扰。

使用 MULTISET 映射一对多关系

如果一个订单有多条明细,直接 join 会在每一行重复订单和收据字段。多个一对多关系同时 join 时,还可能出现笛卡尔式膨胀。

可以将明细查询定义为 MULTISET:

private val orderLinesField =
    DSL.multiset(
        DSL.select(
            ORDER_LINE.ID,
            ORDER_LINE.SKU,
            ORDER_LINE.QUANTITY
        )
        .from(ORDER_LINE)
        .where(ORDER_LINE.ORDER_ID.eq(ORDERS.ID))
    )
    .convertFrom { result ->
        result.map(Records.mapping(::OrderLineDto))
    }
    .`as`("lines")

外层查询只需组合这些结构字段:

fun getOrderSummaries(): List<OrderSummaryData> {
    return dsl
        .select(
            orderSummaryField,
            receiptField,
            orderLinesField
        )
        .from(ORDERS)
        .leftJoin(RECEIPT)
        .on(RECEIPT.ORDER_ID.eq(ORDERS.ID))
        .fetch(Records.mapping(::OrderSummaryData))
}

外层只有三个字段,即使订单 DTO 内部包含较多字段,也不会触及 Row22。

在 MySQL 上,MULTISET 如何渲染或模拟 SQL,取决于 jOOQ dialect 及相关设置。上线前需要检查实际生成的 SQL,并结合 EXPLAIN 和真实数据量确认执行计划。写法更整洁,并不代表它一定比扁平 join 更快。

22-arity 限制意味着什么

Row22 限制主要影响 jOOQ 的类型化 RowN、RecordN 和函数式 mapping API。SQL 的 SELECT 仍然可以返回超过 22 列。

超过 22 个字段时,通常有以下三种处理方式:

  1. 按业务含义拆成多个嵌套 DTO。
  2. 使用 RowN 或普通 Record,再编写显式 mapper。
  3. 保留扁平结果,同时使用唯一别名和受控的外部 mapper。

页面详情查询通常适合第一种方式。例如,可以将 35 个字段拆成:

data class OrderDetailData(
    val core: OrderCoreDto,
    val shipping: OrderShippingDto,
    val payment: OrderPaymentDto?,
    val audit: OrderAuditDto
)

这样外层只有四个字段,各个内部结构也更容易单独测试。

fields 数组方案的限制

下面的写法可以复用 projection:

val fields = arrayOf(
    RECEIPT.ID,
    RECEIPT.BASE_AMOUNT,
    RECEIPT.TIME_AMOUNT
)

不过,Array<Field<*>> 会丢失每个位置的具体泛型信息。将它传给 row(*fields) 后,通常只能得到动态的 RowN,无法像显式调用 row(field1, field2, field3) 那样获得完整的类型化 mapping。

例如:

DSL.row(*ReceiptDto.fields)
    .convertFrom { it.into(ReceiptDto::class.java) }

由于 into() 只能看到收据分组内的字段,这段代码可以避开外层 join 的名称冲突,但仍有以下限制:

  • 内部映射依赖字段名称。
  • 数据库的 snake_case 能否匹配 Kotlin 的 camelCase,取决于映射配置和命名规则。
  • DTO 构造函数发生变化时,问题不一定能在编译期暴露。
  • 如果同一个 ROW 内再次出现同名字段,仍然可能发生冲突。

这种写法可以作为过渡方案,不适合抽象成所有查询共用的映射方式。字段较少时,显式的类型化 row(...).mapping(::Dto) 更可靠。

必须使用扁平结果时的写法

有些数据表格只需要一行对应一个 DTO,也没有嵌套集合。此时可以保留扁平 SQL,但每个输出列都需要唯一名称:

private val articleIdField = ARTICLE.ID.`as`("articleId")
private val authorIdField = ARTICLE.AUTHOR_ID.`as`("authorId")
private val articleStatusField = ARTICLE.STATUS.`as`("articleStatus")
private val authorStatusField = USER.STATUS.`as`("authorStatus")

查询和读取时应复用同一个 Field 实例:

return dsl
    .select(
        articleIdField,
        authorIdField,
        ARTICLE.TITLE,
        articleStatusField,
        authorStatusField
    )
    .from(ARTICLE)
    .leftJoin(USER)
    .on(ARTICLE.AUTHOR_ID.eq(USER.ID))
    .fetch { record ->
        ArticleGridRow(
            articleId = record[articleIdField],
            authorId = record[authorIdField],
            title = record[ARTICLE.TITLE],
            articleStatus = record[articleStatusField],
            authorStatus = record[authorStatusField]
        )
    }

读取时不要重新创建别名字段:

record.get(ARTICLE.ID.`as`("articleId"))

可以在定义 projection 时保存 articleIdField,然后在 select 和 get 中复用。

如果查询字段主要来自单表,还可以先按表提取记录,缩小映射范围:

val articleRecord = record.into(ARTICLE)

这种方式适合表级映射,不适合将多个表的字段组合成页面 DTO。

ROW、MULTISET 与外部 mapper 的选择

不同查询可以采用不同的映射方式。

查询形态建议
单表或唯一别名的简单 projectionfetchInto()、into() 或 Records.mapping()
多个一对一结构ROW
可选的一对一结构ROW.convertFrom,通过主键判断是否为 null
一对多子集合MULTISET
超过 22 个字段拆分子 DTO,必要时使用显式 mapper
报表、导出、动态列扁平 Record 加外部 mapper
SQL 结构不适合嵌套查询扁平查询后在 Kotlin 中分组

对于动态表格、条件列、复杂报表或需要特殊业务转换的查询,显式 mapper 往往更清楚。样板代码可以集中到扩展函数中:

fun Record.toArticleGridRow(
    articleIdField: Field<Long?>,
    authorIdField: Field<Long?>
): ArticleGridRow =
    ArticleGridRow(
        articleId = this[articleIdField],
        authorId = this[authorIdField],
        title = this[ARTICLE.TITLE]
    )

这样既能保持查询代码简洁,也不会让反射决定映射结果。

建议的改造顺序

改造现有代码时,可以按以下顺序进行:

  1. 停止对包含多个表字段的完整 Record 直接调用 into()。
  2. 根据页面用例以及一对一、一对多关系拆分 DTO。
  3. 为每个小 DTO 定义类型化 ROW projection。
  4. 对 LEFT JOIN 结构使用主键判断空值。
  5. 对一对多关系评估 MULTISET,同时检查生成的 SQL 和执行计划。
  6. 对无法自然结构化的查询保留显式外部 mapper。
  7. 如果 mapping 中有较多同类型字段,编写集成测试,防止字段位置交换后仍能通过编译。

在生产代码中,少量显式 mapping 通常比依赖反射、别名约定和隐藏规则的通用映射器更容易维护。应将 mapping contract 放在明确且可测试的位置,无须追求彻底消除样板代码。

备注:内容仅供参考。