← 返回首页
Java学习#Java

15.MyBatis持久层框架

2026-10-11·1 次浏览
15.MyBatis持久层框架

MyBatis 持久层框架:ORM 映射、Mapper 配置与动态 SQL

1 ORM:让 Java 对象和数据表对上号

1.1 是什么

ORM 的英文是 Object Relational Mapping,中文叫对象关系映射。它要处理的问题很具体:Java 程序里操作的是对象,数据库里存的是表和行,两边的形状不一样,怎么来回转换。

一句话概括:用 Java 对象表示一行数据,把查询结果装配成对象,把对象的属性填进 SQL 语句。

1.2 为什么需要它

用 JDBC 查一条记录,代码大致是这样:

java 复制代码
Connection conn = DriverManager.getConnection(url, user, password);
PreparedStatement ps = conn.prepareStatement("select * from user where id = ?");
ps.setInt(1, 1);
ResultSet rs = ps.executeQuery();

User user = null;
if (rs.next()) {
    user = new User();
    user.setId(rs.getInt("id"));
    user.setUsername(rs.getString("username"));
    user.setPassword(rs.getString("password"));
    user.setAge(rs.getInt("age"));
}

这段代码里真正和业务有关的只有一句"查 id 为 1 的用户"。剩下的取值、建对象、逐个赋值都是固定动作:换一张表,这些代码要再抄一遍;加一个字段,就要多写一行 set。

ORM 框架把这些固定动作收走。开发者只说明"查什么"和"结果装进哪个对象",连接、参数绑定、结果装配交给框架。

1.3 全自动与半自动:Hibernate 与 MyBatis

常见的 ORM 框架有 Hibernate、MyBatis、MyBatis-Plus、Spring Data JPA、Spring JDBC。它们的区别在于"框架替你做多少",大致分成两类。

Hibernate 是全自动的。它建立了 Java 对象和数据表之间的映射,能根据对象操作自动生成 SQL 并执行,开发者调用接口就能完成增删改查,SQL 长什么样基本不用管。Spring Data JPA 底层就是用 Hibernate 实现的。

MyBatis 是半自动的。它没有替开发者决定表结构和对象之间怎么对应,SQL 需要自己写;框架负责的是把参数填进 SQL,再把结果集装配成对象。

这里有一处容易说反:MyBatis 并非"不做对象和表的映射",而是把"表和对象怎么对应"交给开发者决定。你在 Mapper XML 里写下 select * from user where id = #{id},就已经说明白了这是在查 user 表;再写上 resultType="entity.User",MyBatis 就知道把列值装进哪个类。

半自动换来的是灵活。同一个 User 对象,可以按业务写出只查用户名、只查年龄、按组合条件查等不同的 SQL,而不是被框架生成的一套语句绑住。业务规则复杂的企业级项目里,MyBatis 用得很广,原因就在这里。

1.4 MyBatis 的优点和代价

优点:

  • 把 JDBC 里重复的连接、参数绑定、结果装配代码收进框架,业务代码明显变短。
  • 简单好用,容易上手。
  • SQL 写在 XML 文件里,和 Java 代码分开,改 SQL 不必动 Java 类。
  • 支持动态 SQL,能按运行时的条件拼出不同的语句。

代价:

  • 相比 Hibernate,SQL 和映射关系要自己写,工作量更大。
  • 需要开发者具备一定的 SQL 编写能力。表和字段一多,这块工作量不小。
  • 可移植性差。SQL 依赖具体数据库的语法,将来换数据库,部分 SQL 要重新写。

2 搭建第一个 MyBatis 程序

2.1 创建 Maven 工程并导入依赖

xml 复制代码
<dependencies>
    <dependency>
        <groupId>mysql</groupId>
        <artifactId>mysql-connector-java</artifactId>
        <version>8.0.33</version>
    </dependency>

    <dependency>
        <groupId>org.mybatis</groupId>
        <artifactId>mybatis</artifactId>
        <version>3.5.19</version>
    </dependency>

    <dependency>
        <groupId>org.projectlombok</groupId>
        <artifactId>lombok</artifactId>
        <version>1.18.48</version>
    </dependency>
</dependencies>

三个依赖各管一件事:MySQL 驱动让 Java 能连上数据库,mybatis 是框架本体,lombok 用来省掉实体类里成片的 getter、setter 和 toString。

关于 MySQL 驱动的坐标:从 8.0.31 起,官方把 mysql:mysql-connector-java 改名为 com.mysql:mysql-connector-j。上面写的旧坐标仍然可用,Maven 会通过重定位自动换成新坐标,最终拿到的还是 com.mysql:mysql-connector-j:8.0.33。两种写法等价,新项目直接用新坐标更清楚。

2.2 准备数据表

sql 复制代码
use mytest1;

create table user(
    id int primary key auto_increment,
    username varchar(11),
    password varchar(11),
    age int
);

insert into user(username, password, age) values('张三', '123456', 18);
insert into user(username, password, age) values('李四', '654321', 19);

id 是自增主键,插入时不用赋值。

2.3 创建实体类

java 复制代码
package entity;

import lombok.Data;

@Data
public class User {
    private Integer id;
    private String username;
    private String password;
    private Integer age;
}

@Data 在编译时生成 getter、setter、toString、equals 和 hashCode,所以后面可以直接写 user.getUsername(),也可以直接 System.out.println(user) 看字段值。

id 和 age 用的是包装类型 Integer 而不是基本类型 int,这一点后面会用上。动态 SQL 要判断"这个条件传了没有",判断写法是 id != null;int 永远不为 null,这个判断恒成立,条件就永远生效,等于没起到筛选作用。另外,查不到记录时返回 null 比返回 0 更能表达"没有查到"。

2.4 编写全局配置文件

在 resources 目录下新建 config.xml:

xml 复制代码
<?xml version="1.0" encoding="UTF-8" ?>
<!DOCTYPE configuration
        PUBLIC "-//mybatis.org//DTD Config 3.0//EN"
        "https://mybatis.org/dtd/mybatis-3-config.dtd">

<configuration>

    <!-- 打印 SQL 日志,方便观察框架真正执行了什么 -->
    <settings>
        <setting name="logImpl" value="STDOUT_LOGGING"/>
    </settings>

    <!-- 运行环境,可以配置多套,default 指定默认用哪一套 -->
    <environments default="development">
        <environment id="development">
            <!-- 事务交给 JDBC 管理,需要手动提交 -->
            <transactionManager type="JDBC"/>
            <!-- POOLED 表示使用 MyBatis 自带的连接池 -->
            <dataSource type="POOLED">
                <property name="driver" value="com.mysql.cj.jdbc.Driver"/>
                <property name="url" value="jdbc:mysql://localhost:3307/mytest1?useSSL=false&amp;allowPublicKeyRetrieval=true&amp;serverTimezone=Asia/Shanghai&amp;characterEncoding=utf8"/>
                <property name="username" value="root"/>
                <property name="password" value="root"/>
            </dataSource>
        </environment>
    </environments>

    <!-- 注册 Mapper 文件 -->
    <mappers>
        <mapper resource="mapper/UserMapper.xml"/>
    </mappers>

</configuration>

几点说明:

  • default="development" 和 <environment id="development"> 对应,告诉 MyBatis 使用哪套环境。
  • transactionManager 取 JDBC 时,增删改之后要自己调用 sqlSession.commit(),否则数据不会真正写入数据库。
  • URL 里的 3307 是数据库端口,按实际情况改。useSSL=false 关掉 SSL;MySQL 8 的用户密码默认用 caching_sha2_password 加密,第一次连接需要 allowPublicKeyRetrieval=true 才能取到服务端公钥;characterEncoding=utf8 保证中文正常读写。
  • XML 里 & 是特殊字符,连接参数之间的分隔符要写成 &amp;。

还有一处新手容易卡住的地方:DTD 规定了 <configuration> 里子元素必须按固定顺序出现,正确顺序是 properties → settings → typeAliases → typeHandlers → objectFactory → objectWrapperFactory → reflectorFactory → plugins → environments → databaseIdProvider → mappers,其中只有 environments 和 mappers 是必需的。

顺序写错时程序启动就会失败,而且错误信息会把正确顺序直接列出来:

text 复制代码
### Error building SqlSession.
### Cause: org.apache.ibatis.builder.BuilderException: Error creating document instance.
Cause: org.xml.sax.SAXParseException; lineNumber: 32; columnNumber: 17;
元素类型为 "configuration" 的内容必须匹配 "(properties?,settings?,typeAliases?,typeHandlers?,
objectFactory?,objectWrapperFactory?,reflectorFactory?,plugins?,environments?,
databaseIdProvider?,mappers?)"。

所以记住两条就够:<settings> 写在 <environments> 前面,<mappers> 放最后。

2.5 两种开发方式

  • 使用原生接口:直接调 SqlSession 的 selectOne、insert 等方法,把 namespace + id 当作字符串传进去。
  • 使用 Mapper 代理实现自定义接口:只定义接口,MyBatis 动态生成实现类,像调普通方法一样调 SQL。

实际开发主要用第二种,第一种能帮助理解前者的底层做了什么。

2.6 使用原生接口

第一步,创建 Mapper 文件。 在 resources/mapper 下新建 UserMapper.xml:

xml 复制代码
<?xml version="1.0" encoding="UTF-8" ?>
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" "https://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace="mapper.UserMapper">

    <select id="getById" parameterType="int" resultType="entity.User">
        select * from user where id = #{id}
    </select>

</mapper>
  • namespace 通常写成"Mapper 文件所在包 + 文件名",这里是 mapper.UserMapper。
  • id 是这条语句的名字,SqlSession 通过 namespace + "." + id 找到它。
  • parameterType 是参数类型,resultType 是返回结果的类型。
  • #{id} 是占位符,执行时会被换成 ?,再把参数值绑上去。

第二步,在全局配置文件中注册。

xml 复制代码
<mappers>
    <mapper resource="mapper/UserMapper.xml"/>
</mappers>

第三步,调用原生接口。

java 复制代码
public class Test {
    public static void main(String[] args) {
        // 加载全局配置文件
        InputStream inputStream = Test.class.getClassLoader().getResourceAsStream("config.xml");
        SqlSessionFactory sqlSessionFactory = new SqlSessionFactoryBuilder().build(inputStream);

        // 打开一个 SqlSession,代表一次和数据库的会话
        SqlSession sqlSession = sqlSessionFactory.openSession();

        // 原生接口:第一个参数是 namespace + id,第二个参数是 SQL 的参数
        User user = sqlSession.selectOne("mapper.UserMapper.getById", 1);
        System.out.println(user);

        sqlSession.close();
    }
}

运行结果:

text 复制代码
==>  Preparing: select * from user where id = ?
==> Parameters: 1(Integer)
<==    Columns: id, username, password, age
<==        Row: 1, 张三, 123456, 18
<==      Total: 1
User(id=1, username=张三, password=123456, age=18)

selectOne 的返回值是泛型,写成 User user = ... 时编译器就知道该转成 User,不需要再强转。

2.7 使用 Mapper 代理实现自定义接口

原生接口把 SQL 的名字写成了字符串,写错要到运行时才发现;参数类型和返回类型也得自己保证一致。Mapper 代理解决的就是这个问题。

第一步,创建接口。 只声明方法,不写实现:

java 复制代码
package mapper;

import entity.User;

import java.util.List;

public interface UserMapper {
    int add(User user);
    int update(User user);
    int delete(Integer id);
    User getById(Integer id);
    List<User> getAll();
}

第二步,创建接口对应的 UserMapper.xml,把接口方法和 SQL 对应起来。statement 标签按业务选 insert、delete、update、select:

xml 复制代码
<?xml version="1.0" encoding="UTF-8" ?>
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" "https://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace="mapper.UserMapper">

    <insert id="add" parameterType="entity.User">
        insert into user(username, password, age) values(#{username}, #{password}, #{age})
    </insert>

    <select id="getById" parameterType="int" resultType="entity.User">
        select * from user where id = #{id}
    </select>

    <select id="getAll" resultType="entity.User">
        select * from user
    </select>

</mapper>

MyBatis 靠四条对应规则把接口方法和 SQL 接起来:

  1. namespace 写成接口的全类名,这里是 mapper.UserMapper。
  2. statement 的 id 写成接口里对应的方法名。
  3. statement 的 parameterType 和方法的参数类型一致。
  4. statement 的 resultType 和方法的返回值类型一致。方法返回 List<User> 时,resultType 仍然写元素类型 entity.User。

第三步,调用代理对象。

java 复制代码
// 获取接口的代理对象,实现类由 MyBatis 在运行时生成
UserMapper userMapper = sqlSession.getMapper(UserMapper.class);

User user = new User();
user.setUsername("张三");
user.setPassword("123456");
user.setAge(18);

// 有事务,插入后要提交,否则数据库里看不到这条记录
int add = userMapper.add(user);
sqlSession.commit();

System.out.println(add);

运行结果:

text 复制代码
==>  Preparing: insert into user(username, password, age) values(?, ?, ?)
==> Parameters: 张三(String), 123456(String), 18(Integer)
<==    Updates: 1
1

add 返回的是受影响的行数,插入一条就是 1。这里没有写任何实现类,getMapper 返回的是 MyBatis 动态生成的代理对象:方法一被调用,它就去找到对应的 SQL 执行,再把结果转成方法声明的类型。

插入后如果需要用到新的主键,默认是拿不到的。上面的 user 对象在插入完成后 id 仍然是 null,因为 MyBatis 并不知道你关心自增列。要拿到它,在 insert 上声明回填:

xml 复制代码
<insert id="add" parameterType="entity.User" useGeneratedKeys="true" keyProperty="id">
    insert into user(username, password, age) values(#{username}, #{password}, #{age})
</insert>

加上这两个属性之后,插入完成时 MyBatis 会把数据库生成的主键写回 user.getId();方法的返回值仍然是受影响的行数 1。

2.8 让 pom.xml 复制 java 目录下的 XML

Maven 默认只把 src/main/resources 下的文件复制到 target/classes。Mapper XML 如果和接口一起放在 src/main/java 的包里,编译后不会出现在 classpath 上,运行时就会报找不到 mapper 文件。

有两种处理办法。把 XML 统一放在 resources 下(上面用的就是这种);或者保留在 java 目录,并在 pom.xml 的 <build> 里显式声明要复制它:

xml 复制代码
<build>
    <resources>

        <!-- 把 java 目录下的 xml 文件也当作资源复制 -->
        <resource>
            <directory>src/main/java</directory>
            <includes>
                <include>**/*.xml</include>
            </includes>
        </resource>

        <!-- 复制 resources 目录中的全部资源文件 -->
        <resource>
            <directory>src/main/resources</directory>
        </resource>

    </resources>
</build>

第二段 <resource> 不能省。一旦自己声明了 <resources>,Maven 就不再使用默认的 src/main/resources,全局配置文件会一起丢掉。

2.9 从配置文件到一条查询

把上面几步串起来,一次查询经过这些环节:

  1. SqlSessionFactoryBuilder 读取 config.xml,构建 SqlSessionFactory。这一步会解析数据源、事务管理器和注册进来的 Mapper XML。
  2. SqlSessionFactory.openSession() 打开一个 SqlSession,它代表一次数据库会话。
  3. getMapper(UserMapper.class) 拿到接口的代理对象。
  4. 调用接口方法时,代理按"接口全类名 + 方法名"找到对应的 statement,把参数填进占位符,执行语句。
  5. resultType 指定的类型被用来创建对象,MyBatis 按列名找到同名属性,逐列赋值。
  6. 增删改要 commit();操作结束后 close() 归还连接。

SqlSessionFactory 是重量级对象,一个应用通常只建一次;SqlSession 是轻量的,每次操作单独开关。

3 Mapper XML 的常用配置

3.1 parameterType:参数类型

parameterType 说明这条语句接收什么参数。常用的有几种。

单个简单类型。 参数只有一个基本类型、包装类或 String 时,#{} 里写什么名字都可以:

java 复制代码
String findUsernameById(Integer id);
xml 复制代码
<select id="findUsernameById" parameterType="int" resultType="string">
    select username from user where id = #{id}
</select>

这里的 #{id} 换成 #{value}、#{abc} 结果一样,因为只有一个参数,不存在歧义。不过还是建议写和语义相符的名字,读起来清楚。

实体类。 parameterType 写类的全限定名,#{} 里写属性名:

xml 复制代码
<insert id="add" parameterType="entity.User">
    insert into user(username, password, age) values(#{username}, #{password}, #{age})
</insert>

MyBatis 会调用 getUsername()、getPassword()、getAge() 取值。

多个参数。 方法有两个及以上参数时,建议用 @Param 给每个参数起名:

java 复制代码
List<User> findByUsernameAndAge(@Param("username") String username, @Param("age") Integer age);
xml 复制代码
<select id="findByUsernameAndAge" resultType="entity.User">
    select * from user where username = #{username} and age = #{age}
</select>

不加 @Param 也能跑,但参数名会变成 arg0、arg1(以及 param1、param2),#{} 里必须写这些位置名:

xml 复制代码
<select id="findByNoParam" resultType="entity.User">
    select * from user where username = #{arg0} and age = #{arg1}
</select>

如果这时还按属性名写 #{username},执行会直接报错,错误信息会把可用的名字列出来:

text 复制代码
org.apache.ibatis.binding.BindingException: Parameter 'username' not found.
Available parameters are [arg1, arg0, param1, param2]

参数一多,位置名很难记,改参数顺序时还容易出错,所以还是加上 @Param。

Map。 参数是 Map 时,#{} 里写 key:

java 复制代码
List<User> findByMap(Map<String, Object> params);
xml 复制代码
<select id="findByMap" parameterType="map" resultType="entity.User">
    select * from user where username = #{username} and age = #{age}
</select>

这种写法适合参数个数不固定的场景,代价是 key 写错也只能等到运行时才发现。

3.2 resultType:结果类型

resultType 说明查询结果要装配成什么。

基本类型和包装类。 查一个数量:

java 复制代码
int countAll();
xml 复制代码
<select id="countAll" resultType="int">
    select count(*) from user
</select>

int、string 这些是 MyBatis 的内置别名,int 对应 Integer,string 对应 String。需要精确对应基本类型时用 _int、_long 这样的下划线写法,完整的别名表可以查官方文档。

包装类在这里的意义。 查询一条可能不存在的记录时,用包装类型才能把"没查到"表达成 null:

java 复制代码
User user = userMapper.getById(999);   // 数据库里没有 id 为 999 的记录
System.out.println(user);
text 复制代码
==>  Preparing: select * from user where id = ?
==> Parameters: 999(Integer)
<==      Total: 0
null

selectOne 没有匹配行时返回 null。如果方法声明成基本类型,这个 null 就没法表达。

实体类。 resultType 写类的全限定名。方法返回集合时,它依然写元素类型:

java 复制代码
List<User> getAll();
xml 复制代码
<select id="getAll" resultType="entity.User">
    select * from user
</select>

Map。 resultType="map" 时,每一行返回一个 Map,key 是列名:

xml 复制代码
<select id="findByIdAsMap" parameterType="int" resultType="map">
    select * from user where id = #{id}
</select>
java 复制代码
Map<String, Object> row = userMapper.findByIdAsMap(1);
System.out.println(row);
text 复制代码
{password=123456, id=1, age=18, username=张三}

列名和属性名一致时,resultType 就能自动完成赋值。一旦对不上——比如列名是 cname、属性名是 name——resultType 就无能为力了,这时要用 resultMap 自己说明对应关系。

3.3 级联查询

实际开发中最常用的是一对多:查一个班级,顺手把它下面的学生也带出来。

准备两张表:

sql 复制代码
create table class(
    id int primary key auto_increment,
    name varchar(20)
);

create table student(
    id int primary key auto_increment,
    name varchar(20),
    cid int
);

insert into class(id, name) values(1, '一班'), (2, '二班');
insert into student(id, name, cid) values(1, '小明', 1), (2, '小红', 1), (3, '小刚', 2);

3.3.1 一对多

一个班级有多个学生,班级这边用 List<Student> 表示:

java 复制代码
package entity;

import lombok.Data;

import java.util.List;

@Data
public class Clazz {
    private Integer id;
    private String name;
    private List<Student> students;
}

学生这边先只保留自己的字段:

java 复制代码
package entity;

import lombok.Data;

@Data
public class Student {
    private Integer id;
    private String name;
}

接口里加一个方法:

java 复制代码
package mapper;

import entity.Clazz;

public interface ClazzMapper {
    Clazz getClazzById(Integer id);
}

XML 用 resultMap 说明"哪个列装进哪个属性",用 <collection> 说明集合里的元素怎么装:

xml 复制代码
<?xml version="1.0" encoding="UTF-8" ?>
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" "https://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace="mapper.ClazzMapper">

    <resultMap id="clazzMap" type="entity.Clazz">
        <!-- 主键用 id 标签,MyBatis 靠它判断两行是不是同一个对象 -->
        <id property="id" column="cid"/>
        <result property="name" column="cname"/>
        <!-- property 是集合属性名,ofType 是集合里元素的类型 -->
        <collection property="students" ofType="entity.Student">
            <id property="id" column="sid"/>
            <result property="name" column="sname"/>
        </collection>
    </resultMap>

    <select id="getClazzById" parameterType="int" resultMap="clazzMap">
        select c.id cid, c.name cname, s.id sid, s.name sname
        from class c, student s
        where c.id = #{id} and s.cid = c.id
    </select>

</mapper>

这里用 resultMap 而不是 resultType,有两个原因:一是列名 cid、cname、sid、sname 和属性名对不上;二是结果要拆成"一个班级 + 一批学生"两层,resultType 表达不了。

SQL 里给每列起了别名,和 resultMap 里的 column 一一对应。查询 id 为 1 的班级:

java 复制代码
ClazzMapper clazzMapper = sqlSession.getMapper(ClazzMapper.class);
Clazz clazz = clazzMapper.getClazzById(1);
System.out.println(clazz);
text 复制代码
==>  Preparing: select c.id cid, c.name cname, s.id sid, s.name sname from class c, student s where c.id = ? and s.cid = c.id
==> Parameters: 1(Integer)
<==    Columns: cid, cname, sid, sname
<==        Row: 1, 一班, 1, 小明
<==        Row: 1, 一班, 2, 小红
<==      Total: 2
Clazz(id=1, name=一班, students=[Student(id=1, name=小明), Student(id=2, name=小红)])

数据库返回了两行,因为一班有两个学生。MyBatis 按 <id column="cid"/> 判断这两行的班级是同一个,于是只建一个 Clazz 对象,把两个 Student 放进它的 students 集合。

如果把主键那行写成普通的 <result>,MyBatis 认不出这两行属于同一个班级,会建出两个各自只装一个学生的对象。

3.3.2 多对一:association

反过来,一个学生属于一个班级。给 Student 加上班级属性:

java 复制代码
package entity;

import lombok.Data;

@Data
public class Student {
    private Integer id;
    private String name;
    private Clazz clazz;
}

这是 Student 的另一个版本,和 3.3.1 里的相比多了 clazz 属性,两个版本按小节分别使用,不要合在一起。Clazz 沿用 3.3.1 的定义。

对应关系用 <association> 表示:

xml 复制代码
<resultMap id="studentClazzMap" type="entity.Student">
    <id property="id" column="id"/>
    <result property="name" column="name"/>
    <association property="clazz" column="cid" select="mapper.ClazzMapper.findById"/>
</resultMap>

<select id="getWithClazz" parameterType="int" resultMap="studentClazzMap">
    select id, name, cid from student where id = #{id}
</select>

<association> 的 property 是要装进去的属性,select 指向另一条语句的 namespace + id,column 说明用哪一列的值作为那条语句的参数。这里是先用 cid 查出学生,再用这个 cid 去 ClazzMapper.findById 查班级。

ClazzMapper 里补上这个单表查询:

xml 复制代码
<select id="findById" parameterType="int" resultType="entity.Clazz">
    select * from class where id = #{id}
</select>

findById 查回来的 Clazz 只包含 class 表自己的列,students 是 null,因为这条语句没有去查班级下的学生。这样拆开是有意的,下一节的延迟加载正好利用这一点。

调用:

java 复制代码
Student student = studentMapper.getWithClazz(1);
System.out.println(student);
text 复制代码
Student(id=1, name=小明, clazz=Clazz(id=1, name=一班, students=null))

3.3.3 多对多

一个学生选多门课,一门课有多个学生,两边都用集合表示。课程这边:

java 复制代码
package entity;

import lombok.Data;

import java.util.List;

@Data
public class Course {
    private Integer id;
    private String name;
    private List<Student> students;
}

学生这边换成课程集合:

java 复制代码
package entity;

import lombok.Data;

import java.util.List;

@Data
public class Student {
    private Integer id;
    private String name;
    private List<Course> courses;
}

这是 Student 的第三个版本,用 courses 替换了 3.3.2 里的 clazz。

多对多需要一张中间表:

sql 复制代码
create table course(
    id int primary key auto_increment,
    name varchar(20)
);

create table course_student(
    id int primary key auto_increment,
    cid int,
    sid int
);

insert into course(id, name) values(1, 'Java'), (2, 'MySQL');
insert into course_student(id, cid, sid) values(1, 1, 1), (2, 1, 2), (3, 2, 2), (4, 2, 3);

查课程和它的学生:

xml 复制代码
<resultMap id="courseMap" type="entity.Course">
    <id property="id" column="cid"/>
    <result property="name" column="cname"/>
    <collection property="students" ofType="entity.Student">
        <id property="id" column="sid"/>
        <result property="name" column="sname"/>
    </collection>
</resultMap>

<select id="getCourseById" parameterType="int" resultMap="courseMap">
    select c.id cid, c.name cname, s.id sid, s.name sname
    from course c
    join course_student cs on c.id = cs.cid
    join student s on s.id = cs.sid
    where c.id = #{id}
</select>
text 复制代码
Course(id=1, name=Java, students=[Student(id=1, name=小明, courses=null), Student(id=2, name=小红, courses=null)])

反方向,从学生查课程:

xml 复制代码
<resultMap id="studentMap" type="entity.Student">
    <id property="id" column="sid"/>
    <result property="name" column="sname"/>
    <collection property="courses" ofType="entity.Course">
        <id property="id" column="cid"/>
        <result property="name" column="cname"/>
    </collection>
</resultMap>

<select id="getById" parameterType="int" resultMap="studentMap">
    select s.id sid, s.name sname, c.id cid, c.name cname
    from student s
    join course_student cs on s.id = cs.sid
    join course c on c.id = cs.cid
    where s.id = #{id}
</select>
text 复制代码
Student(id=2, name=小红, courses=[Course(id=1, name=Java, students=null), Course(id=2, name=MySQL, students=null)])

注意中间表的连接条件:s.id = cs.sid 配课程,c.id = cs.cid 配学生。这两个字段名很容易写混,比如写成 student.cid = cs.id,条件就落到主键上,查出来的课程会张冠李戴。

4 延迟加载

4.1 为什么要延迟加载

持久层有一条经验原则:Java 程序和数据库交互的次数越少越好。每次交互都要建立连接、验证权限、传输数据,都会消耗资源。

但"减少交互次数"不等于"一次把能取的数据都取回来"。看 3.3.2 的查询:查一个学生,同时把他的班级也带出来。如果这次只用到 student.getName(),班级数据就白查了。

延迟加载(也叫惰性加载、懒加载)给了另一种选择:用到关联对象的时候,再去查它。

4.2 怎么做

延迟加载靠的是 3.3.2 里那种"嵌套 select"的写法——先查学生,班级留一条单独的 SQL,等需要时再执行。这种拆法必须体现在 XML 里:把级联拆成两个单表查询。如果写成一条 JOIN 把班级字段一起查回来,数据已经在手上了,就没有延迟可言。

在全局配置文件里打开开关:

xml 复制代码
<settings>
    <!-- 打印 SQL 语句,方便观察到底发了几条 -->
    <setting name="logImpl" value="STDOUT_LOGGING"/>
    <!-- 开启延迟加载 -->
    <setting name="lazyLoadingEnabled" value="true"/>
</settings>

ClazzMapper.xml 里的 findById 和 StudentMapper.xml 里的 getWithClazz 沿用 3.3.2 的定义,这里不用改。

先只看学生的名字:

java 复制代码
Student student = studentMapper.getWithClazz(1);
System.out.println(student.getName());
text 复制代码
==>  Preparing: select id, name, cid from student where id = ?
==> Parameters: 1(Integer)
<==      Total: 1
小明

只发了一条 SQL,班级没有查。

再访问班级:

java 复制代码
Student student = studentMapper.getWithClazz(1);
System.out.println(student.getName());

Clazz clazz = student.getClazz();   // 到这一步才去查班级
System.out.println(clazz.getName());
text 复制代码
==>  Preparing: select id, name, cid from student where id = ?
==> Parameters: 1(Integer)
<==      Total: 1
小明
==>  Preparing: select * from class where id = ?
==> Parameters: 1(Integer)
<==      Total: 1
一班

第二条 SQL 是在调用 getClazz() 的时候才发出的。同一个方法,用不用班级,决定了一条 SQL 还是两条。

作为对照,把开关关掉:

xml 复制代码
<setting name="lazyLoadingEnabled" value="false"/>

同样的代码,两条 SQL 会在 getWithClazz 调用时就一起发出,后面的 getClazz() 只是取已经装好的值,不再产生 SQL。

4.3 一个容易上当的地方

@Data 生成的 toString() 会把所有属性拼成字符串,其中就包括 clazz。所以下面这行看起来只是打印,实际上会触发延迟加载:

java 复制代码
Student student = studentMapper.getWithClazz(1);
System.out.println(student);   // toString() 访问了 clazz,两条 SQL 都会发出去
text 复制代码
==>  Preparing: select id, name, cid from student where id = ?
==> Parameters: 1(Integer)
<==      Total: 1
==>  Preparing: select * from class where id = ?
==> Parameters: 1(Integer)
<==      Total: 1
Student(id=1, name=小明, clazz=Clazz(id=1, name=一班, students=null))

调试时用 println 打印对象,等于把延迟加载关掉了。想确认延迟加载有没有生效,就打印具体字段,或者看 SQL 日志。

还有一点:延迟加载要求在 SqlSession 还开着的时候访问关联属性。如果先在会话里拿到 Student,关掉会话之后才去 getClazz(),那条 SQL 就没有可用的会话了。

5 二级缓存

5.1 两种缓存

延迟加载解决的是"级联查询里的关联对象用不用"的问题。如果需求本身就是反复查同一条记录,可以用缓存。

MyBatis 有两级缓存,区别在作用范围:

一级缓存 二级缓存
作用范围 一个 SqlSession 一个 namespace,也就是一个 Mapper
默认状态 开启 需要手动配置
共享情况 只在本会话内有效 多个 SqlSession 共用

一级缓存默认开启,不需要任何配置。在同一个 SqlSession 里查两次同一条记录,第二次直接从缓存取:

java 复制代码
Clazz first = clazzMapper.findById(1);
Clazz second = clazzMapper.findById(1);   // 第二次不再查库
text 复制代码
==>  Preparing: select * from class where id = ?
==> Parameters: 1(Integer)
<==      Total: 1

两行查询只发了一条 SQL。换一个 SqlSession 就不共享了,因为一级缓存挂在会话上,会话关掉缓存也就没了。

如果希望连同一个会话内都不复用,可以把一级缓存限制在单条语句范围内:

xml 复制代码
<setting name="localCacheScope" value="STATEMENT"/>

设置之后,上面那两行查询会发出两条 SQL。

二级缓存的范围是一个 namespace。只要用的是同一个 Mapper,无论由多少个 SqlSession 创建,数据都是共享的。

5.2 开启二级缓存

三步。

第一步,确认全局开关。 <setting name="cacheEnabled" value="true"/>。这个开关默认就是 true,写出来是为了明确。它控制的是"二级缓存这个功能能不能用",属于总开关。

第二步,在 Mapper XML 里加 <cache/> 标签。 这才是真正打开某个 Mapper 二级缓存的地方:

xml 复制代码
<mapper namespace="mapper.ClazzMapper">

    <cache/>

    <select id="findById" parameterType="int" resultType="entity.Clazz">
        select * from class where id = #{id}
    </select>

</mapper>

<cache/> 写在 <mapper> 下,作用范围是这个 namespace 里的所有语句。

第三步,实体类实现 Serializable。 二级缓存默认按序列化方式存对象,实体没有实现这个接口会直接报错:

text 复制代码
org.apache.ibatis.cache.CacheException: Error serializing object.
Cause: java.io.NotSerializableException: entity.Clazz
java 复制代码
package entity;

import lombok.Data;

import java.io.Serializable;
import java.util.List;

@Data
public class Clazz implements Serializable {
    private Integer id;
    private String name;
    private List<Student> students;
}

5.3 观察效果

两个 SqlSession 依次查同一条记录:

java 复制代码
try (SqlSession sqlSession1 = sqlSessionFactory.openSession()) {
    ClazzMapper clazzMapper1 = sqlSession1.getMapper(ClazzMapper.class);
    Clazz byId = clazzMapper1.findById(1);
    System.out.println(byId);
}

try (SqlSession sqlSession2 = sqlSessionFactory.openSession()) {
    ClazzMapper clazzMapper2 = sqlSession2.getMapper(ClazzMapper.class);
    Clazz again = clazzMapper2.findById(1);
    System.out.println(again);
}
text 复制代码
Cache Hit Ratio [mapper.ClazzMapper]: 0.0
==>  Preparing: select * from class where id = ?
==> Parameters: 1(Integer)
<==      Total: 1
Clazz(id=1, name=一班, students=null)
Cache Hit Ratio [mapper.ClazzMapper]: 0.5
Clazz(id=1, name=一班, students=null)

第二次 findById 没有发出 SQL,直接从二级缓存拿到了对象。

日志里的 Cache Hit Ratio 是缓存命中率:第一次查询没有命中,显示 0.0;第二次直接命中,累计变成 0.5。

数据要等第一个 SqlSession 提交或者关闭之后,才会写进二级缓存。只查不提交、也不关闭,第二个会话还是要查库。

两个会话还必须来自同一个 SqlSessionFactory。每调用一次 new SqlSessionFactoryBuilder().build(...) 都会得到一份独立的配置,缓存也各归各的,跨工厂的查询不会命中。

反过来,如果只写了 <cache/>,但全局 cacheEnabled 设成 false,二级缓存同样不生效,两个会话都会查库。总开关关掉时,各个 Mapper 的 <cache/> 就不起作用。

5.4 使用时的注意

二级缓存里的内容不是永久的,也不是实时的:

  • 同一个 namespace 里执行了 insert、update、delete,缓存会被清空,避免读到旧数据。
  • 缓存的是对象本身。取出来之后如果修改了对象的属性,下一次从缓存取到的就是改过的对象,而不是数据库里的值。读多写少的数据才适合放进二级缓存。
  • 默认的 <cache/> 使用 LRU 淘汰策略,最多缓存 1024 个对象。

6 动态 SQL

6.1 为什么需要动态 SQL

MyBatis 的 SQL 要自己写,这在业务条件复杂时会带来麻烦。比如按用户信息查询,调用方可能只给用户名,也可能同时给年龄,还可能什么都不给,对应三种不同的 SQL:

sql 复制代码
select * from user where username = ?
select * from user where username = ? and age = ?
select * from user

每种组合都写一条 statement,条件一多数量就爆炸,改起来还容易漏。改在 Java 里用字符串拼 SQL 更糟:空格、引号、and 的位置都要小心,稍不注意还会留下 SQL 注入的口子。

动态 SQL 的思路是:只写一个模板,把可能出现的条件标出来,MyBatis 在执行时按参数决定拼哪几段。

后面的例子沿用 2.2 的 user 表和 2.3 的 User 实体类。

6.2 where 与 if

先把固定条件写成一条语句:

xml 复制代码
<select id="get" parameterType="entity.User" resultType="entity.User">
    select * from user where id = #{id} and username = #{username} and password = #{password} and age = #{age}
</select>

这条语句要求四个条件都给全。改成按需拼接:

xml 复制代码
<select id="search" parameterType="entity.User" resultType="entity.User">
    select * from user
    <where>
        <if test="id != null">
            id = #{id}
        </if>
        <if test="username != null">
            and username = #{username}
        </if>
        <if test="password != null">
            and password = #{password}
        </if>
        <if test="age != null">
            and age = #{age}
        </if>
    </where>
</select>

<if test="..."> 里的表达式是 OGNL。参数是实体对象时直接写属性名,id != null 就是判断 user.getId() != null,条件成立才把这一段拼进 SQL。

<where> 负责处理拼接的边界:如果里面的条件一个都没成立,整个 where 不会出现;如果成立的第一个条件是 and username = ...,开头的 and 会被去掉。

接口方法接收实体对象:

java 复制代码
List<User> search(User user);

只给用户名:

java 复制代码
User condition = new User();
condition.setUsername("张三");

List<User> users = userMapper.search(condition);
System.out.println(users);
text 复制代码
==>  Preparing: select * from user WHERE username = ?
==> Parameters: 张三(String)
[User(id=1, username=张三, password=123456, age=18)]

生成的 SQL 里只有一个条件,开头的 and 被去掉,说明 <where> 起了作用。只给年龄:

text 复制代码
==>  Preparing: select * from user WHERE age = ?
==> Parameters: 19(Integer)

两个都给,就拼成两个条件:

text 复制代码
==>  Preparing: select * from user WHERE username = ? and age = ?
==> Parameters: 李四(String), 19(Integer)

什么都不给:

java 复制代码
List<User> users = userMapper.search(new User());
text 复制代码
==>  Preparing: select * from user

<where> 整段被省掉,查出了全部记录。

这里就能看出 2.3 里为什么把 id 和 age 定义成 Integer。如果写成 int,id != null 永远成立,第一个条件会一直拼进 SQL,语句就永远只能按 id 查了。

6.3 choose、when、otherwise

<if> 是"满足就加上",多个 <if> 之间没有排斥关系。如果需求是"多个条件里只挑第一个满足的用",那要用 <choose>:

xml 复制代码
<select id="choose" parameterType="entity.User" resultType="entity.User">
    select * from user
    <where>
        <choose>
            <when test="id != null">
                id = #{id}
            </when>
            <when test="username != null">
                username = #{username}
            </when>
            <otherwise>
                1 = 1
            </otherwise>
        </choose>
    </where>
</select>

<choose>、<when>、<otherwise> 的关系和 Java 里的 switch、case、default 一样:从上往下找第一个成立的 <when>,只拼那一段;一个都不成立时,拼 <otherwise> 里的内容。

注意这里是单独一条 <select id="choose">,和 6.2 的 search 是两种写法,按需要选用其中一种,不要两条都注册给同一个方法。

给 id:

text 复制代码
==>  Preparing: select * from user WHERE id = ?
==> Parameters: 2(Integer)
[User(id=2, username=李四, password=654321, age=19)]

只给用户名:

text 复制代码
==>  Preparing: select * from user WHERE username = ?
==> Parameters: 张三(String)
[User(id=1, username=张三, password=123456, age=18)]

两个都不给,走 <otherwise>:

text 复制代码
==>  Preparing: select * from user WHERE 1 = 1

因为方法声明为 List<User> choose(User user),<otherwise> 命中多行也能正常返回。如果把它声明成返回单个 User,走 <otherwise> 时会报错:

text 复制代码
org.apache.ibatis.exceptions.TooManyResultsException:
Expected one result (or null) to be returned by selectOne(), but found: 2

selectOne 要求结果至多一行,而 1 = 1 这种兜底条件往往不止一行。用 <otherwise> 兜底时,返回类型选集合更稳。

6.4 set

<set> 用在 update 上,按传入的字段拼 SET 子句:

xml 复制代码
<update id="update" parameterType="entity.User">
    update user
    <set>
        <if test="username != null">
            username = #{username},
        </if>
        <if test="password != null">
            password = #{password},
        </if>
        <if test="age != null">
            age = #{age}
        </if>
    </set>
    where id = #{id}
</update>

注意前两段后面都带着逗号,最后一段 age 没有。写的时候不用纠结这个差别,<set> 会自动把结尾多余的逗号去掉。

只改密码:

java 复制代码
User patch = new User();
patch.setId(1);
patch.setPassword("pw-1");

userMapper.update(patch);
sqlSession.commit();
text 复制代码
==>  Preparing: update user SET password = ? where id = ?
==> Parameters: pw-1(String), 1(Integer)
<==    Updates: 1

只拼了 password,username 和 age 保持原值。三个字段都传时:

text 复制代码
==>  Preparing: update user SET username = ?, password = ?, age = ? where id = ?
==> Parameters: 张三(String), 123456(String), 18(Integer), 1(Integer)
<==    Updates: 1

#{id} 在 where 里,不在 <set> 里,所以它总是会被用到——id 必须传,否则会把整张表更新掉。

6.5 #{} 与 ${}

两种占位符长得像,作用完全不同。

#{} 会被编译成 JDBC 的 ?,值通过 PreparedStatement 的参数设置传进去,值本身不参与 SQL 解析。前面所有例子用的都是它。

${} 是直接做字符串替换,替换后的内容会成为 SQL 的一部分。它用在"要替换的是 SQL 结构而不是值"的地方,比如排序字段:

xml 复制代码
<select id="getAllOrderBy" resultType="entity.User">
    select * from user order by ${column}
</select>
java 复制代码
List<User> users = userMapper.getAllOrderBy("age desc");
text 复制代码
==>  Preparing: select * from user order by age desc

order by 后面要跟列名,而列名不能用 ? 传,所以这里只能用 ${}。

代价是它把参数当成 SQL 的一部分。传进去的内容会被数据库当作语句执行:

java 复制代码
userMapper.getAllOrderBy("id; drop table user");
text 复制代码
==>  Preparing: select * from user order by id; drop table user
==> Parameters: 
org.apache.ibatis.exceptions.PersistenceException

这一次因为是多条语句被驱动拒绝而报错,但风险已经摆在那里:拼进 SQL 的内容会被当作语句解析。所以 ${} 只用来传由程序自己确定的、可信的结构性内容;凡是来自用户输入的值,一律用 #{}。

7 回顾

把这一篇用到的配置和标签汇总一下:

位置 名称 作用
全局配置 <environments> / <environment> 数据源和事务管理器
全局配置 <settings> 日志、延迟加载、缓存的全局开关
全局配置 <mappers> / <mapper> 注册 Mapper XML
Mapper XML namespace 对应接口的全类名
Mapper XML <select> <insert> <update> <delete> 对应一种 SQL 语句
Mapper XML id 对应接口的方法名
Mapper XML parameterType / resultType 参数类型、结果类型
Mapper XML resultMap、<id>、<result>、<collection>、<association> 列与属性的对应关系,以及级联
Mapper XML <where>、<if>、<choose>、<when>、<otherwise>、<set> 动态 SQL
SQL 语句内 #{} / ${} 参数占位符、字符串替换

一次查询走过的环节:

  1. SqlSessionFactoryBuilder 读 config.xml,构建 SqlSessionFactory。
  2. openSession() 打开 SqlSession,从连接池取连接。
  3. getMapper(UserMapper.class) 拿到接口的代理对象。
  4. 调用方法,代理按"全类名 + 方法名"找到 statement,把参数绑定到占位符上。
  5. 根据 resultType 或 resultMap 创建对象并逐列赋值。
  6. 增删改调用 commit(),结束调用 close() 归还连接。

需要减少数据库交互时,先看延迟加载能不能用(级联查询里关联对象未必用得上),再看缓存能不能用(同一条记录是否会被反复查询)。两者都建立在"少发 SQL"这个目标上,但适用的场景不同。

8 官方资料

── 完 ──

评论

还没有评论,来说点什么
发表评论
0 / 1000