大家好,我是王有志,一个分享硬核 Java 技术的金融摸鱼侠,欢迎大家加入 Java 人自己的交流群“共同富裕的 Java 人”。
《MyBatis 映射器:实现简单的 SQL 语句》中,我们在 MyBatis 映射器的查询语句中使用 resultType 元素实现了从数据库到 Java 对象的自动映射,但是这种自动映射是有它的局限性的,首先是要求数据库中表的列名必须与 Java 对象中的字段名保持一致,其次是无法实现关联查询,也就是说使用 resultType 元素无法应对一些复杂的场景,那么对于复杂的查询场景,MyBatis 中有没有可以应对的方案呢?
答案是肯定的,MyBatis 对这些复杂场景给出了自己的答案:resultMap 元素。
前期准备工作
正式开始前,我们要做一些准备工作,主要是两部分:数据库准备和项目准备。
数据库准备
在本文及未来的《MyBatis 映射器:一对多关联查询》的中,我们需要使用一套简单的“用户-订单”体系,数据库结构如下:
这里简单的描述下这 4 张表之间的关系:
- 用户表(user)与用户订单表(user_order)之间通过 user_id 进行关联,每个用户可以对应多个订单,即一对多;
- 用户订单表(user_order)与订单明细表(order_item)之间通过 order_id 进行关联,每个订单可以对应多个商品,即一对多;
- 用户订单表(user_order)与支付订单表(pay_order)之间通过 order_id 进行关联,每个订单对应一个支付订单,即一对一。
你不需要照着图片创建这些表,因为我在文章的末尾准备了这 4 张表的建表 SQL 语句和测试数据的初始化 SQL 语句。
项目准备
我们再来准备一个用于练习的 MyBatis 项目,Maven 依赖和 mybatis-config.xml 的配置你可以在《MyBatis入门》中找到,这里我就不再赘述了。
接着,我们为上面的 4 张表创建对应的 Java 对象,Mapper 接口和映射器文件。如果你熟悉 MyBatis 生成工具(如 MyBatis-Generator,MyBatis X 等),你可以使用生成工具来创建这些文件,如果你不熟悉,你可以直接创建这些文件,只需要照着数据库表创建 Java 对象,并且创建对应的空的 Mapper 接口和映射器文件就可以了。
最后,我们准备一个单元测试文件,只需要提前做一些配置工作就可以了,代码如下:
public class CustomizeMappedTest {
private static SqlSession sqlSession;
private static UserMapper userMapper;
private static UserOrderMapper userOrderMapper;
private static PayOrderMapper payOrderMapper;
private static OrderItemMapper orderItemMapper;
@BeforeClass
public static void init() throws IOException {
Reader mysqlReader = Resources.getResourceAsReader("mybatis-config.xml");
SqlSessionFactory sqlSessionFactory = new SqlSessionFactoryBuilder().build(mysqlReader);
sqlSession = sqlSessionFactory.openSession();
userMapper = sqlSession.getMapper(UserMapper.class);
userOrderMapper = sqlSession.getMapper(UserOrderMapper.class);
payOrderMapper = sqlSession.getMapper(PayOrderMapper.class);
orderItemMapper = sqlSession.getMapper(OrderItemMapper.class);
}
}
至此,我们已经完成了所有前期准备工作,来看一下工程的整体结构:
基础用法:定义映射规则
如果你是通过 MyBatis 生成工具创建的映射器文件,那么在你的映射器文件中会出现如下配置(如果不是的话,把这些 copy 到你的项目中),这里以 UserOrderMapper.xml 为例:
<resultMap id="BaseResultMap" type="com.wyz.entity.UserOrderDO">
<id property="orderId" column="order_id" jdbcType="INTEGER"/>
<result property="userId" column="user_id" jdbcType="INTEGER"/>
<result property="orderNo" column="order_no" jdbcType="VARCHAR"/>
<result property="orderPrice" column="order_price" jdbcType="DECIMAL"/>
<result property="orderStatus" column="order_status" jdbcType="INTEGER"/>
<result property="createDate" column="create_date" jdbcType="DATE"/>
<result property="payDate" column="pay_date" jdbcType="DATE"/>
</resultMap>
这便是 resultMap 元素最基础的用法,指定数据库表中字段与 Java 对象中字段的映射关系,定义数据库表与 Java 对象的映射规则。
上面的配置内容并不难理解,这里我做一个简单的解释:
- resultMap 元素:用于自定义映射规则;
- id 属性,定义了 resultMap 元素的唯一标识;
- type 属性,定义了与 resultMap 元素对应的 Java 对象;
- id 元素:定义了数据库中主键与 Java 对象中字段的映射关系;
- property 属性:映射到 Java 对象中的字段名;
- column 属性:数据库表中的字段名;
- jdbcType 属性:声明了数据库表中字段的类型;
- result 元素,定义了数据库中普通字段与 Java 对象中字段的映射关系。
id 元素与 result 元素所使用的属性完全相同, 除了上面的 3 个之外,还有 javaType 属性和 typeHandler 属性,分别用于声明 Java 对象中字段的类型和字段需要使用的类型处理器。
注意,由于使用 resultMap 元素是将数据库表中的字段名与 Java 对象中的字段名建立映射关系,因此我们不需要考虑到数据库字段命名规范与 Java 对象字段命名规范间的差异,也不需要在 mybatis-config.xml 中配置插件“mapUnderscoreToCamelCase”。
假设此时,老板提出了一个需求:“我想要通过订单号查询出订单信息”,那么我们可以这样定义 Mapper 接口中的方法:
UserOrderDO selectByOrderNo(@Param("orderNo")String orderNo);
映射器中的 SQL 语句如下:
<select id="selectByOrderNo" resultMap="BaseResultMap">
select * from user_order where order_no = #{orderNo, jdbcType=VARCHAR}
</select>
最后我们来写单元测试:
@Test
public void selectByOrderNo() {
UserOrderDO userOrder = userOrderMapper.selectByOrderNo("D202405082208045788");
System.out.println("查询结果:");
System.out.println(JSON.toJSONString(userOrder, JSONWriter.Feature.PrettyFormat));
}
执行单元测试,我们来看控制台输出的结果:
可以看到,测试结果符合我们的预期,数据库中表的字段与 Java 对象中的字段完成了映射。
当然,如果 resultMap 元素只有上面的那点功能的话,我们完全没有必要学习它。resultMap 元素最强大的功能在于它能够帮助我们实现关联查询,无论是一对一关联还是一对多关联,resultMap 都提供了解决方案。
进阶用法:使用 association 元素实现一对一关联查询
完成了查询订单信息的功能后,老板提出了新的要求:“我想要用订单号同时查询出订单信息和支付订单信息”。
最容易想到的办法是拆分成多步查询,首先用订单号查询出订单信息,再用订单 ID 去查询关联的支付订单信息,最后将两者的数据合并后输出,但是这么做的问题是,两次查询需要经历两次数据库交互,会带来额外的性能损耗。
如果想要通过一次数据库交互查询出两张表的数据,可以使用联表查询一次性将用户订单与支付订单的数据全部查询出来,SQL 语句如下:
select uo.order_id,
uo.user_id,
uo.order_no,
uo.order_price,
uo.order_status,
uo.create_date,
uo.pay_date,
po.pay_order_id as po_pay_order_id,
po.order_id as po_order_id,
po.pay_order_no as po_pay_order_no,
po.pay_amount as po_pay_amount,
po.pay_channel as po_pay_channel,
po.pay_status as po_pay_status,
po.create_date as po_create_date,
po.finish_date as po_finish_date
from user_order uo,
pay_order po
where uo.order_no = 'D202405082208045788'
and uo.order_id = po.order_id;
在 SQL 语句的查询字段中,我为所有 pay_order 表的字段添加了前缀“po_”,这是因为 user_order 表与 pay_order 表有重名字段 order_id 和 create_date,虽然两张表中的 order_id 字段含义相同,取值也一样,但是 create_date 字段的含义却不相同,为了起到区分的作用,索性为 pay_order 表的所有字段都起了别名。
那么对于这样的查询结果我们该怎样使用 resultMap 元素进行映射呢?答案是使用 resultMap 元素和它的子元素 association 来实现一对一联表查询的结果集映射。
我们先来修改下 UserOrderDO 对象,代码非常简单,只需要将 PayOrderDO 对象组合进来就行,如下:
public class UserOrderDO {
// 省略 UserOrderDO 本身的字段
/**
* 支付订单信息
*/
private PayOrderDO payOrder;
}
接着我们使用 resultMap 元素来构建新的映射规则“userOrderContainPayOrderMap”:
<resultMap id="userOrderContainPayOrderMap" type="com.wyz.entity.UserOrderDO" extends="BaseResultMap">
<association property="payOrder" javaType="com.wyz.entity.PayOrderDO" columnPrefix="po_">
<id property="payOrderId" column="pay_order_id" jdbcType="INTEGER"/>
<result property="orderId" column="order_id" jdbcType="INTEGER"/>
<result property="payOrderNo" column="pay_order_no" jdbcType="VARCHAR"/>
<result property="payAmount" column="pay_amount" jdbcType="DECIMAL"/>
<result property="payChannel" column="pay_channel" jdbcType="INTEGER"/>
<result property="payStatus" column="pay_status" jdbcType="INTEGER"/>
<result property="createDate" column="create_date" jdbcType="DATE"/>
<result property="finishDate" column="finish_date" jdbcType="DATE"/>
</association>
</resultMap>
我们来解释下“userOrderContainPayOrderMap”的配置。
首先是 resultMap 元素中出现的 extends 属性,resultMap 元素中的 extends 属性与 Java 中的关键字 extends 的作用是一样的,用于继承父类(映射规则)。
接着是 association 元素,association 元素表示一个复杂 Java 对象的关联,我们来逐一解释 association 元素中出现的属性:
- property 属性,用于配置该类型在 Java 对象中字段名,在我们的例子中,即 PayOrderDO 对象在 UserOrderDO 对象中的字段名;
- javaType 属性,用于生命 association 元素关联的 Java 对象类型;
- columnPrefix 属性,用于配置数据库查询字段的前缀,使用 columnPrefix 属性后,association 元素中配置的字段可以省略前缀,即 SQL 语句中使用了别名“po_pay_order_id”,在配置时可以直接使用“pay_order_id”。
接着我们来定义 Mapper 接口中的方法:
UserOrderDO selectUserOrderAndPayOrderByOrderNo(@Param("orderNo")String orderNo);
然后是编写映射器中对应的 SQL 语句:
<select id="selectUserOrderAndPayOrderByOrderNo" resultMap="userOrderContainPayOrderMap">
select uo.order_id,
uo.user_id,
uo.order_no,
uo.order_price,
uo.order_status,
uo.create_date,
uo.pay_date,
po.pay_order_id as po_pay_order_id,
po.order_id as po_order_id,
po.pay_order_no as po_pay_order_no,
po.pay_amount as po_pay_amount,
po.pay_channel as po_pay_channel,
po.pay_status as po_pay_status,
po.create_date as po_create_date,
po.finish_date as po_finish_date
from user_order uo,
pay_order po
where uo.order_no = #{orderNo,jdbcType=VARCHAR}
and uo.order_id = po.order_id;
</select>
最后我们来写单元测试:
public void selectUserOrderAndPayOrderByOrderNo() {
UserOrderDO userOrder = userOrderMapper.selectUserOrderAndPayOrderByOrderNo("D202405082208045788");
System.out.println("查询结果:");
System.out.println(JSON.toJSONString(userOrder, JSONWriter.Feature.PrettyFormat));
}
执行单元测试,我们来看控制台输出的内容:
可以看到,控制台输出的结果中,联表查询的结果能够正常的映射到 UserOrderDO 对象中组合的 PayOrderDO 对象上。
进阶用法:使用 association 元素实现嵌套查询
最后我们来看一种使用 association 元素实现嵌套查询的方法。
association 元素可以使用简单的 SQL 语句进行嵌套查询,这与我们最开始想到的分步查询,组合结果的想法是一样的。
首先我们定义嵌套查询的 Mapper 接口中的方法:
UserOrderDO selectUserOrderAndPayOrderByOrderNoNest(@Param("orderNo")String orderNo);
Tips:千万不要诟病我起的方法名啊~~
接着我们来写映射器文件中对应的 SQL 语句:
<select id="selectUserOrderAndPayOrderByOrderNoNest" resultMap="userOrderContainPayOrderNestMap">
select order_id,
user_id,
order_no,
order_price,
order_status,
create_date,
pay_date
from user_order
where order_no = #{orderNo,jdbcType=VARCHAR}
</select>
注意看,这里的 SQL 语句中只是很简单的 user_order 表的单表查询,那么我是如何将 pay_order 表中的数据填充到 UserOrderDO 对象中的呢?
这里我们要先为 PayOrderMapper 补充一个非常简单的接口方法,如下:
PayOrderDO selectPayOrderByOrderId(@Param("orderId") Integer orderId);
接着来完善对应的映射器文件内容,如下:
<resultMap id="BaseResultMap" type="com.wyz.entity.PayOrderDO">
<id property="payOrderId" column="pay_order_id" jdbcType="INTEGER"/>
<result property="orderId" column="order_id" jdbcType="INTEGER"/>
<result property="payOrderNo" column="pay_order_no" jdbcType="VARCHAR"/>
<result property="payAmount" column="pay_amount" jdbcType="DECIMAL"/>
<result property="payChannel" column="pay_channel" jdbcType="INTEGER"/>
<result property="payStatus" column="pay_status" jdbcType="INTEGER"/>
<result property="createDate" column="create_date" jdbcType="DATE"/>
<result property="finishDate" column="finish_date" jdbcType="DATE"/>
</resultMap>
<select id="selectPayOrderByOrderId" resultMap="BaseResultMap">
select * from pay_order where order_id = #{orderId,jdbcType=INTEGER}
</select>
注意,因为没有开启插件“mapUnderscoreToCamelCase”,所以自动映射无法完成下划线与驼峰命名法的转换,所以这里也使用了 resultMap 进行映射。
做完了这些准备工作后,我们在 UserOrderMappe.xml 定义新的映射规则“userOrderContainPayOrderNestMap”,如下:
<resultMap id="userOrderContainPayOrderNestMap" type="com.wyz.entity.UserOrderDO" extends="BaseResultMap">
<association
property="payOrder"
javaType="com.wyz.entity.PayOrderDO"
select="com.wyz.mapper.PayOrderMapper.selectPayOrderByOrderId"
column="{orderId=order_id}" />
</resultMap>
来解释下这里 association 元素中出现的两个陌生属性:
- select 属性,用于配置嵌套查询的 SQL 语句,在这个例子中就是使用订单 id 查询支付订单信息的 SQL 语句;
- column 属性,配置查询语句中所用到的参数,即
PayOrderMapper#selectPayOrderByOrderId
方法中使用的参数,如果有多个参数的话使用英文逗号分隔,例如:{orderId=order_id, payOrderId=pay_order_id}
。
最后我们编写单元测试代码:
public void selectUserOrderAndPayOrderByOrderNoNest() {
UserOrderDO userOrder = userOrderMapper.selectUserOrderAndPayOrderByOrderNoNest("D202405082208045788");
System.out.println("查询结果:");
System.out.println(JSON.toJSONString(userOrder, JSONWriter.Feature.PrettyFormat));
}
执行单元测试后,我们来观察控制台的数据结果:
先来看控制台最后输出的查询结果,依旧符合我们的预期,接着来看 MyBatis 打印的 SQL 语句日志,可以看到这里执行了两条 SQL 语句,分别是通过订单号查询用户订单信息的 SQL 语句和通过用户订单 Id 查询支付订单信息的 SQL 语句,这说明在 association 元素中使用嵌套查询,执行了两次 SQL 语句。
附录:SQL 语句
用户表
建表语句:
create table user (
user_id int not null comment '用户Id' primary key,
name varchar(50) not null comment '用户名',
age int not null comment '年龄',
gender varchar(50) not null comment '性别',
id_type int not null comment '证件类型',
id_number varchar(50) not null comment '证件号'
) comment '用户表';
初始化语句:
INSERT INTO mybatis.user (user_id, name, age, gender, id_type, id_number)
VALUES (1, '刘一', 17, 'M', 1, '1101012000808186530');
用户订单表
建表语句:
create table user_order (
order_id int auto_increment comment '订单表主键' primary key,
user_id int not null comment 'user表的主键',
order_no varchar(50) null comment '订单号',
order_price decimal(18, 2) not null comment '订单价格',
order_status int null comment '订单状态',
create_date date null comment '订单创建时间',
pay_date date null comment '订单支付时间'
) comment '用户订单表';
初始化语句:
INSERT INTO user_order (order_id, user_id, order_no, order_price, order_status, create_date, pay_date)
VALUES (1, 1, 'D202405082208045788', 10000.00, 1, '2024-05-14', '2024-05-14');
INSERT INTO user_order (order_id, user_id, order_no, order_price, order_status, create_date, pay_date)
VALUES (2, 1, 'D202405131542336954', 9900.00, 1, '2024-05-01', '2024-05-01');
订单明细表
建表语句:
create table order_item (
item_id int not null comment '订单商品表主键' primary key,
order_id int null comment '订单表主键',
commodity_id int null comment '商品表主键',
commodity_price decimal(18, 2) null comment '商品价格',
commodity_count int null comment '商品数量'
) comment '订单明细表';
初始化语句:
INSERT INTO order_item (item_id, order_id, commodity_id, commodity_price, commodity_count)
VALUES (1, 1, 350071, 100.00, 10);
INSERT INTO order_item (item_id, order_id, commodity_id, commodity_price, commodity_count)
VALUES (2, 1, 350083, 500.00, 10);
INSERT INTO order_item (item_id, order_id, commodity_id, commodity_price, commodity_count)
VALUES (3, 1, 360302, 400.00, 10);
支付订单表
建表语句:
create table pay_order (
pay_order_id int not null comment '支付订单表主键' primary key,
order_id int null comment '订单表主键',
pay_order_no varchar(50) null comment '支付订单号',
pay_amount decimal(18, 2) null comment '支付金额',
pay_channel int null comment '支付渠道',
pay_status int null comment '支付状态',
create_date date null comment '支付订单创建时间',
finish_date date null comment '支付订单完成时间'
) comment '支付订单表';
初始化语句:
INSERT INTO pay_order (pay_order_id, order_id, pay_order_no, pay_amount, pay_channel, pay_status, create_date, finish_date)
VALUES (1, 1, 'Z202405082208557945', 10000.00, 37, 1, '2024-05-15', '2024-05-15');
INSERT INTO pay_order (pay_order_id, order_id, pay_order_no, pay_amount, pay_channel, pay_status, create_date, finish_date)
VALUES (2, 2, 'Z202405131543079921', 9900.00, 98, 1, '2024-05-01', '2024-05-01');
附录:数据库别名的“骚”操作
使用数据库别名实现自动映射
实际上,使用数据库别名也可以在不使用插件“mapUnderscoreToCamelCase”时,实现自动映射,例如:
<select id="selectByOrderNoUseAlias" resultType="com.wyz.entity.UserOrderDO">
select order_id as orderId,
user_id as userId,
order_no as orderNo,
order_price as orderPrice,
order_status as orderStatus,
create_date as createDate,
pay_date as payDate
from user_order
where order_no = #{orderNo,jdbcType=VARCHAR}
</select>
使用数据库表名实现一对一关联自动映射
前面我们借助了 resultMap 元素和其子元素 association 实现了一对一关联映射,但实际上,这种场景我们依旧可以借助数据库别名来实现自动映射。
先来写一个 Mapper 接口:
UserOrderDO selectUserOrderAndPayOrderByOrderNoUseAlias(@Param("orderNo")String orderNo);
接着我们来写映射器文件中的 SQL 语句:
<select id="selectUserOrderAndPayOrderByOrderNoUseAlias" resultType="com.wyz.entity.UserOrderDO">
select uo.order_id as orderId,
uo.user_id as userId,
uo.order_no as orderNo,
uo.order_price as orderPrice,
uo.order_status as orderStatus,
uo.create_date as createDate,
uo.pay_date as payDate,
po.pay_order_id as "payOrder.payOrderId",
po.order_id as "payOrder.orderId",
po.pay_order_no as "payOrder.payOrderNo",
po.pay_amount as "payOrder.payAmount",
po.pay_channel as "payOrder.payChannel",
po.pay_status as "payOrder.payStatus",
po.create_date as "payOrder.createDate",
po.finish_date as "payOrder.finishDate"
from user_order uo, pay_order po
where uo.order_no = #{orderNo,jdbcType=VARCHAR}
and uo.order_id = po.order_id
</select>
最后你可以写一个单元测试来测试下结果。