快速上手:一个没有实现类的接口,凭什么能查数据库
以「Mapper 接口只有方法签名,mapper.selectById(1) 却查回了对象」的魔术引入:Spring Boot 集成最小工程、注解 CRUD 五连、参数传递四条路、#{} 与 ${} 的注入对照实验、主键回填,附 MapperProxy 原理图与三个排错实录。
前置知识
- 从 JDBC 到 MyBatis:知道「SQL 文本与方法签名绑定」的心智模型——没读过也能跟,本篇开头会把魔术现场摆出来;
- Java 数据库连接:知道 PreparedStatement 与占位符——没读过也能跟,第 5 节用到时会现场补;
- SQL 数据操作与查询:会写 SELECT、INSERT、UPDATE、DELETE。
本模块只讲 MyBatis 的 Spring Boot 集成形态。原生 MyBatis 要手工构建 SqlSessionFactory(全局配置 XML、SqlSessionFactoryBuilder、openSession 一套仪式)——那是无框架时代的老套路,一句话交代完毕,正文不再回头。
学习目标
读完本文你将能够:
- 从零搭起一个 MyBatis + MySQL 的 Spring Boot 工程,说出每一项配置解决什么问题;
- 用注解跑通增删改查五连,判断注解与 XML 各自的适用分野;
- 用单参数、@Param 多参数、POJO 三种方式传参,说出 Map 为什么不推荐;
- 分清 #{} 与 ${} 的执行差异,亲手完成一次注入对照实验;
- 写出主键回填,讲清 useGeneratedKeys 两个属性各管什么;
- 揭开开篇魔术:用三步执行链说清 Mapper 接口是谁在干活;
- 对着 BindingException、字段全 null、Bean 找不到三个经典报错,直接报出病因。
预计 45 分钟,需要一个能跑 Spring Boot 3.5.x 的环境与一个 MySQL 8 实例。
1. 你现在要解决什么问题
先看一段让你卡住三十秒的代码:
@Mapper
public interface ProductMapper {
@Select("SELECT id, product_name, price, stock FROM products WHERE id = #{id}")
Product selectById(Long id);
}这是一个接口。它没有实现类——全工程搜不到一句 implements ProductMapper。但把它注入进来调用 productMapper.selectById(1L),数据库里那行「机械键盘」就变成了一个 Product 对象回到你手里。接口的方法没有方法体,调用它本该抛异常,凭什么能查数据库?带着这个魔术读完全篇:中间你会搭好环境、跑通 CRUD、弄懂参数怎么传、SQL 注入怎么防,最后原理小节把魔术拆给你看——拆完你会发现,这个「魔术」恰恰是 MyBatis 心智模型(SQL 文本与方法签名绑定)的物理实现。
2. 准备现场:五分钟搭出可跑工程
Spring Boot 3.5.x 工程,pom 加两个依赖(mybatis-spring-boot-starter 选 3.x 线,与 Boot 3 配套):
<dependency>
<groupId>org.mybatis.spring.boot</groupId>
<artifactId>mybatis-spring-boot-starter</artifactId>
<version>3.0.4</version>
</dependency>
<dependency>
<groupId>com.mysql</groupId>
<artifactId>mysql-connector-j</artifactId>
<scope>runtime</scope>
</dependency>建表与数据,MySQL 8:
CREATE DATABASE IF NOT EXISTS shop DEFAULT CHARSET utf8mb4;
USE shop;
CREATE TABLE products (
id BIGINT PRIMARY KEY AUTO_INCREMENT,
product_name VARCHAR(100) NOT NULL,
price DECIMAL(10,2) NOT NULL,
stock INT NOT NULL DEFAULT 0,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
);
INSERT INTO products (product_name, price, stock) VALUES
('机械键盘', 329.00, 50),
('游戏鼠标', 199.00, 120);spring:
datasource:
url: jdbc:mysql://localhost:3306/shop
username: root
password: yourpass
# driver-class-name 可省略:Boot 能从 URL 推断驱动,写上权当注释
mybatis:
mapper-locations: classpath*:mapper/*.xml # XML 载体的扫描路径;本篇用注解,暂用不上
configuration:
map-underscore-to-camel-case: true # 本篇最重要的一个开关,原因见下
log-impl: org.apache.ibatis.logging.stdout.StdOutImpl # 开发期把 SQL 与参数打进控制台map-underscore-to-camel-case 是新手第一个必踩的坑,值得单独讲透。数据库列名的惯例是 snake_case(product_name、created_at),Java 属性的惯例是 camelCase(productName、createdAt),两边天生对不上号。MyBatis 默认按「列名与属性名完全一致」装配,这个开关打开后,它把 product_name 的下划线抹掉再忽略大小写去配对。忘了开的经典现象精确得可以背下来:查回来的对象里,id、price、stock 有值,product_name 和 created_at 全是 null——名字相同的列装上了,名字不同的列丢了,而控制台不报任何错。面试与线上排错里,这是 MyBatis 出场率最高的一题。
实体类,纯 POJO 即可,MyBatis 对它没有任何注解要求:
public class Product {
private Long id;
private String productName; // 对应 product_name
private BigDecimal price;
private Integer stock;
private LocalDateTime createdAt; // 对应 created_at
// getter/setter 略
}3. 注解五连:跑通增删改查
MyBatis 里写 SQL 有两种载体。注解(@Select、@Insert、@Update、@Delete)把 SQL 直接贴在方法上,简单查询一目了然;XML 把 SQL 集中放在 mapper 目录的 XML 文件里,靠 namespace = 接口全限定名 与 id = 方法名 对上号。分野一句话:**简单 SQL 用注解,动态 SQL 与复杂 SQL 用 XML,工程主流在 XML。**理由到 030 篇讲动态 SQL 时自会明白——上百行的条件拼接塞在注解字符串里没法看。本篇先用注解把五连跑通:
@Mapper
public interface ProductMapper {
@Select("SELECT id, product_name, price, stock, created_at FROM products WHERE id = #{id}")
Product selectById(Long id);
@Select("SELECT id, product_name, price, stock, created_at FROM products ORDER BY id")
List<Product> selectAll();
@Insert("INSERT INTO products (product_name, price, stock) " +
"VALUES (#{productName}, #{price}, #{stock})")
@Options(useGeneratedKeys = true, keyProperty = "id") // 主键回填,第 6 节讲
int insert(Product product);
@Update("UPDATE products SET price = #{price}, stock = #{stock} WHERE id = #{id}")
int updateById(Product product);
@Delete("DELETE FROM products WHERE id = #{id}")
int deleteById(Long id);
}@Mapper 让 Spring Boot 为这个接口生成代理并注册成 Bean——一个接口标一个注解;整个包都要扫描时,改用启动类上的 @MapperScan(“com.example.shop.mapper”),二选一即可。返回值 MyBatis 自动定夺:查询返回实体或 List,增删改返回受影响行数。
4. 参数怎么传进去:四条路
方法签名里的参数如何变成 SQL 里的取值,共四条路:
单个参数。selectById(Long id) 配 #{id}——名字随意对上即可,MyBatis 只有一个值可取,#{whatever} 都取到它。但规范起见名字保持一致。
**多参数用 @Param。**两个以上参数时,靠 @Param 给每个参数起名:
@Select("SELECT * FROM products WHERE price <= #{max} AND stock >= #{min}")
List<Product> selectByPriceAndStock(@Param("max") BigDecimal maxPrice,
@Param("min") Integer minStock);POJO 对象。#{productName} 的本质:从入参对象上按 getter 链取值——#{productName} 相当于调用 getProductName()。insert 与 update 五连里已经在用。多属性的条件查询也该封装成查询对象传进来,这是工程正解。
Map。#{key} 直接取 Map 的键。语法上通,但不推荐:调用方只能凭记忆或翻 SQL 才知道该放哪些键,拼错键名不报编译错、只在运行期静默取出 null——无类型、无提示、无约束,三者全占。POJO 能把「这个查询需要哪些参数」写进类型系统,Map 把它藏进字符串。
5. #{} 与 ${}:一条注入实验的分水岭
两个占位符长得像,进入 SQL 的方式天差地别。#{} 是预编译占位符:SQL 骨架先发给数据库预编译,参数值走 PreparedStatement 的参数通道另行传递,值永远不参与 SQL 结构。${} 是字符串拼接:MyBatis 在发 SQL 之前直接把值替换进文本里,值成为了 SQL 的一部分。
做个实验就再也忘不掉。新增一个按商品名查询的方法,先写对:
@Select("SELECT id, product_name, price FROM products WHERE product_name = #{name}")
Product selectByNameSafe(String name);调用 selectByNameSafe("' OR '1'='1")——攻击者经典的万能钥匙。开着 StdOutImpl 看控制台:
==> Preparing: SELECT id, product_name, price FROM products WHERE product_name = ?
==> Parameters: ' OR '1'='1(String)
<== Total: 0整串恶意输入被当作一个普通的字符串值去与 product_name 精确匹配——数据库里没有商品叫这个名字,查不到,防线天然成立。把 #{} 换成 ${} 再跑一遍:
@Select("SELECT id, product_name, price FROM products WHERE product_name = '${name}'")
Product selectByNameBroken(String name);==> Preparing: SELECT id, product_name, price FROM products WHERE product_name = '' OR '1'='1'
<== Total: 2用户输入变成了 SQL 语法,OR 恒真,两张表全泄。结论钉死:#{} 免疫注入,因为值不参与 SQL 结构;${} 是拼接,原样放行。
那 {} 是不是彻底禁用?也不是——它有一个合法辖区:**SQL 的结构位**。表名、列名、ORDER BY 的字段名,这些位置预编译占位符管不了(`ORDER BY ?` 会变成按常量排序,语法合法但排序静默失效——这是排序「不报错却无效」的头号成因)。结构位只能拼接,拼接就必须白名单:用 <choose> 枚举合法值,其他一律落到默认分支(写法见 030 篇)。纪律一句话:**值的位置永远 #{},结构位用 {} 且必须白名单。**
6. 主键回填:insert 之后拿回自增 id
下单成功后要拿订单 id 发通知、写流水——但 id 是数据库 AUTO_INCREMENT 发的,Java 对象里本来没有。主键回填就是把数据库发的新 id 写回你的入参对象:
Product p = new Product();
p.setProductName("USB 集线器");
p.setPrice(new BigDecimal("59.90"));
p.setStock(200);
productMapper.insert(p);
System.out.println(p.getId()); // 3:insert 执行后,id 已被写回原对象注解写法就是第 3 节那行 @Options(useGeneratedKeys = true, keyProperty = "id"):useGeneratedKeys 要求 JDBC 取回数据库生成的主键,keyProperty 指定写回到入参对象的哪个属性。XML 里则写在标签属性上(useGeneratedKeys="true" keyProperty="id"),语义完全一致。注意它写回的是你传进去的那个对象,返回值 int 仍只是受影响行数——两者别混。
7. 揭秘开篇魔术:三步执行链
现在拆魔术。MyBatis 在启动时(@MapperScan 扫描或 @Mapper 注册)为每个 Mapper 接口生成一个 JDK 动态代理并注册为 Bean——你注入的从来不是接口本身,而是代理对象;接口方法被调用时,代理的 InvocationHandler(MyBatis 里叫 MapperProxy)按「接口全限定名 + 方法名」去语句登记表里查找对应的 SQL 语句(内部叫 MappedStatement),找到后交给执行体系跑完 JDBC 流程:
flowchart LR
A["mapper.selectById(1)"] --> B["MapperProxy 动态代理拦截调用"]
B --> C["按 接口名.方法名 定位 MappedStatement"]
C --> D["SqlSession 与 Executor 执行 JDBC"]
D --> E["ResultSetHandler 装配对象返回"]三步记住:代理拦截、按名寻句、执行装配。第 1 节的矛盾就此消解——方法确实没有实现类,但代理替它「实现」了:实现的内容就是那份绑定关系。这条链上还有两处深水区本篇只点到为止:Executor 外面套着的缓存装饰(050 篇的正题),以及拦截器插桩的链路(060 篇插件原理)。MyBatis-Plus 的「凭空多出来的单表方法」也是同一套代理机制玩出的花,070 篇见。
8. 排错实录:三个必踩的坑
**坑一:BindingException——Invalid bound statement (not found)。**调用方法时直接炸这个异常,含义是「按接口名.方法名找不到 SQL 语句」。三个病因按频率排查:XML 里的 id 与方法名拼错或对不上;XML 的 namespace 没写成接口全限定名;mapper-locations 没扫到 XML 文件(路径写错或忘了配)。注解 SQL 不受后两条影响——语句在启动时就随接口登记了。
**坑二:字段批量变 null。**第 2 节讲过的精确症状——同名列有值、蛇形列全 null,八成是 map-underscore-to-camel-case 忘了开。顺带一个变体:resultType 写错成 Map 或别的类,拿到的是「列名为键」的散装数据而非对象,症状相似,病根不同。
坑三:启动就报找不到 Bean。Field productMapper in XxxService required a bean of type 'ProductMapper' that could not be found.——接口既没标 @Mapper,启动类也没配 @MapperScan,代理根本没被创建。两个都配也不要紧,但配漏一个必炸。
动手实践
任务一:五连迁移。 参照第 3 节,为一张新表 members(字段:id、nickname、level、created_at)用注解写出完整五连:selectById、selectAll、insert(带主键回填)、updateById、deleteById,并保证查询结果的字段全部装上实体。提示:写完先自查 map-underscore-to-camel-case 是否开着,再跑一次看控制台 SQL 与实体各字段。
任务二:注入对照实验。 把 selectById 抄一份改成 ${id} 版本(参数类型临时改成 String),传入 1 OR 1 = 1 调用,观察控制台 SQL 与返回结果;再用 #{} 版本传同样的值对比。提示:对比点是「SQL 文本被改写」与「整串成了一个参数值」;实验完删掉 ${} 版本,这个写法不进工程。
任务三:主键回填验证。 调用 insert 后打印对象的 id 属性与方法的 int 返回值,确认「返回值是行数、回填在对象上」。提示:@Options 的两个属性缺一个都会让 id 保持 null。
先自己写,再对照参考实现:
任务一参考实现
@Mapper
public interface MemberMapper {
@Select("SELECT id, nickname, level, created_at FROM members WHERE id = #{id}")
Member selectById(Long id);
@Select("SELECT id, nickname, level, created_at FROM members ORDER BY id")
List<Member> selectAll();
@Insert("INSERT INTO members (nickname, level) VALUES (#{nickname}, #{level})")
@Options(useGeneratedKeys = true, keyProperty = "id")
int insert(Member member);
@Update("UPDATE members SET nickname = #{nickname}, level = #{level} WHERE id = #{id}")
int updateById(Member member);
@Delete("DELETE FROM members WHERE id = #{id}")
int deleteById(Long id);
}public class Member {
private Long id;
private String nickname; // nickname 列:同名直接配对
private Integer level;
private LocalDateTime createdAt; // created_at 列:靠驼峰开关装上
// getter/setter 略
}验证点:查回的 Member 打印出来,nickname 与 createdAt 都有值;把 map-underscore-to-camel-case 临时关掉再跑,createdAt 变 null——亲手复现一次「忘开的症状」,这个坑就长在身上了。
任务二参考实现(实验现象记录)
// 危险版本,仅实验用
@Select("SELECT id, product_name, price FROM products WHERE id = ${id}")
Product selectByIdUnsafe(String id);
// 调用:selectByIdUnsafe("1 OR 1 = 1")
// 控制台 SQL:SELECT ... WHERE id = 1 OR 1 = 1 ← 拼接后语义被改写
// 返回:id 最小的那行(条件恒真,全表通过)
// 安全版本:selectByIdSafe("1 OR 1 = 1")
// 控制台 SQL:SELECT ... WHERE id = ? 参数:1 OR 1 = 1(String)
// 返回:null —— 整串被当成一个字符串值去比对 id,匹配不到任何行对照结论:${} 是文本替换(拼进 SQL 文本),#{} 是预编译占位(值进参数位)。注入的本质是「值被当成了 SQL 语法」,所以防注入的答案永远是 #{};只能用 ${} 的位置(表名、列名、ORDER BY 字段)靠白名单纪律兜底,见 030 篇的 choose 用法。
官方文档
- MyBatis 中文文档(Java API 与配置章节):https://mybatis.org/mybatis-3/zh/index.html
- mybatis-spring-boot-starter(版本配套关系见 README 表格):https://github.com/mybatis/spring-boot-starter
自检
- 遮住第 7 节的图,用三步说清「接口没有实现类,方法凭什么能调用」;MapperProxy 在里面扮演什么角色?
- map-underscore-to-camel-case 解决什么问题?忘开的症状精确到哪些列?
- #{} 与 {},用什么纪律兜底?ORDER BY 用 #{} 会发生什么?
- 多参数传递该用什么?Map 为什么不推荐,说出三条理由。
- 主键回填的两个属性各管什么?回填写到了哪个对象上?insert 方法的返回值是什么?
- 注解与 XML 的分野一句话;工程主流为什么在 XML?
- BindingException 的三个病因分别是什么?注解 SQL 为什么不受其中两条影响?