做MyBatis查数据的时候,最让人头大的不是SQL写不出来,而是SQL明明查出了字段,Java对象里却对应不上,结果一片null。尤其是“数据库字段下划线命名”和“Java属性驼峰命名”这种看似差一点点的问题,调试起来特别容易让人原地怀疑人生:字段名看着对,属性名也认识,但结果就是装不进去。
这个问题的标准解法,就是自定义映射resultMap。往浅了说,它解决的是“user_id”和“userId”谁对应谁的问题;往深了说,它可以管理主键、构造器参数、嵌套对象、结果集自动映射规则,甚至多表联查里的列名冲突。这篇是自定义映射专题的第四篇,我聚焦在最基础也最容易翻车的一块:字段名和属性名的映射关系到底是怎么一回事,以及resultMap应当怎么配才算真正配明白。不管你是刚接触MyBatis不久,还是已经写过不少resultMap,我相信这篇里都有一些值得你停下来细看的内容。
1. 为什么明明字段名一样,查出来却是null
1.1 一个最常见的“看起来没问题”的翻车现场
先看一段很多人都会写的代码。数据库表里有user_id、user_name、email三个字段,Java实体类长这样:
public class User { private Integer userId; private String userName; private String email; // getter/setter 省略 }Mapper XML里的查询也很常规:
<select id="selectById" resultType="com.example.entity.User"> select user_id, user_name, email from user where id = #{id} </select>查出来的结果是:email有值,userId和userName是null。为什么?因为MyBatis默认没有开启下划线转驼峰,它拿数据库返回的列名user_id去找Java属性时,不会自动把下划线去掉再变成userId。换句话说,MyBatis不是不知道user_id是什么,而是它不知道user_id应该对应userId。
如果你给SQL里的列名手动起别名,例如user_id as userId,那可以解决当前一条SQL的问题。但如果项目里有几十个表、几百个查询,每一条SQL都要靠别名来纠正命名差异,工作量上去不说,还特别容易漏。这种场景天生就是resultMap的主场。
1.2 从JDBC到MyBatis:结果集是怎么变成对象的
要真正理解resultMap,得先知道MyBatis默认是怎么把结果集变成对象的。
数据库执行完SQL后,返回的是JDBC的ResultSet。MyBatis拿到结果集之后,会调用ResultSetMetaData.getColumnLabel()拿到每一列的名字,然后在配置的目标类型里找对应的属性名,找到就去调用它的setter方法,找不到就跳过这一列,不报错也不提示。这个自动匹配过程就叫自动映射。
自动映射的默认策略是autoMappingBehavior为PARTIAL。在这个策略下,如果列名和属性名本身能对应上,就会自动赋值;如果列名是user_id而属性名是userId,又没有开启下划线转驼峰,那就只能得到一个null。
这也就解释了为什么翻车现场往往静悄悄:MyBatis不会跟你说“我找不到userId的列”,它只会默默地忽略掉那一列。所以排查这类问题,第一反应不该是怀疑SQL写错了,而是先想清楚当前查询用的是resultType还是resultMap、列名和属性名之间到底差在哪。
2. resultMap到底做了什么:核心原理拆解
2.1 它不是“代码生成器”,而是一张映射说明书
很多人对resultMap有个误解,以为它只是把数据库字段和Java属性一对一列出来就行。其实resultMap更像是一张“人工翻译词典”,它的核心元素是type,指明了结果要装进哪个类;内部再通过id、result、constructor这些子元素,告诉MyBatis每一列应该怎么处理。
一个最小的resultMap长这样:
<resultMap id="userMap" type="com.example.entity.User"> <id property="id" column="id" /> <result property="userName" column="user_name" /> <result property="email" column="email" /> </resultMap>这里property写的是Java属性名,column写的是数据库列名或SQL别名。MyBatis拿到结果集后,会按照这张说明书的描述去创建对象、调setter、赋值。type的作用是告诉MyBatis该创建哪个类的实例,所以这个类必须有默认构造方法,或者你通过constructor显式指定构造参数。
2.2 id与result标签:看似一样,实际分工完全不同
在resultMap里,<id>和<result>都能把数据库列映射到Java属性,但它们的语义完全不同。很多新手图省事,把所有字段全部写成<result>,或者反过来把所有字段都写成<id>,短时间看不出问题,等用到二级缓存或者嵌套结果映射时就会踩坑。
| 标签 | 作用 | 典型使用场景 |
|---|---|---|
<id> | 标记主键字段,MyBatis会用它的值参与缓存key的计算和对象唯一性判断 | 映射数据库主键、逻辑主键、唯一业务键 |
<result> | 映射普通字段,只负责赋值,不参与唯一性逻辑 | 普通列、业务字段、聚合字段 |
主键字段在结果集里是“这一行是谁”的标识。如果一张结果集里没有唯一键,或者你非要把一个姓名列写成<id>,缓存和嵌套映射都可能错乱。开发规范比较严格的项目里,一条resultMap通常只有一个<id>,其余全用<result>。
2.3 type属性与autoMapping的隐藏规则
还有一个容易被忽略的点:resultMap并不要求把所有列都列全。默认情况下,MyBatis在应用resultMap之后,仍然会尝试对没有显式映射的列做自动映射。也就是说下面这种resultMap只声明了两列:
<resultMap id="userMap" type="com.example.entity.User"> <id property="id" column="id" /> <result property="userName" column="user_name" /> </resultMap>如果查询结果里还有email这一列,email属性依然可能被自动映射上,因为MyBatis发现它没在resultMap里,会回过头按自动映射规则再跑一遍。
这个机制是好是坏要分场景。好处是省事,坏处是容易“偷偷映射”到你不希望被赋值的字段。当你希望结果集里的列完全由resultMap掌控,可以在<resultMap>上显式加autoMapping="false":
<resultMap id="userMap" type="com.example.entity.User" autoMapping="false"> ... </resultMap>这样就不会再有“漏网之列”被自动塞进对象里了。
3. 从实际案例看字段名和属性名的四种映射困境
3.1 下划线转驼峰:能解决但别乱开全局配置
针对user_name到userName这种映射需求,MyBatis提供了一个全局开关:
<settings> <setting name="mapUnderscoreToCamelCase" value="true"/> </settings>开启之后,所有查询的列名会自动去掉下划线并转成驼峰,user_id会变成userId。这个开关确实好用,很多新项目一上来就把它打开。
但我建议你在老项目里谨慎动这个全局配置。因为它影响的是所有Mapper、所有查询,不仅包括你写的SQL,还包括公共模块里别人写的SQL,甚至第三方组件内置的SQL。一旦某个SQL里的列名用了user_name但Java属性偏偏也叫user_name,开启后可能反而对不上。所以我的习惯是:新项目可以开,老项目尽量用resultMap做局部映射,不轻易动全局配置。
3.2 数据库聚合字段与DTO属性的错位
写统计报表时,SQL经常会有这种聚合结果:
select count(*) as total_count, max(amount) as max_price, avg(amount) as avg_price from orderDTO里的属性大概率是totalCount、maxPrice、avgPrice。如果查询直接用resultType,就还得靠别名把它们写成驼峰,比如count(*) as totalCount。但这种写法的问题是:SQL里的别名跟Java对象耦合在了一起,换一个DTO复用这段SQL时又要重新改别名。
用resultMap可以把这个耦合拆开:
<resultMap id="orderStatMap" type="com.example.vo.OrderStatVO"> <result property="totalCount" column="total_count" /> <result property="maxPrice" column="max_price" /> <result property="avgPrice" column="avg_price" /> </resultMap>查询方法再写哪一列就无所谓了:
<select id="selectOrderStat" resultMap="orderStatMap"> select count(*) as total_count, max(amount) as max_price, avg(amount) as avg_price from order </select>这样SQL里保留数据库习惯的命名,Java对象里保留Java风格的命名,中间只靠resultMap这一层翻译。
3.3 查询结果里多出来的冗余字段
有时候SQL是同事写好的,里面顺手select *了一堆字段,你的对象里却只想要其中一部分。如果使用自动映射,多余字段只要能对上属性名就会被Java对象接收,多出来的数据白白增加了内存占用,更危险的是可能把不该暴露的字段也装进去。
这种场景我建议在resultMap上把该映射的列显式声明出来,同时关掉自动映射:
<resultMap id="safeUserMap" type="com.example.vo.SafeUserVO" autoMapping="false"> <id property="id" column="id" /> <result property="nickname" column="nick_name" /> </resultMap>这么写的效果是:无论查询结果里多出多少列,只要不在resultMap里,MyBatis都不会把它们映射到对象上,安全边界非常清晰。
3.4 多表联查的字段前缀冲突
多表联查是字段名映射的重灾区。两个表都叫id、都叫name时,如果你直接select *,后一个字段很可能把前一个覆盖掉。即便你用resultType加别名,也很难保证后面新同事改SQL时还会记得保留别名。
更稳妥的方式是用resultMap统一管理联查列:
<resultMap id="orderUserMap" type="com.example.vo.OrderUserVO"> <id property="orderId" column="order_id" /> <result property="userId" column="user_id" /> <result property="userName" column="user_name" /> <result property="totalAmount" column="total_amount" /> </resultMap>SQL这边只要保证列名或者别名跟resultMap里的column一致就行:
<select id="selectOrderUser" resultMap="orderUserMap"> select o.id as order_id, o.user_id as user_id, u.name as user_name, o.total_amount as total_amount from orders o left join user u on o.user_id = u.id where o.id = #{orderId} </select>联查结果里真正要落到Java对象的字段,全由resultMap说了算。多加一张表,只需要在SQL里追加列名和别名,Java对象的映射不会再被意外破坏。
4. 动手配置resultMap的完整流程(含完整XML示例)
4.1 配置前先想清楚的三个问题
动手写resultMap之前,我建议你先回答三个问题:
- 返回值是一个普通POJO,还是需要嵌套另一个对象的复杂结果?这决定了你是用
<result>还是需要<association>、<collection>。 - 列名与属性名之间是否存在无法靠自动映射解决的差异?如果所有列名都和属性名一致,resultMap可以写得非常精简。
- 这条映射规则以后会不会复用?如果会,建议给它起一个语义清晰的
id,方便其他查询通过resultMap引用。
想清楚之后,写resultMap就是填空式的操作。
4.2 XML里的书写顺序与常见错误
一个完整但简单的resultMap配置可以是这样的:
<resultMap id="orderUserMap" type="com.example.vo.OrderUserVO" autoMapping="false"> <id property="orderId" column="order_id" /> <result property="userId" column="user_id" /> <result property="userName" column="user_name" /> <result property="totalAmount" column="total_amount" /> </resultMap>对应的select改造为:
<select id="selectOrderUser" resultMap="orderUserMap"> select o.id as order_id, o.user_id as user_id, u.name as user_name, o.total_amount as total_amount from orders o left join user u on o.user_id = u.id where o.id = #{orderId} </select>这里有一个很容易被XML解析器教训的坑:resultMap子元素的书写顺序是有要求的。MyBatis的XML DTD规定,<constructor>要在最前面,接着是<id>和<result>,然后才是<association>和<collection>。我见过不少同事把<association>写在<result>前面,结果启动项目时报了一堆“内容类型必须匹配”的错误,其实就是顺序不对。
4.3 与注解方式的对比:什么时候用注解更省事
除了XML,MyBatis也支持用注解声明映射。
@Select("select user_id, user_name, email from user where id = #{id}") @Results(id = "userMap", value = { @Result(id = true, property = "userId", column = "user_id"), @Result(property = "userName", column = "user_name"), @Result(property = "email", column = "email") }) User selectById(Integer id);注解方式的优点是写起来集中,查询方法、SQL、映射关系都在同一个地方,适合简单查询。但如果映射关系复杂,或者一个resultMap要被多个查询复用,我个人更推荐XML。原因很简单:XML里的resultMap是一个独立命名的配置块,任何Mapper方法都能通过resultMap="xxx"引用,而注解里写的@Results(id = "userMap")虽然也能复用,但在大量SQL方法里维护起来远不如XML直观。
5. resultMap配置得好不好,看两个细节
5.1 constructor标签:处理不可变对象
如果实体类的属性是final的,或者没有无参构造方法,默认的对象创建方式就行不通。这时得用<constructor>告诉MyBatis怎么调用构造方法。
举个例子,一个统计VO只有构造器没有setter:
public class OrderStatVO { private final Long totalCount; private final BigDecimal maxPrice; public OrderStatVO(Long totalCount, BigDecimal maxPrice) { this.totalCount = totalCount; this.maxPrice = maxPrice; } }resultMap配置:
<resultMap id="orderStatMap" type="com.example.vo.OrderStatVO"> <constructor> <idArg column="total_count" javaType="long" /> <arg column="max_price" javaType="java.math.BigDecimal" /> </constructor> </resultMap>注意<idArg>一般用于标记构造参数里的主键字段,跟<id>的语义类似;普通构造参数用<arg>。如果你的实体类有默认构造方法,大部分情况下不需要写<constructor>,但真遇到不可变对象时,这个配置就是唯一解法。
5.2 复用resultMap:extends的坑和正确姿势
当一个resultMap的基础映射可以复用时,可以用extends减少重复配置:
<resultMap id="baseUserMap" type="com.example.entity.User"> <id property="id" column="id" /> <result property="userName" column="user_name" /> </resultMap> <resultMap id="orderUserMap" extends="baseUserMap" type="com.example.vo.OrderUserVO"> <result property="orderNo" column="order_no" /> </resultMap>这里有个容易忽略的坑:父resultMap的type和子resultMap的type可以不同,但子type对应的类必须继承或至少包含父resultMap里映射的那些属性。也就是说,子VO里必须有id、userName这些字段,否则MyBatis在往子对象里赋值时会找不到setter而报错。
另外,如果父级和子级对同一个column做了不同的映射,运行结果可能和你想的不一样。我不建议靠“覆盖”去处理这种问题,最好在设计SQL时就给列起不同的别名,从源头避免同一个column出现在两个层级里。
5.3 调试技巧:如何快速确认映射是不是对的
排查字段名映射问题,最快的方法不是盯着实体类看,而是把日志打开。MyBatis的日志配置里可以看到SQL执行后返回的列名列表,例如:
logging.level.com.example.mapper=debug日志里通常会输出<== Columns: user_id, user_name, email和<== Row: 1, 张三, email@example.com。看到列名之后,再去比对resultMap里的column值,问题基本一眼就能定位。
如果日志里没有详细列名,还有一个土办法:先把查询的resultType临时改成map,让MyBatis返回Map<String, Object>。这样你能直接看到SQL实际返回的key是什么,是user_id还是userId,然后再去调整resultMap。等确认好后,再改回正规的返回类型。这个办法虽然不够“优雅”,但在排场上非常管用。
最后说一个我自己的习惯:不要把resultMap当成一个“出了问题才救火”的工具,而是在设计Mapper接口时就把字段映射规则固定下来。宁可多写几行XML,也不要依赖SQL别名和全局开关“凑巧”映射成功。项目里的SQL会越来越多,但一份结构清晰的resultMap,永远比临时补的一个别名更能经得起时间考验。