【JavaEE18-后端部分】 MyBatis 入门第二篇:使用注解完成增删改查(含有参数传递底层原理)

在第一篇中,我们搭建了 MyBatis 环境,并完成了第一个查询。但老铁们可能对 MyBatis 如何接收参数、如何将结果映射成对象感到好奇。

今天我们就来搞懂这些问题,通过注解完成增删改查,并深入 MyBatis 的"后台",看看它到底是怎么处理参数、怎么自动赋值的。


1. 配置日志:让 MyBatis "开口说话"

1.1 为什么需要日志?

目前我们执行查询,控制台只输出结果,却看不到 MyBatis 到底执行了什么 SQL、传递了什么参数。如果 SQL 写错了,我们根本不知道错误出在哪。配置日志后,MyBatis 会把执行的 SQL 语句、参数以及返回的行数打印出来,就像给 MyBatis 装了一个"监控器",让我们能清楚地看到它的一举一动。

1.2 怎么配置?

application.yml 中添加:

yaml 复制代码
mybatis:
  configuration:
    log-impl: org.apache.ibatis.logging.stdout.StdOutImpl

如果使用 application.properties,则是:

properties 复制代码
mybatis.configuration.log-impl=org.apache.ibatis.logging.stdout.StdOutImpl

配置后重新运行测试,你会看到类似这样的输出:

  • Preparing 后面是 MyBatis 预编译后的 SQL(占位符 ? 代替了具体值)。
  • Parameters 是实际传递的参数值。
  • Columns 是返回的列名。
  • Row 是具体的数据行。
  • Total 是返回的记录数。

1.3 原理是什么?

简单来说就是我们的MyBatis 内部集成了多种日志框架(如 SLF4J、Log4j、Logback 等),并通过日志适配器 统一输出。StdOutImpl 是 MyBatis 提供的一个简单实现,它直接把日志打印到控制台上,所以你才会看到上述的日志信息。

一旦我们配置日志之后,MyBatis 在执行 SQL 的各个阶段(如参数设置、执行、结果处理)都会调用日志输出方法,从而打印出这些信息。


2. 参数传递:从方法到数据库的完整旅程

参数传递是 MyBatis 最核心的功能之一。我们需要把 Java 方法的参数传递给 SQL 语句中的占位符,MyBatis 在背后做了很多工作。下面我们分五种常见情况,一步步介绍 MyBatis 的处理流程,那么老铁们继续往下看:

2.1 总体流程概览

无论哪种参数情况,MyBatis 处理参数的整体流程都是类似的,如下图所示:

注意:为了便于理解,我们将 MyBatis 处理参数的 "数据源" 抽象为 "参数容器",

"参数容器"是关键,它可以是:

  • 单个简单值(如 Integer、String)
  • 一个 Java 对象(如 Student)
  • 一个 Map 集合

下面我们分别来看。


2.2 情况一:单个简单参数

场景:根据 ID 查询学生。

java 复制代码
    //根据id查询学生
    @Select("select id, name, age from student where id = #{id}")
    Student findById(Integer id);

测试:

结果:

MyBatis 内部处理步骤

  1. 方法调用 :传入一个 Integer 值,比如 1
  2. 参数解析ParamNameResolver 发现只有一个参数,且没有 @Param 注解。它决定不构建 Map,而是直接保留这个参数值作为"参数源"。
  3. SQL 解析 :当解析到 #{id} 时,MyBatis 知道要从参数源中取值。由于只有一个参数,无论 #{} 里写什么名字,都取这个唯一的参数值。这一步相当于 参数值 = 1
  4. 设置参数 :MyBatis 调用 PreparedStatement.setInt(1, 1) 将参数设置到第一个占位符。
  5. 执行 SQL

图示

关键点:单个简单参数时,MyBatis 不会创建 Map,而是直接持有参数值。无论 #{} 里写什么名字(如 #{id}/#{xxx}/#{arg0}),都会取这个唯一的参数值;但为了代码可读性,建议 #{} 内的名称与方法参数名保持一致。


2.3 多个参数(无 @Param)

多个参数(无 @Param)的真相:取决于编译参数

你可能会在不同项目中发现奇怪的现象:有时不加 @Param 也能用 #{name} 正常工作,有时却报错。这是为什么呢?我们先来看一个具体场景。

场景:根据姓名和年龄查询学生。

java 复制代码
// 传递多个参数,没有 @Param
@Select("SELECT * FROM student WHERE name = #{name} AND age = #{age}")
Student findByNameAndAge(String name, Integer age);

调用:findByNameAndAge("张三", 18)

这个代码一定能工作吗?

答案:不一定 。它能否工作取决于你的项目编译时是否保留了方法参数的真实名称 (即 nameage)。

情况 1:编译时未保留参数名(传统默认行为)

如果你使用的编译器没有开启保留参数名的选项,那么编译后的 .class 文件中,参数名会变成 arg0arg1 之类的占位符。此时 MyBatis 通过反射获取到的参数名就是 arg0arg1。它会像下面这样处理:

  1. 方法调用 :传入 "张三"18
  2. 参数解析ParamNameResolver 发现有多个参数,且没有 @Param,它会创建一个 ParamMap (一个特殊的 Map),并放入两套键值对:
    • 按索引:arg0 → "张三"arg1 → 18
    • 按顺序:param1 → "张三"param2 → 18
      最终 Map 中包含 4 个条目。
  3. SQL 解析 :当解析 #{name} 时,MyBatis 去 Map 中找 key 为 "name" 的项,但 Map 中没有,所以报错(如 Parameter 'name' not found)。如果改为 #{arg0}#{param1},就能找到对应的值。
  4. 设置参数 :如果使用 #{arg0}#{arg1},MyBatis 从 Map 中取出值,设置到 PreparedStatement。

图示(未保留参数名时):

再举一个例子:如果你的项目不会保留参数名(编译后参数名变成 arg0/arg1);

那么对于接口:

而我们的sql:

此时运行之后,我们就会出现一下的报错:

如果我们加上注解@param 那么此时就不会出现这个错误

此时我们在控制台上就不会出现错误日志了而是出现正确的结果:


情况 2:编译时保留了参数名(如 Spring Boot 默认行为)

可能在以前的版本会说,不加 @Param 就必须用 #{arg0}#{param1} 之类的默认名称,否则会报错。但在现代开发环境(尤其是 Spring Boot)中,你可能会发现下面这段代码竟然能正常工作

java 复制代码
// 传递多个参数,没有 @Param
@Select("select * from student where name = #{name} and age = #{age}")
Student findByNameAndAge(String name, Integer age);

测试:

结果:

那么调用 findByNameAndAge("张三", 18) 后,数据正确返回,并没有报错。这是为什么呢?

原因:编译时保留了参数真实名称

从 Java 8 开始,可以通过编译参数 -parameters 让编译器在 .class 文件中保留方法参数的真实名称 (例如 nameage)。而我们的Spring Boot 的 Maven/Gradle 插件默认开启了这一选项,因此我们在 Spring Boot 项目中写上述代码,MyBatis 能够通过反射获取到参数的真实名字,并将它们也作为 key 放入参数容器中。那么具体的步骤是什么呢?

MyBatis 内部处理步骤

  1. 方法调用 :传入 "张三"18
  2. 参数解析ParamNameResolver参数名称解析器 发现有多个参数,且没有 @Param。它会自动创建一个 ParamMap (一个特殊的 Map),并放入多套键值对:
    • 如果编译时保留了参数名,就会包含真实参数名:name → "张三"age → 18
    • 同时,为了兼容性,还会放入按索引的默认名:arg0 → "张三"arg1 → 18
    • 以及按顺序的通用名:param1 → "张三"param2 → 18
      最终 Map 中包含多个条目。
  3. SQL 解析 :当解析 #{name} 时,MyBatis 从 Map 中找 key 为 "name" 的项,此时 Map 中已经存在 name,所以能取出 "张三"。同理 #{age} 取出 18。
  4. 设置参数:MyBatis 将取出的值设置到 PreparedStatement。

可能老铁们读起来会有点抽象,所以我们使用一个图来描述上述的流程:
图示

关键点

  • 为什么现在能直接使用 #{name}

    因为 Spring Boot 默认开启了编译参数 -parameters,保留了参数的真实名称,MyBatis 在构建 ParamMap 时会把真实参数名也作为 key。

  • 是否意味着以后都可以不加 @Param

    虽然不加 @Param 在 Spring Boot 环境中能工作,但强烈建议仍然使用 @Param 注解。原因:

    • 显式意图 :看到 @Param("name"),代码阅读者立刻知道 SQL 中的 #{name} 对应这个参数。
    • 环境无关 :如果你的代码被移植到未开启 -parameters 的项目中,加了 @Param 依然能正确运行。
    • 避免混淆:防止后续维护者误以为参数名可以随意使用而引发错误。

所以,虽然现在我们虽然可以不加 @Param 也能跑通,但养成加 @Param 的习惯,能让你的代码更健壮、更清晰。


2.4 情况三:多个参数(使用 @Param)

场景 :使用 @Param 明确指定参数名。

java 复制代码
@Select("select * from student where name = #{name} and age = #{age}")
Student findByNameAndAge(@Param("name") String name, @Param("age") Integer age);

测试:

结果:

MyBatis 内部处理步骤

  1. 方法调用 :传入 "李四"20

  2. 参数解析ParamNameResolver 发现有多个参数,且都带有 @Param 注解。它创建一个 ParamMap ,以注解的值为 key,参数值为 value:

    • "name" → "李四"
    • "age" → 20
      同时,为了兼容性,也会保留默认的 arg0/arg1param1/param2
  3. SQL 解析(BoundSql) :读取原始 SQL,扫描所有 #{} 占位符,将其替换为 ?,得到预编译 SQL:

    sql 复制代码
    select * from student where name = ? and age = ?

    同时,记录每个占位符对应的属性名 :第一个占位符属性名为 "name",第二个为 "age"

  4. 从参数容器中取值 :遍历每个占位符,根据属性名从 ParamMap 中取出实际的值:

    • 属性名 "name"map.get("name")"李四"
    • 属性名 "age"map.get("age")20
  5. 设置 PreparedStatement :将取出的值按顺序设置到 ? 上:

    • 第一个 ? 设置为 "李四"setString
    • 第二个 ? 设置为 20setInt
  6. 执行 SQL

那么这个流程对应的图示如下:

关键点

  • 使用 @Param 可以自定义 Map 的 key,让 SQL 中的 #{} 能直接引用这些名字,清晰易懂。
  • 虽然默认的 arg0param1 等 key 仍然存在,但推荐只使用自定义 key,避免混乱。
  • 整个流程严格遵循:先解析 SQL 生成占位符并记录属性名,再从参数容器中取值,最后设置参数

2.5 情况四:单个对象参数

场景:插入一个学生对象。

java 复制代码
@Insert("INSERT INTO student(name, age, gender) VALUES(#{name}, #{age}, #{gender})")
int insert(Student student);

测试:

结果:

MyBatis 内部处理步骤

  1. 方法调用 :传入一个 Student 对象,里面有 name="王五", age=22, gender=1

  2. 参数解析ParamNameResolver 发现只有一个参数,且是对象类型。它不会创建 Map,而是直接把这个对象作为"参数源"。

  3. SQL 解析(BoundSql) :读取原始 SQL,扫描所有 #{} 占位符,将其替换为 ?,得到预编译 SQL:

    sql 复制代码
    insert into student(name, age, gender) values(?, ?, ?)

    同时,记录每个占位符对应的属性名 :第一个占位符属性名为 "name",第二个为 "age",第三个为 "gender"

  4. 从参数容器中取值 :遍历每个占位符,根据属性名从参数源(即 student 对象)中取出实际的值。MyBatis 使用 OGNL(Object-Graph Navigation Language,对象图导航语言, 该知识在后面有补充第7个知识点) 表达式通过反射调用对应的 getter 方法:

    • 属性名 "name" → 调用 student.getName()"王五"
    • 属性名 "age" → 调用 student.getAge()22
    • 属性名 "gender" → 调用 student.getGender()1
  5. 设置 PreparedStatement :将取出的值按顺序设置到 ? 上:

    • 第一个 ? 设置为 "王五"setString
    • 第二个 ? 设置为 22setInt
    • 第三个 ? 设置为 1setInt
  6. 执行 SQL

图示:

关键点

  • 单个对象参数时,MyBatis 不会创建 Map,而是直接使用对象本身作为参数源。
  • #{} 中的名字必须是对象的属性名,MyBatis 会通过 OGNL 调用对应的 getter 方法取值。
  • 整个流程遵循:先解析 SQL 生成占位符并记录属性名,再通过 OGNL 从对象取值,最后设置参数

2.6 情况五:混合参数(对象 + 简单参数)

场景:根据学生对象的部分属性和额外参数查询。

java 复制代码
@Select("select * from student where name = #{stu.name} and age > #{minAge}")
List<Student> findByNameAndMinAge(@Param("stu") Student student, @Param("minAge") Integer minAge);

测试

结果:


如果不使用stu.就会报错:

那么底层到底做了什么?为什么需要有stu.呢?
MyBatis 内部处理步骤

  1. 方法调用 :传入一个 Student 对象(name="赵六", age=25)和一个 Integer 18

  2. 参数解析ParamNameResolver 发现有两个参数,且都有 @Param 注解。它创建一个 ParamMap

    • "stu" → student对象
    • "minAge" → 18
      同时,为了兼容性,也会保留默认的 arg0/arg1param1/param2
  3. SQL 解析(BoundSql) :读取原始 SQL,扫描所有 #{} 占位符,将其替换为 ?,得到预编译 SQL:

    sql 复制代码
    select * from student where name = ? and age > ?

    同时,记录每个占位符对应的属性名 :第一个占位符属性名为 "stu.name",第二个为 "minAge"

  4. 从参数容器中取值 :遍历每个占位符,根据属性名从 ParamMap 中取值:

    • 对于 #{stu.name},MyBatis 先解析出两部分:对象引用 stu 和属性 name。它先从 Map 中取 key 为 "stu",得到 student 对象,然后通过 OGNL 从该对象中取 name 属性(即 student.getName())。
    • 对于 #{minAge},直接从 Map 中取 key 为 "minAge" 的值,得到 18
  5. 设置 PreparedStatement :将取出的值按顺序设置到 ? 上:

    • 第一个 ? 设置为 "赵六"setString
    • 第二个 ? 设置为 18setInt
  6. 执行 SQL

图示:

关键点

  • 混合参数时,所有参数(包括对象)都被放入 ParamMap 中。
  • 对于 #{stu.name} 这种点号表达式,MyBatis 会先从 Map 中取出对象,再通过 OGNL 取对象属性。
  • 整个流程依然遵循:先解析 SQL 生成占位符并记录属性名,再从参数容器中取值,最后设置参数

对比2.5和2.6的总结:对于参数是对象的,如果不加@Param的话,那么可以使用属性【因为对象就是一个容器】,如果加了@Param注解的话,那么属性就得需要使用对象来点。


3. 参数传递总结

参数情况 MyBatis 内部构建 SQL 中如何引用 底层原理
单个简单参数 直接保存参数值 #{任意名} 直接取值,不涉及 Map
单个对象参数 直接保存对象 #{属性名} 通过 OGNL 从对象取属性值
多个参数(无@Param) 创建 ParamMap,key 为 arg0/arg1, param1/param2 #{arg0}#{param1} 从 Map 取默认 key
多个参数(有@Param) 创建 ParamMap,key 为 @Param 指定的值 #{指定名} 从 Map 取指定 key
混合参数 创建 ParamMap,包含对象和简单值 对象属性用 #{key.属性名},简单值直接用 #{key} 先从 Map 取对象,再 OGNL 取属性

核心思想 :MyBatis 通过一个"参数容器"来统一管理传入的参数。这个容器要么是单个值/对象,要么是 Map。SQL 中的 #{} 占位符根据名称从容器中取值。理解了这个容器模型,你就掌握了 MyBatis 参数传递的精髓。


4. 增删改操作

增删改操作是数据库最基本的操作,它们背后同样遵循我们刚刚学过的参数传递规则。下面我们逐一拆解,看看 MyBatis 是如何处理这些操作的,以及为什么这么设计。


4.1 插入数据(Insert)

4.1.1 插入一个对象:为什么 #{} 里是属性名?

我们先看一个标准的插入方法:

java 复制代码
@Insert("INSERT INTO student(name, age, gender) VALUES(#{name}, #{age}, #{gender})")
int insert(Student student);

你可能会问:为什么 #{name} 能直接取到 Student 对象的 name 属性?MyBatis 是怎么知道 #{name} 对应 student.getName() 的?

底层原理:

  1. 参数容器:因为只有一个参数且是对象类型,MyBatis 不会创建 Map,而是直接把这个 Student 对象作为"参数源"。
  2. OGNL 表达式 :当解析 SQL 中的 #{name} 时,MyBatis 使用 OGNL(对象图导航语言) 来从参数源中取值。OGNL 本质上是一个表达式引擎,它能够根据字符串表达式(比如 "name")去调用对象的 getter 方法。
  3. 反射调用 :OGNL 会通过反射找到 student.getName() 方法并执行,从而拿到属性值。

所以,#{} 里的名字必须和对象的属性名一致,因为 MyBatis 会把这个名字当作 OGNL 表达式去对象里找对应的 getter 方法。如果名字不对,比如写成 #{username},而 Student 类里没有 getUsername() 方法,就会抛出异常。

4.1.2 插入后为什么需要获取主键?怎么获取?

为什么需要主键?

在很多业务场景中,插入一条数据后,我们需要知道这条数据的 ID,以便进行后续操作。比如:

  • 电商系统中,下单后需要拿着订单 ID 去通知库存、物流系统。
  • 社交系统中,发布文章后需要返回文章 ID 供前端跳转。
  • 关联操作中,需要在其他表中引用新插入数据的 ID。

如果插入后拿不到 ID,我们就得再用查询语句去查一遍,既浪费资源又麻烦。所以 MyBatis 提供了直接获取自增主键的功能。

怎么获取?

使用 @Options 注解:

java 复制代码
@Options(useGeneratedKeys = true, keyProperty = "id")
@Insert("INSERT INTO student(name, age, gender) VALUES(#{name}, #{age}, #{gender})")
int insert(Student student);
  • useGeneratedKeys = true:useGeneratedKeys 译为使用自动生成的键值,告诉 MyBatis,我们想要数据库自动生成的主键。
  • keyProperty = "id":告诉 MyBatis,把生成的主键值赋值给传入对象的哪个属性(这里是 id 属性)。

底层原理是什么?

这个功能的底层依赖于 JDBC 的 getGeneratedKeys() 方法。让我们看看完整的流程:

  1. JDBC 层面 :当我们通过 PreparedStatement 执行插入时,如果数据库支持自增主键,JDBC 允许我们在创建 PreparedStatement 时指定一个标志,表示想要返回生成的主键。例如:

    java 复制代码
    ps = conn.prepareStatement(sql, Statement.RETURN_GENERATED_KEYS);

    执行插入后,可以通过 ps.getGeneratedKeys() 获取一个 ResultSet,里面就是生成的主键。

  2. MyBatis 层面 :当我们在 @Options 中设置了 useGeneratedKeys=true,MyBatis 在内部创建 PreparedStatement 时就会加上 Statement.RETURN_GENERATED_KEYS 标志。插入执行完毕后,MyBatis 会自动调用 getGeneratedKeys() 拿到主键值,然后通过反射找到传入对象的 setId() 方法,把主键值设置进去。

取出来存在哪里?

主键值被存回了你传入的那个对象中。也就是说,执行完插入方法后,你之前创建的 student 对象的 id 属性就不再是 null 了,而是变成了数据库生成的值。这样你就可以直接从原对象中拿到主键,非常方便。

4.1.3 插入成功返回的结果是什么?

插入方法的返回值类型通常是 int,表示受影响的行数。如果插入成功一条,返回值就是 1;如果插入多条(批量插入),返回值就是成功插入的条数。

即使我们通过 @Options 获取了主键,返回值依然是受影响的行数,而不是主键。主键是通过对象属性带回来的。


4.2 删除数据(Delete)

4.2.1 删除为什么通常根据 id 删除?

删除操作的核心是精准定位要删除的记录。在关系型数据库中,主键(通常是 id)是唯一标识一条记录的字段。通过主键删除可以确保只删除目标记录,不会误删其他数据。

如果我们用其他字段(比如 name)删除,可能会删除多条同名记录,造成数据丢失。所以,标准的删除操作都是根据主键进行的。

4.2.2 删除方法的参数传递

删除方法通常接收一个 id 参数,这是一个单个简单参数

java 复制代码
@Delete("DELETE FROM student WHERE id = #{id}")
int deleteById(Integer id);
  • 调用时传入一个 Integer 值,比如 deleteById(5)
  • 根据我们之前学的参数传递规则,单个简单参数不会被包装成 Map,而是直接作为参数源。
  • MyBatis 解析 #{id} 时,直接从参数源中取值(5),设置到 PreparedStatement 中。
4.2.3 删除成功返回的结果是什么?

删除方法的返回值也是 int,表示受影响的行数。如果删除成功一条,返回 1;如果删除的记录不存在,返回 0。我们可以根据返回值判断是否真的删除了数据。


4.3 更新数据(Update)

4.3.1 更新为什么通常根据 id 修改?

和删除一样,更新也需要精确定位到要修改的记录。主键 id 是唯一标识,所以更新条件中几乎总是包含 id,确保只修改目标记录,不会改到其他数据。

4.3.2 更新方法的两种常见形式

形式一:接收多个参数(id + 要修改的字段)

java 复制代码
@Update("UPDATE student SET name = #{name}, age = #{age} WHERE id = #{id}")
int updateById(@Param("id") Integer id, @Param("name") String name, @Param("age") Integer age);
  • 这里有三个参数,我们用了 @Param 指定名字。
  • MyBatis 会构建一个 ParamMap,key 为 "id""name""age"
  • 解析 SQL 时,从 Map 中取出对应的值设置到 PreparedStatement。

形式二:传递一个对象,根据对象里的 id 修改

java 复制代码
@Update("UPDATE student SET name = #{name}, age = #{age} WHERE id = #{id}")
int update(Student student);
  • 这是一个单个对象参数
  • MyBatis 直接把 student 对象作为参数源,通过 OGNL 从对象中取出 nameageid 的值。
  • 注意:SQL 中的 #{id} 就是从 student 对象的 getId() 方法取值。这就要求传入的 student 对象必须已经设置了 id(即你要修改哪条记录)。

为什么可以用对象?

因为更新时,我们通常会把要修改的数据封装成一个对象,包括 id。这样代码更简洁,而且和插入操作风格一致。

4.3.3 更新成功返回的结果是什么?

更新方法同样返回 int,表示受影响的行数。如果更新成功一条,返回 1;如果更新的记录不存在,返回 0。我们可以通过返回值判断更新是否生效。


5. 增删改操作中的参数传递总结

操作 常见参数形式 参数类型 MyBatis 处理方式 SQL 中引用 返回值含义
插入 单个对象 对象参数 直接使用对象作为参数源,通过 OGNL 取属性值 #{属性名} 受影响行数(通常为 1)
插入(带主键返回) 单个对象 对象参数 同上,但执行后会通过反射将生成的主键设置回对象 #{属性名} 受影响行数,主键在对象中
删除 单个简单参数(id) 简单参数 直接持有参数值 #{任意名} 受影响行数
删除 多个参数(如根据复合条件) 多参数(有@Param) 构建 ParamMap #{指定key} 受影响行数
更新 多个参数(id+字段) 多参数(有@Param) 构建 ParamMap #{指定key} 受影响行数
更新 单个对象(含 id) 对象参数 直接使用对象作为参数源,通过 OGNL 取属性值 #{属性名} 受影响行数

核心思想:增删改操作的本质就是 SQL 执行,参数传递规则与查询完全一致。理解了参数容器模型,你就掌握了所有操作的参数传递。


5. 查询结果映射:解决字段名不一致的问题

5.1 查询结果是如何变成 Java 对象的?

当我们执行一条查询 SQL 后,数据库会返回一个 ResultSet 结果集,你可以把它想象成一个表格------有行有列。MyBatis 需要把表格中的 每一行数据 转换成一个 Java 对象 ( 比如 Student 对象),这个转换过程就叫 结果映射(Result Mapping)

MyBatis 的默认映射规则非常直接:它会遍历结果集中的每一列,获取该列的列名,然后在目标 Java 类中查找 同名属性 (忽略大小写)。如果找到了对应的属性,MyBatis 就会通过反射调用该属性的 setter 方法,将这一列的值赋进去。

举个例子,假设 SQL 返回的列名是 idnameage,MyBatis 就会去 Student 类中找 idnameage 这三个属性,并分别调用 setIdsetNamesetAge 方法把值填进去。这个过程对开发者完全透明,我们只需要定义好 Java 类,MyBatis 就自动帮我们把数据库的行变成了对象。

这种默认规则在列名和属性名完全一致 (或仅有大小写区别)时工作得很好。但现实开发中,数据库的命名习惯往往是下划线命名 (如 create_time),而 Java 属性习惯驼峰命名 (如 createTime)。这就导致默认匹配失效------MyBatis 拿着 create_time 这个列名去类里找,找不到叫 create_time 的属性,于是这一列就被忽略,对应的 Java 属性就保持默认值 null

5.2 现象回顾:为什么 createTime 是 null?

我们之前查询所有学生:

java 复制代码
@Select("select id, name, age, gender, create_time, update_time from student")
List<Student> findAll();

结果:

运行后,你会发现 createTimeupdateTime 这两个属性是 null。原因就是上面说的:数据库返回的列名是 create_timeupdate_time,而 Student 类的属性名是 createTimeupdateTime,两者并不相等(即使忽略大小写,下划线和驼峰也不同)。MyBatis 找不到对应的属性,自然不会赋值。

为了解决这个问题,MyBatis 提供了三种方式,让我们可以告诉它"这个列应该对应哪个属性"。

5.3 解决方法一:SQL 别名

最直接的方法就是在 SQL 中给列起别名,让别名直接等于 Java 属性名。这样 MyBatis 看到的列名就和属性名一致了,自动映射就能成功。

java 复制代码
@Select("select id, name, age, gender, " +
        "create_time as createTime, update_time as updateTime " +
        "from student")
List<Student> findAll();

结果:

  • 原理 :数据库返回的列名被改成了 createTimeupdateTime,MyBatis 在自动映射时就能直接找到 createTimeupdateTime 属性。
  • 优点:简单直观,哪里不匹配改哪里。
  • 缺点:每个查询都要手动写别名,如果查询很多或字段很多,SQL 会变得冗长且难以维护。

5.4 解决方法二:使用 @Results 手动映射

MyBatis 提供了 @Results@Result 注解,允许我们显式指定列名与属性名的对应关系。这相当于给 MyBatis 一张"对照表",明确告诉它哪一列对应哪个属性。

java 复制代码
@Results(id = "studentMap", value = {
    @Result(column = "create_time", property = "createTime"),
    @Result(column = "update_time", property = "updateTime")
})
@Select("SELECT id, name, age, gender, create_time, update_time FROM student")
List<Student> findAll();
  • @Results:定义一组映射规则,可以给一个 id 方便复用。
  • @Result:指定单个映射,column 是数据库列名,property 是 Java 属性名。
  • 对于没有在 @Results 中指定的列(如 idname 等),MyBatis 仍然会尝试自动匹配(前提是列名和属性名一致)。

如果其他查询也想复用这组映射,可以使用 @ResultMap 注解引用:

java 复制代码
@ResultMap("studentMap")
@Select("SELECT * FROM student WHERE id = #{id}")
Student findById(Integer id);
  • 原理 :MyBatis 内部维护了一个映射注册表,当遇到 @ResultMap 时,它会根据 id 找到预先定义的映射规则,然后按照规则将列值赋给对应属性。
  • 优点:映射规则可以复用,适合复杂映射或跨多个方法。
  • 缺点 :每个需要映射的字段都要写 @Result,如果字段很多,代码依然比较繁琐。

5.5 解决方法三:开启驼峰命名自动转换(推荐使用)

MyBatis 提供了一个全局配置开关:mapUnderscoreToCamelCase 。开启后,MyBatis 会在自动映射时自动将数据库列名的下划线命名转换为驼峰命名,然后再去匹配 Java 属性。

application.yml 中配置:

yaml 复制代码
mybatis:
  configuration:
    map-underscore-to-camel-case: true

配置后,代码无需任何修改:

java 复制代码
@Select("SELECT id, name, age, gender, create_time, update_time FROM student")
List<Student> findAll();  // 现在 createTime 和 updateTime 都能正确赋值了
  • 原理 :开启该配置后,MyBatis 在创建自动映射时,会对列名进行转换:将下划线后的字母大写,并去掉下划线。例如 create_timecreateTimeupdate_timeupdateTime,然后拿着转换后的名字去查找 Java 对象的属性。这样即使列名和属性名风格不同,也能成功匹配。
  • 优点:全局生效,一劳永逸,代码最简洁。
  • 缺点 :如果某些字段命名不符合驼峰规则(例如列名就是 createtime 全小写),转换后可能还是对不上,但这种情况极少见。绝大多数场景下这是最佳实践。

5.6 三种方法对比

方法 原理 优点 缺点 适用场景
SQL 别名 改变列名,直接匹配 简单直接 每个查询都要写,冗余 临时解决,少量字段
@Results 手动建立映射表 规则可复用,精确控制 每个字段都要写,仍显繁琐 映射规则复杂或需复用
驼峰配置 自动转换列名 全局生效,代码最简 需配置,少数特殊命名无效 推荐,绝大多数项目

在实际开发中,推荐优先使用驼峰命名自动转换 ,因为它只需要一次配置,就能解决所有字段名不一致的问题,让代码更简洁、更易维护。只有在某些特殊场景下(例如需要处理复杂的嵌套映射),再考虑使用 @Results 或别名。

现在我们不仅知道如何解决字段名不一致的问题,还明白了 MyBatis 是如何把数据库的行变成 Java 对象的------这一切都离不开 ResultSet 的遍历、列名匹配、反射调用 setter 方法 这一系列幕后工作。理解了这一点,以后遇到映射问题,我们就能快速定位是哪个环节出了偏差。


6. 深入理解 @Param 注解

@Param 的作用我们已经知道:给方法参数起名字,让 MyBatis 在封装 Map 时使用这个名字作为 key。

但你可能好奇:它是对形参命名,还是对底层参数命名?

答案是:@Param 是对底层参数命名 。它在编译阶段会被保留,并通过反射在运行时获取。MyBatis 在处理参数时,会检查方法参数上是否有 @Param 注解,如果有,就用注解的 value 作为 Map 的 key;如果没有,则使用默认的 arg0/arg1param1/param2

举个例子

java 复制代码
Student findByNameAndAge(@Param("name") String name, @Param("age") Integer age);
  • MyBatis 构建一个 Map,放入两个键值对:{"name": name的值, "age": age的值}
  • 当解析 SQL 中的 #{name} 时,从 Map 中取 key 为 "name" 的值。

如果没有 @Param

java 复制代码
Student findByNameAndAge(String name, Integer age);
  • MyBatis 构建的 Map 中会有:{"arg0": name的值, "arg1": age的值, "param1": name的值, "param2": age的值}
  • 此时 SQL 中可以用 #{arg0}#{param1},但不能用 #{name}【不过这个取决于编译时是否保留了方法参数的真实名称,我们前面提到过】,因为 Map 中没有这个 key。

所以,@Param 的本质是为参数在 Map 中指定一个易于识别的 key,避免使用默认的索引名。


7.补充 【OGNL 是什么?为什么在 MyBatis 里会用到它?】

声明:以下内容查阅资料所得

OGNL 的全称是 Object-Graph Navigation Language ,翻译过来叫"对象图导航语言"。名字听起来很唬人,但它的作用其实很简单:帮我们从 Java 对象里取出属性值

你可以把 OGNL 想象成一个智能的"取货员"

  • 你给他一张纸条,上面写着要取什么货(比如 name)。
  • 他知道去哪里找(当前的对象是谁)。
  • 他知道怎么取(通过调用 getter 方法)。
  • 最后把取到的货(属性值)交给你。

1. 为什么需要 OGNL?

在 MyBatis 里,我们经常这样写 SQL:

java 复制代码
@Insert("INSERT INTO student(name, age) VALUES(#{name}, #{age})")
int insert(Student student);

这里 #{name}#{age} 看起来只是简单的占位符,但 MyBatis 怎么知道要从 student 对象里取 nameage 的值呢?

答案就是 OGNL。当 MyBatis 解析到 #{name} 时,它会用 OGNL 表达式去计算:"从当前参数对象中取出名为 name 的属性值"。

2. OGNL 怎么工作?

假设我们有一个 Student 类:

java 复制代码
public class Student {
    private String name;
    private int age;
    // getter 和 setter 省略
}

当我们执行 insert(student) 时,MyBatis 内部大致做了这样几步:

  1. 确定参数源 :因为只有一个参数且是对象,所以参数源就是 student 对象本身。
  2. 解析 #{name} :OGNL 拿到表达式 "name",然后在 student 对象上执行"获取 name 属性"的操作。这背后其实是通过反射调用 student.getName() 方法。
  3. 拿到值 :OGNL 把得到的值(比如 "张三")返回给 MyBatis。
  4. 设置到 SQL 占位符 :MyBatis 用这个值去设置 PreparedStatement 的参数。

3. OGNL 表达式的威力

OGNL 不只是取一层属性,它还能"导航"到更深的对象。比如如果 Student 里有一个 Address 对象:

java 复制代码
public class Student {
    private String name;
    private Address address;
    // ...
}

public class Address {
    private String city;
    // ...
}

那么在 SQL 里可以写 #{address.city},OGNL 就会自动先取 address 对象,再从这个对象里取 city 属性,相当于执行了 student.getAddress().getCity()

4. OGNL 和 Map 的关系

前面我们讲过,当参数是多个时,MyBatis 会把它们封装成一个 Map。这时候 OGNL 还会发挥作用吗?

答案是:如果参数是 Map,OGNL 就会直接从这个 Map 里根据 key 取值。比如:

java 复制代码
@Select("SELECT * FROM student WHERE name = #{name} AND age = #{age}")
Student findByNameAndAge(@Param("name") String name, @Param("age") Integer age);

MyBatis 内部构建了一个 Map:

复制代码
{
    "name" : "张三",
    "age"  : 18
}

当解析 #{name} 时,OGNL 会把这个 Map 当作对象,然后执行 map.get("name") 来取值。这和使用对象时的原理是一样的------都是通过 OGNL 表达式从参数源中取值,只是参数源的类型不同(一个是普通 Java 对象,一个是 Map 对象)。

5. 用生活中的例子理解 OGNL

想象你去一个巨大的仓库(Java 对象)取货:

  • 仓库里有很多货架(属性),每个货架上贴着标签(属性名)。
  • 你拿着一张提货单(SQL 中的 #{}),上面写着要取的货名(比如 name)。
  • 仓库管理员(OGNL)接过提货单,走到仓库里,根据标签找到对应的货架,把货取出来交给你。

如果货架上放的又是一个小仓库(嵌套对象),比如 address 货架上放的是另一个仓库,那么提货单上就可以写 address.city,管理员会先打开 address 仓库,再在里面找 city 货架。

6. 总结

  • OGNL 就是 MyBatis 用来从参数中取值的工具
  • 当参数是对象时,OGNL 通过反射调用 getter 方法取值。
  • 当参数是 Map 时,OGNL 直接根据 key 从 Map 中取值。
  • 我们写 #{属性名} 就是在写一个 OGNL 表达式,告诉 MyBatis 要从参数里取什么。

老铁们,到这里我们的注解实现增删改查就告一段落了。相信通过这篇文章,你对 MyBatis 的参数传递、结果映射以及背后的原理有了更深刻的理解。

下一期,我们将继续深入,学习如何使用 XML 文件来完成增删改查,看看 XML 方式和注解方式有哪些异同,以及如何应对更复杂的 SQL 场景。

如果觉得这篇文章对你有帮助,别忘了
👍 点赞
⭐ 收藏
👀 关注

你的支持是我持续输出的最大动力!我们下期见!

相关推荐
于先生吖2 小时前
SpringBoot+Vue 前后端分离短剧漫剧系统开发实战
vue.js·spring boot·后端
小王不爱笑1322 小时前
SpringBoot 自动装配深度解析:从底层原理到自定义 starter 实战(含源码断点调试)
java·spring boot·mybatis
while(1){yan}2 小时前
个人抽奖系统测试报告
spring boot·java-ee·压力测试
asom223 小时前
DDD(领域驱动设计) 核心概念详解
java·开发语言·数据库·spring boot
Fu-dada4 小时前
Spring Boot 开发接口指南
spring boot
大傻^4 小时前
LangChain4j Spring Boot Starter:自动配置与声明式 Bean 管理
java·人工智能·spring boot·spring·langchain4j
yhole4 小时前
springboot 修复 Spring Framework 特定条件下目录遍历漏洞(CVE-2024-38819)
spring boot·后端·spring
l软件定制开发工作室5 小时前
Spring开发系列教程(34)——打包Spring Boot应用
java·spring boot·后端·spring·springboot
青槿吖6 小时前
SpringMVC通关秘籍(下):日期转换器、拦截器与文件上传的奇幻冒险
java·开发语言·数据库·sql·mybatis·状态模式