Java基础(18) | MyBatis 数据访问:SQL 映射、动态 SQL 与 MyBatis-Plus

📚 本系列系统梳理了 Java 开发的详细知识点,从基础语法到工程实践层层递进,内容详实成体系,建议先收藏再慢慢阅读,方便日后随时回顾查阅。

前言

上一篇用 MyBatis-Plus 写了 Mapper 层,BaseMapper 提供的方法已经能覆盖大部分简单 CRUD。但实际项目中经常会遇到多表 join、复杂条件、聚合统计这类 LambdaQueryWrapper 写不出来的查询,这时就需要回到 MyBatis 本身——写 XML 或注解形式的 SQL。这篇文章覆盖原生 MyBatis 的核心用法,以及 MyBatis-Plus 在它基础上做了哪些增强。

1. JPA vs MyBatis:怎么选?

前面用的 MyBatis-Plus 属于 MyBatis 体系——SQL 由你控制(或用 LambdaQueryWrapper 自动生成),但底层执行的还是真实 SQL。另一个主流方向是 JPA(Java Persistence Association,Java 持久化规范,常见实现是 Hibernate),思路完全不同:你不写 SQL,只定义 Entity 和方法名,框架自动生成 SQL。

// JPA:只需要定义接口和方法名,不写 SQL
public interface UserRepository extends JpaRepository<User, Long> {
    Optional<User> findByEmail(String email);
    List<User> findByNameContaining(String keyword);
    // 框架根据方法名自动解析成:
    // SELECT * FROM user WHERE email = ?
    // SELECT * FROM user WHERE name LIKE '%?%'
}
// MyBatis-Plus:写 LambdaQueryWrapper 或 XML,SQL 是显式的
userMapper.selectOne(
    new LambdaQueryWrapper<User>().eq(User::getEmail, email)
);
<!-- pom.xml -->
<dependency>
    <groupId>com.baomidou</groupId>
    <artifactId>mybatis-plus-boot-starter</artifactId>
    <version>3.5.7</version>
</dependency>

两者对比:

维度JPA (Hibernate)MyBatis
SQL 控制自动生成 SQL,可用 @Query 自定义完全手写 SQL,精确控制
学习曲线低(方法名即查询)中(需要写 XML 或注解 SQL)
复杂查询多表关联、动态条件较痛苦灵活,复杂 SQL 很自然
性能调优黑盒较多,调优需要理解 Hibernate 内部机制SQL 透明,优化直接改 SQL
国内使用中小项目、快速开发大厂主流(阿里、美团、OPPO…)
ORM 程度全自动 ORM半自动 ORM(SQL 手写,映射自动)

为什么国内大厂更偏好 MyBatis? JPA 自动生成的 SQL 在简单场景下很方便,但遇到多表 join、动态拼接条件、批量操作、SQL 性能调优时,往往很难精确控制生成的 SQL(甚至需要倒过来去猜框架生成了什么)。MyBatis 把 SQL 写出来,所见即所得,出了性能问题直接改 SQL、加索引、用 EXPLAIN 分析,不需要先理解一层框架黑盒。

2. Spring Boot 整合 MyBatis

2.1 依赖引入

// build.gradle.kts
dependencies {
    implementation("org.mybatis.spring.boot:mybatis-spring-boot-starter:3.0.3")
    runtimeOnly("com.mysql:mysql-connector-j:8.2.0")
}
<!-- pom.xml -->
<dependency>
    <groupId>org.mybatis.spring.boot</groupId>
    <artifactId>mybatis-spring-boot-starter</artifactId>
    <version>3.0.3</version>
</dependency>
<dependency>
    <groupId>com.mysql</groupId>
    <artifactId>mysql-connector-j</artifactId>
    <version>8.2.0</version>
    <scope>runtime</scope>
</dependency>

2.2 配置

# application.yml
spring:
  datasource:
    url: jdbc:mysql://localhost:3306/mydb?useSSL=false&serverTimezone=UTC
    username: root
    password: 123456
    driver-class-name: com.mysql.cj.jdbc.Driver

mybatis:
  mapper-locations: classpath:mapper/*.xml    # XML 映射文件位置
  type-aliases-package: com.example.entity    # 实体类包名(XML 中可以用简名)
  configuration:
    map-underscore-to-camel-case: true        # 下划线自动转驼峰(user_name → userName)
    log-impl: org.apache.ibatis.logging.stdout.StdOutImpl  # 打印 SQL(开发用)

2.3 项目结构

src/main/java/com/example/
├── entity/
│   └── User.java
├── mapper/
│   └── UserMapper.java          ← 接口
├── service/
│   └── UserService.java
└── controller/
    └── UserController.java

src/main/resources/
└── mapper/
    └── UserMapper.xml            ← SQL 映射文件

3. 基础 CRUD

3.1 实体类

public class User {
    private Long id;
    private String name;
    private String email;
    private LocalDateTime createdAt;

    // 构造方法
    public User() {}
    public User(String name, String email) {
        this.name = name;
        this.email = email;
    }

    // getter / setter
    public Long getId() { return id; }
    public void setId(Long id) { this.id = id; }
    public String getName() { return name; }
    public void setName(String name) { this.name = name; }
    public String getEmail() { return email; }
    public void setEmail(String email) { this.email = email; }
    public LocalDateTime getCreatedAt() { return createdAt; }
    public void setCreatedAt(LocalDateTime createdAt) { this.createdAt = createdAt; }
}

3.2 Mapper 接口

@Mapper
public interface UserMapper {
    User selectById(Long id);
    List<User> selectAll();
    int insert(User user);
    int update(User user);
    int deleteById(Long id);
}

@Mapper 标记这是一个 MyBatis 接口。接口里只声明方法签名,不写实现——具体的 SQL 写在对应的 XML 文件里,MyBatis 在运行时通过反射把接口方法和 XML 里的 SQL 关联起来。

3.3 XML 映射文件

XML 文件和 Mapper 接口是一一对应的:文件名通常和接口名相同(UserMapper.xml),放在 resources/mapper/ 目录下。namespace 指定对应哪个接口,文件里每个标签的 id 对应接口里的一个方法。

<?xml version="1.0" encoding="UTF-8" ?>
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
    "http://mybatis.org/dtd/mybatis-3-mapper.dtd">

<mapper namespace="com.example.mapper.UserMapper">
    <!-- namespace 必须是 Mapper 接口的完整类名 -->
    <!-- 下面每个标签的 id 必须和接口里的方法名一致 -->
</mapper>
resultMap:数据库字段 → Java 属性的映射
<resultMap id="userMap" type="User">
    <id property="id" column="id"/>              <!-- 主键 -->
    <result property="name" column="name"/>       <!-- 普通字段 -->
    <result property="email" column="email"/>
    <result property="createdAt" column="created_at"/>  <!-- 字段名不一致时必须写 -->
</resultMap>

<resultMap id="..." type="...">

定义一个"结果映射规则",描述查询出来的数据库行该怎么转成 Java 对象。

  • id:这个 resultMap 的名字,给后面的 <select> 引用用,可以随便起,只要文件内唯一
  • type:转换成什么 Java 类型,这里是 User(如果配置了 typeAliasesPackage,可以直接写类名;否则要写全限定名 com.example.User

<id property="..." column="..."/>

标记主键字段的映射规则:

  • property:Java 对象的属性名,对应 User 类的 id 字段(实际调用 setId()
  • column:数据库结果集里的列名,对应 SQL 查出来的 id

为什么主键要单独用 <id> 而不是 <result>?因为 MyBatis 用主键来判断"这两行结果是不是同一个对象"——一对多场景下尤其重要(详见 6.2)。

<result property="..." column="..."/>

标记普通字段的映射规则,含义和 <id> 一样,区别只是它不是主键。property 是 Java 属性名,column 是数据库列名。

如果开启了 map-underscore-to-camel-case、且字段名能自动对应,这些 <result> 可以省略——但 join 查询经常涉及别名(o.id AS order_id),保险起见显式写出来更清楚。

select:查询,对应接口的查询方法
<!-- 对应 User selectById(Long id) -->
<select id="selectById" resultMap="userMap">
    SELECT id, name, email, created_at
    FROM users
    WHERE id = #{id}
</select>
  • id="selectById" 对应接口里的 selectById 方法
  • resultMap="userMap" 指定用上面定义的映射规则把查询结果转成 User 对象
  • #{id} 是参数占位符,对应方法参数 selectById(Long id) 里的 id
<!-- 对应 List<User> selectAll() -->
<select id="selectAll" resultMap="userMap">
    SELECT id, name, email, created_at
    FROM users
    ORDER BY created_at DESC
</select>

返回 List<User> 时,<select> 标签写法不变,MyBatis 根据接口方法的返回类型自动判断返回单个对象还是列表。

insert:插入,对应接口的插入方法
<!-- 对应 int insert(User user) -->
<insert id="insert" useGeneratedKeys="true" keyProperty="id">
    INSERT INTO users (name, email, created_at)
    VALUES (#{name}, #{email}, NOW())
</insert>
  • #{name}#{email} 对应参数对象 User user 的属性 user.getName()user.getEmail()
  • useGeneratedKeys="true" + keyProperty="id":插入后数据库自动生成的主键值会被写回 user.id,效果和 MyBatis-Plus 的 IdType.AUTO 一致
update:更新,对应接口的更新方法
<!-- 对应 int update(User user) -->
<update id="update">
    UPDATE users
    SET name = #{name}, email = #{email}
    WHERE id = #{id}
</update>

写法和普通 SQL 一致,#{字段名} 取传入对象对应的属性值。

delete:删除,对应接口的删除方法
<!-- 对应 int deleteById(Long id) -->
<delete id="deleteById">
    DELETE FROM users WHERE id = #{id}
</delete>
五个标签和接口方法对应关系
XML 标签对应接口方法SQL 类型
<select>查询方法SELECT
<insert>插入方法INSERT
<update>更新方法UPDATE
<delete>删除方法DELETE

标签的 id 属性 = 接口的方法名,是连接 Java 代码和 SQL 的桥梁。

#{} 参数引用:简单类型 vs 对象

#{属性名} 默认是从参数对象里取属性值(调用对应的 getter)。但参数类型不同,写法会不一样:

参数是单个简单类型LongString 等),#{} 里随便写什么名字都行,习惯上和参数名一致:

User selectById(Long id);
<select id="selectById" resultMap="userMap">
    SELECT * FROM users WHERE id = #{id}
    <!-- 这里写 #{id}、#{xxx}、#{anything} 效果都一样,因为只有一个参数 -->
</select>

参数是对象#{} 里必须是对象的属性名(对应 getter):

int insert(User user);
<insert id="insert">
    INSERT INTO users (name, email) VALUES (#{name}, #{email})
    <!-- #{name} 实际调用 user.getName()
         #{email} 实际调用 user.getEmail() -->
</insert>

参数是多个时,必须用 @Param 注解给每个参数命名,#{} 里写注解指定的名字:

List<User> findByNameAndStatus(@Param("name") String name, @Param("status") int status);
<select id="findByNameAndStatus" resultMap="userMap">
    SELECT * FROM users WHERE name = #{name} AND status = #{status}
    <!-- #{name} 对应 @Param("name") 的那个参数
         #{status} 对应 @Param("status") 的那个参数 -->
</select>

不加 @Param 时多参数会报错(Parameter 'xxx' not found),因为 MyBatis 不知道 #{} 里的名字该对应哪个参数。

一句话总结:单个简单类型参数 → #{} 里名字随意;对象参数 → #{} 里写属性名;多个参数 → 必须 @Param 命名,#{} 里写注解里的名字。

3.4 #{} vs ${}

#{}:预编译参数,绝大多数场景用这个
<select id="selectById" resultType="User">
    SELECT * FROM users WHERE id = #{id}
</select>

MyBatis 把 #{id} 转成 ? 占位符,参数单独传给数据库驱动:

-- 实际执行的 SQL
SELECT * FROM users WHERE id = ?
-- 参数 id = 1 单独传递,不会和 SQL 语句拼接在一起

这种方式叫预编译(PreparedStatement)——SQL 结构和参数是分开的,数据库先编译好 SQL 模板,再把参数当作纯数据填进去,参数里即使包含 '; 等特殊字符也只会被当作普通字符串值,不会改变 SQL 的结构。

${}:字符串替换,有 SQL 注入风险
<select id="selectByColumn" resultType="User">
    SELECT * FROM users ORDER BY ${columnName}
</select>

${columnName}直接把字符串拼接进 SQL,不经过预编译:

// 正常调用
selectByColumn("name")
// 拼接后:SELECT * FROM users ORDER BY name   ✅ 正常

// 恶意调用
selectByColumn("name; DROP TABLE users; --")
// 拼接后:SELECT * FROM users ORDER BY name; DROP TABLE users; --   ❌ 表被删了!

因为是字符串拼接,参数里的内容会直接成为 SQL 的一部分,恶意输入可以改变 SQL 的结构和语义——这就是 SQL 注入

什么时候必须用 ${}

#{} 生成的是 ? 占位符,只能用在的位置(WHERE id = ?),不能用在SQL 结构的位置——表名、列名、ORDER BY 的字段名都不行:

<!-- ❌ 错的:表名不能用 #{},会生成 WHERE table_name = ? 这种无意义的 SQL -->
<select id="selectFromTable" resultType="User">
    SELECT * FROM #{tableName} WHERE id = #{id}
</select>

<!-- ✅ 对的:动态表名/列名只能用 ${} -->
<select id="selectFromTable" resultType="User">
    SELECT * FROM ${tableName} WHERE id = #{id}
    <!--         ↑ 表名,只能 ${}        ↑ 值,用 #{} -->
</select>

这种场景下用 ${} 是无法避免的,但必须自己在 Java 代码里做白名单校验,确保传入的值只能是预期范围内的几个固定字符串:

private static final Set<String> ALLOWED_TABLES = Set.of("users", "orders", "products");

public List<User> selectFromTable(String tableName) {
    if (!ALLOWED_TABLES.contains(tableName)) {
        throw new BizException("Invalid table name: " + tableName);
    }
    return userMapper.selectFromTable(tableName);
}

原则:永远优先用 #{};只有动态表名/列名这种 SQL 结构本身需要变化的场景才用 ${},并且必须配合白名单校验。

4. 注解方式(简单 SQL 可以不写 XML)

3.3 节用 XML 写 SQL,需要单独维护一个 XML 文件,Java 方法和 SQL 分散在两处。对于简单的单表 CRUD,MyBatis 提供注解方式,直接把 SQL 写在接口方法上:

@Mapper
public interface UserMapper {

    @Select("SELECT * FROM users WHERE id = #{id}")
    User selectById(Long id);

    @Select("SELECT * FROM users")
    List<User> selectAll();

    @Insert("INSERT INTO users (name, email, created_at) VALUES (#{name}, #{email}, NOW())")
    @Options(useGeneratedKeys = true, keyProperty = "id")
    int insert(User user);

    @Update("UPDATE users SET name = #{name}, email = #{email} WHERE id = #{id}")
    int update(User user);

    @Delete("DELETE FROM users WHERE id = #{id}")
    int deleteById(Long id);
}

注解和 XML 标签是一一对应的:

注解对应 XML 标签
@Select<select>
@Insert<insert>
@Update<update>
@Delete<delete>
@Options(useGeneratedKeys=true, keyProperty="id")<insert useGeneratedKeys="true" keyProperty="id">

#{id}#{name} 的取值规则和 XML 里完全一样——单个简单类型参数随意命名,对象参数写属性名,多参数需要 @Param

XML vs 注解怎么选?

注解XML
适合场景简单单表 CRUD多表 join、动态条件、复杂统计
SQL 和代码的关系SQL 和方法定义在一起,一眼看到SQL 和接口分离,需要切换文件
长 SQL 可读性SQL 写在字符串里,换行麻烦XML 天然支持换行和格式化
动态 SQL(if/where等)不支持支持

实际项目中两者可以混用——同一个 Mapper 接口里,简单方法用注解,复杂方法写在对应的 XML 文件里,MyBatis 会自动识别。

5. 动态 SQL

MyBatis 最强大的特性之一——根据传入参数的不同,动态拼接出不同的 SQL。比如一个搜索接口,用户可能只传 name,也可能只传 email,也可能两者都传,对应的 SQL 条件需要动态变化,而不是写死。

5.1 if:条件存在时才加这段 SQL

<select id="search" resultMap="userMap">
    SELECT * FROM users
    WHERE 1 = 1
    <if test="name != null and name != ''">
        AND name LIKE CONCAT('%', #{name}, '%')
    </if>
    <if test="email != null and email != ''">
        AND email = #{email}
    </if>
</select>

<if test="条件"> 里的 test 是判断表达式,判断传入参数对象的属性。条件为真时,标签内的 SQL 片段才会拼进最终的 SQL:

参数:name="Alice", email=null
拼接结果:SELECT * FROM users WHERE 1=1 AND name LIKE '%Alice%'

参数:name=null, email="a@b.com"
拼接结果:SELECT * FROM users WHERE 1=1 AND email = 'a@b.com'

参数:name=null, email=null
拼接结果:SELECT * FROM users WHERE 1=1

WHERE 1=1 是一个技巧——保证不管有没有 AND 条件,WHERE 后面始终有内容,SQL 语法不会出错。但这样写不优雅,所以有了下面的 <where>

5.2 where:自动处理 WHERE 和多余的 AND

<select id="search" resultMap="userMap">
    SELECT * FROM users
    <where>
        <if test="name != null and name != ''">
            AND name LIKE CONCAT('%', #{name}, '%')
        </if>
        <if test="email != null">
            AND email = #{email}
        </if>
    </where>
    ORDER BY created_at DESC
</select>

<where> 标签做了三件事:

  1. 如果里面有任意一个 <if> 命中,自动在最前面加上 WHERE
  2. 自动去掉第一个生效条件开头多余的 AND(不管你写不写都行)
  3. 如果所有 <if> 都没命中,整个 WHERE 都不会出现

注意:每个 <if> 内部的 SQL 片段都要以 AND(或 OR)开头,这是固定写法。<where> 只负责去掉最终拼接结果最开头那一个多余的 AND,中间的 AND 都要保留,否则多个条件之间无法正确连接:

<if test="name != null">AND name = #{name}</if>
<if test="email != null">AND email = #{email}</if>
两个都命中时拼接结果:AND name = 'Alice' AND email = 'a@x.com'
<where> 去掉最开头的 AND:name = 'Alice' AND email = 'a@x.com'
补上 WHERE:WHERE name = 'Alice' AND email = 'a@x.com'

结论:每个 <if> 都要以 AND 开头,不要心存"反正是第一个就不用写"的想法——<where> 只保证去掉最终结果开头的那一个 AND,不会帮你补全中间缺失的连接符。

<where> 替代手写 WHERE 1=1,是更规范的写法。

5.3 set:动态更新部分字段

更新操作经常遇到"只更新传了值的字段,其他字段保持不变"的需求:

<update id="updateSelective">
    UPDATE users
    <set>
        <if test="name != null">name = #{name},</if>
        <if test="email != null">email = #{email},</if>
    </set>
    WHERE id = #{id}
</update>

<set> 自动处理逗号——不管命中几个 <if>,最后一个字段后面多余的逗号会被自动去掉:

参数:name="Bob", email=null
结果:UPDATE users SET name = 'Bob' WHERE id = 1
      (email 那个 if 没命中,name 后面的逗号被自动去掉)

参数:name="Bob", email="bob@x.com"
结果:UPDATE users SET name = 'Bob', email = 'bob@x.com' WHERE id = 1

5.4 choose / when / otherwise:多选一,类似 switch

<select id="search" resultMap="userMap">
    SELECT * FROM users
    <where>
        <choose>
            <when test="id != null">
                AND id = #{id}
            </when>
            <when test="email != null">
                AND email = #{email}
            </when>
            <otherwise>
                AND created_at > DATE_SUB(NOW(), INTERVAL 7 DAY)
            </otherwise>
        </choose>
    </where>
</select>

<if> 最大的区别:<if> 是"每个条件独立判断,可能多个同时命中";<choose> 是"按顺序判断,只命中第一个为真的分支",剩下的全部跳过——就像 Java 的 if-else if-else

参数:id=5
命中 <when test="id != null">,结果:... AND id = 5
(即使 email 也不为 null,也不会再判断第二个 when)

参数:id=null, email="a@b.com"
第一个 when 不命中,命中第二个,结果:... AND email = 'a@b.com'

参数:id=null, email=null
两个 when 都不命中,走 <otherwise>,结果:... AND created_at > DATE_SUB(NOW(), INTERVAL 7 DAY)

5.5 foreach:遍历集合,常用于 IN 查询和批量插入

批量查询(IN 条件)

<select id="selectByIds" resultMap="userMap">
    SELECT * FROM users
    WHERE id IN
    <foreach collection="ids" item="id" open="(" separator="," close=")">
        #{id}
    </foreach>
</select>
List<User> selectByIds(@Param("ids") List<Long> ids);

<foreach> 的属性含义:

属性作用
collection要遍历的集合,对应 @Param 指定的名字
item遍历时每个元素的临时变量名,循环体内用 #{item} 引用
open拼接结果的开头
separator每个元素之间的分隔符
close拼接结果的结尾
参数:ids = [1, 2, 3]
拼接结果:WHERE id IN ( 1 , 2 , 3 )
                    ↑open       ↑close

批量插入

<insert id="batchInsert">
    INSERT INTO users (name, email, created_at)
    VALUES
    <foreach collection="users" item="user" separator=",">
        (#{user.name}, #{user.email}, NOW())
    </foreach>
</insert>
int batchInsert(@Param("users") List<User> users);

注意这里 item="user" 是一个对象,所以循环体里用 #{user.name}#{user.email} 取对象的属性(和 3.3 节"#{} 引用对象属性"的规则一致):

参数:users = [User("Alice","a@x.com"), User("Bob","b@x.com")]
拼接结果:
INSERT INTO users (name, email, created_at)
VALUES ('Alice', 'a@x.com', NOW()), ('Bob', 'b@x.com', NOW())

一条 SQL 插入多行,比循环调用单条 insert 性能好得多。

5.6 sql 片段复用

多个查询经常需要返回同样的几个字段,重复写容易遗漏或不一致,可以提取成公共片段:

<!-- 定义可复用的 SQL 片段,id 是这个片段的名字 -->
<sql id="baseColumns">
    id, name, email, created_at
</sql>

<!-- 用 <include refid="片段id"/> 引用 -->
<select id="selectById" resultMap="userMap">
    SELECT <include refid="baseColumns"/>
    FROM users
    WHERE id = #{id}
</select>

<select id="selectAll" resultMap="userMap">
    SELECT <include refid="baseColumns"/>
    FROM users
</select>

两个 <select> 实际执行的 SQL 是:

SELECT id, name, email, created_at FROM users WHERE id = ?
SELECT id, name, email, created_at FROM users

以后要加一个字段,只需要改 <sql id="baseColumns"> 这一处,所有引用它的查询都会同步更新。

6. 多表关联

6.1 一对一

数据库表

-- users 表
| id | name  |
|----|-------|
| 1  | Alice |

-- user_profiles 表
| user_id | bio        | avatar       |
|---------|------------|--------------|
| 1       | developer  | avatar1.jpg  |

一个用户对应一条 profile 记录——一对一关系。

JOIN 查询结果

SELECT u.id, u.name, p.user_id, p.bio, p.avatar
FROM users u
LEFT JOIN user_profiles p ON u.id = p.user_id
WHERE u.id = 1
| id | name  | user_id | bio        | avatar       |
|----|-------|---------|------------|--------------|
| 1  | Alice | 1       | developer  | avatar1.jpg  |

数据库返回的是一行扁平的数据,但 Java 里 User 想要的是嵌套结构:

public class User {
    private Long id;
    private String name;
    private UserProfile profile;  // 嵌套对象
}

public class UserProfile {
    private Long userId;
    private String bio;
    private String avatar;
}

<association> 就是用来做这个转换的——把结果行里的某几列,组装成一个嵌套对象:

XML 标签和属性详解

<resultMap id="userWithProfile" type="User">
    <id property="id" column="id"/>
    <result property="name" column="name"/>

    <association property="profile" javaType="UserProfile">
        <result property="userId" column="user_id"/>
        <result property="bio" column="bio"/>
        <result property="avatar" column="avatar"/>
    </association>
</resultMap>

<select id="selectWithProfile" resultMap="userWithProfile">
    SELECT u.id, u.name, p.user_id, p.bio, p.avatar
    FROM users u
    LEFT JOIN user_profiles p ON u.id = p.user_id
    WHERE u.id = #{id}
</select>

逐个解释:

<association property="..." javaType="...">

定义"嵌套对象"的映射规则:

  • property:嵌套对象赋给外层对象的哪个字段,这里是 profile,实际调用 User.setProfile(...)
  • javaType:这个嵌套对象本身的类型,这里是 UserProfile

<association> 标签内部又写了一组 <result>,规则和外层一样——propertyUserProfile 类的属性名,column 是 SQL 结果中对应的列名。

<select id="..." resultMap="...">

定义一个查询语句。

  • id:必须和 Mapper 接口里的方法名完全一致(这里对应 User selectWithProfile(Long id)
  • resultMap:用哪个 <resultMap> 来组装结果,这里引用上面定义的 userWithProfile

也可以用 resultType="User" 替代 resultMap——resultType 适合"字段名能自动映射"的简单场景,resultMap 适合"需要自定义映射规则"或"涉及嵌套对象"的复杂场景。

整体流程

1. <select> 执行 SQL,从数据库拿到一行扁平数据:
   [id=1, name="Alice", user_id=1, bio="后端开发者", avatar="avatar1.jpg"]

2. resultMap="userWithProfile" 告诉 MyBatis 用哪套规则转换

3. MyBatis 按规则组装:
   - 创建一个 User 对象(type="User")
   - 用 column="id" 的值赋给 property="id"(setId(1))
   - 用 column="name" 的值赋给 property="name"(setName("Alice"))
   - 遇到 <association>:创建一个 UserProfile 对象(javaType="UserProfile")
     - 用 column="user_id" 的值赋给 property="userId"(setUserId(1))
     - ... 类似地处理 bio 和 avatar
   - 把 UserProfile 对象赋给 User 的 profile 字段(setProfile(...))

4. 最终返回一个嵌套好的 User 对象

最终组装出来的 Java 对象:

User {
    id = 1,
    name = "Alice",
    profile = UserProfile {
        userId = 1,
        bio = "developer",
        avatar = "avatar1.jpg"
    }
}

一句话<association> 用于"一个对象里嵌套另一个对象"的场景(一对一)。

6.2 一对多

数据库表

-- users 表
| id | name  |
|----|-------|
| 1  | Alice |

-- orders 表
| id | user_id | amount | status |
|----|---------|--------|--------|
| 101| 1       | 99.00  | PAID   |
| 102| 1       | 50.00  | PENDING|

一个用户对应多条订单——一对多关系。

JOIN 查询结果

SELECT u.id, u.name, o.id AS order_id, o.amount, o.status
FROM users u
LEFT JOIN orders o ON u.id = o.user_id
WHERE u.id = 1
| id | name  | order_id | amount | status  |
|----|-------|----------|--------|---------|
| 1  | Alice | 101      | 99.00  | PAID    |
| 1  | Alice | 102      | 50.00  | PENDING |

数据库返回两行(因为 JOIN 后一个用户对应多条订单各占一行),但 Java 里 User 想要的是"一个用户对象,里面包含一个订单列表":

public class User {
    private Long id;
    private String name;
    private List<Order> orders;  // 一个用户对应多个订单
}

<collection> 就是用来做这个转换的——把多行结果中重复的部分合并成一个对象,把变化的部分收集成一个列表

<resultMap id="userWithOrders" type="User">
    <id property="id" column="id"/>           <!-- id 列 → User.id(两行的 id 都是 1,合并为同一个 User) -->
    <result property="name" column="name"/>   <!-- name 列 → User.name -->

    <!-- collection:把每一行的这几列组装成一个 Order 对象,收集进 List -->
    <collection property="orders" ofType="Order">
        <id property="id" column="order_id"/>
        <result property="amount" column="amount"/>
        <result property="status" column="status"/>
    </collection>
</resultMap>

<select id="selectWithOrders" resultMap="userWithOrders">
    SELECT u.id, u.name, o.id AS order_id, o.amount, o.status
    FROM users u
    LEFT JOIN orders o ON u.id = o.user_id
    WHERE u.id = #{id}
</select>

MyBatis 处理这两行数据时的逻辑:

第一行:id=1, name="Alice", order_id=101, amount=99.00, status="PAID"
  → User.id 和 User.name 相同的行,会被识别为"同一个 User"
  → 把 order_id/amount/status 组装成一个 Order,放进 orders 列表

第二行:id=1, name="Alice", order_id=102, amount=50.00, status="PENDING"
  → id=1 和上一行相同,还是"同一个 User",不会创建第二个 User 对象
  → 把这一行的 order_id/amount/status 组装成另一个 Order,加进同一个 orders 列表

最终组装出来的 Java 对象:

User {
    id = 1,
    name = "Alice",
    orders = [
        Order { id = 101, amount = 99.00, status = "PAID" },
        Order { id = 102, amount = 50.00, status = "PENDING" }
    ]
}

一句话<collection> 用于"一个对象里嵌套一个列表"的场景(一对多),<resultMap> 里的 <id> 标签是判断"哪几行属于同一个对象"的依据——id 相同的行会被合并。

<association> vs <collection>

<association><collection>
关系一对一一对多
结果嵌套一个对象嵌套一个列表
属性javaType="目标类型"ofType="列表元素类型"

7. 分页

7.1 手动分页

<select id="selectPage" resultMap="userMap">
    SELECT * FROM users
    ORDER BY created_at DESC
    LIMIT #{offset}, #{size}
</select>

<select id="countAll" resultType="long">
    SELECT COUNT(*) FROM users
</select>

7.2 PageHelper 插件(推荐)

dependencies {
    implementation("com.github.pagehelper:pagehelper-spring-boot-starter:1.4.7")
}
<dependency>
    <groupId>com.github.pagehelper</groupId>
    <artifactId>pagehelper-spring-boot-starter</artifactId>
    <version>1.4.7</version>
</dependency>
// 使用方式:在查询前调用 PageHelper.startPage
public PageInfo<User> listPage(int pageNum, int pageSize) {
    PageHelper.startPage(pageNum, pageSize);  // 自动拦截下一条查询,追加 LIMIT
    List<User> list = userMapper.selectAll();  // 正常的查询,不需要改 SQL
    return new PageInfo<>(list);               // 包含总数、总页数、当前页等信息
}
// PageInfo 包含的信息
PageInfo<User> page = listPage(1, 10);
page.getTotal();      // 总记录数
page.getPages();      // 总页数
page.getPageNum();    // 当前页码
page.getPageSize();   // 每页大小
page.getList();       // 当前页数据
page.isHasNextPage(); // 是否有下一页

8. MyBatis-Plus:MyBatis 的增强工具

MyBatis-Plus 不改变 MyBatis 的任何东西,只在它基础上做增强——单表 CRUD 零 SQL,复杂查询仍然用 MyBatis 原生方式。

8.1 引入依赖

// MyBatis-Plus 替代 mybatis-spring-boot-starter,不需要同时引入
dependencies {
    implementation("com.baomidou:mybatis-plus-spring-boot3-starter:3.5.5")
    runtimeOnly("com.mysql:mysql-connector-j:8.2.0")
}
<dependency>
    <groupId>com.baomidou</groupId>
    <artifactId>mybatis-plus-spring-boot3-starter</artifactId>
    <version>3.5.12</version>
</dependency>

8.2 实体类注解

@TableName("users")
public class User {
    @TableId(type = IdType.AUTO)
    private Long id;

    private String name;        // 自动映射 name 列
    private String email;

    @TableField("created_at")   // 列名和字段名不一致时指定
    private LocalDateTime createdAt;

    @TableField(exist = false)  // 标记非数据库字段
    private String extra;

    @TableLogic                 // 逻辑删除标记(0=未删除,1=已删除)
    private Integer deleted;

    // getter / setter
}

8.3 Mapper 接口

@Mapper
public interface UserMapper extends BaseMapper<User> {
    // 继承 BaseMapper 后,自动拥有以下方法:
    // insert(entity)
    // deleteById(id)
    // deleteBatchIds(idList)
    // updateById(entity)
    // selectById(id)
    // selectBatchIds(idList)
    // selectList(wrapper)
    // selectCount(wrapper)
    // selectPage(page, wrapper)
    // ... 共 17 个通用方法

    // 如果需要自定义复杂 SQL,仍然可以写 XML 或注解
    @Select("SELECT * FROM users WHERE name LIKE CONCAT('%', #{keyword}, '%')")
    List<User> customSearch(String keyword);
}

8.4 Service 层

8.3 节的 BaseMapper<User> 是 Mapper 层的通用封装;MyBatis-Plus 在 Service 层也提供了一套对应的通用封装——IServiceServiceImpl

// 1. Service 接口继承 IService<User>
public interface UserService extends IService<User> {
    // IService<User> 已经声明了 save / removeById / updateById
    // getById / list / page / count 等通用方法,不需要自己写
}

// 2. 实现类继承 ServiceImpl<UserMapper, User>
@Service
public class UserServiceImpl extends ServiceImpl<UserMapper, User> implements UserService {
    // ServiceImpl 已经实现了 IService 的所有通用方法
    // 这里留空也能直接用;自定义业务方法写在这里
}

ServiceImpl<UserMapper, User> 这一长串拆开看:

  • ServiceImpl<M, T> 是一个泛型类M 是 Mapper 类型、T 是实体类型——和 BaseMapper<User> 是同一种写法,只是这里要传两个类型参数
  • 传入 <UserMapper, User> 后,ServiceImpl 内部就知道"该用 UserMapper 操作 User 表",自动注入 UserMapper,并基于它实现 savegetByIdlistpage 等方法的具体逻辑
  • implements UserService 是为了满足第 1 步定义的接口,这样其他类只需要依赖 UserService 接口,不用关心具体实现

写完这两段代码后,UserServiceImpl 就自动拥有了一整套 CRUD 方法,不需要再手写:

@Autowired
private UserService userService;

userService.getById(1L);          // 等价于 mapper.selectById(1L)
userService.save(user);           // 等价于 mapper.insert(user)
userService.list();               // 等价于 mapper.selectList(null),查全部
userService.removeById(1L);       // 等价于 mapper.deleteById(1L)

这一层的意义:业务代码统一面向 UserService 编程,简单 CRUD 直接调用 IService 现成的方法;遇到自定义业务逻辑,就在 UserServiceImpl 里加新方法。

8.5 条件构造器(Wrapper):用代码拼 WHERE 条件

为什么需要 Wrapper

BaseMapper 提供的方法基本都是围绕主键的(selectByIdupdateByIddeleteById),但实际业务里经常要按别的字段查询——比如"按邮箱查用户"是按 email 查,不是按主键 id 查。这种"自定义 WHERE 条件"就是 Wrapper(条件构造器)要解决的问题:用 Java 代码拼出 WHERE 子句,不用写 SQL 或 XML

new LambdaQueryWrapper<User>()
    .eq(User::getEmail, "alice@example.com")
// 等价于 SELECT * FROM users WHERE email = ?

构造好的 wrapper 直接传给 BaseMapper 的方法:

User user = userMapper.selectOne(wrapper);   // 查一条
List<User> users = userMapper.selectList(wrapper); // 查多条
QueryWrapper vs LambdaQueryWrapper

两者功能完全一样,区别只在字段怎么写

// QueryWrapper:字段名是字符串
new QueryWrapper<User>().eq("email", email)

// LambdaQueryWrapper:字段是方法引用
new LambdaQueryWrapper<User>().eq(User::getEmail, email)
QueryWrapperLambdaQueryWrapper
字段写法字符串 "email"方法引用 User::getEmail
写错字段名编译通过,运行时才报 SQL 错误编译失败,IDE 直接标红
字段重命名字符串不会跟着改,容易漏改IDE 重构方法名时一起改
写的是什么数据库列名(下划线)Java 属性名(驼峰),框架自动转列名

结论:优先用 LambdaQueryWrapper——多写一点 ::getXxx,换来的是字段改名、打错字都能在编译期发现。

常用条件方法

每个方法对应一段 WHERE 条件,链式调用多个方法时,默认用 AND 连接:

方法生成的 SQL 片段说明
.eq(字段, 值)字段 = ?等于,最常用
.ne(字段, 值)字段 != ?不等于
.gt(字段, 值) / .ge(字段, 值)字段 > ? / 字段 >= ?大于 / 大于等于
.lt(字段, 值) / .le(字段, 值)字段 < ? / 字段 <= ?小于 / 小于等于
.like(字段, 值)字段 LIKE '%值%'模糊匹配,两边自动加 %
.likeLeft(字段, 值) / .likeRight(字段, 值)字段 LIKE '%值' / 字段 LIKE '值%'只在一侧加 %
.in(字段, 集合) / .notIn(字段, 集合)字段 IN (...) / 字段 NOT IN (...)属于 / 不属于集合中的值
.isNull(字段) / .isNotNull(字段)字段 IS NULL / 字段 IS NOT NULL是否为空
.between(字段, 起, 止)字段 BETWEEN ? AND ?闭区间
.orderByDesc(字段) / .orderByAsc(字段)ORDER BY 字段 DESC/ASC排序
List<User> users = new LambdaQueryWrapper<User>()
    .eq(User::getStatus, 1)                               // status = 1
    .in(User::getId, List.of(1L, 2L, 3L))                 // AND id IN (1,2,3)
    .like(User::getName, "Ali")                           // AND name LIKE '%Ali%'
    .orderByDesc(User::getCreatedAt)                      // ORDER BY created_at DESC
    .list();

对应的 SQL:

SELECT * FROM users
WHERE status = 1
  AND id IN (1, 2, 3)
  AND name LIKE '%Ali%'
ORDER BY created_at DESC
动态条件:第一个参数是 boolean

5.1 节讲过 XML 用 <if test="..."> 实现"参数存在才拼这段 SQL"。Wrapper 里每个条件方法都有一个重载版本,第一个参数是 boolean condition,为 false 时这个条件直接不会拼进 SQL——这就是 Wrapper 版本的 <if>

public List<User> search(String name, Integer status, List<Long> ids) {
    return new LambdaQueryWrapper<User>()
        .like(StringUtils.hasText(name), User::getName, name)   // name 有值才加这个条件
        .eq(status != null, User::getStatus, status)            // status 不为 null 才加
        .in(ids != null && !ids.isEmpty(), User::getId, ids)
        .list();
}
调用 search("Alice", null, null)
→ SELECT * FROM users WHERE name LIKE '%Alice%'

调用 search(null, 1, null)
→ SELECT * FROM users WHERE status = 1

调用 search(null, null, null)
→ SELECT * FROM users   (没有任何条件)

不需要写 if-else 拼字符串,就实现了"传了什么条件就用什么条件",比 XML 动态 SQL 更直观。

and / or:条件分组(带括号的逻辑)

链式调用的多个条件默认是 AND。如果要表达 status = 1 AND (id = 1 OR id = 2) 这种带括号的逻辑,用 .and() 配合 Lambda:

new LambdaQueryWrapper<User>()
    .eq(User::getStatus, 1)
    .and(w -> w.eq(User::getId, 1L).or().eq(User::getId, 2L));
// WHERE status = 1 AND (id = 1 OR id = 2)

.and(w -> ...) 里的 w 是一个独立的子条件构造器,它内部拼出的条件会被括号包裹,再整体用 AND 接到外层条件后面;.or() 把它前后两个条件之间的连接符从默认的 AND 换成 OR

UpdateWrapper:更新条件
LambdaUpdateWrapper<User> wrapper = new LambdaUpdateWrapper<>();
wrapper.set(User::getName, "NewName")
       .set(User::getEmail, "new@example.com")
       .eq(User::getId, 1L);

userMapper.update(null, wrapper);
// UPDATE users SET name = 'NewName', email = 'new@example.com' WHERE id = 1

.set(字段, 新值) 指定要更新成什么;.eq() 等条件方法指定 WHEREupdate() 第一个参数是要更新的实体对象,传 null 表示"更新内容全部由 wrapper 的 .set() 指定,不需要再传一个实体"。

selectOne / selectList / selectCount 怎么选
方法返回值适用场景结果命中多行时
selectOne(wrapper)单个对象或 null业务上唯一的查询,如按 slotId 查广告位抛异常TooManyResultsException
selectList(wrapper)List<T>查多条记录,如查某广告位关联的所有 DSP正常返回多条
selectCount(wrapper)Long只要数量,不要数据本身(分页总数、判断是否存在)-

selectOne 的关键点:它不是"取第一条",而是"断言只有一条"——如果 WHERE 条件命中了多行,MyBatis-Plus 会直接抛异常而不是悄悄返回第一行。只在业务上能保证唯一(如 slotId 是唯一索引)的场景用它;如果不确定是否唯一,用 selectList 再在 Service 层处理多条的情况。

8.6 分页

// 1. 配置分页插件
@Configuration
public class MyBatisPlusConfig {
    @Bean
    public MybatisPlusInterceptor mybatisPlusInterceptor() {
        MybatisPlusInterceptor interceptor = new MybatisPlusInterceptor();
        interceptor.addInnerInterceptor(new PaginationInnerInterceptor(DbType.MYSQL));
        return interceptor;
    }
}

// 2. 使用
Page<User> page = new Page<>(1, 10);  // 第 1 页,每页 10 条

LambdaQueryWrapper<User> wrapper = new LambdaQueryWrapper<>();
wrapper.like(User::getName, keyword);

Page<User> result = userMapper.selectPage(page, wrapper);

result.getRecords();   // 当前页数据
result.getTotal();     // 总记录数
result.getPages();     // 总页数
result.getCurrent();   // 当前页码
result.getSize();      // 每页大小

8.7 自动填充

// 自动填充创建时间和更新时间
@Component
public class MyMetaObjectHandler implements MetaObjectHandler {
    @Override
    public void insertFill(MetaObject metaObject) {
        this.strictInsertFill(metaObject, "createdAt", LocalDateTime::now, LocalDateTime.class);
        this.strictInsertFill(metaObject, "updatedAt", LocalDateTime::now, LocalDateTime.class);
    }

    @Override
    public void updateFill(MetaObject metaObject) {
        this.strictUpdateFill(metaObject, "updatedAt", LocalDateTime::now, LocalDateTime.class);
    }
}

// 实体类中标记
@TableField(fill = FieldFill.INSERT)
private LocalDateTime createdAt;

@TableField(fill = FieldFill.INSERT_UPDATE)
private LocalDateTime updatedAt;

9. MyBatis vs MyBatis-Plus 选型

场景推荐
单表 CRUDMyBatis-Plus(零 SQL)
动态查询条件MyBatis-Plus(LambdaQueryWrapper 比 XML 动态 SQL 简洁)
复杂多表关联原生 MyBatis XML
存储过程 / 复杂报表 SQL原生 MyBatis XML
新项目MyBatis-Plus(兼容原生 MyBatis,按需用 XML)

两者不是二选一——MyBatis-Plus 内部就是 MyBatis,简单查询用 Wrapper,复杂查询写 XML,完美共存。

10. 小结

主题关键要点
MyBatis 定位半自动 ORM,SQL 手写,结果映射自动
#{} vs ${}#{} 预编译防注入(默认用这个),${} 字符串拼接(仅用于动态表名/列名)
XML vs 注解简单用注解,复杂用 XML,可以混用
动态 SQLif、where、set、choose、foreach、sql 片段
多表关联association(一对一)、collection(一对多)
分页PageHelper 插件或 MyBatis-Plus 分页插件
MyBatis-Plus继承 BaseMapper 零 SQL,LambdaQueryWrapper 动态查询
条件构造第一个参数传 boolean condition,false 时不拼接——替代 XML 的 if 标签
自动填充MetaObjectHandler 自动设置创建/更新时间
共存策略单表用 Plus,复杂 SQL 用原生 XML

下一篇预告:设计模式在 Java 中的典型应用——工厂、策略、观察者、代理


🎯 如果这篇文章对你有帮助,别忘了点赞、收藏、关注三连!关注我,让你在 Java 学习的道路上不迷路,持续为你带来成体系的 Java 干货~

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

rebibabo

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值