当使用 MyBatis 进行对象关系映射(ORM)时,我们经常需要处理一对一映射、一对多映射和多对多映射的关系。同时还可能遇到需要进行自定义类型映射和分页查询的场景。
注意:为了节约篇幅,这里直接给出处理方案,不再进行完整程序的演示。
一对一映射指的是两个实体之间的关系,其中一个实体与另一个实体关联,每个实体实例只能关联一个对应的实体实例。假设有两个实体类 User
和 Account
,每个 User
有一个 Account
,即一对一的关系。
如果是基于 XML 映射文件的方式,我们可以通过 ResultMap
+ association
标签来实现:
<select id="getUser" resultMap="userAccountMap">
SELECT u.*, a.* FROM user u LEFT JOIN account a ON u.id = a.user_id WHERE u.id = #{id}
select>
<resultMap id="userAccountMap" type="User">
<id property="id" column="id"/>
<result property="username" column="username"/>
<result property="password" column="password"/>
<association property="account" javaType="Account">
<id property="id" column="account_id"/>
<result property="userId" column="user_id"/>
association>
resultMap>
这里的
标签就是一对一的映射关系,通过 javaType
属性指定类型,将查询结果映射到 User
的 account
属性上。
如果是基于注解则对应如下:
// UserMapper.java
@Select("SELECT u.*, a.* FROM user u LEFT JOIN account a ON u.id = a.user_id WHERE u.id = #{id}")
@Results({
@Result(id = true, property = "id", column = "id"),
@Result(property = "username", column = "username"),
@Result(property = "password", column = "password"),
@Result(property = "account", column = "account_id", javaType = Account.class,
one = @One(select = "cn.javgo.mapper.AccountMapper.selectAccount"))
})
User selectUser(Integer id);
这里的 @One
注解表示一对一的映射关系,可以看到 @One
中给定的是具体获取方法的全限定类名加方法名,会根据 User
的 account_id
字段作为参数查询对应的 Account
。
一对多映射指的是两个实体之间的关系,其中一个实体与多个另一个实体关联,而多个另一个实体实例只能关联一个实体实例。假设有两个实体类 Class
和 Student
,一个 Class
有多个 Student
,即一对多的关系。
如果是基于 XML 映射文件的方式,处理一对多关系,我们可以使用 ResultMap
+ collection
标签来实现:
<select id="getClass" resultMap="classStudentMap">
SELECT c.*, s.* FROM class c LEFT JOIN student s ON c.id = s.class_id WHERE c.id = #{id}
select>
<resultMap id="classStudentMap" type="Class">
<id property="id" column="id"/>
<result property="name" column="name"/>
<collection property="students" ofType="Student">
<id property="id" column="student_id"/>
<result property="name" column="student_name"/>
collection>
resultMap>
这里的
标签就是一对多的映射关系,将查询结果映射到 Class
的 students
属性上。
如果是基于注解则对应如下:
// ClassMapper.java
@Select("SELECT c.*, s.* FROM class c LEFT JOIN student s ON c.id = s.class_id WHERE c.id = #{id}")
@Results({
@Result(id = true, property = "id", column = "id"),
@Result(property = "name", column = "name"),
@Result(property = "students", column = "id", javaType = List.class,
many = @Many(select = "cn.javgo.StudentMapper.selectStudentsByClassId"))
})
Class selectClass(Integer id);
这里的 @Many
注解表示一对多的映射关系,可以看到 @Many
中给定的是具体获取方法的全限定类名加方法名,会根据 Class
的 id
字段作为参数查询对应的所有 Student
。
多对多映射指的是两个实体之间的关系,其中一个实体与多个另一个实体关联,多个另一个实体实例也可以关联多个实体实例。例如,学生表(Student
)和课程表(Course
),一个学生可以选修多门课程,一门课程也可以被多个学生选修。这其实与上述的一对多关系本质上是一样的,因此处理方案也相同。
以下是基于 XML 的示例:
<select id="getStudent" resultMap="studentCourseResult">
SELECT * FROM Student s LEFT JOIN StudentCourseRelation scr ON s.id = scr.student_id
LEFT JOIN Course c ON scr.course_id = c.id WHERE s.id = #{id}
select>
<resultMap id="studentCourseResult" type="Student">
<id property="id" column="id" />
<result property="name" column="name" />
<collection property="courses" ofType="Course">
<id property="id" column="course_id" />
<result property="courseName" column="course_name" />
collection>
resultMap>
以下是基于注解的示例:
public interface StudentMapper {
@Select("SELECT * FROM Student s LEFT JOIN StudentCourseRelation scr ON s.id = scr.student_id LEFT JOIN Course c ON scr.course_id = c.id WHERE s.id = #{id}")
@Results({
@Result(id = true, property = "id", column = "id"),
@Result(property = "name", column = "name"),
@Result(property = "courses", column = "student_id", javaType = List.class,
many = @Many(select = "cn.javgo.mapper.CourseMapper.selectByStudentId"))
})
Student getStudent(Long id);
}
MyBatis 允许你在几乎任何时候都使用自定义的 TypeHandler
来处理 SQL 语句的参数绑定以及结果映射。如果你有一个特定的数据类型需要做一些特殊的处理,你可以编写自定义的 TypeHandler
。
首先,需要实现 TypeHandler
或 BaseTypeHandler
抽象类。例如,有一个枚举类型 State
,包含了 ACTIVE
和 INACTIVE
两个状态:
/**
* 状态枚举
*/
public enum State {
ACTIVE(1, "激活"),
INACTIVE(0, "未激活");
private Integer code;
private String desc;
State(Integer code, String desc) {
this.code = code;
this.desc = desc;
}
public Integer getCode() {
return code;
}
public String getDesc() {
return desc;
}
public static State getByCode(Integer code) {
for (State state : State.values()) {
if (state.getCode().equals(code)) {
return state;
}
}
return null;
}
}
但在数据库中,我们希望它们分别保存为 1 和 0,可以定义一个自定义的类型处理器重写该类的四个方法:
/**
* 自定义枚举类型转换器({@link State} -> {@link Integer})
*/
public class StateTypeHandler extends BaseTypeHandler<State> {
/**
* 用于定义设置参数时,该如何把Java类型的参数转换为对应的数据库类型( Java -> DB )
* @param preparedStatement 用于设置参数的PreparedStatement对象
* @param i 参数的位置
* @param state 参数的值
* @param jdbcType JDBC类型
* @throws SQLException 数据库异常
*/
@Override
public void setNonNullParameter(PreparedStatement preparedStatement, int i, State state, JdbcType jdbcType) throws SQLException {
preparedStatement.setInt(i, state.getCode());
}
/**
* 用于定义通过字段名称获取字段数据时,如何把数据库类型转换为对应的Java类型( DB -> Java )
* @param resultSet 结果集
* @param s 字段名称
* @return 转换后的Java对象
* @throws SQLException 数据库异常
*/
@Override
public State getNullableResult(ResultSet resultSet, String s) throws SQLException {
int code = resultSet.getInt(s);
return State.getByCode(code);
}
/**
* 用于定义通过字段索引获取字段数据时,如何把数据库类型转换为对应的Java类型( DB -> Java )
* @param resultSet 结果集
* @param i 字段索引
* @return 转换后的Java对象
* @throws SQLException 数据库异常
*/
@Override
public State getNullableResult(ResultSet resultSet, int i) throws SQLException {
int code = resultSet.getInt(i);
return State.getByCode(code);
}
/**
* 用定义调用存储过程后,如何把数据库类型转换为对应的Java类型( DB -> Java )
* @param callableStatement CallableStatement对象
* @param i 字段索引
* @return 转换后的Java对象
* @throws SQLException 数据库异常
*/
@Override
public State getNullableResult(CallableStatement callableStatement, int i) throws SQLException {
int code = callableStatement.getInt(i);
return State.getByCode(code);
}
}
然后,在 Spring Boot 的配置文件中配置 TypeHandler
所在的包路径:
mybatis:
type-handlers-package: cn.javgo.learningmybatis.support.handler
最后,在 mapper XML 文件中,你就可以直接使用 State
类型了:
<select id="selectByState" parameterType="cn.javgo.learningmybatis.enums.State" resultType="User">
SELECT * FROM User WHERE state = #{state}
select>
在注解方式中,你可以直接在 @Results
注解中使用 @Result
注解的 typeHandler
属性来指定 TypeHandler
:
public interface UserMapper {
@Select("SELECT * FROM User WHERE state = #{state}")
@Results({
@Result(id = true, property = "id", column = "id"),
@Result(property = "username", column = "username"),
@Result(property = "state", column = "state", javaType = State.class, typeHandler = StateTypeHandler.class)
})
List<User> selectByState(@Param("state") State state);
}
这样,当查询 User
并将结果映射到 User
对象时,state
字段将使用我们自定义的 StateTypeHandler
来处理。
TIP:
MyBatis 为 Java 枚举类型的处理提供了两种方式:
EnumTypeHandler
和EnumOrdinalTypeHandler
。
EnumTypeHandler
:默认枚举处理器,它将枚举的名称(如 ACTIVE 或 INACTIVE)保存到数据库中。EnumOrdinalTypeHandler
:它将枚举的顺序(ordinal)保存到数据库中,如 1 或 0。因此,我们其实可以省略上述的操作,直接使用现成的这两个处理器就可以实现枚举相关的操作了。
在实际场景中,我们只有在 MyBatis 没有提供合适的内置 TypeHandler
时,才自定义自己的类型处理器。一个常见的例子就是讲 Money
类型的属性值和 Long
类型之间的转换,因为金额一般我们都是用 Money
类来表示,但是在数据库中一般却以分为单位进行存储,在取出时则以人民币为币种还原为 Money
。
这里需要使用一个货币相关的类库,Joda-Money 是一个开源的 Java 库,旨在提供强大而灵活的处理货币和货币金额的功能。它是 Joda-Time 日期和时间库的姊妹项目,专门用于处理货币价值和货币操作。Joda-Money 提供了一组类和方法,使您能够进行精确的货币计算和处理。
需要使用该类库需要添加如下依赖:
<dependency>
<groupId>org.jodagroupId>
<artifactId>joda-moneyartifactId>
<version>1.0.1version>
dependency>
处理 Money
类型的 MoneyTypeHandler
代码如下:
/**
* 自定义类型转换器({@link Money} -> {@link Long})
*/
public class MoneyTypeHandler extends BaseTypeHandler<Money> {
/**
* 用定义调用存储过程后,如何把数据库类型转换为对应的Java类型( DB -> Java )
* @param value 数据库中的数据
* @return 转换后的Java对象
*/
private Money parseMoney(Long value) {
// 创建Money对象(货币单位为分,货币类型为人民币)
return Money.ofMinor(CurrencyUnit.of("CNY"), value);
}
/**
* 用于定义设置参数时,该如何把Java类型的参数转换为对应的数据库类型( Java -> DB )
* @param preparedStatement 用于设置参数的PreparedStatement对象
* @param i 参数的位置
* @param money 参数的值
* @param jdbcType JDBC类型
* @throws SQLException 数据库异常
*/
@Override
public void setNonNullParameter(PreparedStatement preparedStatement, int i, Money money, JdbcType jdbcType) throws SQLException {
// 获取金额(分),并设置到PreparedStatement对象中
preparedStatement.setLong(i, money.getAmountMinorLong());
}
/**
* 用于定义通过字段名称获取字段数据时,如何把数据库类型转换为对应的Java类型( DB -> Java )
* @param resultSet 结果集
* @param s 字段名称
* @return 转换后的Java对象
* @throws SQLException 数据库异常
*/
@Override
public Money getNullableResult(ResultSet resultSet, String s) throws SQLException {
return parseMoney(resultSet.getLong(s));
}
/**
* 用于定义通过字段索引获取字段数据时,如何把数据库类型转换为对应的Java类型( DB -> Java )
* @param resultSet 结果集
* @param i 字段索引
* @return 转换后的Java对象
* @throws SQLException 数据库异常
*/
@Override
public Money getNullableResult(ResultSet resultSet, int i) throws SQLException {
return parseMoney(resultSet.getLong(i));
}
/**
* 用定义调用存储过程后,如何把数据库类型转换为对应的Java类型( DB -> Java )
* @param callableStatement CallableStatement对象
* @param i 字段索引
* @return 转换后的Java对象
* @throws SQLException 数据库异常
*/
@Override
public Money getNullableResult(CallableStatement callableStatement, int i) throws SQLException {
return parseMoney(callableStatement.getLong(i));
}
}
PageHelper 是一个简单且易用的 MyBatis 分页插件。它的设计思想是只对紧跟在 PageHelper.startPage
方法后的第一个 MyBatis 查询方法进行分页。这就意味着如果再次调用查询方法,它就会返回所有的记录,而不是一个分页结果。PageHelper-Spring-Boot-Starter 是 PageHelper 与 Spring Boot 的集成。
PageHelper 官方 GItHub 地址:https://github.com/pagehelper/Mybatis-PageHelper
Spring Boot Starter GItHub 地址:https://github.com/pagehelper/pagehelper-spring-boot
首先,你需要在项目的 pom.xml
文件中添加 pagehelper-spring-boot-starter
的依赖:
<dependency>
<groupId>com.github.pagehelpergroupId>
<artifactId>pagehelper-spring-boot-starterartifactId>
<version>${pagehelper-starter.version}version>
dependency>
注意:上面的
${pagehelper-starter.version}
需要根据实际需求选择对应的版本。
然后在 Spring Boot 的配置文件中进行 PageHelper 相关的基本配置:
# PageHelper 分页插件配置
pagehelper:
# 配置数据库方言
helper-dialect: mysql
# 配置分页合理化参数
reasonable: true
# 配置支持通过 Mapper 接口参数来传递分页参数
support-methods-arguments: true
# 配置参数映射,即从 Map 中根据指定的名字取值,用于从 Map 中取值时的 key
params: count=countSql
# 如果 pageSize=0 或者 RowBounds.limit = 0 就会查询出全部的结果
page-size-zero: true
常用的配置项如下:
配置项 | 说明 |
---|---|
pagehelper.helper-dialect | 配置数据库的方言 |
pagehelper.page-size-zero | 如果 pageSize=0 或者 RowBounds.limit = 0 就会查询出全部的结果 |
pagehelper.reasonable | 配置分页合理化参数,默认值为 false。当该参数设置为 true 时,pageNum<=0 会查询第一页,pageNum>pages (超过总页数时)会查询最后一页。默认 false 时,直接根据参数进行查询。 |
pagehelper.support-methods-arguments | 支持通过 Mapper 接口方法参数来传递分页参数,默认值false ,分页插件会从查询方法的参数值中,自动根据上面 params 配置的字段中取值,查找到合适的值时就会进行分页。 |
pagehelper.params | 为了支持 startPage(Object params) 方法,增加了该配置来配置参数映射,用于从对象中根据属性名取值(一般为 Map 中根据指定的名字取值,用于从 Map 中取值时的 key),可以配置 pageNum ,pageSize ,count ,pageSizeZero ,reasonable ,不配置映射的用法可以参考 startPage(Object params) 方法的示例。 |
然后,在 Mapper 接口中,你可以直接进行分页查询:
@Mapper
public interface UserMapper {
@Select("select * from user")
List<User> selectAll();
}
在 Service 层,你可以通过调用 PageHelper.startPage
方法实现分页:
@Service
public class UserService {
@Autowired
private UserMapper userMapper;
/**
* 查询所有用户(分页)
* @param pageNum 当前页码
* @param pageSize 每页大小
* @return PageInfo
*/
public PageInfo<User> selectAll(int pageNum, int pageSize) {
// 开启分页(将会自动拦截到下面这查询sql)
PageHelper.startPage(pageNum, pageSize);
// 执行查询
List<User> users = userMapper.selectAll();
// 封装为PageInfo对象
PageInfo<User> pageInfo = new PageInfo<>(users);
// 返回
return pageInfo;
}
}
在这个示例中,我们首先调用了 PageHelper.startPage
方法,然后调用了 userMapper.selectAll
方法。PageHelper 会对这个方法进行分页,然后我们将结果包装成 PageInfo
对象并返回。
PageInfo
是一个包含了分页信息的对象,包括当前页码、每页的数量、总记录数、总页数、是否为第一页、是否为最后一页、是否有前一页、是否有下一页等。
PageHelper 底层实现分页的原理:
PageHelper 插件的实现是基于 MyBatis 的拦截器接口
Interceptor
。它会对执行 SQL 操作的StatementHandler
进行拦截。在进行 SQL 查询之前,插件会改写要执行的 SQL,加入对应数据库的分页查询语句(即 limit 条件),从而实现物理分页的效果。具体来说,使用 PageHelper 插件时,当调用PageHelper.startPage(pageNum, pageSize)
方法后,会创建一个Page
对象并保存到本地线程变量ThreadLocal
中,因此操作需要在一个线程中。在执行查询之前,会取出Page
对象的信息,并利用这些信息改写 SQL。在查询完成后,将分页信息清空。方法源码如下:
/** * 分页查询 * @param pageNum 当前页 * @param pageSize 每页大小 * @return 一个经过分页后的 Page 对象 */ public static <E> Page<E> startPage(int pageNum, int pageSize) { // 调用下面的 startPage 方法,传入 pageNum、pageSize、DEFAULT_COUNT 参数,返回一个 Page
对象 return startPage(pageNum, pageSize, DEFAULT_COUNT); } /** * 真正的分页方法 * @param pageNum 当前页 * @param pageSize 每页大小 * @param count 数据表中的记录总数,用于计算分页的页数和当前页的数据数量,以便于实现分页功能 * @return 一个经过分页后的 Page 对象 */ public static <E> Page<E> startPage(int pageNum, int pageSize, int count) { // 创建 Page 对象 Page<E> page = new Page<>(pageNum, pageSize, count); // 将 Page 对象设置到 ThreadLocal 中,方便在同一线程的任意地方获得 Page 对象 PAGE_LOCAL.set(page); // 返回 Page 对象 return page; }
当然,MyBatis 本身其实也提供了 RowBounds
对象和在 Mapper 方法参数中指定分页信息的方式进行分页。RowBounds
是 MyBatis 提供的一个用于物理分页的类,它有两个重要的属性,offset
和 limit
。其中 offset
表示开始读取的位置(偏移量),limit
表示读取的数量。例如,new RowBounds(10, 20)
表示从第10条记录开始,读取20条记录。
注意:当我们配置了
pagehelper.offset-as-page-num=true
后会将offset
当成pageNum
页码使用,第二个参数limit
为pageSize
参数。
使用 RowBounds
对象的例子:
@Select("select * from user")
List<User> selectAllByRowBounds(RowBounds rowBounds);
在方法参数中指定分页信息的例子:
/**
* 查询所有用户(分页)
* @param pageNum 当前页码
* @param pageSize 每页大小
* @return PageInfo
*/
public PageInfo<User> selectAllByRowBounds(int pageNum, int pageSize) {
// 执行查询
List<User> users = userMapper.selectAllByRowBounds(new RowBounds(pageNum, pageSize));
// 封装为PageInfo对象
PageInfo<User> pageInfo = new PageInfo<>(users);
// 返回
return pageInfo;
}
PageHelper VS RowBounds:
- PageHelper 是基于 MyBatis 插件实现的,只需要在查询前调用
PageHelper.startPage
方法即可实现分页,无需修改原有的 SQL。它会自动识别数据库类型,并生成对应的分页 SQL。PageHelper 只返回需要的记录,不必查询所有数据,性能较好。- RowBounds 是 MyBatis 提供的一个用于分页的对象,通过查询所有数据后,再返回指定范围的记录实现的,所以在数据量较大时,性能较差。
因此,大多数情况下,推荐使用 PageHelper 插件进行分页,不仅可以减少代码的复杂度,而且更加易用。