Skip to content

MyBatis 数据持久化 ​

S3-008必学进阶约 90 分钟JDK 21企业开发基础 · 数据持久化

1. 本节定位 ​

《JDBC 与数据库连接》 用纯 JDBC 写了一遍数据访问,留下了三处痛点:

痛点《JDBC 与数据库连接》 的样子
手写结果映射每查一列就 resultSet.getString("name") 一次
SQL 与代码混在一起长 SQL 拼在 Java 字符串里,编译期查不出错
动态条件靠拼串条件一变就要 StringBuilder,还容易拼出注入漏洞

MyBatis 解决这三件事:SQL 写到 XML 里、结果自动映射、动态条件用标签。

但要先记住一句话:MyBatis 没有消除注入风险,它只是让"安全的写法"变得更容易——${} 就是 MyBatis 版的"拼串" ✓ 这是本节的第二个重点。

2. 学习目标 ​

完成本节后,你应该能够:

  • 说出 MyBatis 的定位(半自动 ORM)以及它和 JPA 的分工;
  • 说出 SqlSessionFactory / SqlSession / Mapper 接口三者的关系;
  • 说出 #{} 与 ${} 的区别,并解释为什么 ${} 有注入风险;
  • 用 <if> / <where> / <foreach> 写动态 SQL;
  • 用 useGeneratedKeys 拿回插入后的自增主键;
  • 说出 MyBatis 与 Spring Boot 集成的三样东西(starter / @Mapper / 配置项);
  • 知道怎么"看到 MyBatis 实际发出的 SQL"(学 MyBatis 最重要的调试手段)。

3. 前置知识检查 ​

4. 为什么需要这个知识 ​

先说清楚 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》 要补的。

5. 核心概念 ​

5.1 三个核心对象 ​

text
SqlSessionFactory           ← 一个应用一个(单例)
      │  由它创建
      ▼
SqlSession                  ← 一次"会话"(对应一个 JDBC Connection)
      │  通过它拿
      ▼
Mapper 接口(你的接口)       ← 只需要写方法签名
对象职责生命周期
SqlSessionFactory读取配置、管理映射、生产会话应用级单例
SqlSession一次数据库会话(含事务)方法级(用完关)
Mapper 接口你定义的方法 → 一条 SQL应用级(由框架代理生成)

关键机制:Mapper 接口没有实现类。

java
public interface ProductMapper {
    List<Product> findAll();          // 只有签名
}

MyBatis 在运行时用 JDK 动态代理为它生成实现,把方法调用翻译成:

text
findAll()  →  查 namespace="cn.kb.learn.data.mapper.ProductMapper" + id="findAll" 的 SQL
           →  执行、映射结果、返回

"方法名怎么找到 SQL 的":靠 namespace + id 这对坐标 ✓

xml
<mapper namespace="cn.kb.learn.data.mapper.ProductMapper">       <!-- 接口的全限定名 -->
    <select id="findAll" resultType="...">                       <!-- 方法名 -->
        SELECT ...
    </select>
</mapper>

所以 namespace 写错、方法名对不上,启动时就会报"找不到语句" ✓(这是最容易踩的坑之一)

5.2 与 Spring 集成:三样东西 ​

xml
<!-- ① 一个 starter -->
<dependency>
  <groupId>com.baomidou</groupId>
  <artifactId>mybatis-plus-spring-boot3-starter</artifactId>
</dependency>
java
// ② 接口上标 @Mapper(或在启动类上统一 @MapperScan)
@Mapper
public interface ProductMapper { ... }
yaml
# ③ 配置 SQL 到哪里找、驼峰要不要自动映射
mybatis-plus:
  mapper-locations: classpath*:mapper/*.xml
  configuration:
    map-underscore-to-camel-case: true

starter 替我们做了什么(《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")接口多时更省事(推荐)
java
@SpringBootApplication
@MapperScan("cn.kb.learn.data.mapper")
public class DataAccessApplication { ... }

5.3 #{} 与 ${}(本节重点) ​

两者的区别,一句话:

写法底层作用
#{}PreparedStatement 的 ?参数作为"值"传入 ✓
${}字符串替换(SQL 文本拼接)参数作为"SQL 片段"拼进去 ✗

看到这张表就该反应过来:${} 就是 《JDBC 与数据库连接》 讲的"拼串" ✓

同一句 SQL 的两种写法:

xml
<!-- 安全 -->
<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):

text
用户输入:' 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 节解释过)✓

xml
<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>

用它必须配白名单:

java
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);
}

实测结果:

text
按价格降序:[人体工学椅, 显示器支架, 机械键盘, 降噪耳机]
非法排序字段被拦截:不允许的排序字段:id; DROP TABLE product
表还在,当前总数 = 4

注意白名单的两个细节:

细节为什么
先 split("\\s+")[0] 取列名允许 "price_in_cents DESC" 这种"列名 + 方向"的合法输入
白名单是"集合"不是"正则"正则容易被绕过(大小写、注释、编码),集合是精确匹配 ✓

三条铁律:

  1. 能用 #{} 就用 #{}(99% 的场景);
  2. ${} 只能用于"结构"(表名、列名、排序),不能用于"值";
  3. 用 ${} 必须过白名单 ✓

5.4 结果映射 ​

两件自动的事:

yaml
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(列名与属性名对不上、需要嵌套对象时):

xml
<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 够用)
列名要映射到不同名字的属性✅ 需要
需要嵌套对象/集合(联表查询)✅ 需要(或写两次查询手动组装)

5.5 动态 SQL ​

没有动态 SQL 时(《JDBC 与数据库连接》 的样子):三个可选条件 → 你手写 2³ = 8 种拼法 ✗

有了动态 SQL:

xml
<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 &gt;= #{minPrice}
        </if>
        <if test="maxPrice != null">
            AND price_in_cents &lt;= #{maxPrice}
        </if>
    </where>
    ORDER BY id
</select>

<where> 的两个作用(都省事):

作用说明
有条件时才加 WHERE 关键字不用自己判断"是不是第一个条件"
自动去掉开头多余的 AND上面每个 <if> 都以 AND 开头,但第一个生效的 AND 会被去掉 ✓

实测:四种条件组合,SQL 确实跟着变:

text
① 无任何条件
==>  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 与数据库连接》 里要手数问号个数的那件事):

xml
<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>

实测:

text
==>  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> 值得单独提一句:做"只更新非空字段"时,它自动处理"末尾多余的逗号" ✓

xml
<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>

5.6 主键回填 ​

xml
<insert id="insert" useGeneratedKeys="true" keyProperty="id">
    INSERT INTO product (name, price_in_cents, stock, deleted)
    VALUES (#{name}, #{priceInCents}, #{stock}, 0)
</insert>
java
Product created = new Product("4K 显示器", 129_900, 3);
productMapper.insert(created);
System.out.println("回填的主键 id = " + created.getId());       // ← 插入前是 null,插入后有值

实测:

text
==>  Preparing: INSERT INTO product (name, price_in_cents, stock, deleted) VALUES (?, ?, ?, 0)
  影响行数 = 1,回填的主键 id = 5

为什么需要它:插入之后往往要立刻用这个 id(写关联表、返回给前端)✓

用 JDBC 自己实现要再查一次(SELECT LAST_INSERT_ID() 或 getGeneratedKeys())✗ useGeneratedKeys 帮你省掉这一步 ✓

5.7 看清 MyBatis 到底发了什么 SQL ​

这是学 MyBatis 最重要的调试手段:

yaml
mybatis-plus:
  configuration:
    log-impl: org.apache.ibatis.logging.stdout.StdOutImpl

输出:

text
==>  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 级别(《日志框架与日志规范》):

yaml
logging:
  level:
    cn.kb.learn.data.mapper: debug

6. 递进示例 ​

6.1 最小示例:一个查询 ​

java
@Mapper
public interface ProductMapper {
    List<Product> findAll();
}
xml
<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>

6.2 业务示例:动态查询 + 主键回填 ​

见第 7 节。

6.3 反例:用 ${} 处理用户输入的值 ​

xml
<!-- ✗ 只要是"值",就不该用 ${} -->
<select id="findByName">
    SELECT * FROM product WHERE name = '${name}'
</select>

症状:与 《JDBC 与数据库连接》 的拼串完全一样——注入成功,全表被拖出 ✗

修复:#{name} ✓

自查方法:全局搜 XML 里的 ${,逐个确认"这个参数是不是"结构"(表名/列名/排序),且过了白名单"** ✓

7. 完整可运行代码 ​

文件清单(labs/data-access/):

text
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 接口:

java
@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);
}

运行命令:

powershell
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

预期输出(节选真实结果):

text
===== 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 自动加上这个条件 ✓

8. Debug 观察点 ​

  1. 在 SqlSessionFactory 的创建处断点(或看启动日志):观察它读了多少个 XML、注册了多少条语句;
  2. 在 Mapper 接口方法上打断点:观察调用栈——你会看到 MapperProxy(MyBatis 生成的动态代理)✓
  3. 观察 #{id} 的绑定:在 DefaultParameterHandler.setParameters 处断点,看它如何把参数设进 PreparedStatement——和 《JDBC 与数据库连接》 手写的 statement.setLong(1, id) 是同一件事;
  4. 把 map-underscore-to-camel-case 改成 false,重启——观察 priceInCents 变成 null(而不报错)✗ 这是"静默失败"的现场;
  5. 故意把 XML 里的 id 改错(如 findAll2):观察启动报错 Invalid bound statement (not found)——理解 namespace + id 这对坐标。

9. 常见错误 ​

错误 1:${} 用在"值"上 ​

见 5.3 与 6.3 节。这是本节最高危的错误。

错误 2:XML 没被扫到 ​

text
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 下,避开了这个问题)

错误 3:驼峰映射没开,字段静默为 null ​

见 5.4 节。症状是"不报错但值是 null"——最难查的一类问题 ✗

排查:先看 SQL 日志的 Preparing(列有没有查出来),再看映射配置 ✓

错误 4:多个参数没用 @Param ​

java
Product findByCodeAndStatus(String code, String status);        // ✗ XML 里无法引用

症状:Parameter 'code' not found. Available parameters are [arg0, arg1, param1, param2]

原因:Java 默认不保留方法参数名(《Spring MVC 请求映射与参数绑定》 讲过的 -parameters 是同一件事)✗

两种修复:

java
// 方式一:加 @Param(推荐,显式、不依赖编译配置)
Product findByCodeAndStatus(@Param("code") String code, @Param("status") String status);
xml
<!-- 方式二:用 arg0/arg1(不推荐,可读性差) -->
WHERE code = #{arg0} AND status = #{arg1}

顺带:本模块的 pom 已经开了 -parameters(《Spring MVC 请求映射与参数绑定》 的 5.4 节),所以不加 @Param 有时也能跑——但显式写 @Param 更稳妥,因为别人可能复制代码到没开编译标志的模块 ✓

错误 5:<if test> 里用 == 比较字符串 ​

xml
<if test="status == 'PAID'">      <!-- ✗ 单引号里的单字符会被当 char(OGNL 的坑) -->

修复:

xml
<if test="status == 'PAID'">          <!-- 通常没问题 -->
<if test='"PAID".equals(status)'>     <!-- 或更明确地这样写 -->

最稳的写法:用 Java 侧先判空/格式化,别在 XML 里做复杂逻辑 ✓

错误 6:忘了逻辑删除的条件 ​

xml
<select id="findAll">
    SELECT * FROM product                     <!-- ✗ 会把已逻辑删除的数据也查出来 -->
</select>

修复:该加的 deleted = 0 一个都不能少;或者换 MyBatis-Plus 的 @TableLogic(《MyBatis-Plus 通用 CRUD 与条件构造器》)✓

错误 7:MyBatis-Plus 3.5.9 找不到 PaginationInnerInterceptor ​

这是本项目实际踩到的坑:

text
[ERROR] 找不到符号
  符号: 类 PaginationInnerInterceptor
  位置: 包 com.baomidou.mybatisplus.extension.plugins.inner

原因:3.5.9 起,分页插件被拆到了独立模块 ✗

修复:加依赖 ✓

xml
<dependency>
  <groupId>com.baomidou</groupId>
  <artifactId>mybatis-plus-jsqlparser</artifactId>
  <version>3.5.9</version>
</dependency>

教训:版本升级时,先看 Release Notes——"依赖被拆分/改名"是常见变更 ✓

10. 动手实验 ​

目标:把注入、动态 SQL、映射三件事亲手验一遍。

步骤:

  1. 运行第 7 节的命令,核对三处关键输出;
  2. 制造注入:把 searchByNameSafe 的 XML 从 #{name} 改成 '${name}',重启并运行——观察 Total 从 0 变成 5,并在日志里找到"带值的 SQL";
  3. 验证白名单的作用:把 ALLOWED_ORDER_BY 里的 price_in_cents 去掉,重新运行——观察"按价格降序"那步被拦截;
  4. 关掉驼峰映射:把 map-underscore-to-camel-case 改成 false,重启——观察 priceInCents 变成 null(而程序不报错);
  5. 加一个动态 SQL:给 search 加一个 <if test="inStock != null">AND stock &gt; 0</if>,在接口与调用处各加一个参数,验证五种条件组合;
  6. 观察 <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 复用同一预编译语句)。

11. 练习题 ​

基础题

  1. 判断:#{} 会被替换成 SQL 里的字面值,${} 会变成 ?。(对 / 错)
  2. 填空:Mapper 方法与 SQL 靠 ______ 与 ______ 这对坐标关联;<where> 会自动去掉开头的 ______。
  3. 简答:为什么"看到 SQL 日志里是 ?"就说明没有注入风险?

代码阅读题

xml
<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 &gt;= #{minAge}</if>
        <if test="orderBy != null">AND 1=1 ORDER BY ${orderBy}</if>
    </where>
</select>
  1. 这段 XML 有问题吗?问题在哪?如何修复?
  2. 如果 orderBy 传 "id",最终 SQL 里的 AND 1=1 起什么作用?

编程题

  1. 为"文章"表设计完整的 Mapper:
    • 表结构: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 查询;
    • 要求:
      • 全部用 #{},只有排序字段用 ${} 并配白名单;
      • 所有查询都要排除已逻辑删除的数据;
      • 在调用方写一个"排序字段白名单"的校验方法;
    • 运行并贴出五种条件组合的 SQL 日志,说明动态 SQL 生效了。

综合任务

  1. 设计一个"安全的数据访问规范"并落地成代码:
    • 背景:团队里有人用 ${} 写查询,出过一次注入事故;
    • 要求:
      • 写一份至少 6 条的规范(覆盖:占位符、白名单、参数绑定、日志、权限、测试);
      • 把"能自动检查的部分"落地:写一个测试,扫描所有 XML 文件,找出用 ${} 的地方,输出清单(提示:用 Files.walk 递归找 .xml,正则匹配 \$\{);
      • 说明哪几条能自动检查、哪几条只能靠评审;
      • 额外要求:说明"为什么不能简单地"禁用 ${}""(提示:表名/列名/排序确实需要它——好的规范是"约束使用姿势",不是"禁止")。
分级提示

提示 1(方向):第 1 题的方向反过来了——#{} 才是变 ? 的那个。

提示 2(关键点):第 4 题的关键是 AND 1=1 是个"占位技巧",很可能在某个组合下出问题。

提示 3(接近答案):第 7 题的自动化检查用 JUnit 测试写最顺手——它能在 CI 里拦住"新增了 ${}"。

12. 参考答案 ​

基础题
  1. 错,正好相反。
写法编译后
#{}?(交给 PreparedStatement 作为参数) ✓
${}直接文本替换(变成 SQL 的一部分) ✗

记忆方法:# 长得像"井号占位符",$ 是"美元符号——真金白银的值直接放进文本里"(后者更危险)✓

验证方式:看 SQL 日志——#{} 的 SQL 里是 ?,${} 的 SQL 里是具体的值 ✓

  1. namespace(接口全限定名);id(方法名);多余的 AND。

补充:<where> 还会在"所有条件都不成立"时不生成 WHERE 关键字 ✓(否则会拼出 WHERE ORDER BY id 这种非法 SQL)

  1. 因为 ? 意味着"SQL 结构已经固定,参数是另外传进去的"。
日志里看到说明风险
WHERE name = ?结构固定,name 的值由参数绑定传入✅ 安全
WHERE name = 'x' OR '1'='1'参数已经融进 SQL 文本,参与了语法解析❌ 危险
ORDER BY price_in_cents DESC结构被替换——这里是合法的(过了白名单)⚠️ 要看白名单

注意第三行:${} 用在"结构"上是正常的(表名、列名、排序),日志里必然带值 ✓ 所以不能简单地"看到带值就认为有问题"——要结合"这个位置本来该不该是结构"来判断 ✓

一个更实用的自查规则:

"这个参数的内容是用户能控制的吗?它出现在 SQL 的"值位置"还是"结构位置"?"

用户可控 + 值位置 → 必须 #{} ✓ 用户可控 + 结构位置 → ${} + 白名单 ✓

代码阅读题(第 4、5 题)

第 4 题:有问题,两处。

问题一:AND 1=1 ORDER BY ${orderBy} 是一个"技巧",很脆弱 ✗

xml
<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} 无白名单 ✗(同问题一第三条)

修复:

xml
<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 &gt;= #{minAge}</if>
    </where>
    <if test="orderBy != null">ORDER BY ${orderBy}</if>
</select>

同时必须在 Java 侧加白名单:

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 起"占位"作用。

为什么需要占位:

text
如果没有 AND 1=1:
    <where> 里的所有条件都不成立 → 生成 SELECT * FROM users WHERE
                                                              ↑ 凭空多出一个 WHERE ✗

<where> 的机制是"如果标签内没有任何内容,就不生成 WHERE"——但它判断的是"标签内有没有文本",而 AND 1=1 ORDER BY id 这段文本是"标签外拼进去的内容"吗?

更准确地说:加 AND 1=1 的目的是保证"标签内至少有内容",

但真正的麻烦是:

text
条件全不成立时:
    <where> 里只剩 "AND 1=1 ORDER BY id" → 去掉开头的 AND → WHERE 1=1 ORDER BY id  ✓(能跑)

所以它"能跑"——但代价是:

代价说明
误导读者后面的人会以为 1=1 是业务条件
ORDER BY 混进 <where>语义错位
一旦有人调整标签结构就炸依赖的是"实现细节"

更好的做法(第 4 题的修复):

xml
<where>
    <if test="name != null">AND name LIKE ...</if>
    <if test="minAge != null">AND age &gt;= ...</if>
</where>
<if test="orderBy != null">ORDER BY ${orderBy}</if>

<where> 自己就处理"条件全空"的情况——根本不需要 1=1 这种占位技巧 ✓

这个题的通用启示:

当框架标签的默认行为够用时,别加"技巧性的占位"。

"能跑"和"读得懂"是两件事——而后者决定了半年后改这块代码的人(可能是你自己)要花多久。

编程题(第 6 题)

Mapper 接口:

java
@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);
}

建表:

sql
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:

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>

白名单校验:

java
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 日志(预期输出):

text
① 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 里是列名${} 只用在结构位置(且过了白名单)
综合任务(第 7 题)

《数据访问安全规范(6 条)》

#规范可自动检查?
1所有"值"必须用 #{},禁止用 ${} 传值✅ 可自动(扫 XML 找 ${)
2${} 只能用于表名/列名/排序字段,且必须过白名单⚠️ 半自动(能扫出用在哪,但"是否过白名单"要人看)
3白名单必须是"精确集合",不能用正则或 startsWith❌ 需人工评审
4动态 SQL 必须由 mapper 层封装,Controller 不得直接接收排序字段❌ 需人工评审(架构约束)
5生产环境的 SQL 日志只记录参数个数与耗时,不记录参数值✅ 可自动(检查日志配置)
6所有 Mapper 方法都要有测试(正常 + 边界 + 空集合)✅ 可自动(覆盖率门槛 / 反射扫描接口方法数)

落地:自动扫描 ${} 的测试

java
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 里的拼串更难发现

好的规范是"约束使用姿势"(用什么、在哪用、怎么校验),不是"禁止某个语法"。

禁止会让问题转入地下;约束能让问题留在明处。 ✓

这与 《参数校验与全局异常处理》 里"业务校验不能替代数据库约束"的思维方式一致:能自动化的部分自动化,剩下的用清晰的约定 + 评审兜住 ✓

13. 掌握标准 ​

  • [ ] 能说出 MyBatis 的定位(半自动 ORM)与它相对 JPA 的取舍;
  • [ ] 能说出 Mapper 方法与 SQL 靠 namespace + id 关联;
  • [ ] 能解释 #{} 与 ${} 的区别,并知道 ${} 的 SQL 日志里会"带值";
  • [ ] 知道 ${} 只能用于结构位置,且必须配白名单;
  • [ ] 会用 <if> / <where> / <foreach> / <set> 写动态 SQL;
  • [ ] 会用 useGeneratedKeys 回填主键;
  • [ ] 知道驼峰映射不开会导致"静默为 null";
  • [ ] 会用 SQL 日志排查"查不到数据""条件没生效";
  • [ ] 知道 MyBatis-Plus 3.5.9 分页插件要单独引依赖。

14. 扩展资源 ​

15. 下一步学习 ​

《MyBatis-Plus 通用 CRUD 与条件构造器》:把单表增删改查也省掉。

进度只保存在本机浏览器,可随时清除

个人学习项目:AI 生成内容均经人工复核,第三方资源仅作索引