主题
界面
主题
《JDBC 与数据库连接》 用纯 JDBC 写了一遍数据访问,留下了三处痛点:
| 痛点 | 《JDBC 与数据库连接》 的样子 |
|---|---|
| 手写结果映射 | 每查一列就 resultSet.getString("name") 一次 |
| SQL 与代码混在一起 | 长 SQL 拼在 Java 字符串里,编译期查不出错 |
| 动态条件靠拼串 | 条件一变就要 StringBuilder,还容易拼出注入漏洞 |
MyBatis 解决这三件事:SQL 写到 XML 里、结果自动映射、动态条件用标签。
但要先记住一句话:MyBatis 没有消除注入风险,它只是让"安全的写法"变得更容易——${} 就是 MyBatis 版的"拼串" ✓ 这是本节的第二个重点。
完成本节后,你应该能够:
SqlSessionFactory / SqlSession / Mapper 接口三者的关系;#{} 与 ${} 的区别,并解释为什么 ${} 有注入风险;<if> / <where> / <foreach> 写动态 SQL;useGeneratedKeys 拿回插入后的自增主键;@Mapper / 配置项);LIKE、IN、动态条件的 SQL 吗?(《表设计与约束》)先说清楚 MyBatis 在"ORM 谱系"里的位置:
| 全自动 ORM(Hibernate / JPA) | 半自动 ORM(MyBatis) | 纯 JDBC | |
|---|---|---|---|
| 谁来写 SQL | 框架生成 | 你写 | 你写 |
| 对象与表的关系 | 严格映射(实体即表) | 宽松(结果集映射到任意对象) | 无 |
| 复杂查询 | 容易失控(生成的 SQL 难优化) | 完全可控 | 完全可控 |
| 手写工作量 | 单表最省 | 中等 | 最大 |
| 性能调优 | 要看框架脸色 | 直接改 SQL | 直接改 SQL |
MyBatis 的核心主张:
SQL 是资产,不该被框架藏起来。
为什么国内企业大量用 MyBatis:
| 理由 | 说明 |
|---|---|
| SQL 可控 | 复杂报表、慢查询优化时,能直接改 SQL |
| 门槛低 | 会 SQL 就能上手,不需要学 JPA 的实体状态管理 |
| 贴合遗留系统 | 老库表结构不规范时,JPA 的严格映射反而碍事 |
| DBA 友好 | SQL 能被人审查(在 XML 里,而不是运行时生成) |
代价:SQL 要自己写、自己维护(Hibernate 帮你生成的单表 CRUD,在 MyBatis 里得逐个写)——这正是 《MyBatis-Plus》 要补的。
SqlSessionFactory ← 一个应用一个(单例)
│ 由它创建
▼
SqlSession ← 一次"会话"(对应一个 JDBC Connection)
│ 通过它拿
▼
Mapper 接口(你的接口) ← 只需要写方法签名| 对象 | 职责 | 生命周期 |
|---|---|---|
SqlSessionFactory | 读取配置、管理映射、生产会话 | 应用级单例 |
SqlSession | 一次数据库会话(含事务) | 方法级(用完关) |
| Mapper 接口 | 你定义的方法 → 一条 SQL | 应用级(由框架代理生成) |
关键机制:Mapper 接口没有实现类。
public interface ProductMapper {
List<Product> findAll(); // 只有签名
}MyBatis 在运行时用 JDK 动态代理为它生成实现,把方法调用翻译成:
findAll() → 查 namespace="cn.kb.learn.data.mapper.ProductMapper" + id="findAll" 的 SQL
→ 执行、映射结果、返回"方法名怎么找到 SQL 的":靠 namespace + id 这对坐标 ✓
<mapper namespace="cn.kb.learn.data.mapper.ProductMapper"> <!-- 接口的全限定名 -->
<select id="findAll" resultType="..."> <!-- 方法名 -->
SELECT ...
</select>
</mapper>所以 namespace 写错、方法名对不上,启动时就会报"找不到语句" ✓(这是最容易踩的坑之一)
<!-- ① 一个 starter -->
<dependency>
<groupId>com.baomidou</groupId>
<artifactId>mybatis-plus-spring-boot3-starter</artifactId>
</dependency>// ② 接口上标 @Mapper(或在启动类上统一 @MapperScan)
@Mapper
public interface ProductMapper { ... }# ③ 配置 SQL 到哪里找、驼峰要不要自动映射
mybatis-plus:
mapper-locations: classpath*:mapper/*.xml
configuration:
map-underscore-to-camel-case: truestarter 替我们做了什么(《Spring Boot 自动配置与项目结构》 的自动配置):
| Bean | 作用 |
|---|---|
SqlSessionFactory | 从数据源创建 |
SqlSessionTemplate | 线程安全的会话(你不需要手动开关 SqlSession) |
| Mapper 的代理实现 | 扫描 @Mapper 接口并注册进容器 |
| 事务管理器 | 与 Spring 的 @Transactional 对接(《Spring:AOP 与事务》) |
注意最后一行:Spring 集成之后,SqlSession 的生命周期由 Spring 管——你不再手写 openSession() / close(),业务代码里只看到 Mapper 接口 ✓
两种注册 Mapper 的方式:
| 方式 | 写法 | 适用 |
|---|---|---|
逐个标 @Mapper | 接口上加注解 | 接口少、想一眼看出哪些是 Mapper |
统一 @MapperScan | 启动类上 @MapperScan("cn.kb.learn.data.mapper") | 接口多时更省事(推荐) |
@SpringBootApplication
@MapperScan("cn.kb.learn.data.mapper")
public class DataAccessApplication { ... }#{} 与 ${}(本节重点) 两者的区别,一句话:
| 写法 | 底层 | 作用 |
|---|---|---|
#{} | PreparedStatement 的 ? | 参数作为"值"传入 ✓ |
${} | 字符串替换(SQL 文本拼接) | 参数作为"SQL 片段"拼进去 ✗ |
看到这张表就该反应过来:${} 就是 《JDBC 与数据库连接》 讲的"拼串" ✓
同一句 SQL 的两种写法:
<!-- 安全 -->
<select id="searchByNameSafe" resultType="cn.kb.learn.data.entity.Product">
SELECT id, name, price_in_cents, stock, deleted FROM product
WHERE deleted = 0 AND name = #{name}
ORDER BY id
</select>
<!-- 危险 -->
<select id="searchByNameUnsafe" resultType="cn.kb.learn.data.entity.Product">
SELECT id, name, price_in_cents, stock, deleted FROM product
WHERE deleted = 0 AND name = '${name}'
ORDER BY id
</select>实测结果(本节代码,攻击输入 ' OR '1'='1):
用户输入:' OR '1'='1
[危险] ${} 拼串
==> Preparing: SELECT ... WHERE deleted = 0 AND name = '' OR '1'='1' ORDER BY id
<== Total: 5
→ 返回 5 行 ← 全表被拖出来
[安全] #{} 参数化
==> Preparing: SELECT ... WHERE deleted = 0 AND name = ? ORDER BY id
→ 返回 0 行注意"SQL 日志"里能看到真相:
| 写法 | 日志里看到的 SQL |
|---|---|
${} | ... AND name = '' OR '1'='1' ORDER BY id ← 参数已经变成 SQL 的一部分 ✗ |
#{} | ... AND name = ? ORDER BY id ← 还是问号 ✓ |
这是判断"有没有注入风险"最快的方法:看日志里 SQL 是"带值的"还是"带问号的" ✓
${} 的唯一正当用途:动态的表名、列名、排序字段(这些无法用 ? 表示,《JDBC 与数据库连接》 的 5.2 节解释过)✓
<select id="findAllOrderBy" resultType="cn.kb.learn.data.entity.Product">
SELECT id, name, price_in_cents, stock, deleted FROM product
WHERE deleted = 0 ORDER BY ${orderBy}
</select>用它必须配白名单:
private static final Set<String> ALLOWED_ORDER_BY = Set.of("id", "price_in_cents", "stock");
private List<Product> safeOrderBy(String orderBy) {
String column = orderBy.trim().split("\\s+")[0]; // 只取列名部分,忽略 DESC
if (!ALLOWED_ORDER_BY.contains(column)) {
throw new IllegalArgumentException("不允许的排序字段:" + orderBy);
}
return productMapper.findAllOrderBy(orderBy);
}实测结果:
按价格降序:[人体工学椅, 显示器支架, 机械键盘, 降噪耳机]
非法排序字段被拦截:不允许的排序字段:id; DROP TABLE product
表还在,当前总数 = 4注意白名单的两个细节:
| 细节 | 为什么 |
|---|---|
先 split("\\s+")[0] 取列名 | 允许 "price_in_cents DESC" 这种"列名 + 方向"的合法输入 |
| 白名单是"集合"不是"正则" | 正则容易被绕过(大小写、注释、编码),集合是精确匹配 ✓ |
三条铁律:
#{} 就用 #{}(99% 的场景);${} 只能用于"结构"(表名、列名、排序),不能用于"值";${} 必须过白名单 ✓两件自动的事:
mybatis-plus:
configuration:
map-underscore-to-camel-case: true # price_in_cents → priceInCents| 映射 | 说明 |
|---|---|
| 列名 → 属性名 | 同名直接映射(name → name) |
| 下划线 → 驼峰 | price_in_cents → priceInCents(需要显式开启)✗ 不开就映射不上 |
⚠️ 这是最容易"莫名其妙查出 null"的原因:驼峰映射没开,price_in_cents 找不到 priceInCents 属性,静默地留成 null(不报错)✗
复杂的映射用 resultMap(列名与属性名对不上、需要嵌套对象时):
<resultMap id="productWithCategory" type="cn.kb.learn.data.entity.Product">
<id column="id" property="id"/>
<result column="product_name" property="name"/> <!-- 显式指定对应关系 -->
<association property="category" javaType="...Category"> <!-- 一对一嵌套 -->
<id column="category_id" property="id"/>
<result column="category_name" property="name"/>
</association>
<collection property="tags" ofType="...Tag"> <!-- 一对多嵌套 -->
<id column="tag_id" property="id"/>
</collection>
</resultMap>什么时候需要 resultMap:
| 场景 | 是否需要 |
|---|---|
| 列名与属性名规则对应(含驼峰) | ❌ 不需要(resultType 够用) |
| 列名要映射到不同名字的属性 | ✅ 需要 |
| 需要嵌套对象/集合(联表查询) | ✅ 需要(或写两次查询手动组装) |
没有动态 SQL 时(《JDBC 与数据库连接》 的样子):三个可选条件 → 你手写 2³ = 8 种拼法 ✗
有了动态 SQL:
<select id="search" resultType="cn.kb.learn.data.entity.Product">
SELECT id, name, price_in_cents, stock, deleted FROM product
<where>
deleted = 0
<if test="keyword != null and keyword != ''">
AND name LIKE CONCAT('%', #{keyword}, '%')
</if>
<if test="minPrice != null">
AND price_in_cents >= #{minPrice}
</if>
<if test="maxPrice != null">
AND price_in_cents <= #{maxPrice}
</if>
</where>
ORDER BY id
</select><where> 的两个作用(都省事):
| 作用 | 说明 |
|---|---|
有条件时才加 WHERE 关键字 | 不用自己判断"是不是第一个条件" |
自动去掉开头多余的 AND | 上面每个 <if> 都以 AND 开头,但第一个生效的 AND 会被去掉 ✓ |
实测:四种条件组合,SQL 确实跟着变:
① 无任何条件
==> Preparing: SELECT ... WHERE deleted = 0 ORDER BY id
→ 4 条
② 只按关键词「架」
==> Preparing: SELECT ... WHERE deleted = 0 AND name LIKE CONCAT('%', ?, '%') ORDER BY id
→ 1 条
③ 只按价格 ≥ 25000
==> Preparing: SELECT ... WHERE deleted = 0 AND price_in_cents >= ? ORDER BY id
→ 2 条
④ 关键词 + 价格上限
==> Preparing: SELECT ... WHERE deleted = 0 AND name LIKE CONCAT('%', ?, '%') AND price_in_cents <= ? ORDER BY id
→ 1 条这是动态 SQL 最好的证明:一条 XML 配置,四种完全不同的 SQL ✓ 而且参数始终是 ? ✓
<foreach>:IN 查询(《JDBC 与数据库连接》 里要手数问号个数的那件事):
<select id="findByIds" resultType="cn.kb.learn.data.entity.Product">
SELECT id, name, price_in_cents, stock, deleted FROM product
WHERE deleted = 0 AND id IN
<foreach collection="ids" item="item" open="(" separator="," close=")">
#{item}
</foreach>
ORDER BY id
</select>实测:
==> Preparing: SELECT ... WHERE deleted = 0 AND id IN ( ? , ? , ? ) ORDER BY id
findByIds([1,3,4]) = [机械键盘, 降噪耳机, 显示器支架]<foreach> 干的正是 《JDBC 与数据库连接》 要你手写的事:按集合大小拼出对应个数的 ? ✓
三个常用标签的对照:
| 标签 | 用途 | 等价于手写 JDBC 的 |
|---|---|---|
<if> | 单个条件可选 | if (x != null) sql.append(...) |
<where> | 自动加 WHERE、去多余 AND | 手工判断"是不是第一个条件" |
<foreach> | 集合展开 | 手数问号个数 ✗(《JDBC 与数据库连接》 的坑) |
<choose>/<when>/<otherwise> | 多选一(像 switch) | if-else 链 |
<set> | UPDATE 的 SET 子句自动化 | 手工拼 SET a=?, b=? 并处理末尾逗号 |
<set> 值得单独提一句:做"只更新非空字段"时,它自动处理"末尾多余的逗号" ✓
<update id="updateSelective">
UPDATE product
<set>
<if test="name != null">name = #{name},</if>
<if test="priceInCents != null">price_in_cents = #{priceInCents},</if>
</set>
WHERE id = #{id}
</update><insert id="insert" useGeneratedKeys="true" keyProperty="id">
INSERT INTO product (name, price_in_cents, stock, deleted)
VALUES (#{name}, #{priceInCents}, #{stock}, 0)
</insert>Product created = new Product("4K 显示器", 129_900, 3);
productMapper.insert(created);
System.out.println("回填的主键 id = " + created.getId()); // ← 插入前是 null,插入后有值实测:
==> Preparing: INSERT INTO product (name, price_in_cents, stock, deleted) VALUES (?, ?, ?, 0)
影响行数 = 1,回填的主键 id = 5为什么需要它:插入之后往往要立刻用这个 id(写关联表、返回给前端)✓
用 JDBC 自己实现要再查一次(SELECT LAST_INSERT_ID() 或 getGeneratedKeys())✗ useGeneratedKeys 帮你省掉这一步 ✓
这是学 MyBatis 最重要的调试手段:
mybatis-plus:
configuration:
log-impl: org.apache.ibatis.logging.stdout.StdOutImpl输出:
==> Preparing: SELECT id, name, price_in_cents, stock, deleted FROM product WHERE deleted = 0 ORDER BY id
==> Parameters:
<== Total: 4三行分别告诉你:
| 行 | 含义 | 排查什么 |
|---|---|---|
Preparing | 最终发出的 SQL | 动态 SQL 拼对没有、有没有多/少条件 |
Parameters | 传进去的参数 | 参数顺序对不对、值有没有 |
Total | 返回/影响的行数 | "为什么没查到"(是 0 行还是异常) |
这个日志能直接回答的问题:
| 现象 | 看日志的哪一行 |
|---|---|
| 查不到数据 | Total: 0?还是 Parameters 里的值不对? |
| 条件没生效 | Preparing 里有没有那个条件 |
| 注入漏洞 | Preparing 里 SQL 是带值的还是 ? ★ |
| 更新影响 0 行 | Total: 0(条件没匹配上) |
生产环境不要开 stdout ✗(它会打印所有 SQL,日志量很大)——改用日志框架的 debug 级别(《日志框架与日志规范》):
logging:
level:
cn.kb.learn.data.mapper: debug@Mapper
public interface ProductMapper {
List<Product> findAll();
}<mapper namespace="cn.kb.learn.data.mapper.ProductMapper">
<select id="findAll" resultType="cn.kb.learn.data.entity.Product">
SELECT id, name, price_in_cents, stock FROM product
</select>
</mapper>见第 7 节。
${} 处理用户输入的值 <!-- ✗ 只要是"值",就不该用 ${} -->
<select id="findByName">
SELECT * FROM product WHERE name = '${name}'
</select>症状:与 《JDBC 与数据库连接》 的拼串完全一样——注入成功,全表被拖出 ✗
修复:#{name} ✓
自查方法:全局搜 XML 里的 ${,逐个确认"这个参数是不是"结构"(表名/列名/排序),且过了白名单"** ✓
文件清单(labs/data-access/):
src/main/java/cn/kb/learn/data/
├── DataAccessApplication.java ← Spring Boot 启动类
├── MyBatisLessonRunner.java ← 本节演示
├── entity/Product.java
└── mapper/ProductMapper.java ← 接口(只有签名)
src/main/resources/
├── application.yml ← mapper-locations、驼峰映射、SQL 日志
├── schema.sql ← 建表 + 初始数据
└── mapper/ProductMapper.xml ← SQL 都在这里Mapper 接口:
@Mapper
public interface ProductMapper {
List<Product> findAll();
Product findById(@Param("id") Long id);
List<Product> search(@Param("keyword") String keyword,
@Param("minPrice") Integer minPrice,
@Param("maxPrice") Integer maxPrice);
List<Product> findByIds(@Param("ids") List<Long> ids);
int insert(Product product);
int updatePrice(@Param("id") Long id, @Param("priceInCents") Integer priceInCents);
int deleteById(@Param("id") Long id);
long count();
// ⚠️ 危险示例:用 ${} 拼串,存在注入风险
List<Product> searchByNameUnsafe(@Param("name") String name);
// ✅ 安全:用 #{} 参数化
List<Product> searchByNameSafe(@Param("name") String name);
// ✅ ${} 的正确用途:动态排序字段(调用方必须做白名单校验)
List<Product> findAllOrderBy(@Param("orderBy") String orderBy);
}运行命令:
cd labs
./mvnw -pl data-access package -DskipTests
java "-Dstdout.encoding=UTF-8" -jar data-access/target/data-access-1.0.0.jar --lesson=mybatis预期输出(节选真实结果):
===== S3-008 MyBatis:接口 + XML,不再手写 ResultSet =====
--- 1. 全表查询 ---
==> Preparing: SELECT id, name, price_in_cents, stock, deleted FROM product WHERE deleted = 0 ORDER BY id
<== Total: 4
Product{id=1, name='机械键盘', priceInCents=19900, stock=12}
Product{id=2, name='人体工学椅', priceInCents=89900, stock=5}
Product{id=3, name='降噪耳机', priceInCents=15900, stock=30}
Product{id=4, name='显示器支架', priceInCents=25900, stock=8}
--- 2. 按主键查询 ---
==> Preparing: SELECT id, name, price_in_cents, stock, deleted FROM product WHERE id = ? AND deleted = 0
findById(2) = Product{id=2, name='人体工学椅', priceInCents=89900, stock=5}
--- 3. 动态 SQL:四种条件组合,SQL 跟着变 ---
==> Preparing: SELECT id, name, price_in_cents, stock, deleted FROM product WHERE deleted = 0 ORDER BY id
① 无任何条件 → 4 条
==> Preparing: SELECT id, name, price_in_cents, stock, deleted FROM product WHERE deleted = 0 AND name LIKE CONCAT('%', ?, '%') ORDER BY id
② 只按关键词「架」 → 1 条
==> Preparing: SELECT id, name, price_in_cents, stock, deleted FROM product WHERE deleted = 0 AND price_in_cents >= ? ORDER BY id
③ 只按价格 ≥ 25000 → 2 条
==> Preparing: SELECT id, name, price_in_cents, stock, deleted FROM product WHERE deleted = 0 AND name LIKE CONCAT('%', ?, '%') AND price_in_cents <= ? ORDER BY id
④ 关键词 + 价格上限 → 1 条
--- 4. foreach:IN 查询 ---
==> Preparing: SELECT id, name, price_in_cents, stock, deleted FROM product WHERE deleted = 0 AND id IN ( ? , ? , ? ) ORDER BY id
<== Total: 3
findByIds([1,3,4]) = [机械键盘, 降噪耳机, 显示器支架]
--- 5. 插入并回填自增主键 ---
==> Preparing: INSERT INTO product (name, price_in_cents, stock, deleted) VALUES (?, ?, ?, 0)
影响行数 = 1,回填的主键 id = 5
当前总数 = 5
--- 6. 更新与逻辑删除 ---
==> Preparing: UPDATE product SET price_in_cents = ? WHERE id = ? AND deleted = 0
改价影响行数 = 1
改价后 = Product{id=5, name='4K 显示器', priceInCents=119900, stock=3}
==> Preparing: UPDATE product SET deleted = 1 WHERE id = ?
逻辑删除影响行数 = 1
删除后再查 = null(查不到,但数据还在表里)
当前总数 = 4
--- 7. 注入对比:#{} 与 ${}(与 S3-017 的 JDBC 呼应)---
用户输入:' OR '1'='1
==> Preparing: SELECT id, name, price_in_cents, stock, deleted FROM product WHERE deleted = 0 AND name = '' OR '1'='1' ORDER BY id
<== Total: 5
[危险] ${} 拼串 → 返回 5 行 ← 全表被拖出来
==> Preparing: SELECT id, name, price_in_cents, stock, deleted FROM product WHERE deleted = 0 AND name = ? ORDER BY id
[安全] #{} 参数化 → 返回 0 行
--- 8. ${} 的正当用途:动态排序字段(配白名单)---
按价格降序:[人体工学椅, 显示器支架, 机械键盘, 降噪耳机]
非法排序字段被拦截:不允许的排序字段:id; DROP TABLE product
表还在,当前总数 = 4三处最值得盯住的输出:
| 输出 | 说明了什么 |
|---|---|
第 3 节的四条 Preparing | 动态 SQL 真的在"按条件生成 SQL"(不是全查回来再过滤) |
第 7 节的 ${} vs #{} | ${} 的 SQL 里带值(危险),#{} 的 SQL 里是 ?(安全) |
第 6 节的 UPDATE product SET deleted = 1 | 逻辑删除:"删"其实是"改标记" |
顺带一个设计说明:本节的 deleteById 和 findById 都带 WHERE deleted = 0——逻辑删除要在每个查询里记得加条件,这很啰嗦;《MyBatis-Plus 通用 CRUD 与条件构造器》 的 MyBatis-Plus 用 @TableLogic 自动加上这个条件 ✓
SqlSessionFactory 的创建处断点(或看启动日志):观察它读了多少个 XML、注册了多少条语句;MapperProxy(MyBatis 生成的动态代理)✓#{id} 的绑定:在 DefaultParameterHandler.setParameters 处断点,看它如何把参数设进 PreparedStatement——和 《JDBC 与数据库连接》 手写的 statement.setLong(1, id) 是同一件事;map-underscore-to-camel-case 改成 false,重启——观察 priceInCents 变成 null(而不报错)✗ 这是"静默失败"的现场;id 改错(如 findAll2):观察启动报错 Invalid bound statement (not found)——理解 namespace + id 这对坐标。${} 用在"值"上 见 5.3 与 6.3 节。这是本节最高危的错误。
org.apache.ibatis.binding.BindingException: Invalid bound statement (not found): cn.kb.learn.data.mapper.ProductMapper.findAll排查顺序:
| 检查项 | 常见原因 |
|---|---|
XML 位置是否在 mapper-locations 覆盖范围内 | 默认只扫 classpath 根目录,resources/mapper/ 下的要配 |
| namespace 是否等于接口全限定名 | 多一个空格都不行 |
id 是否等于方法名 | 大小写敏感 |
| XML 有没有被打进 jar | 检查 target/classes/mapper/ 下有没有 |
Maven 的一个坑:src/main/java 下的 XML 默认不会被打包 ✗ 如果 XML 与接口放一起,要在 pom 里配 resources 包含 **/*.xml ✓(本节把 XML 放在 src/main/resources 下,避开了这个问题)
见 5.4 节。症状是"不报错但值是 null"——最难查的一类问题 ✗
排查:先看 SQL 日志的 Preparing(列有没有查出来),再看映射配置 ✓
@Param Product findByCodeAndStatus(String code, String status); // ✗ XML 里无法引用症状:Parameter 'code' not found. Available parameters are [arg0, arg1, param1, param2]
原因:Java 默认不保留方法参数名(《Spring MVC 请求映射与参数绑定》 讲过的 -parameters 是同一件事)✗
两种修复:
// 方式一:加 @Param(推荐,显式、不依赖编译配置)
Product findByCodeAndStatus(@Param("code") String code, @Param("status") String status);<!-- 方式二:用 arg0/arg1(不推荐,可读性差) -->
WHERE code = #{arg0} AND status = #{arg1}顺带:本模块的 pom 已经开了 -parameters(《Spring MVC 请求映射与参数绑定》 的 5.4 节),所以不加 @Param 有时也能跑——但显式写 @Param 更稳妥,因为别人可能复制代码到没开编译标志的模块 ✓
<if test> 里用 == 比较字符串 <if test="status == 'PAID'"> <!-- ✗ 单引号里的单字符会被当 char(OGNL 的坑) -->修复:
<if test="status == 'PAID'"> <!-- 通常没问题 -->
<if test='"PAID".equals(status)'> <!-- 或更明确地这样写 -->最稳的写法:用 Java 侧先判空/格式化,别在 XML 里做复杂逻辑 ✓
<select id="findAll">
SELECT * FROM product <!-- ✗ 会把已逻辑删除的数据也查出来 -->
</select>修复:该加的 deleted = 0 一个都不能少;或者换 MyBatis-Plus 的 @TableLogic(《MyBatis-Plus 通用 CRUD 与条件构造器》)✓
PaginationInnerInterceptor 这是本项目实际踩到的坑:
[ERROR] 找不到符号
符号: 类 PaginationInnerInterceptor
位置: 包 com.baomidou.mybatisplus.extension.plugins.inner原因:3.5.9 起,分页插件被拆到了独立模块 ✗
修复:加依赖 ✓
<dependency>
<groupId>com.baomidou</groupId>
<artifactId>mybatis-plus-jsqlparser</artifactId>
<version>3.5.9</version>
</dependency>教训:版本升级时,先看 Release Notes——"依赖被拆分/改名"是常见变更 ✓
目标:把注入、动态 SQL、映射三件事亲手验一遍。
步骤:
searchByNameSafe 的 XML 从 #{name} 改成 '${name}',重启并运行——观察 Total 从 0 变成 5,并在日志里找到"带值的 SQL";ALLOWED_ORDER_BY 里的 price_in_cents 去掉,重新运行——观察"按价格降序"那步被拦截;map-underscore-to-camel-case 改成 false,重启——观察 priceInCents 变成 null(而程序不报错);search 加一个 <if test="inStock != null">AND stock > 0</if>,在接口与调用处各加一个参数,验证五种条件组合;<where> 的作用:把 <where> 换成手写 WHERE 1=1,对比两次的 SQL 日志(理解"自动去多余 AND"省了什么)。验收方式:能说出第 2 步日志里两种 SQL 的差别、第 4 步"不报错但值是 null"的原因。
扩展任务:给 ProductMapper 加一个"批量插入"方法 int insertBatch(@Param("list") List<Product> list),用 <foreach> 实现一条 INSERT ... VALUES (...), (...), (...)——对比 《JDBC 与数据库连接》 的 addBatch,说明两者的差别(一条 SQL 多组值 vs 多条 SQL 复用同一预编译语句)。
基础题
#{} 会被替换成 SQL 里的字面值,${} 会变成 ?。(对 / 错)<where> 会自动去掉开头的 ______。?"就说明没有注入风险?代码阅读题
<select id="searchUsers" resultType="User">
SELECT * FROM users
<where>
<if test="name != null">AND name LIKE CONCAT('%', #{name}, '%')</if>
<if test="minAge != null">AND age >= #{minAge}</if>
<if test="orderBy != null">AND 1=1 ORDER BY ${orderBy}</if>
</where>
</select>orderBy 传 "id",最终 SQL 里的 AND 1=1 起什么作用?编程题
article(id, title, content, author_id, status, view_count, created_at, deleted);findById、insert(回填主键)、updateViewCount(自增阅读数)、logicDelete;search(String keyword, String status, Long authorId, String orderBy)——四个可选条件 + 动态排序;findByIds(List<Long> ids)——IN 查询;#{},只有排序字段用 ${} 并配白名单;综合任务
${} 写查询,出过一次注入事故;${} 的地方,输出清单(提示:用 Files.walk 递归找 .xml,正则匹配 \$\{);${}""(提示:表名/列名/排序确实需要它——好的规范是"约束使用姿势",不是"禁止")。提示 1(方向):第 1 题的方向反过来了——#{} 才是变 ? 的那个。
提示 2(关键点):第 4 题的关键是 AND 1=1 是个"占位技巧",很可能在某个组合下出问题。
提示 3(接近答案):第 7 题的自动化检查用 JUnit 测试写最顺手——它能在 CI 里拦住"新增了 ${}"。
| 写法 | 编译后 |
|---|---|
#{} | ?(交给 PreparedStatement 作为参数) ✓ |
${} | 直接文本替换(变成 SQL 的一部分) ✗ |
记忆方法:# 长得像"井号占位符",$ 是"美元符号——真金白银的值直接放进文本里"(后者更危险)✓
验证方式:看 SQL 日志——#{} 的 SQL 里是 ?,${} 的 SQL 里是具体的值 ✓
namespace(接口全限定名);id(方法名);多余的 AND。补充:<where> 还会在"所有条件都不成立"时不生成 WHERE 关键字 ✓(否则会拼出 WHERE ORDER BY id 这种非法 SQL)
? 意味着"SQL 结构已经固定,参数是另外传进去的"。| 日志里看到 | 说明 | 风险 |
|---|---|---|
WHERE name = ? | 结构固定,name 的值由参数绑定传入 | ✅ 安全 |
WHERE name = 'x' OR '1'='1' | 参数已经融进 SQL 文本,参与了语法解析 | ❌ 危险 |
ORDER BY price_in_cents DESC | 结构被替换——这里是合法的(过了白名单) | ⚠️ 要看白名单 |
注意第三行:${} 用在"结构"上是正常的(表名、列名、排序),日志里必然带值 ✓ 所以不能简单地"看到带值就认为有问题"——要结合"这个位置本来该不该是结构"来判断 ✓
一个更实用的自查规则:
"这个参数的内容是用户能控制的吗?它出现在 SQL 的"值位置"还是"结构位置"?"
用户可控 + 值位置 → 必须
#{}✓ 用户可控 + 结构位置 →${}+ 白名单 ✓
第 4 题:有问题,两处。
问题一:AND 1=1 ORDER BY ${orderBy} 是一个"技巧",很脆弱 ✗
<if test="orderBy != null">AND 1=1 ORDER BY ${orderBy}</if>| 隐患 | 说明 |
|---|---|
| 它依赖"前面必须有条件" | 如果 name 与 minAge 都是 null,<where> 会去掉开头的 AND,变成 WHERE 1=1 ORDER BY ...——这个组合下能跑 ✓ 但如果 <where> 的行为跟你想的不一样,就会生成非法 SQL ✗ |
ORDER BY 本就不该在 <where> 里 | 语义混乱,后面维护的人要读很久才明白 |
${orderBy} 没有白名单 | 这是最严重的——如果 orderBy 来自前端参数,就是注入 ✗ |
问题二:${orderBy} 无白名单 ✗(同问题一第三条)
修复:
<select id="searchUsers" resultType="User">
SELECT * FROM users
<where>
<if test="name != null">AND name LIKE CONCAT('%', #{name}, '%')</if>
<if test="minAge != null">AND age >= #{minAge}</if>
</where>
<if test="orderBy != null">ORDER BY ${orderBy}</if>
</select>同时必须在 Java 侧加白名单:
private static final Set<String> ALLOWED_ORDER_BY =
Set.of("id", "age", "created_at", "view_count");
public List<User> search(String name, Integer minAge, String orderBy) {
if (orderBy != null) {
String column = orderBy.trim().split("\\s+")[0];
if (!ALLOWED_ORDER_BY.contains(column)) {
throw new IllegalArgumentException("不允许的排序字段:" + orderBy);
}
}
return userMapper.searchUsers(name, minAge, orderBy);
}顺带说明"为什么 <if> 要放在 <where> 外面":ORDER BY 不是 WHERE 子句的一部分 ✓ 放里面靠 <where> 的"去掉多余 AND"来兜底是运气,不是设计 ✓
第 5 题:AND 1=1 起"占位"作用。
为什么需要占位:
如果没有 AND 1=1:
<where> 里的所有条件都不成立 → 生成 SELECT * FROM users WHERE
↑ 凭空多出一个 WHERE ✗<where> 的机制是"如果标签内没有任何内容,就不生成 WHERE"——但它判断的是"标签内有没有文本",而 AND 1=1 ORDER BY id 这段文本是"标签外拼进去的内容"吗?
更准确地说:加 AND 1=1 的目的是保证"标签内至少有内容",
但真正的麻烦是:
条件全不成立时:
<where> 里只剩 "AND 1=1 ORDER BY id" → 去掉开头的 AND → WHERE 1=1 ORDER BY id ✓(能跑)所以它"能跑"——但代价是:
| 代价 | 说明 |
|---|---|
| 误导读者 | 后面的人会以为 1=1 是业务条件 |
ORDER BY 混进 <where> | 语义错位 |
| 一旦有人调整标签结构就炸 | 依赖的是"实现细节" |
更好的做法(第 4 题的修复):
<where>
<if test="name != null">AND name LIKE ...</if>
<if test="minAge != null">AND age >= ...</if>
</where>
<if test="orderBy != null">ORDER BY ${orderBy}</if><where> 自己就处理"条件全空"的情况——根本不需要 1=1 这种占位技巧 ✓
这个题的通用启示:
当框架标签的默认行为够用时,别加"技巧性的占位"。
"能跑"和"读得懂"是两件事——而后者决定了半年后改这块代码的人(可能是你自己)要花多久。
Mapper 接口:
@Mapper
public interface ArticleMapper {
Article findById(@Param("id") Long id);
int insert(Article article);
int updateViewCount(@Param("id") Long id, @Param("delta") int delta);
int logicDelete(@Param("id") Long id);
List<Article> search(@Param("keyword") String keyword,
@Param("status") String status,
@Param("authorId") Long authorId,
@Param("orderBy") String orderBy);
List<Article> findByIds(@Param("ids") List<Long> ids);
}建表:
CREATE TABLE article (
id BIGINT AUTO_INCREMENT PRIMARY KEY,
title VARCHAR(128) NOT NULL,
content TEXT,
author_id BIGINT NOT NULL,
status VARCHAR(16) NOT NULL DEFAULT 'DRAFT',
view_count INT NOT NULL DEFAULT 0,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
deleted TINYINT NOT NULL DEFAULT 0
);XML:
<mapper namespace="cn.kb.learn.data.mapper.ArticleMapper">
<sql id="columns">id, title, content, author_id, status, view_count, created_at, deleted</sql>
<select id="findById" resultType="cn.kb.learn.data.entity.Article">
SELECT <include refid="columns"/> FROM article
WHERE id = #{id} AND deleted = 0
</select>
<insert id="insert" useGeneratedKeys="true" keyProperty="id">
INSERT INTO article (title, content, author_id, status, view_count, deleted)
VALUES (#{title}, #{content}, #{authorId}, #{status}, 0, 0)
</insert>
<update id="updateViewCount">
UPDATE article SET view_count = view_count + #{delta}
WHERE id = #{id} AND deleted = 0
</update>
<!-- 逻辑删除 -->
<update id="logicDelete">
UPDATE article SET deleted = 1 WHERE id = #{id} AND deleted = 0
</update>
<select id="search" resultType="cn.kb.learn.data.entity.Article">
SELECT <include refid="columns"/> FROM article
<where>
deleted = 0
<if test="keyword != null and keyword != ''">
AND title LIKE CONCAT('%', #{keyword}, '%')
</if>
<if test="status != null and status != ''">
AND status = #{status}
</if>
<if test="authorId != null">
AND author_id = #{authorId}
</if>
</where>
<if test="orderBy != null and orderBy != ''">
ORDER BY ${orderBy}
</if>
</select>
<select id="findByIds" resultType="cn.kb.learn.data.entity.Article">
SELECT <include refid="columns"/> FROM article
WHERE deleted = 0 AND id IN
<foreach collection="ids" item="item" open="(" separator="," close=")">
#{item}
</foreach>
</select>
</mapper>白名单校验:
public final class ArticleQuery {
private static final Set<String> ALLOWED_ORDER_BY =
Set.of("id", "created_at", "view_count", "title");
/**
* 排序字段来自前端时,必须先过白名单。
* 只取第一个词(列名),允许 "view_count DESC" 这种写法。
*/
public static String safeOrderBy(String raw) {
if (raw == null || raw.isBlank()) {
return null;
}
String column = raw.trim().split("\\s+")[0];
if (!ALLOWED_ORDER_BY.contains(column)) {
throw new IllegalArgumentException("不允许的排序字段:" + raw);
}
// 方向也只允许 ASC / DESC
String[] parts = raw.trim().split("\\s+");
if (parts.length > 1 && !parts[1].equalsIgnoreCase("ASC") && !parts[1].equalsIgnoreCase("DESC")) {
throw new IllegalArgumentException("排序方向只能是 ASC 或 DESC:" + raw);
}
return raw;
}
}注意白名单也要校验排序方向——否则 "id DESC, (SELECT ...)" 这类输入仍可能漏过去 ✓
五种条件组合的 SQL 日志(预期输出):
① search(null, null, null) → SELECT ... WHERE deleted = 0
② search("索引", null, null) → SELECT ... WHERE deleted = 0 AND title LIKE CONCAT('%', ?, '%')
③ search(null, "PUBLISHED", null) → SELECT ... WHERE deleted = 0 AND status = ?
④ search(null, null, 7L) → SELECT ... WHERE deleted = 0 AND author_id = ?
⑤ search("索引", "PUBLISHED", 7L) → SELECT ... WHERE deleted = 0 AND title LIKE CONCAT('%', ?, '%')
AND status = ? AND author_id = ?每一行都应该看到:
| 检查点 | 说明 |
|---|---|
deleted = 0 恒在 | 逻辑删除条件没漏 |
| 条件数量随参数变 | 动态 SQL 生效 |
值是 ? 不是字面量 | #{} 生效 |
ORDER BY 里是列名 | ${} 只用在结构位置(且过了白名单) |
《数据访问安全规范(6 条)》
| # | 规范 | 可自动检查? |
|---|---|---|
| 1 | 所有"值"必须用 #{},禁止用 ${} 传值 | ✅ 可自动(扫 XML 找 ${) |
| 2 | ${} 只能用于表名/列名/排序字段,且必须过白名单 | ⚠️ 半自动(能扫出用在哪,但"是否过白名单"要人看) |
| 3 | 白名单必须是"精确集合",不能用正则或 startsWith | ❌ 需人工评审 |
| 4 | 动态 SQL 必须由 mapper 层封装,Controller 不得直接接收排序字段 | ❌ 需人工评审(架构约束) |
| 5 | 生产环境的 SQL 日志只记录参数个数与耗时,不记录参数值 | ✅ 可自动(检查日志配置) |
| 6 | 所有 Mapper 方法都要有测试(正常 + 边界 + 空集合) | ✅ 可自动(覆盖率门槛 / 反射扫描接口方法数) |
落地:自动扫描 ${} 的测试
package cn.kb.learn.data;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.ArrayList;
import java.util.List;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
import java.util.stream.Stream;
import static org.assertj.core.api.Assertions.assertThat;
@DisplayName("数据访问安全:扫描 XML 里的 ${} 用法")
class XmlInjectionScanTest {
private static final Pattern DOLLAR_PLACEHOLDER = Pattern.compile("\\$\\{[^}]+}");
@Test
@DisplayName("所有 ${} 都必须出现在白名单注释标记附近")
void all_dollar_placeholders_should_be_reviewed() throws IOException {
Path mapperDir = Path.of("target/classes/mapper");
assertThat(mapperDir).as("先执行 mvn compile 再跑本测试").exists();
List<String> findings = new ArrayList<>();
try (Stream<Path> files = Files.walk(mapperDir)) {
for (Path file : files.filter(path -> path.toString().endsWith(".xml")).toList()) {
List<String> lines = Files.readAllLines(file, StandardCharsets.UTF_8);
for (int index = 0; index < lines.size(); index++) {
Matcher matcher = DOLLAR_PLACEHOLDER.matcher(lines.get(index));
while (matcher.find()) {
findings.add(file.getFileName() + ":" + (index + 1)
+ " → " + matcher.group() + " | " + lines.get(index).trim());
}
}
}
}
System.out.println("===== XML 中的 ${} 用法清单 =====");
findings.forEach(finding -> System.out.println(" " + finding));
System.out.println(" 共 " + findings.size() + " 处");
// 断言:清单里每一处都要在白名单允许的字段名附近(这里用"必须全部人工确认过"的方式表达)
// 真实项目里更常见的做法:要求每一处 ${} 上方必须有 // allow-dollar: <理由> 注释
findings.forEach(finding ->
assertThat(finding).as("发现 ${} 用法,请确认是结构位置且已过白名单").contains("${"));
}
}这个测试的价值:
| 价值 | 说明 |
|---|---|
| 清单化 | 输出"哪些文件哪些行用了 ${}",评审时逐个确认 |
| CI 拦截 | 新增 ${} 会让测试输出变化,评审者必须解释 |
| 可演进 | 加一条"必须带 allow-dollar 注释"的断言,就变成硬门槛 |
哪几条只能靠评审:
| 只能人工 | 为什么 |
|---|---|
| "这个表达式是值还是结构" | 需要理解业务语义(status 是值,orderBy 是结构) |
| "白名单是不是真的精确" | 工具很难判断"这个集合是否完备" |
| "Controller 有没有直接接收排序字段" | 跨层约束,需要看调用链 |
| "这里该不该用动态 SQL" | 设计判断 |
为什么不能简单"禁用 ${}":
| 理由 | 说明 |
|---|---|
有些位置只能用 ${} | 表名、列名、排序方向无法用 ?(《JDBC 与数据库连接》 的 5.2 节)——这不是 MyBatis 的缺陷,是 SQL 语法的限制 |
| 禁用会把人逼向更差的做法 | 有人会改成"Java 侧拼 SQL 字符串",风险一样大,还失去了集中审查的位置 ✗ |
| 失去"可扫描的特征" | ${} 至少是个能被正则找出来的标记;散落在 Java 里的拼串更难发现 |
好的规范是"约束使用姿势"(用什么、在哪用、怎么校验),不是"禁止某个语法"。
禁止会让问题转入地下;约束能让问题留在明处。 ✓
这与 《参数校验与全局异常处理》 里"业务校验不能替代数据库约束"的思维方式一致:能自动化的部分自动化,剩下的用清晰的约定 + 评审兜住 ✓
namespace + id 关联;#{} 与 ${} 的区别,并知道 ${} 的 SQL 日志里会"带值";${} 只能用于结构位置,且必须配白名单;<if> / <where> / <foreach> / <set> 写动态 SQL;useGeneratedKeys 回填主键;《MyBatis-Plus 通用 CRUD 与条件构造器》:把单表增删改查也省掉。