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&allowPublicKeyRetrieval=true&serverTimezone=Asia/Shanghai&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 里
&是特殊字符,连接参数之间的分隔符要写成&。
还有一处新手容易卡住的地方: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 接起来:
namespace写成接口的全类名,这里是mapper.UserMapper。- statement 的
id写成接口里对应的方法名。 - statement 的
parameterType和方法的参数类型一致。 - 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 从配置文件到一条查询
把上面几步串起来,一次查询经过这些环节:
SqlSessionFactoryBuilder读取config.xml,构建SqlSessionFactory。这一步会解析数据源、事务管理器和注册进来的 Mapper XML。SqlSessionFactory.openSession()打开一个SqlSession,它代表一次数据库会话。getMapper(UserMapper.class)拿到接口的代理对象。- 调用接口方法时,代理按"接口全类名 + 方法名"找到对应的 statement,把参数填进占位符,执行语句。
resultType指定的类型被用来创建对象,MyBatis 按列名找到同名属性,逐列赋值。- 增删改要
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 语句内 | #{} / ${} |
参数占位符、字符串替换 |
一次查询走过的环节:
SqlSessionFactoryBuilder读config.xml,构建SqlSessionFactory。openSession()打开SqlSession,从连接池取连接。getMapper(UserMapper.class)拿到接口的代理对象。- 调用方法,代理按"全类名 + 方法名"找到 statement,把参数绑定到占位符上。
- 根据
resultType或resultMap创建对象并逐列赋值。 - 增删改调用
commit(),结束调用close()归还连接。
需要减少数据库交互时,先看延迟加载能不能用(级联查询里关联对象未必用得上),再看缓存能不能用(同一条记录是否会被反复查询)。两者都建立在"少发 SQL"这个目标上,但适用的场景不同。
8 官方资料
- MyBatis 3 中文文档:https://mybatis.org/mybatis-3/zh_CN/index.html
- 配置(settings、environments、mappers):https://mybatis.org/mybatis-3/zh_CN/configuration.html
- XML 映射文件(resultMap、级联、缓存):https://mybatis.org/mybatis-3/zh_CN/sqlmap-xml.html
- 动态 SQL:https://mybatis.org/mybatis-3/zh_CN/dynamic-sql.html
- MyBatis 3 源码仓库:https://github.com/mybatis/mybatis-3
- MySQL Connector/J 官方文档:https://dev.mysql.com/doc/connector-j/en/

评论