尧图网站设计 尧图网站设计YAOTU DESIGN
ARTICLE DETAIL

资讯详情

深耕网站设计与一线实操的经验洞察。

MyBatis Mapper接口全解析:从动态代理到XML绑定与排错

MyBatis Mapper接口全解析:从动态代理到XML绑定与排错 搭建MyBatis框架系列已经写到第四篇前面我们把全局配置文件、SqlSessionFactory、数据源连接这些地基部分聊得差不多了。今天这篇专门针对mapper接口来展开原因很简单在真实的项目里框架搭好之后天天打交道的几乎都是mapper接口和它背后的XML映射文件而绝大多数新手卡住的点也集中在这里。你可能会遇到一调用就报Invalid bound statement (not found)或者参数明明传了却提示Parameter不存在的报错又或者返回的数据莫名其妙全是null。这些问题十有八九出在mapper接口编写、XML绑定或者参数处理这些环节上。这篇文章的定位很明确从“为什么MyBatis要设计成接口”讲起再把接口定义、XML映射、Spring Boot整合、常见报错全部串起来讲一遍最后附上我实际踩坑总结的排查清单。适合刚会用JDBC但还没吃透MyBatis的读者也适合正在准备MyBatis面试题、需要把执行原理讲清楚的同学。我会尽量用大白话加实际例子说透尽量让每个配置都能直接抄到你的项目里。1. mapper接口到底是怎么回事先理解MyBatis的数据访问设计1.1 从SqlSession到mapper接口的演进如果你用过早期版本的MyBatis大概还记得那种直接用SqlSession操作的方式。那会儿的写法是取出一个SqlSession然后调用session.selectList(com.example.mapper.UserMapper.selectById, id)这样的方法第一个参数是statement的ID字符串写得稍微长一点手一抖拼错一个点或者少写个方法名运行时才报错。我当时维护老项目就吃过这种亏一条SQL排查了半天最后发现是XML里的id和Java代码里的字符串对不上。后来MyBatis引入了mapper接口把这种“字符串驱动”改成了“类型安全”的方式。你定义一个Java接口比如UserMapper在里面声明方法调用的时候不用自己拼statement id直接userMapper.selectById(id)就行。框架会把你的方法调用翻译成对应的SQL执行。这一层翻译机制本质上就是JDK动态代理。1.2 mapper接口的本质JDK动态代理很多人面试被问“MyBatis的mapper接口为什么不能有实现类”时答不到点上。其实答案不复杂MyBatis启动时会扫描mapper接口为每个接口生成一个代理对象代理的是MapperProxy这个代理会拦截你对接口方法的调用然后把接口名加方法名拼成statement id去Configuration里找对应的MappedStatement。所以接口本身只是一个“声明”真正的SQL逻辑要么写在注解里要么写在XML里运行时由代理对象去调度。为了加深理解我把关键环节涉及到的几个类梳理一下MapperRegistry负责注册mapper接口维护接口Class和MapperProxyFactory的对应关系。MapperProxyFactory专门为某个接口创建代理对象的工厂。MapperProxy实现了InvocationHandler是代理对象的核心逻辑所在方法调用会被转发到这里。MapperMethod真正执行SQL的地方它从方法签名里解析出SQL类型、参数名和返回类型然后调用SqlSession的方法。其中MapperRegistry的初始化非常关键。在Spring Boot集成场景里如果配置了MapperScan(com.example.mapper)框架会扫描这个包下的所有接口逐个添加到MapperRegistry。如果你用Mapper注解标记在每个接口上效果类似只是粒度更小。这两种方式在后面的实战小节里我会分别演示。1.3 接口与XML的绑定规则理解完代理机制接下来要记住一个铁律Mapper接口的全限定名必须和XML映射文件的namespace完全一致同时接口的方法名必须和XML中某个statement的id完全一致。这里任何一处对不上就会抛出org.apache.ibatis.binding.BindingException: Invalid bound statement (not found)。我用一个最简单的例子描述这个关系。假设接口是package com.example.mapper; public interface UserMapper { User selectById(Integer id); }那么XML文件里必须是这样mapper namespacecom.example.mapper.UserMapper select idselectById resultTypecom.example.entity.User select * from user where id #{id} /select /mappernamespace是接口的全限定名select标签的id是方法名selectById。这个对应关系没有例外也不存在智能推断。所以你在创建XML文件的时候最稳妥的方法是先用接口的全限定名建目录和文件名比如接口是com.example.mapper.UserMapperXML就放在resources/mapper/UserMapper.xml文件里namespace写成com.example.mapper.UserMapper。多花十秒钟确认这两行能帮你少调半小时错。2. 动手创建mapper接口之前的准备工作2.1 工程结构与依赖选择在开始写mapper接口之前先确认你的项目结构是清晰的。通常一个标准工程里mapper接口放在com.example.mapper包或者com.example.dao包下实体类放在com.example.entity或com.example.domain包下XML文件放在src/main/resources/mapper目录下。这个结构并不强制但如果你指定了统一的包扫描路径后续扩展会很方便。如果项目基于Maven最少需要引入MyBatis和数据库驱动依赖。基础的非Spring Boot项目需要dependency groupIdorg.mybatis/groupId artifactIdmybatis/artifactId version3.5.16/version /dependency dependency groupIdcom.mysql/groupId artifactIdmysql-connector-j/artifactId version8.4.0/version /dependency如果是Spring Boot项目推荐直接用MyBatis官方提供的starterdependency groupIdorg.mybatis.spring.boot/groupId artifactIdmybatis-spring-boot-starter/artifactId version3.0.3/version /dependency这个starter会自动完成SqlSessionFactory的创建、mapper接口的扫描注册以及事务管理等一堆事情。但注意版本要与Spring Boot版本兼容比如Spring Boot 3.x对应mybatis-spring-boot-starter 3.xSpring Boot 2.x对应2.x。版本选错会出现一些莫名其妙的兼容性异常。2.2 全局配置文件里容易忽略的两个开关当我们说“搭建MyBatis框架”时除了Java代码层面的mapper接口至少还有两个配置项与mapper接口能否被正确加载息息相关很多人都会漏掉。第一是XML映射文件位置的声明。在Spring Boot的application.yml里面常见配置是mybatis: mapper-locations: classpath:mapper/*.xml type-aliases-package: com.example.entity configuration: map-underscore-to-camel-case: truemapper-locations就是告诉MyBatis去哪里加载XML文件。如果你的XML文件不在classpath:mapper目录下或者目录层级和接口包名不一致即使接口定义得再正确也找不到对应的statement。第二是type-aliases-package它给实体类设置短别名这样XML里的resultType就不用写全限定名直接写User即可。至于map-underscore-to-camel-case这个开关企业开发里几乎必开它能把数据库的user_name自动映射成Java属性的userName省掉一堆手动resultMap。如果是在纯MyBatis环境下不依赖Spring那么对应的配置写在mybatis-config.xml里configuration typeAliases package namecom.example.entity/ /typeAliases mappers package namecom.example.mapper/ /mappers /configuration提到mybatis-config.xml就顺带说一个面试高频点XMLConfigBuilder的工作流程。MyBatis启动时XMLConfigBuilder负责解析mybatis-config.xml里的settings、typeAliases、typeHandlers、environments、mappers等节点。所有解析结果都会保存到Configuration对象中Configuration可以说是MyBatis的“中枢大脑”。当mappers节点被解析时XMLConfigBuilder会判断你配置的是接口包还是具体的XML文件路径。如果配置的是接口包MyBatis会扫描包下所有接口并注册到MapperRegistry里这就是后面mapper接口能被代理的前提。2.3 创建接口前先想清楚方法签名在落笔写第一个mapper方法前有个习惯非常推荐先想清楚方法的输入输出再写接口和XML。很多人上来就把接口方法一挥而就等写XML的时候才纠结参数怎么传返回结果怎么映射。一个相对规范的方法是关注三件事方法名要能表达SQL意图比如selectByPrimaryKey、insertBatch、deleteByCondition参数类型要明确是单个基础类型还是对象还是多个参数配合Param注解返回类型要确定是实体对象、List、Map还是Integer。这三件事想清楚之后再去写XML就很顺畅了。如果你拿不准返回集合和返回单对象分别怎么写记住一条原则方法返回List时XML里select语句不用额外包裹什么MyBatis会自动根据返回类型封装成List方法返回单个对象时查询结果也要求是单条记录。3. 创建mapper接口核心细节与实操要点3.1 接口方法定义的基本规则基础版本的mapper接口看起来像这样package com.example.mapper; import com.example.entity.User; import org.apache.ibatis.annotations.Param; import java.util.List; public interface UserMapper { User selectById(Integer id); ListUser selectAll(); int insert(User user); int updateNameById(Param(id) Integer id, Param(name) String name); int deleteById(Integer id); }这个接口里出现了几种典型的方法样式逐个说一下。selectById是单参数查询参数是Integer返回User对象selectAll是无参数查询返回ListUserinsert接收一个User对象MyBatis会自动把对象的属性值取出来绑定到#{属性名}上updateNameById是多参数方法这里用了Param分别指定参数名deleteById是单个基础类型参数。特别强调一下多参数场景。如果一个方法有两个或两个以上的参数而且没有加Param注解MyBatis会使用param1、param2这样的命名方式来访问参数比如#{param1}和#{param2}显得既不直观也容易写错。加了Param之后就可以用有业务含义的名字了。所以多参数方法务必养成加Param注解的习惯这是代码可读性和运行安全性的双重保障。3.2 返回类型与ResultMap的选择返回类型这块很多新手容易踩坑的是实体属性和数据库字段对不上的问题。如果你没开启驼峰映射也没配置resultMap那么查询结果返回时框架会把列名和实体属性名逐一匹配。数据库里的user_name和Java里的userName对应不上结果就是那个字段永远是null。应对方案有三种第一种在全局配置中开启驼峰映射这也是我最推荐的方式一句配置解决大部分场景。第二种把数据库字段名和实体类属性名设计成完全一致比如数据库也叫userName但下划线命名在数据库设计里更常见。第三种专门为查询写一个resultMap把列名和属性名显式对应起来适合多表关联查询或者部分特殊映射场景。resultMap的典型写法是resultMap idUserResultMap typecom.example.entity.User id propertyid columnuser_id/ result propertyuserName columnuser_name/ result propertyemail columnemail/ /resultMap查出来之后直接在select标签里写resultMapUserResultMap。要注意的是select标签里resultType和resultMap只能二选一不能同时写。换用resultMap后全局的驼峰映射对这些显式指定的列依然生效不过如果你已经写了column和property的对应关系那就以resultMap为准了。3.3 注解SQL和XML的取舍mapper接口的方法上可以直接写SQL注解比如Select(select * from user where id #{id}) User selectById(Integer id);这种写法看起来简洁简单查询用起来很舒服。但一旦SQL变复杂比如多表关联、动态条件、批量更新注解方式会变得很难维护字符串拼接一多代码里全是if判断换个行都容易看错。我个人的习惯是简单的单表查询用注解可以接受复杂的、带动态SQL的一律写在XML里。XML的好处是可以集中管理可以用if、where、foreach这类动态标签换环境调整SQL不用重新编译Java代码。3.4 接口注册方式MapperScan和Mapper的取舍在Spring Boot项目里注册Mapper接口有两种主流方式。第一种是在启动类或者配置类上写MapperScan(com.example.mapper)启动时扫描整个包下的接口批量注册。第二种是在每个mapper接口上标注Mapper注解单独注册。从实际使用来说我更推荐MapperScan理由有三个。一是包扫描集中新加接口时不用记着去加注解二是扫描范围清晰整个项目的mapper接口都在同一个包下团队协作时不容易乱三是Spring Boot启动类上只需要维护一处注解即可。如果项目里有多个数据源或者多个mapper包MapperScan也可以多次声明分别指定不同包的接口。当然如果项目里mapper接口数量很少比如就是个demo两三个接口用Mapper也完全没问题。4. 实战完整创建mapper接口并跑通增删改查4.1 从建表到实体类纸上谈兵没意思我们直接用一个案例把整套流程串起来。假设有一张用户表CREATE TABLE user ( id INT AUTO_INCREMENT PRIMARY KEY, user_name VARCHAR(50) NOT NULL, email VARCHAR(100), create_time DATETIME );对应的实体类package com.example.entity; import java.time.LocalDateTime; public class User { private Integer id; private String userName; private String email; private LocalDateTime createTime; public Integer getId() { return id; } public void setId(Integer id) { this.id id; } public String getUserName() { return userName; } public void setUserName(String userName) { this.userName userName; } public String getEmail() { return email; } public void setEmail(String email) { this.email email; } public LocalDateTime getCreateTime() { return createTime; } public void setCreateTime(LocalDateTime createTime) { this.createTime createTime; } Override public String toString() { return User{id id , userName userName , email email , createTime createTime }; } }注意实体类里用了userName这种驼峰命名而表里的列是user_name。这里启用map-underscore-to-camel-case: true之后查询结果可以直接封装不用写任何额外映射。如果你还没开启这个配置现在就去application.yml里补上。4.2 编写UserMapper接口和XML接下来创建UserMapper接口package com.example.mapper; import com.example.entity.User; import org.apache.ibatis.annotations.Param; import java.util.List; public interface UserMapper { User selectById(Integer id); ListUser selectAll(); int insert(User user); int updateUserNameById(Param(id) Integer id, Param(userName) String userName); int deleteById(Integer id); }然后在resources/mapper目录下创建同名XML文件UserMapper.xml注意路径要在classpath下?xml version1.0 encodingUTF-8 ? !DOCTYPE mapper PUBLIC -//mybatis.org//DTD Mapper 3.0//EN https://mybatis.org/dtd/mybatis-3-mapper.dtd mapper namespacecom.example.mapper.UserMapper select idselectById resultTypecom.example.entity.User select id, user_name, email, create_time from user where id #{id} /select select idselectAll resultTypecom.example.entity.User select id, user_name, email, create_time from user /select insert idinsert parameterTypecom.example.entity.User useGeneratedKeystrue keyPropertyid insert into user (user_name, email, create_time) values (#{userName}, #{email}, #{createTime}) /insert update idupdateUserNameById update user set user_name #{userName} where id #{id} /update delete iddeleteById delete from user where id #{id} /delete /mapper有几个细节值得展开说说。useGeneratedKeystrue配合keyPropertyid的含义是执行insert语句后把数据库自动生成的主键值回填到传入对象user的id属性上。这样调用完insert方法后你直接user.getId()就能拿到新记录的自增主键。如果不加这两句你得再查一次数据库才知道主键是多少多一次IO不说代码还啰嗦。parameterType在select、update、delete里其实可以省略MyBatis会通过参数对象自行推断类型。但insert这种接收复杂对象的时候写上也无妨代码意图更清楚。值得注意的是MyBatis 3.5及以上版本里parameterType已经很少强制要求了但写上也不违规。select标签里的resultType如果全局配置了type-aliases-package可以简写成resultTypeUser。为了减少手误概率我更推荐直接写全限定名或者确保别名配置无误后再用简称。4.3 在Spring Boot中完成注册与调用接口和XML都写好后在启动类上加扫描注解package com.example; import org.mybatis.spring.annotation.MapperScan; import org.springframework.boot.SpringApplication; import org.springframework.boot.autoconfigure.SpringBootApplication; SpringBootApplication MapperScan(com.example.mapper) public class Application { public static void main(String[] args) { SpringApplication.run(Application.class, args); } }然后在Service里注入接口并使用package com.example.service; import com.example.entity.User; import com.example.mapper.UserMapper; import org.springframework.stereotype.Service; import java.util.List; Service public class UserService { private final UserMapper userMapper; public UserService(UserMapper userMapper) { this.userMapper userMapper; } public User getUserById(Integer id) { return userMapper.selectById(id); } public ListUser listUsers() { return userMapper.selectAll(); } public void createUser(User user) { userMapper.insert(user); System.out.println(生成的主键ID为 user.getId()); } public void updateUserName(Integer id, String userName) { userMapper.updateUserNameById(id, userName); } public void deleteUser(Integer id) { userMapper.deleteById(id); } }这里我用的是构造器注入方式Spring Boot 3.x时代官方推荐的方式。用Autowired字段注入也能跑通但到了单元测试的时候你就知道构造器注入的好处了它让依赖关系更清晰也方便mock。4.4 打印SQL验证接口到XML的绑定启动项目后为了确认mapper接口真的绑定到了正确的SQL我们需要把MyBatis执行的SQL打印出来。在application.yml里加logging: level: com.example.mapper: debug这样设置之后控制台会输出mapper接口包下所有SQL的执行日志包括Preparing和Parameters。我第一次调通整套流程的时候就是靠这个日志确认#{id}参数究竟有没有传进去以及在SQL执行时被替换成了什么值。如果你发现日志没打印先检查mapper接口所在的包名是否写对是com.example.mapper而不是com.example.service。另外可以顺带检查一下Configuration对象的几个关键设置是否生效。如果你在代码中打印SqlSessionFactory的configuration属性能看到cacheEnabled、localCacheScope等状态这对后面排查缓存问题很有帮助。5. 常见问题与排查技巧实录5.1 Invalid bound statement (not found)该怎么查这个报错出现率最高我来总结一个固定排查顺序。第一步检查XML中的namespace是否等于接口的全限定名。第二步检查方法名和XML中statement的id是否完全一致注意大小写和空格。第三步确认Spring Boot配置了mapper-locations而且XML确实在对应目录下能被扫描到可以直接解压打出来的jar或者target/classes目录看一眼XML是否被复制进去了。第四步如果使用了MapperScan检查它扫描的包是否覆盖了你的接口所在包。我遇到过最离奇的一次是XML文件放在了src/main/resources下但是文件名是UserMapper.xml.backupMyBatis当然找不到当时排查了半天才反应过来。所以平时写文件时注意命名别给编辑器自动加了后缀。5.2 参数绑定失败Parameter userName not found这个报错基本都出在多参数方法上。比如你定义了User selectByNameAndEmail(String userName, String email);XML里写的是where user_name #{userName}运行时会报Parameter userName not found。原因很简单Java编译器在默认参数编译参数名时如果没开启-parameters编译参数这些名字在字节码里是拿不到的。MyBatis只能看到arg0、arg1或者param1、param2。解决办法就是在方法参数上明确加Param比如User selectByNameAndEmail(Param(userName) String userName, Param(email) String email);这里顺便说一下另一个容易被忽略的问题单个对象参数的时候不要乱加Param。比如insert(User user)进到XML里就应该用#{userName}、#{email}来取对象属性。如果你写成了#{user.userName}反而会报错或者取不到值因为单参数且没加Param时MyBatis会把对象本身作为参数上下文。5.3 查询结果字段全是null的问题字段全是null先检查数据库列和实体属性之间是否满足驼峰映射规则。如果user_name对应userName确认map-underscore-to-camel-case确实开了。如果开了还不行大概率是你的select语句自己起了别名把列名覆盖了。比如你写select user_name as username from userMyBatis拿到username这个列名后和userName对比那当然匹配不上。列别名要么保持一致要么就别起别名让驼峰映射去处理。如果查询涉及多表且有同名列务必要用resultMap显式声明映射关系。5.4 二级缓存和一级缓存的踩坑记录MyBatis的缓存是面试几乎绕不开的话题这里说几个实际问题。一级缓存默认开启作用范围是SqlSession。在Spring Boot集成的场景中每次数据库操作默认都从SqlSessionTemplate获取SqlSessionSqlSession在整个事务内是同一个所以一级缓存能在同一个事务里生效。但如果你开了二级缓存那范围就扩大到SqlSessionFactory级别了多个SqlSession之间共享缓存数据。二级缓存实际上默认是不开启的需要你在XML里加cache/标签同时实体类要实现Serializable接口。这里最关键的问题是数据一致性和脏读如果你的项目里有写操作频繁的表格开二级缓存务必谨慎。另外如果你在同一个事务里先查询再更新因为一级缓存的存在更新之后再次查询同一条记录可能拿到的是缓存里的旧数据这也是很多人莫名“数据怎么不更新”的原因之一。我的经验是对于缓存这种功能项目初期一律先不开等真正遇到性能瓶颈再针对具体的、读多写少的查询单独开启。5.5 枚举与TypeHandler的实际应用数据库字段和Java枚举之间的转换在日常项目里也很常见。比如用户状态字段status数据库里存的是IntegerJava里对应一个枚举。MyBatis默认对枚举的处理方式是通过EnumTypeHandler它根据枚举的name()名称匹配字符串。但如果你存的不是枚举名称而是数字比如1代表启用2代表禁用那默认的枚举处理就不合适了。解决方法是自定义TypeHandler实现一个把数据库数值转为枚举类型的处理器。这里理解MyBatis中TypeHandler的工作流程就很重要了在MyBatis初始化阶段TypeHandlerRegistry会注册内置的各种TypeHandler当你执行查询时ResultSetHandler从结果集中取到目标值调用TypeHandler的getResult方法完成JDBC类型到Java类型的转换执行写入时PreparedStatementHandler调用TypeHandler的setParameter方法把Java参数设置到PreparedStatement里。所以自定义TypeHandler本质上就是实现setParameter和getResult两个方向的方法。一个简单的自定义枚举处理器大致是这样package com.example.handler; import com.example.enums.UserStatus; import org.apache.ibatis.type.BaseTypeHandler; import org.apache.ibatis.type.JdbcType; import java.sql.CallableStatement; import java.sql.PreparedStatement; import java.sql.ResultSet; import java.sql.SQLException; public class UserStatusTypeHandler extends BaseTypeHandlerUserStatus { Override public void setNonNullParameter(PreparedStatement ps, int i, UserStatus parameter, JdbcType jdbcType) throws SQLException { ps.setInt(i, parameter.getCode()); } Override public UserStatus getNullableResult(ResultSet rs, String columnName) throws SQLException { int code rs.getInt(columnName); return UserStatus.fromCode(code); } Override public UserStatus getNullableResult(ResultSet rs, int columnIndex) throws SQLException { int code rs.getInt(columnIndex); return UserStatus.fromCode(code); } Override public UserStatus getNullableResult(CallableStatement cs, int columnIndex) throws SQLException { int code cs.getInt(columnIndex); return UserStatus.fromCode(code); } }然后在mybatis配置里注册这个TypeHandler或者在XML查询列上指定typeHandlercom.example.handler.UserStatusTypeHandler。如果你只是偶尔一两处字段需要这种转换直接在resultMap里指定typeHandler就够了不必全局注册。5.6 批量操作与特殊参数绑定for each如何正确使用批量插入在项目里是硬需求比如一次导入几千条用户数据。写过批量的人基本都知道要避免在循环里单条insert而是用一条SQL搞定MyBatis里用foreach实现insert idinsertBatch insert into user (user_name, email, create_time) values foreach collectionlist itemuser separator, (#{user.userName}, #{user.email}, #{user.createTime}) /foreach /insert注意collection的取值当接口方法参数是一个List且没加Param时MyBatis默认的集合名是list如果加了Param(userList)那collection就要写userList。参数是数组时默认名是array。这是除了Param之外另一个最容易出错的地方。我见过不少同事在这里踩坑报错信息都是There is no getter for list in class java.lang.String一看就知道是collection写错了。批量更新其实也可以写在一条SQL里但相对复杂。复杂场景下批量操作涉及事务和数据一致性我的经验是尽量控制批次大小比如每次500条左右这样既能减少数据库连接往返次数又不会因为单条SQL过长导致数据库解析压力太大。写在最后的补充这一篇把mapper接口从设计原理到编写细节再到常见排查都完整讲了一遍我自己在整个过程中最大的体会是MyBatis的很多报错其实都在提醒你“绑定”出了问题要么是Java接口和XML的namespace绑定失败要么是参数和#{xxx}的绑定失败要么是查询列和resultType的映射绑定失败。抓住“绑定”这两个字排查思路就清晰了。如果你按照上面的步骤自己动手写一遍刚起步时可能还是会碰到一两个意外的小坑这很正常。我建议你保持一个习惯遇到报错先看完整的异常栈不要只看第一行MyBatis的报错信息其实挺直白的它会直接告诉你哪个接口的哪个方法找不到对应语句或者哪个参数没法解析。顺着提示定位到对应的XML和接口方法大多数问题都能在五分钟内找到答案。另外如果你想深入了解MyBatis初始化时XMLConfigBuilder是如何加载配置、如何解析这些绑定关系的可以去翻一翻mybatis源码中XMLConfigBuilder和XMLMapperBuilder这两个类它们做的事正好对应我们今天讲的配置加载和mapper解析。把代码层面的调用链和实际配置对应起来之后你会觉得MyBatis整个运行过程清晰了很多。后面如果我继续写这个系列应该会侧重讲动态SQL和分页插件这部分到时再聊。
返回列表