【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 场景。

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

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

相关推荐
StockTV6 分钟前
印度股票实时数据 NSE和BSE的实时行情、K 线及指数数据
java·开发语言·spring boot·python
橘子海全栈攻城狮1 小时前
【最新源码】养老院系统管理A013
java·spring boot·后端·web安全·微信小程序
敖正炀1 小时前
反模式与排查宝典:Spring Boot 自动配置与核心机制的常见陷阱
spring boot
直奔標竿2 小时前
Java开发者AI转型第二十六课!Spring AI 个人知识库实战(五)——联网搜索增强实战
java·开发语言·人工智能·spring boot·后端·spring
吴爃3 小时前
Spring Boot 项目在 K8S 中的打包、部署与运维发布实践
运维·spring boot·kubernetes
a8a3023 小时前
Laravel8.x新特性全解析
java·spring boot·后端
白露与泡影3 小时前
Spring Boot 完整流程
java·spring boot·后端
小鲁蛋儿4 小时前
Dynamic + ShardingSphere整合
spring boot·shardingsphere·dynamic
北风toto5 小时前
Spring Boot / Spring Cloud 配置文件加密详解:使用 jasypt-spring-boot 实现 ENC() 加密
spring boot·后端·spring cloud