Mybatis源码分析之(五)mapper如何将数据库数据转换成java对象的
1. 从 ResultSet 到 Java 对象mapper 映射链路到底在做什么你写了一个select语句Mybatis 执行完 SQL 拿到ResultSet最后方法返回的却是一个装好数据的User对象。中间这段「数据库行 → Java 对象」的转换就是 Mybatis 里最值得抠的一段源码。核心检索词先摆出来Mybatis mapper 把数据库数据转换成 Java 对象靠的是ResultSetHandler调度、ResultMap描述映射、TypeHandler负责单个字段的类型转换三者串起来才完成一次行到对象的落地。它适合谁看写过 mapper 但只会「配好 XML 就能跑」的同学遇到Invalid bound statement、字段全是 null、reading choices之类报错想搞清根因的同学以及想给 Mybatis 加自定义类型处理、做二次封装的同学。这篇不堆概念直接沿着调用链走一遍再给你能复制进项目的配置片段和断点位置让你在本地把「字段 → 属性」这一步亲眼看到。先给一张调用链全景后面每一节都围绕它展开PreparedStatementHandler.query() - ResultSetHandler.handleResultSets(stmt) // DefaultResultSetHandler - getFirstResultSet(stmt) // 包装成 ResultSetWrapper - mappedStatement.getResultMaps() // 取出初始化阶段建好的 ResultMap - handleResultSet(rsw, resultMap, ...) - handleRowValues(...) - handleRowValuesForSimpleResultMap(...) // 无嵌套时走这里 - getRowValue(rsw, resultMap) // 关键造对象 赋值 - createResultObject(...) // 反射/构造器创建实例 - applyAutomaticMappings(...) // 自动映射 - applyPropertyMappings(...) // 显式 result 映射 - TypeHandler.getResult(...) // 单列取值 类型转换 - storeObject(...) // 存进 ResultHandler 的 list这条链里有两个「缓存」很关键ResultSetWrapper缓存了列名、JdbcType、以及每列对应的TypeHandlerDefaultResultSetHandler里的autoMappingsCache缓存了「未映射列 → 属性」的自动映射结果。理解缓存你才能解释为什么同一个ResultMap第二次执行会更快也才能明白改配置后为什么要重启。ResultSetWrapper在构造时会做几件事记录columnNames、jdbcTypes、classNames并通过TypeHandlerRegistry为每一列预解析出TypeHandler。这一步是「列级」的准备工作等到getRowValue时就不用再反复查注册表了。而ResultMap是在Configuration初始化阶段解析 mapper XML 或注解时构建的里面存着mappedColumns、ResultMapping列表、type目标 Java 类等。运行时只是「取出来用」不再重新解析。所以整条链可以概括成一句话初始化阶段把「怎么映射」编译成 ResultMap运行阶段把「这一行的值」通过 TypeHandler 塞进新建的对象。下面按这个思路从环境准备到断点验证一步步复现。2. 前置准备本地复现映射链路需要什么要跟源码光看文章不够得能打断点、能单步。这一节把环境搭好同时把调试期要用到的统一 Key/API 通道接进来方便你在排查模型相关调用或做 AI 辅助编码时少折腾配置。先说工程侧。你需要一个能跑起来的 Mybatis 最小工程依赖建议锁版本避免不同版本DefaultResultSetHandler内部方法签名差异导致断点对不上dependencies dependency groupIdorg.mybatis/groupId artifactIdmybatis/artifactId version3.5.13/version /dependency dependency groupIdmysql/groupId artifactIdmysql-connector-java/artifactId version8.0.33/version /dependency dependency groupIdorg.slf4j/groupId artifactIdslf4j-simple/artifactId version2.0.9/version /dependency /dependencies数据库建一张最简单的表字段故意用下划线命名方便后面验证mapUnderscoreToCamelCaseCREATE TABLE t_user ( id BIGINT PRIMARY KEY AUTO_INCREMENT, user_name VARCHAR(64), age INT, create_time DATETIME ); INSERT INTO t_user (user_name, age, create_time) VALUES (tom, 18, NOW());对应的 Java 类public class User { private Long id; private String userName; private Integer age; private java.util.Date createTime; // getter / setter 省略 }然后是调试环境里的统一通道。做 AI 辅助编码或让模型帮你读源码时我习惯把 Key 和 Base URL 收敛到一处避免每个工具各配一套。TaoToken 提供统一 Key/API 通道官网入口是 https://taotoken.net/?utm_sourcetaotoken_aicg_blog_endutm_mediumcsdnutm_campaignrewriteutm_content API 地址是 https://taotoken.net/api 。在调试工程里你可以把它当成一个普通的 OpenAI 兼容端点来用比如写个脚本让模型解释某段DefaultResultSetHandler的源码curl https://taotoken.net/api/v1/chat/completions \ -H Authorization: Bearer $TAOTOKEN_API_KEY \ -H Content-Type: application/json \ -d { model: claude-sonnet-4-5, messages: [ {role: user, content: 解释 Mybatis DefaultResultSetHandler.getRowValue 的流程} ] }注意这里model字段要填你实际可用的模型 IDKey 从控制台生成。如果你用 Claude Code 这类命令行工具做源码阅读可以在其配置里把 Base URL 指向https://taotoken.net/apiKey 用同一个模型 ID 按需选。这样「读源码 问模型」在同一个通道里完成排查映射问题时不用来回切配置。注意调试环境里不要把 Key 硬编码进提交到仓库的文件用环境变量或本地settings文件并加进.gitignore。前置准备做到这里就够了一个能跑的 Mybatis 工程、一张表、一个实体类、一个可用的 API 通道。接下来进入正题把映射配置写出来。3. 可复制配置ResultMap、TypeHandler 与 settings 片段这一节给的是能直接抄进项目的配置。先看 mapper XML把「显式映射」和「自动映射」两种方式都放进去方便对比?xml version1.0 encodingUTF-8? !DOCTYPE mapper PUBLIC -//mybatis.org//DTD Mapper 3.0//EN http://mybatis.org/dtd/mybatis-3-mapper.dtd mapper namespacecom.demo.mapper.UserMapper resultMap idBaseResultMap typecom.demo.entity.User id columnid propertyid jdbcTypeBIGINT/ result columnuser_name propertyuserName jdbcTypeVARCHAR/ result columnage propertyage jdbcTypeINTEGER/ result columncreate_time propertycreateTime jdbcTypeTIMESTAMP/ /resultMap select idselectById resultMapBaseResultMap SELECT id, user_name, age, create_time FROM t_user WHERE id #{id} /select select idselectAuto resultTypecom.demo.entity.User SELECT id, user_name, age, create_time FROM t_user WHERE id #{id} /select /mapperresultMap走的是applyPropertyMappingsresultType走的是applyAutomaticMappings。两条路最终都会调用TypeHandler.getResult区别在于「属性名从哪来」前者从ResultMapping里读后者靠metaObject.findProperty按列名推断。Mybatis 主配置里和映射强相关的几个开关建议显式写出来configuration settings setting namemapUnderscoreToCamelCase valuetrue/ setting namecallSettersOnNulls valuetrue/ setting namereturnInstanceForEmptyRow valuefalse/ setting nameautoMappingUnknownColumnBehavior valueWARNING/ setting namelogImpl valueSLF4J/ /settings typeHandlers typeHandler handlercom.demo.handler.JsonTypeHandler javaTypecom.demo.entity.ExtInfo/ /typeHandlers mappers mapper resourcemapper/UserMapper.xml/ /mappers /configuration逐个说清楚它们影响源码里的哪一步mapUnderscoreToCamelCasetrue会让metaObject.findProperty在匹配时把user_name转成userName这是自动映射能对上属性的前提。callSettersOnNullstrue对应applyAutomaticMappings里那个判断即使value null也调用 setter否则字段保持默认值。returnInstanceForEmptyRow对应getRowValue末尾的三元表达式决定「一行全是 null」时返回对象还是 null。autoMappingUnknownColumnBehavior对应createAutomaticMappings里找不到属性时的分支设成WARNING能在日志里看到哪些列没被映射排查「字段莫名是 null」很有用。自定义 TypeHandler 的写法以 JSON 字段为例MappedTypes(ExtInfo.class) MappedJdbcTypes(JdbcType.VARCHAR) public class JsonTypeHandler extends BaseTypeHandlerExtInfo { private static final ObjectMapper MAPPER new ObjectMapper(); Override public void setNonNullParameter(PreparedStatement ps, int i, ExtInfo parameter, JdbcType jdbcType) throws SQLException { ps.setString(i, toJson(parameter)); } Override public ExtInfo getNullableResult(ResultSet rs, String columnName) throws SQLException { return fromJson(rs.getString(columnName)); } Override public ExtInfo getNullableResult(ResultSet rs, int columnIndex) throws SQLException { return fromJson(rs.getString(columnIndex)); } Override public ExtInfo getNullableResult(CallableStatement cs, int columnIndex) throws SQLException { return fromJson(cs.getString(columnIndex)); } // toJson / fromJson 省略 }注册后ResultSetWrapper在解析列类型时会命中这个 handlergetRowValue里mapping.typeHandler.getResult调用的就是它。这就是「单列类型转换」的扩展点。如果你用 Cline MCP 或 Codex 这类工具做源码辅助配置里同样要写全三件套缺一个都会连不上{ baseUrl: https://taotoken.net/api, apiKey: sk-你的Key, model: claude-sonnet-4-5 }Base URL、Key、Model ID 三者对应任何一处写错都会在请求阶段报错而不是在映射阶段。配置就绪后进入验证环节。4. 断点验证亲眼看到字段被塞进对象配置写完最爽的一步是打断点看数据怎么流。按下面的位置下断点用selectAuto触发自动映射路径第一处DefaultResultSetHandler.handleResultSets入口。看mappedStatement.getResultMaps()返回的ResultMap里type是不是com.demo.entity.UsermappedColumns是否为空自动映射时为空显式映射时有值。第二处handleRowValuesForSimpleResultMap里的while循环。观察rsw.getResultSet().next()为 true 时进入循环体resolveDiscriminatedResultMap返回的discriminatedResultMap就是当前行要用的映射。第三处getRowValue。这里能看到createResultObject造出的rowValue是User实例metaObject包着它。继续单步进applyAutomaticMappings。第四处createAutomaticMappings。重点看unmappedColumnNames列表它来自ResultSetWrapper.getUnmappedColumnNames。此时mappedColumns为空所以四个列名全在未映射列表里。接着看循环里metaObject.findProperty(user_name, true)返回userNamehasSetter为 truetypeHandlerRegistry.hasTypeHandler命中StringTypeHandler于是生成UnMappedColumnAutoMapping。第五处回到applyAutomaticMappings的 for 循环。mapping.typeHandler.getResult(rsw.getResultSet(), mapping.column)这一行执行后value就是数据库里的值metaObject.setValue(mapping.property, value)执行后rowValue的对应属性被赋值。你可以在setValue后展开rowValue看到userName从 null 变成tom。第六处storeObject。resultContext和resultHandler把rowValue存进DefaultResultHandler的list最后collapseSingleResultList返回。跑完这一遍控制台日志里应该能看到类似输出开了logImplSLF4J后 Preparing: SELECT id, user_name, age, create_time FROM t_user WHERE id ? Parameters: 1(Long) Total: 1方法返回的User对象里userNametom、age18、createTime有值。如果userName是 null先检查mapUnderscoreToCamelCase是否生效如果整个对象是 null检查returnInstanceForEmptyRow和该行是否全空。再验证显式映射路径把selectById跑一遍断点落在applyPropertyMappings。这里遍历的是resultMap.getPropertyResultMappings()每个ResultMapping带着column、property、typeHandler。getPropertyMappingValue内部同样调typeHandler.getResult然后metaObject.setValue。两条路径在「取值 赋值」这步是收敛的差别只在映射来源。验证自定义 TypeHandler 时把ExtInfo字段加进表里断点落在JsonTypeHandler.getNullableResult确认它被调用而不是默认的StringTypeHandler。如果没命中多半是MappedTypes或注册配置没对上。5. 常见报错排查401、local proxy failed、reading choices、OAuth映射链路本身报错不多但调试环境里经常混着接入类报错。这一节按真实报错对照排查先给一张速查表报错关键字常见根因处理方向401 UnauthorizedKey 缺失/过期/带多余空格检查 Authorization 头重新生成 Keylocal proxy failed本地代理配置与目标地址不匹配核对 Base URL 与代理设置reading choices响应体结构与解析代码不匹配打印原始响应确认字段路径OAuth / invalid_grant授权流程参数或回调不一致重走授权核对 client 配置401最典型。请求头里Authorization: Bearer sk-xxx如果 Key 前后有换行或空格服务端解析失败就返回 401。用 curl 验证curl -i https://taotoken.net/api/v1/chat/completions \ -H Authorization: Bearer $TAOTOKEN_API_KEY \ -H Content-Type: application/json \ -d {model:claude-sonnet-4-5,messages:[{role:user,content:ping}]}返回 200 说明通道正常返回 401 就检查 Key 和请求头。注意 Base URL 不要多写或少写/v1不同工具对路径拼接方式不同写错会 404 而不是 401。local proxy failed通常出现在工具配置了本地代理、但目标地址不在代理白名单或代理端口写错时。处理方式是核对工具里的 Base URL 与代理设置是否指向同一目标必要时把代理关掉直连测试。这里只做配置层面的核对不涉及任何网络规避手段。reading choices是解析响应时字段路径不对。OpenAI 兼容格式里内容在choices[0].message.content如果代码按别的结构读就会报这个。打印原始响应体最快String body response.body().string(); System.out.println(body);看到真实结构后再改解析路径。OAuth相关报错多出现在授权类工具invalid_grant一般是授权码过期或回调地址不一致重新走一遍授权、核对 client 配置即可。映射侧的报错单独说两个。Invalid bound statement (not found)是 mapper 接口方法和 XML 的id对不上或namespace写错和映射链路无关但经常被误认为映射问题。字段全 null 则回到第 4 节的断点看createAutomaticMappings里findProperty是否返回 null以及autoMappingUnknownColumnBehavior有没有打出 WARNING。6. 把映射链路用起来从读懂到能改走到这里你已经能把ResultSetHandler → ResultMap → TypeHandler这条链在本地跑通并断点验证。真正让它产生价值的是把它变成你排查问题的固定动作遇到字段映射异常先看ResultSetWrapper的列名和mappedColumns再看findProperty的匹配结果最后看TypeHandler是否命中。三步定位比盲改 XML 快得多。如果你想让模型帮你读DefaultResultSetHandler的源码或者生成自定义 TypeHandler 的骨架可以用统一通道把问题丢过去模型对话入口在 https://taotoken.net/api Key 和 Base URL 沿用第 2 节的配置即可。长期做源码阅读和 Agent 辅助编码的话Coding Plan 更适合把这类调用固定下来入口是 https://taotoken.net/api 按你的使用频率选即可。最后留一个我踩过的坑改完mapUnderscoreToCamelCase后一定要重启应用因为ResultMap和autoMappingsCache都在启动阶段构建热改配置不生效会让你误以为配置写错了。把这条记住能省不少排查时间。