最近在项目中使用 Kotlin 的 data class 与 MyBatis 进行数据库映射时,遇到了一个令人费解的异常。经过一番折腾,终于找到了问题的根源,并解决了这个问题。在这里记录一下整个排查和解决的过程。

问题描述

在运行项目时,出现了以下异常:

1
2
org.springframework.dao.DataIntegrityViolationException: Error attempting to get column 'name' from result set.  Cause: org.postgresql.util.PSQLException: 不良的类型值 int : 测试工程1
; 不良的类型值 int : 测试工程1; nested exception is org.postgresql.util.PSQLException: 不良的类型值 int : 测试工程1

从错误信息来看,在尝试从结果集中获取 name 列时出现了问题。数据库返回的 name 值是字符串 “测试工程1”,但程序却提示存在类型 int 的错误。这明显不合理,name 字段在数据库中明明是 varchar 类型,为什么会出现这种错误呢?

问题分析

首先,我检查了数据库中相关表的结构,确认 name 字段的类型确实是 varchar

pro_project 表结构

1
2
3
4
5
6
7
8
9
create table pro_project
(
id serial
primary key,
name varchar not null,
voltage_level varchar not null,
create_time timestamp default now() not null,
manager integer default 1 not null
);

接着,查看了 pro_attendance 表的结构:

pro_attendance 表结构

1
2
3
4
5
6
7
8
9
10
11
create table pro_attendance
(
id integer
project_id integer not null
user_id integer not null,
sign_in_time timestamp not null,
sign_out_time timestamp,
sign_in_coordinate geometry(PointZ, 4326) not null,
sign_out_coordinate geometry(PointZ, 4326),
working_hours integer
);

然后,查看了 MyBatis 的 ResultMap 和 SQL 查询语句,确认字段的映射关系是正确的。

ResultMap 配置

1
2
3
4
5
6
7
8
9
10
11
12
13
<resultMap id="AttendanceVOMap" type="com.heny.server.controller.project.response.AttendanceVO">
<id column="id" property="id"/>
<result column="user_id" property="userId"/>
<result column="project_id" property="projectId"/>
<result column="name" property="projectName"/>
<result column="sign_in_time" property="signInTime"/>
<result column="sign_in_coordinate" property="signInCoordinate"
typeHandler="com.heny.web.handler.mybatis.PointTypeHandler"/>
<result column="sign_out_time" property="signOutTime"/>
<result column="sign_out_coordinate" property="signOutCoordinate"
typeHandler="com.heny.web.handler.mybatis.PointTypeHandler"/>
<result column="working_hours" property="workingHours"/>
</resultMap>

SQL 查询语句

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
<select id="selectCurrentVOByUser" resultMap="AttendanceVOMap">
SELECT a.id,
a.user_id,
a.project_id,
p.name,
a.sign_in_time,
a.sign_in_coordinate,
a.sign_out_time,
a.sign_out_coordinate,
a.working_hours
FROM pro_attendance a
LEFT JOIN pro_project p ON a.project_id = p.id
WHERE a.user_id = #{userId}
AND sign_out_time IS NULL LIMIT 1
</select>

VO 类定义

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
data class AttendanceVO(
@field:Schema(description = "考勤记录ID")
val id: Int,
@field:Schema(description = "项目ID")
val projectId: Int,
@field:Schema(description = "项目名称")
val projectName: String,
@field:Schema(description = "用户ID")
val userId: Int,
@field:Schema(description = "签到时间")
val signInTime: LocalDateTime,
@field:Schema(description = "签到坐标")
@field:JsonSerialize(using = GeometrySerializer::class)
val signInCoordinate: Point,
@field:Schema(description = "签退时间")
val signOutTime: LocalDateTime?,
@field:Schema(description = "签退坐标")
@field:JsonSerialize(using = GeometrySerializer::class)
val signOutCoordinate: Point?,
@field:Schema(description = "工作时长,秒")
val workingHours: Int?,
)

从以上代码可以看出,数据库字段类型、SQL 查询、ResultMap 配置、VO 类的属性类型都是对应的,看不出明显的问题。

那么,问题到底出在哪里呢?

解决过程

在仔细检查了所有的配置后,我开始怀疑问题可能出在 Kotlin 的 data class 与 MyBatis 的兼容性上。Kotlin 的 data class 默认是不会生成无参构造函数的,而 MyBatis 在映射结果集时,往往需要通过反射调用无参构造函数来实例化对象。

经过搜索,发现很多人在使用 Kotlin 的 data class 配合 MyBatis 时,都遇到了类似的问题。这是因为 MyBatis 默认需要一个无参构造函数来创建映射对象,如果没有无参构造函数,就可能导致无法实例化对象,进而导致类型不匹配的异常。

方法一:手动添加无参构造函数

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
data class AttendanceVO(
@field:Schema(description = "考勤记录ID")
val id: Int = 0,
@field:Schema(description = "项目ID")
val projectId: Int = 0,
@field:Schema(description = "项目名称")
val projectName: String = "",
@field:Schema(description = "用户ID")
val userId: Int = 0,
@field:Schema(description = "签到时间")
val signInTime: LocalDateTime = LocalDateTime.now(),
@field:Schema(description = "签到坐标")
@field:JsonSerialize(using = GeometrySerializer::class)
val signInCoordinate: Point = Point(0.0, 0.0),
@field:Schema(description = "签退时间")
val signOutTime: LocalDateTime? = null,
@field:Schema(description = "签退坐标")
@field:JsonSerialize(using = GeometrySerializer::class)
val signOutCoordinate: Point? = null,
@field:Schema(description = "工作时长,秒")
val workingHours: Int? = null,
)

通过为每个属性提供默认值,我们的 data class 就拥有了一个无参构造函数。但是我既然使用Kotlin了,为所有成员变量都指定一个默认值是否有点过于不优雅了呢?我是无法容忍在data class设置一些莫名其妙的初始值的。

方法二:使用 @NoArg 注解和编译器插件

经过查找,Jetbrains官方似乎也意识到了这个问题,提供了kotlin-noarg 插件。通过注解的方式为类生成空参构造函数。类似于Lombok中的@NoArgsConstructor 。所以这个方法似乎比上面优雅一些,引入 Kotlin 的 kotlin-noarg 编译器插件的步骤如下。

  1. 添加自定义注解

    定义一个只能用于类的 @NoArg 注解,当然这个注解的名称可以自定义

    1
    2
    3
    @Retention(AnnotationRetention.SOURCE)
    @Target(AnnotationTarget.CLASS)
    annotation class NoArg
  2. 配置编译器插件

    在项目的 build.gradle 中添加插件配置:

    1
    2
    3
    4
    5
    6
    plugins {
    id 'org.jetbrains.kotlin.plugin.noarg' version '1.9.25' // 请使用当前 Kotlin 版本
    }
    noArg {
    annotation('com.yourpackage.NoArg')
    }
  3. data class 上使用注解

    1
    2
    3
    4
    @NoArg
    data class AttendanceVO(
    // 属性定义...
    )

编译器插件会自动为被 @NoArg 注解的类生成无参构造函数。

再次运行

在修改了 VO 类后,我再次运行项目,发现之前的异常不再出现,问题成功解决。

思考

这个问题的根源在于 MyBatis 依赖于 Java Bean 的规范,需要无参构造函数和 setter 方法。然而,Kotlin 的 data class 是不可变的,属性都是 val,而且默认不生成无参构造函数。这种设计上的差异,导致了两者之间的兼容性问题。

MyBatis 作为一款经典的 ORM 框架,却没有很好地支持 Kotlin 的数据类,在面对现代语言的新特性时,显得有些滞后。这着实有些令人失望。

作为一名开发者,我们需要在实际项目中权衡不同技术的特性和限制。在这个例子中,使用 kotlin-noarg 插件为 Kotlin 的 data class 生成无参构造函数,是一种优雅且实用的解决方案。