在学习 MyBatis Plus 的过程中,我参考了黑马程序员的相关课程。

课程地址如下:

黑马程序员SpringCloud微服务开发与实战,java黑马商城项目微服务实战开发(涵盖MybatisPlus、Docker、MQ、ES、Redis高级等)

本文主要内容:

  • 使用 MyBatis Plus 完成基础 CRUD
  • 使用条件构造器编写查询与更新
  • Mybatis Plus的“伪”批量插入
  • 常用注解的应用
  • 处理枚举、JSON 类型字段
  • 分页功能的使用

1.快速入门

为了让后续的示例更好跑起来,我先准备了一个小项目和几张表。整体流程非常简单,就是把环境搭好,然后开始用 MP 写 CRUD。

1.1.环境准备

首先,复制课前资料提供好的一个项目到你的工作空间(不要包含空格和特殊字符):

img

然后用你的IDEA工具打开,项目结构如下:

img

里面已经包含了一个 User 实体类、对应的 UserMapper 以及最基本的 Spring Boot 项目结构,拿来跑 MP 示例是足够的。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
@TableName(value = "tb_user", autoResultMap = true)
@Data
public class User {
@TableId(type = IdType.AUTO)
private Long id;
private String username;
private String password;
private String phone;
@TableField(typeHandler = JacksonTypeHandler.class)
private UserInfo info;
private UserStatus status;
private Integer balance;
private LocalDateTime createTime;
private LocalDateTime updateTime;
}

上面这个类就是我们后面用来演示各种 MP 功能的基础模型:包含普通字段、枚举字段,还有一个 JSON 字段用于测试 MP 对复杂类型的支持。

UserMapper 也很简洁,只继承了 BaseMapper

1
2
public interface UserMapper extends BaseMapper<User> {
}

这里最妙的地方就是:
只要继承了 BaseMapper,单表 CRUD 自动到位,不用你自己写 SQL,也不用写 XML。

1
2
3
4
5
<?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.chenpi.mp.mapper.UserMapper">

</mapper>

UserMapper.xml暂时不需要写内容。

1
2
3
4
5
6
7
8
9
10
11
12
spring:
datasource:
url: jdbc:mysql://127.0.0.1:3306/mp?useUnicode=true&characterEncoding=UTF-8&autoReconnect=true&serverTimezone=Asia/Shanghai
driver-class-name: com.mysql.cj.jdbc.Driver
username: username
password: password
logging:
level:
com.chenpi: debug
pattern:
dateformat: HH:mm:ss

注意配置一下项目的JDK版本为JDK11。img

课程资料里提供了一个mp.sql,执行后会有两张表:useraddress
我把 SQL 一并放到下面了,复制后在你本地库里跑一下即可:

img

对应的数据库表结构如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
-- --------------------------------------------------------
-- 主机: 127.0.0.1
-- 服务器版本: 8.0.28 - MySQL Community Server - GPL
-- 服务器操作系统: Win64
-- HeidiSQL 版本: 12.2.0.6576
-- --------------------------------------------------------

/*!40101 SET @OLD_CHARACTER_SET_CLIENT=@@CHARACTER_SET_CLIENT */;
/*!40101 SET NAMES utf8 */;
/*!50503 SET NAMES utf8mb4 */;
/*!40103 SET @OLD_TIME_ZONE=@@TIME_ZONE */;
/*!40103 SET TIME_ZONE='+00:00' */;
/*!40014 SET @OLD_FOREIGN_KEY_CHECKS=@@FOREIGN_KEY_CHECKS, FOREIGN_KEY_CHECKS=0 */;
/*!40101 SET @OLD_SQL_MODE=@@SQL_MODE, SQL_MODE='NO_AUTO_VALUE_ON_ZERO' */;
/*!40111 SET @OLD_SQL_NOTES=@@SQL_NOTES, SQL_NOTES=0 */;


-- 导出 mp 的数据库结构
CREATE DATABASE IF NOT EXISTS `mp` /*!40100 DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci */ /*!80016 DEFAULT ENCRYPTION='N' */;
USE `mp`;

-- 导出 表 mp.address 结构
CREATE TABLE IF NOT EXISTS `address` (
`id` bigint NOT NULL AUTO_INCREMENT,
`user_id` bigint DEFAULT NULL COMMENT '用户ID',
`province` varchar(10) CHARACTER SET utf8 COLLATE utf8_general_ci DEFAULT NULL COMMENT '省',
`city` varchar(10) CHARACTER SET utf8 COLLATE utf8_general_ci DEFAULT NULL COMMENT '市',
`town` varchar(10) CHARACTER SET utf8 COLLATE utf8_general_ci DEFAULT NULL COMMENT '县/区',
`mobile` varchar(255) CHARACTER SET utf8 COLLATE utf8_general_ci DEFAULT NULL COMMENT '手机',
`street` varchar(255) CHARACTER SET utf8 COLLATE utf8_general_ci DEFAULT NULL COMMENT '详细地址',
`contact` varchar(255) CHARACTER SET utf8 COLLATE utf8_general_ci DEFAULT NULL COMMENT '联系人',
`is_default` bit(1) DEFAULT b'0' COMMENT '是否是默认 1默认 0否',
`notes` varchar(255) CHARACTER SET utf8 COLLATE utf8_general_ci DEFAULT NULL COMMENT '备注',
PRIMARY KEY (`id`) USING BTREE,
KEY `user_id` (`user_id`) USING BTREE
) ENGINE=InnoDB AUTO_INCREMENT=67 DEFAULT CHARSET=utf8mb3 ROW_FORMAT=COMPACT;

-- 正在导出表 mp.address 的数据:~7 rows (大约)
DELETE FROM `address`;
INSERT INTO `address` (`id`, `user_id`, `province`, `city`, `town`, `mobile`, `street`, `contact`, `is_default`, `notes`) VALUES
(59, 2, '北京', '北京', '朝阳区', '13900112222', '金燕龙办公楼', 'Rose', b'1', NULL),
(60, 1, '北京', '北京', '朝阳区', '13700221122', '修正大厦', 'Jack', b'0', NULL),
(61, 1, '上海', '上海', '浦东新区', '13301212233', '航头镇航头路', 'Jack', b'1', NULL),
(63, 2, '广东', '佛山', '永春', '13301212233', '永春武馆', 'Rose', b'0', NULL),
(64, 3, '浙江', '杭州', '拱墅区', '13567809102', '浙江大学', 'Hope', b'1', NULL),
(65, 3, '浙江', '杭州', '拱墅区', '13967589201', '左岸花园', 'Hope', b'0', NULL),
(66, 4, '湖北', '武汉', '汉口', '13967519202', '天天花园', 'Thomas', b'1', NULL);

-- 导出 表 mp.user 结构
CREATE TABLE IF NOT EXISTS `user` (
`id` bigint NOT NULL AUTO_INCREMENT COMMENT '用户id',
`username` varchar(50) CHARACTER SET utf8 COLLATE utf8_general_ci NOT NULL COMMENT '用户名',
`password` varchar(128) CHARACTER SET utf8 COLLATE utf8_general_ci NOT NULL COMMENT '密码',
`phone` varchar(20) CHARACTER SET utf8 COLLATE utf8_general_ci DEFAULT NULL COMMENT '注册手机号',
`info` json NOT NULL COMMENT '详细信息',
`status` int DEFAULT '1' COMMENT '使用状态(1正常 2冻结)',
`balance` int DEFAULT NULL COMMENT '账户余额',
`create_time` datetime NOT NULL COMMENT '创建时间',
`update_time` datetime NOT NULL COMMENT '更新时间',
PRIMARY KEY (`id`) USING BTREE,
UNIQUE KEY `username` (`username`) USING BTREE
) ENGINE=InnoDB AUTO_INCREMENT=1674613593516095922 DEFAULT CHARSET=utf8mb3 ROW_FORMAT=COMPACT COMMENT='用户表';

-- 正在导出表 mp.user 的数据:~4 rows (大约)
DELETE FROM `user`;
INSERT INTO `user` (`id`, `username`, `password`, `phone`, `info`, `status`, `balance`, `create_time`, `update_time`) VALUES
(1, 'Jack', '123', '13900112224', '{"age": 20, "intro": "佛系青年", "gender": "male"}', 1, 1600, '2023-05-19 20:50:21', '2023-06-19 20:50:21'),
(2, 'Rose', '123', '13900112223', '{"age": 19, "intro": "青涩少女", "gender": "female"}', 1, 300, '2023-05-19 21:00:23', '2023-06-19 21:00:23'),
(3, 'Hope', '123', '13900112222', '{"age": 25, "intro": "上进青年", "gender": "male"}', 1, 100000, '2023-06-19 22:37:44', '2023-06-19 22:37:44'),
(4, 'Thomas', '123', '17701265258', '{"age": 29, "intro": "伏地魔", "gender": "male"}', 1, 800, '2023-06-19 23:44:45', '2023-06-19 23:44:45');

/*!40103 SET TIME_ZONE=IFNULL(@OLD_TIME_ZONE, 'system') */;
/*!40101 SET SQL_MODE=IFNULL(@OLD_SQL_MODE, '') */;
/*!40014 SET FOREIGN_KEY_CHECKS=IFNULL(@OLD_FOREIGN_KEY_CHECKS, 1) */;
/*!40101 SET CHARACTER_SET_CLIENT=@OLD_CHARACTER_SET_CLIENT */;
/*!40111 SET SQL_NOTES=IFNULL(@OLD_SQL_NOTES, 1) */;

img

最后,在application.yaml中修改jdbc参数为你自己的数据库参数:

1
2
3
4
5
6
7
8
9
10
11
spring:
datasource:
url: jdbc:mysql://127.0.0.1:3306/mp?useUnicode=true&characterEncoding=UTF-8&autoReconnect=true&serverTimezone=Asia/Shanghai
driver-class-name: com.mysql.cj.jdbc.Driver
username: root
password: MySQL123
logging:
level:
com.chenpi: debug
pattern:
dateformat: HH:mm:ss

1.2.快速开始

环境准备好了之后,就可以正式体验一下 MyBatis Plus 的“零 SQL” CRUD 了。

整个流程非常简单,总结一下就两步:

  1. 引入 MP 的依赖
  2. 创建一个继承 BaseMapper 的 Mapper

做完这两件事,单表 CRUD 就已经全部自动生成了。

1.2.1引入依赖

MyBatis Plus 给我们准备了 starter,所以不用自己配 SqlSessionFactory,也不用管扫描配置,只需要在 Maven 中加一行依赖:

1
2
3
4
5
<dependency>
<groupId>com.baomidou</groupId>
<artifactId>mybatis-plus-boot-starter</artifactId>
<version>3.5.3.1</version>
</dependency>

由于这个 starter 已经包含了 MyBatis 的自动装配,你完全可以把原来的 MyBatis starter 删掉,保持依赖干净一点。

最终整个项目的常规依赖大概是这样:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
<dependencies>
<dependency>
<groupId>com.baomidou</groupId>
<artifactId>mybatis-plus-boot-starter</artifactId>
<version>3.5.3.1</version>
</dependency>
<dependency>
<groupId>com.mysql</groupId>
<artifactId>mysql-connector-j</artifactId>
<scope>runtime</scope>
</dependency>
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
<optional>true</optional>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
</dependencies>

加上这些就够跑单表 CRUD 了。

1.2.2.定义Mapper

MyBatis Plus 的理念很清爽:
既然绝大部分项目都要做基础的 CRUD,那为什么还要每个人自己写 SQL?

img

所以 MP 直接给我们准备了一个 BaseMapper<T>,里面已经提供了常见的单表操作,包括:

  • insert
  • delete
  • update
  • select
  • 批量查询
  • 条件查询……

你想用谁,就直接调用谁。

所以我们自己的 Mapper 就不需要写 XML,也不需要写方法,只要继承一下:

1
2
public interface UserMapper extends BaseMapper<User> {
}

到这里,User 表的基础 CRUD 已经全部就绪了。

1.2.3.测试

新建一个测试类,编写几个单元测试,来感受一下 MP 的操作方式。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
@SpringBootTest
class UserMapperTest {

@Autowired
private UserMapper userMapper;

@Test
void testInsert() {
User user = new User();
user.setId(5L);
user.setUsername("Lucy");
user.setPassword("123");
user.setPhone("18688990011");
user.setBalance(200);
user.setInfo("{\"age\": 24, \"intro\": \"英文老师\", \"gender\": \"female\"}");
user.setCreateTime(LocalDateTime.now());
user.setUpdateTime(LocalDateTime.now());
userMapper.insert(user);
}

@Test
void testSelectById() {
User user = userMapper.selectById(5L);
System.out.println("user = " + user);
}

@Test
void testSelectByIds() {
List<User> users = userMapper.selectBatchIds(List.of(1L, 2L, 3L, 4L, 5L));
users.forEach(System.out::println);
}

@Test
void testUpdateById() {
User user = new User();
user.setId(5L);
user.setBalance(20000);
userMapper.updateById(user);
}

@Test
void testDelete() {
userMapper.deleteById(5L);
}
}

跑完之后,应该能在控制台看到完整的 SQL 日志,比如:

1
2
3
4
5
6
11:05:01  INFO 15524 --- [           main] com.zaxxer.hikari.HikariDataSource       : HikariPool-1 - Starting...
11:05:02 INFO 15524 --- [ main] com.zaxxer.hikari.HikariDataSource : HikariPool-1 - Start completed.
11:05:02 DEBUG 15524 --- [ main] c.i.mp.mapper.UserMapper.selectById : ==> Preparing: SELECT id,username,password,phone,info,status,balance,create_time,update_time FROM user WHERE id=?
11:05:02 DEBUG 15524 --- [ main] c.i.mp.mapper.UserMapper.selectById : ==> Parameters: 5(Long)
11:05:02 DEBUG 15524 --- [ main] c.i.mp.mapper.UserMapper.selectById : <== Total: 1
user = User(id=5, username=Lucy, password=123, phone=18688990011, info={"age": 21}, status=1, balance=20000, createTime=Fri Jun 30 11:02:30 CST 2023, updateTime=Fri Jun 30 11:02:30 CST 2023)

这些 SQL 都是 MP 自动帮你生成的,语法也非常规范。

说实话,第一次用 MP 时我挺惊讶的:
不写 XML、不写 SQL,却能跑出完整 CRUD,这感觉真挺轻松的。

1.3.常见注解

在快速入门里,我们只是引入了依赖,加了一个继承 BaseMapper 的接口,就能跑 CRUD。
看起来非常轻松,但这也引出了一个问题:

MyBatis Plus 是怎么知道你想查哪张表、字段有哪些、主键是什么的?

核心就是:
MP 会根据实体类推断数据库表结构。

比如,在继承 BaseMapper<User> 时,泛型里的 User 就是你和数据库绑定的那张表的 PO 类。

默认情况下 MP 的推断规则是:

  • 类名驼峰转下划线 → 表名
    UserInfouser_info
  • 字段名驼峰转下划线 → 列名
    createTimecreate_time
  • 名为 id 的字段默认当主键

这些默认规则一般够用,但在真实项目里,总会遇到不匹配的情况:
字段名不一致、关键字冲突、主键策略不同等等。

所以 MP 提供了一套常用注解,用来“手动声明”实体和表的对应关系。

1.3.1.@TableName

@TableName 用来声明实体类对应的表名。

示例:

1
2
3
4
5
@TableName("user")
public class User {
private Long id;
private String name;
}

除了直接指定表名以外,这个注解还有不少扩展属性,例如 schema、自动 resultMap、字段排除等。

属性类型必须指定默认值描述
valueString“”表名
schemaString“”schema
keepGlobalPrefixbooleanfalse是否保持使用全局的 tablePrefix 的值(当全局 tablePrefix 生效时)
resultMapString“”xml 中 resultMap 的 id(用于满足特定类型的实体类对象绑定)
autoResultMapbooleanfalse是否自动构建 resultMap 并使用(如果设置 resultMap 则不会进行 resultMap 的自动构建与注入)
excludePropertyString[]{}需要排除的属性名 @since 3.3.1

常用场景主要是两类:

  • 实体类命名和表名不一致
  • 有 schema、前缀、或者多数据源要求

1.3.2.@TableId

顾名思义,就是给主键字段标识用的。

示例:

1
2
3
4
5
6
@TableName("user")
public class User {
@TableId
private Long id;
private String name;
}

TableId注解支持两个属性:

属性类型必须指定默认值描述
valueString“”表名
typeEnumIdType.NONE指定主键类型

IdType支持的类型有:

描述
AUTO数据库 ID 自增
NONE无状态,该类型为未设置主键类型(注解里等于跟随全局,全局里约等于 INPUT)
INPUTinsert 前自行 set 主键值
ASSIGN_ID分配 ID(主键类型为 Number(Long 和 Integer)或 String)(since 3.3.0),使用接口IdentifierGenerator的方法nextId(默认实现类为DefaultIdentifierGenerator雪花算法)
ASSIGN_UUID分配 UUID,主键类型为 String(since 3.3.0),使用接口IdentifierGenerator的方法nextUUID(默认 default 方法)
ID_WORKER分布式全局唯一 ID 长整型类型(please use ASSIGN_ID)
UUID32 位 UUID 字符串(please use ASSIGN_UUID)
ID_WORKER_STR分布式全局唯一 ID 字符串类型(please use ASSIGN_ID)

这里比较常见的有三种:

  • AUTO:利用数据库的id自增长
  • INPUT:主键你自己 set,MP 不管
  • ASSIGN_ID:雪花算法生成Long类型的全局唯一id,这是默认的ID策略,在微服务项目里非常常用,不依赖数据库自增

1.3.3.@TableField

绝大多数字段不用加 @TableField,但以下情况必须加:

  1. 字段名和数据库列名不一致
  2. 字段以 isXxx 命名(按照JavaBean的规范,MybatisPlus识别字段时会把is去除,这就导致与数据库不符)
  3. 成员变量名与数据库一致,但是与数据库的关键字冲突。字段名是数据库关键字,比如 orderdescconcat;使用@TableField注解给字段名添加转义字符:````

示例:

1
2
3
4
5
6
7
8
9
10
11
@TableName("user")
public class User {
@TableId
private Long id;
private String name;
private Integer age;
@TableField(is_married")
private Boolean isMarried;
@TableField("`concat`")
private String concat;
}

支持的其它属性如下:

属性类型必填默认值描述
valueString“”数据库字段名
existbooleantrue是否为数据库表字段
conditionString“”字段 where 实体查询比较条件,有值设置则按设置的值为准,没有则为默认全局的 , 参考(opens new window)
updateString“”字段 update set 部分注入,例如:当在version字段上注解update="%s+1" 表示更新时会 set version=version+1 (该属性优先级高于 el 属性)
insertStrategyEnumFieldStrategy.DEFAULT举例:NOT_NULL insert into table_a(column) values (#{columnProperty})
updateStrategyEnumFieldStrategy.DEFAULT举例:IGNORED update table_a set column=#{columnProperty}
whereStrategyEnumFieldStrategy.DEFAULT举例:NOT_EMPTY wherecolumn=#{columnProperty}
fillEnumFieldFill.DEFAULT字段自动填充策略
selectbooleantrue是否进行 select 查询
keepGlobalFormatbooleanfalse是否保持使用全局的 format 进行处理
jdbcTypeJdbcTypeJdbcType.UNDEFINEDJDBC 类型 (该默认值不代表会按照该值生效)
typeHandlerTypeHander类型处理器 (该默认值不代表会按照该值生效)
numericScaleString“”指定小数点后保留的位数

1.4.常见配置

MyBatis Plus 的配置项都可以写在 application.yml 里,详见官方文档:

https://baomidou.com/reference/

虽然默认值已经足够应付大多数场景,但有些配置必须你自己声明,比如:

  • 实体类别名包扫描
  • 全局主键策略
1
2
3
4
5
mybatis-plus:
type-aliases-package: com.chenpi.mp.domain.po
global-config:
db-config:
id-type: auto # 全局 id 策略:自增

需要注意的是,MyBatisPlus也支持手写SQL的,而mapper文件的读取地址可以自己配置:

1
2
mybatis-plus:
mapper-locations: "classpath*:/mapper/**/*.xml" # Mapper.xml文件地址,当前这个是默认值。

默认值是classpath*:/mapper/**/*.xml,也就是说我们只要把mapper.xml文件放置这个目录下就一定会被加载。

例如,我们新建一个UserMapper.xml文件:

img

然后在其中定义一个方法:

1
2
3
4
5
6
7
8
<?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.chenpi.mp.mapper.UserMapper">

<select id="queryById" resultType="User">
SELECT * FROM user WHERE id = #{id}
</select>
</mapper>

然后在测试类UserMapperTest中测试该方法:

1
2
3
4
5
@Test
void testQuery() {
User user = userMapper.queryById(1L);
System.out.println("user = " + user);
}

2.核心功能

刚才的案例中都是以id为条件的简单CRUD,一些复杂条件的SQL语句就要用到一些更高级的功能了。

2.1.条件构造器

除了新增以外,修改、删除、查询的SQL语句都需要指定where条件。因此BaseMapper中提供的相关方法除了以id作为where条件以外,还支持更加复杂的where条件。

img

参数中的Wrapper就是条件构造的抽象类,其下有很多默认实现,继承关系如图:

img

Wrapper的子类AbstractWrapper提供了where中包含的所有条件构造方法:

img

而QueryWrapper在AbstractWrapper的基础上拓展了一个select方法,允许指定查询字段:

img

而UpdateWrapper在AbstractWrapper的基础上拓展了一个set方法,允许指定SQL中的SET部分:

img

接下来,我们就来看看如何利用Wrapper实现复杂查询。

2.1.1.QueryWrapper

无论是修改、删除、查询,都可以使用QueryWrapper来构建查询条件。

例如:

查询:查询出名字中带o的,存款大于等于1000元的人。代码如下:

1
2
3
4
5
6
7
8
9
10
11
@Test
void testQueryWrapper() {
// 1.构建查询条件 where name like "%o%" AND balance >= 1000
QueryWrapper<User> wrapper = new QueryWrapper<User>()
.select("id", "username", "info", "balance")
.like("username", "o")
.ge("balance", 1000);
// 2.查询数据
List<User> users = userMapper.selectList(wrapper);
users.forEach(System.out::println);
}

更新:更新用户名为jack的用户的余额为2000,代码如下:

1
2
3
4
5
6
7
8
9
@Test
void testUpdateByQueryWrapper() {
// 1.构建查询条件 where name = "Jack"
QueryWrapper<User> wrapper = new QueryWrapper<User>().eq("username", "Jack");
// 2.更新数据,user中非null字段都会作为set语句
User user = new User();
user.setBalance(2000);
userMapper.update(user, wrapper);
}

2.1.2.UpdateWrapper

基于BaseMapper中的update方法更新时只能直接赋值,对于一些复杂的需求就难以实现。 例如:更新id为1,2,4的用户的余额,扣200,对应的SQL应该是:

1
UPDATE user SET balance = balance - 200 WHERE id in (1, 2, 4)

SET的赋值结果是基于字段现有值的,这个时候就要利用UpdateWrapper中的setSql()功能了:

1
2
3
4
5
6
7
8
9
10
11
@Test
void testUpdateWrapper() {
List<Long> ids = List.of(1L, 2L, 4L);
// 1.生成SQL
UpdateWrapper<User> wrapper = new UpdateWrapper<User>()
.setSql("balance = balance - 200") // SET balance = balance - 200
.in("id", ids); // WHERE id in (1, 2, 4)
// 2.更新,注意第一个参数可以给null,也就是不填更新字段和数据,
// 而是基于UpdateWrapper中的setSQL来更新
userMapper.update(null, wrapper);
}

2.1.3.LambdaQueryWrapper

无论是QueryWrapper还是UpdateWrapper在构造条件的时候都需要写死字段名称,会出现字符串魔法值。这在编程规范中显然是不推荐的。 那怎么样才能不写字段名,又能知道字段名呢?

其中一种办法是基于变量的gettter方法结合反射技术。因此我们只要将条件对应的字段的getter方法传递给MybatisPlus,它就能计算出对应的变量名了。而传递方法可以使用JDK8中的方法引用Lambda表达式。 因此MybatisPlus又提供了一套基于Lambda的Wrapper,包含两个:

  • LambdaQueryWrapper
  • LambdaUpdateWrapper

分别对应QueryWrapper和UpdateWrapper

其使用方式如下:

1
2
3
4
5
6
7
8
9
10
11
12
@Test
void testLambdaQueryWrapper() {
// 1.构建条件 WHERE username LIKE "%o%" AND balance >= 1000
QueryWrapper<User> wrapper = new QueryWrapper<>();
wrapper.lambda()
.select(User::getId, User::getUsername, User::getInfo, User::getBalance)
.like(User::getUsername, "o")
.ge(User::getBalance, 1000);
// 2.查询
List<User> users = userMapper.selectList(wrapper);
users.forEach(System.out::println);
}

2.2.自定义SQL

在演示UpdateWrapper的案例中,我们在代码中编写了更新的SQL语句:

img

这种写法在某些企业也是不允许的,因为SQL语句最好都维护在持久层,而不是业务层。就当前案例来说,由于条件是in语句,只能将SQL写在Mapper.xml文件,利用foreach来生成动态SQL。 这实在是太麻烦了。假如查询条件更复杂,动态SQL的编写也会更加复杂。

所以,MybatisPlus提供了自定义SQL功能,可以让我们利用Wrapper生成查询条件,再结合Mapper.xml编写SQL

2.2.1.基本用法

以当前案例来说,我们可以这样写:

1
2
3
4
5
6
7
8
9
@Test
void testCustomWrapper() {
// 1.准备自定义查询条件
List<Long> ids = List.of(1L, 2L, 4L);
QueryWrapper<User> wrapper = new QueryWrapper<User>().in("id", ids);

// 2.调用mapper的自定义方法,直接传递Wrapper
userMapper.deductBalanceByIds(200, wrapper);
}

然后在UserMapper中自定义SQL:

1
2
3
4
public interface UserMapper extends BaseMapper<User> {
@Select("UPDATE user SET balance = balance - #{money} ${ew.customSqlSegment}")
void deductBalanceByIds(@Param("money") int money, @Param("ew") QueryWrapper<User> wrapper);
}

这样就省去了编写复杂查询条件的烦恼了。

2.2.2.多表关联

理论上来讲MyBatisPlus是不支持多表查询的,不过我们可以利用Wrapper中自定义条件结合自定义SQL来实现多表查询的效果。 例如,我们要查询出所有收货地址在北京的并且用户id在1、2、4之中的用户 要是自己基于mybatis实现SQL,大概是这样的:

1
2
3
4
5
6
7
8
9
10
<select id="queryUserByIdAndAddr" resultType="com.chenpi.mp.domain.po.User">
SELECT *
FROM user u
INNER JOIN address a ON u.id = a.user_id
WHERE u.id
<foreach collection="ids" separator="," item="id" open="IN (" close=")">
#{id}
</foreach>
AND a.city = #{city}
</select>

可以看出其中最复杂的就是WHERE条件的编写,如果业务复杂一些,这里的SQL会更变态。

但是基于自定义SQL结合Wrapper的玩法,我们就可以利用Wrapper来构建查询条件,然后手写SELECT及FROM部分,实现多表查询。

查询条件这样来构建:

1
2
3
4
5
6
7
8
9
10
11
12
@Test
void testCustomJoinWrapper() {
// 1.准备自定义查询条件
QueryWrapper<User> wrapper = new QueryWrapper<User>()
.in("u.id", List.of(1L, 2L, 4L))
.eq("a.city", "北京");

// 2.调用mapper的自定义方法
List<User> users = userMapper.queryUserByWrapper(wrapper);

users.forEach(System.out::println);
}

然后在UserMapper中自定义方法:

1
2
@Select("SELECT u.* FROM user u INNER JOIN address a ON u.id = a.user_id ${ew.customSqlSegment}")
List<User> queryUserByWrapper(@Param("ew")QueryWrapper<User> wrapper);

当然,也可以在UserMapper.xml中写SQL:

1
2
3
<select id="queryUserByIdAndAddr" resultType="com.chenpi.mp.domain.po.User">
SELECT * FROM user u INNER JOIN address a ON u.id = a.user_id ${ew.customSqlSegment}
</select>

2.3.Service接口

MybatisPlus不仅提供了BaseMapper,还提供了通用的Service接口及默认实现,封装了一些常用的service模板方法。 通用接口为IService,默认实现为ServiceImpl,其中封装的方法可以分为以下几类:

  • save:新增
  • remove:删除
  • update:更新
  • get:查询单个结果
  • list:查询集合结果
  • count:计数
  • page:分页查询

2.3.1.CRUD

我们先俩看下基本的CRUD接口。 新增

img

  • save是新增单个元素
  • saveBatch是批量新增
  • saveOrUpdate是根据id判断,如果数据存在就更新,不存在则新增
  • saveOrUpdateBatch是批量的新增或修改

删除:

img

  • removeById:根据id删除
  • removeByIds:根据id批量删除
  • removeByMap:根据Map中的键值对为条件删除
  • remove(Wrapper<T>):根据Wrapper条件删除
  • ~~removeBatchByIds~~:暂不支持

修改:

img

  • updateById:根据id修改
  • update(Wrapper<T>):根据UpdateWrapper修改,Wrapper中包含setwhere部分
  • update(T,Wrapper<T>):按照T内的数据修改与Wrapper匹配到的数据
  • updateBatchById:根据id批量修改

Get:

img

  • getById:根据id查询1条数据
  • getOne(Wrapper<T>):根据Wrapper查询1条数据
  • getBaseMapper:获取Service内的BaseMapper实现,某些时候需要直接调用Mapper内的自定义SQL时可以用这个方法获取到Mapper

List:

img

  • listByIds:根据id批量查询
  • list(Wrapper<T>):根据Wrapper条件查询多条数据
  • list():查询所有

Count

img

  • count():统计所有数量
  • count(Wrapper<T>):统计符合Wrapper条件的数据数量

getBaseMapper: 当我们在service中要调用Mapper中自定义SQL时,就必须获取service对应的Mapper,就可以通过这个方法:

img

2.3.2.基本用法

由于Service中经常需要定义与业务有关的自定义方法,因此我们不能直接使用IService,而是自定义Service接口,然后继承IService以拓展方法。同时,让自定义的Service实现类继承ServiceImpl,这样就不用自己实现IService中的接口了。

首先,定义IUserService,继承IService

1
2
3
public interface IUserService extends IService<User> {
// 拓展自定义方法
}

然后,编写UserServiceImpl类,继承ServiceImpl,实现UserService

1
2
3
4
@Service
public class UserServiceImpl extends ServiceImpl<UserMapper, User>
implements IUserService {
}

项目结构如下:

img

接下来,我们快速实现下面4个接口:

编号接口请求方式请求路径请求参数返回值
1新增用户POST/users用户表单实体
2删除用户DELETE/users/{id}用户id
3根据id查询用户GET/users/{id}用户id用户VO
4根据id批量查询GET/users用户id集合用户VO集合

首先,我们在项目中引入几个依赖:

1
2
3
4
5
6
7
8
9
10
11
<!--swagger-->
<dependency>
<groupId>com.github.xiaoymin</groupId>
<artifactId>knife4j-openapi2-spring-boot-starter</artifactId>
<version>4.1.0</version>
</dependency>
<!--web-->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>

然后需要配置swagger信息:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
knife4j:
enable: true
openapi:
title: 用户管理接口文档
description: "用户管理接口文档"
email: nano.chenpi@foxmail.com
concat: nano.chenpi
url:
version: v1.0.0
group:
default:
group-name: default
api-rule: package
api-rule-resources:
- com.chenpi.mp.controller

然后,接口需要一个Controller以及两个实体:

  • UserFormDTO:代表新增时的用户表单
  • UserVO:代表查询的返回结果
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
@Data
@ApiModel(description = "用户表单实体")
public class UserFormDTO {

@ApiModelProperty("id")
private Long id;

@ApiModelProperty("用户名")
private String username;

@ApiModelProperty("密码")
private String password;

@ApiModelProperty("注册手机号")
private String phone;

@ApiModelProperty("详细信息,JSON风格")
private String info;

@ApiModelProperty("账户余额")
private Integer balance;
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
@Data
@ApiModel(description = "用户VO实体")
public class UserVO {

@ApiModelProperty("用户id")
private Long id;

@ApiModelProperty("用户名")
private String username;

@ApiModelProperty("详细信息")
private String info;

@ApiModelProperty("使用状态(1正常 2冻结)")
private Integer status;

@ApiModelProperty("账户余额")
private Integer balance;
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
@Api(tags = "用户管理接口")
@RequiredArgsConstructor
@RestController
@RequestMapping("users")
public class UserController {

private final IUserService userService;

@PostMapping
@ApiOperation("新增用户")
public void saveUser(@RequestBody UserFormDTO userFormDTO){
// 1.转换DTO为PO
User user = BeanUtil.copyProperties(userFormDTO, User.class);
// 2.新增
userService.save(user);
}

@DeleteMapping("/{id}")
@ApiOperation("删除用户")
public void removeUserById(@PathVariable("id") Long userId){
userService.removeById(userId);
}

@GetMapping("/{id}")
@ApiOperation("根据id查询用户")
public UserVO queryUserById(@PathVariable("id") Long userId){
// 1.查询用户
User user = userService.getById(userId);
// 2.处理vo
return BeanUtil.copyProperties(user, UserVO.class);
}

@GetMapping
@ApiOperation("根据id集合查询用户")
public List<UserVO> queryUserByIds(@RequestParam("ids") List<Long> ids){
// 1.查询用户
List<User> users = userService.listByIds(ids);
// 2.处理vo
return BeanUtil.copyToList(users, UserVO.class);
}
}

可以看到上述接口都直接在controller即可实现,无需编写任何service代码,非常方便。

不过,一些带有业务逻辑的接口则需要在service中自定义实现了。例如下面的需求:

  • 根据id扣减用户余额

这看起来是个简单修改功能,只要修改用户余额即可。但这个业务包含一些业务逻辑处理:

  • 判断用户状态是否正常
  • 判断用户余额是否充足

这些业务逻辑都要在service层来做,另外更新余额需要自定义SQL,要在mapper中来实现。因此,我们除了要编写controller以外,具体的业务还要在service和mapper中编写。

首先在UserController中定义一个方法:

1
2
3
4
5
@PutMapping("{id}/deduction/{money}")
@ApiOperation("扣减用户余额")
public void deductBalance(@PathVariable("id") Long id, @PathVariable("money")Integer money){
userService.deductBalance(id, money);
}

然后是UserService接口:

1
2
3
public interface IUserService extends IService<User> {
void deductBalance(Long id, Integer money);
}

最后是UserServiceImpl实现类:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
@Service
public class UserServiceImpl extends ServiceImpl<UserMapper, User> implements IUserService {
@Override
public void deductBalance(Long id, Integer money) {
// 1.查询用户
User user = getById(id);
// 2.判断用户状态
if (user == null || user.getStatus() == 2) {
throw new RuntimeException("用户状态异常");
}
// 3.判断用户余额
if (user.getBalance() < money) {
throw new RuntimeException("用户余额不足");
}
// 4.扣减余额
baseMapper.deductMoneyById(id, money);
}
}

最后是mapper:

1
2
@Update("UPDATE user SET balance = balance - #{money} WHERE id = #{id}")
void deductMoneyById(@Param("id") Long id, @Param("money") Integer money);

2.3.3.Lambda

IService中还提供了Lambda功能来简化我们的复杂查询及更新功能。通过两个案例来学习一下。

案例一:实现一个根据复杂条件查询用户的接口,查询条件如下:

  • name:用户名关键字,可以为空
  • status:用户状态,可以为空
  • minBalance:最小余额,可以为空
  • maxBalance:最大余额,可以为空

可以理解成一个用户的后台管理界面,管理员可以自己选择条件来筛选用户,因此上述条件不一定存在,需要做判断。

我们首先需要定义一个查询条件实体,UserQuery实体:

1
2
3
4
5
6
7
8
9
10
11
12
@Data
@ApiModel(description = "用户查询条件实体")
public class UserQuery {
@ApiModelProperty("用户名关键字")
private String name;
@ApiModelProperty("用户状态:1-正常,2-冻结")
private Integer status;
@ApiModelProperty("余额最小值")
private Integer minBalance;
@ApiModelProperty("余额最大值")
private Integer maxBalance;
}

接下来我们在UserController中定义一个controller方法:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
@GetMapping("/list")
@ApiOperation("根据id集合查询用户")
public List<UserVO> queryUsers(UserQuery query){
// 1.组织条件
String username = query.getName();
Integer status = query.getStatus();
Integer minBalance = query.getMinBalance();
Integer maxBalance = query.getMaxBalance();
LambdaQueryWrapper<User> wrapper = new QueryWrapper<User>().lambda()
.like(username != null, User::getUsername, username)
.eq(status != null, User::getStatus, status)
.ge(minBalance != null, User::getBalance, minBalance)
.le(maxBalance != null, User::getBalance, maxBalance);
// 2.查询用户
List<User> users = userService.list(wrapper);
// 3.处理vo
return BeanUtil.copyToList(users, UserVO.class);
}

在组织查询条件的时候,我们加入了 username != null 这样的参数,意思就是当条件成立时才会添加这个查询条件,类似Mybatis的mapper.xml文件中的<if>标签。这样就实现了动态查询条件效果了。

不过上述条件构建的代码太麻烦了。 因此Service中对LambdaQueryWrapperLambdaUpdateWrapper的用法进一步做了简化。我们无需自己通过new的方式来创建Wrapper,而是直接调用lambdaQuerylambdaUpdate方法:

基于Lambda查询:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
@GetMapping("/list")
@ApiOperation("根据id集合查询用户")
public List<UserVO> queryUsers(UserQuery query){
// 1.组织条件
String username = query.getName();
Integer status = query.getStatus();
Integer minBalance = query.getMinBalance();
Integer maxBalance = query.getMaxBalance();
// 2.查询用户
List<User> users = userService.lambdaQuery()
.like(username != null, User::getUsername, username)
.eq(status != null, User::getStatus, status)
.ge(minBalance != null, User::getBalance, minBalance)
.le(maxBalance != null, User::getBalance, maxBalance)
.list();
// 3.处理vo
return BeanUtil.copyToList(users, UserVO.class);
}

可以发现lambdaQuery方法中除了可以构建条件,还需要在链式编程的最后添加一个list(),这是在告诉MP我们的调用结果需要是一个list集合。这里不仅可以用list(),可选的方法有:

  • .one():最多1个结果
  • .list():返回集合结果
  • .count():返回计数结果

MybatisPlus会根据链式编程的最后一个方法来判断最终的返回结果。

与lambdaQuery方法类似,IService中的lambdaUpdate方法可以非常方便的实现复杂更新业务。

例如下面的需求:

需求:改造根据id修改用户余额的接口,要求如下

  • 如果扣减后余额为0,则将用户status修改为冻结状态(2)

在扣减用户余额时,需要对用户剩余余额做出判断,如果发现剩余余额为0,则应该将status修改为2,就是说update语句的set部分是动态的。

实现如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
@Override
@Transactional
public void deductBalance(Long id, Integer money) {
// 1.查询用户
User user = getById(id);
// 2.校验用户状态
if (user == null || user.getStatus() == 2) {
throw new RuntimeException("用户状态异常!");
}
// 3.校验余额是否充足
if (user.getBalance() < money) {
throw new RuntimeException("用户余额不足!");
}
// 4.扣减余额 update tb_user set balance = balance - ?
int remainBalance = user.getBalance() - money;
lambdaUpdate()
.set(User::getBalance, remainBalance) // 更新余额
.set(remainBalance == 0, User::getStatus, 2) // 动态判断,是否更新status
.eq(User::getId, id)
.eq(User::getBalance, user.getBalance()) // 乐观锁
.update();
}

2.3.4.批量新增

IService 提供的批量新增功能用起来确实非常方便,不过这里面有几个容易被忽略的性能差异。我们先通过一个简单的测试来看看到底有什么不同。

2.3.4.1.逐条插入

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
@Test
void testSaveOneByOne() {
long b = System.currentTimeMillis();
for (int i = 1; i <= 100000; i++) {
userService.save(buildUser(i));
}
long e = System.currentTimeMillis();
System.out.println("耗时:" + (e - b));
}

private User buildUser(int i) {
User user = new User();
user.setUsername("user_" + i);
user.setPassword("123");
user.setPhone("" + (18688190000L + i));
user.setBalance(2000);
user.setInfo("{\"age\": 24, \"intro\": \"英文老师\", \"gender\": \"female\"}");
user.setCreateTime(LocalDateTime.now());
user.setUpdateTime(user.getCreateTime());
return user;
}

执行结果如下:

img

这段代码没什么技巧,就是纯循环 insert,实际的耗时也印证了这一点:非常慢

2.3.4.2.MyBatisPlus 批处理插入

接下来使用 MP 的批量插入功能,每 1000 条做一次批处理:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
@Test
void testSaveBatch() {
// 准备10万条数据
List<User> list = new ArrayList<>(1000);
long b = System.currentTimeMillis();
for (int i = 1; i <= 100000; i++) {
list.add(buildUser(i));
// 每1000条批量插入一次
if (i % 1000 == 0) {
userService.saveBatch(list);
list.clear();
}
}
long e = System.currentTimeMillis();
System.out.println("耗时:" + (e - b));
}

执行最终耗时如下:

img

实际测试显示:使用了批处理以后,比逐条新增效率提高了10倍左右,性能还是不错的。

2.3.4.3.MP 的批处理本质上是什么?

虽然性能提升了,但看一下 MyBatisPlus 的源码,可以发现它的批处理实现方式其实是基于 PreparedStatement预编译 + 批量提交

1
2
3
4
5
6
@Transactional(rollbackFor = Exception.class)
@Override
public boolean saveBatch(Collection<T> entityList, int batchSize) {
String sqlStatement = getSqlStatement(SqlMethod.INSERT_ONE);
return executeBatch(entityList, batchSize, (sqlSession, entity) -> sqlSession.insert(sqlStatement, entity));
}

继续跟进 SqlHelper 的实现:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
// ...SqlHelper
public static <E> boolean executeBatch(Class<?> entityClass, Log log, Collection<E> list, int batchSize, BiConsumer<SqlSession, E> consumer) {
Assert.isFalse(batchSize < 1, "batchSize must not be less than one");
return !CollectionUtils.isEmpty(list) && executeBatch(entityClass, log, sqlSession -> {
int size = list.size();
int idxLimit = Math.min(batchSize, size);
int i = 1;
for (E element : list) {
consumer.accept(sqlSession, element);
if (i == idxLimit) {
sqlSession.flushStatements();
idxLimit = Math.min(idxLimit + batchSize, size);
}
i++;
}
});
}

从源码可以发现,其实MybatisPlus的批处理是基于PrepareStatement的预编译模式,然后批量提交,最终在数据库执行时还是会有多条insert语句,逐条插入数据。

MyBatisPlus 的批处理不是“多值 insert”,而是在循环里调用多次 insert,只是每一批用预编译减少 SQL 解析开销。

SQL类似这样:

1
2
3
4
Preparing: INSERT INTO user ( username, password, phone, info, balance, create_time, update_time ) VALUES ( ?, ?, ?, ?, ?, ?, ? )
Parameters: user_1, 123, 18688190001, "", 2000, 2023-07-01, 2023-07-01
Parameters: user_2, 123, 18688190002, "", 2000, 2023-07-01, 2023-07-01
Parameters: user_3, 123, 18688190003, "", 2000, 2023-07-01, 2023-07-01

也就是说:

  • SQL 仍然是一条一条执行的
  • 只是 prepare 一次、执行多次
  • 性能提升来自“减少 SQL 解析”,不是减少 SQL 数量

2.3.4.4.进一步提升:让 SQL 变成真正的批量插入

如果想获得最好的插入性能,关键在于“让多条 insert 合并为一条多值 insert”,像这样:

1
2
3
4
5
6
INSERT INTO user ( username, password, phone, info, balance, create_time, update_time )
VALUES
(user_1, 123, 18688190001, "", 2000, 2023-07-01, 2023-07-01),
(user_2, 123, 18688190002, "", 2000, 2023-07-01, 2023-07-01),
(user_3, 123, 18688190003, "", 2000, 2023-07-01, 2023-07-01),
(user_4, 123, 18688190004, "", 2000, 2023-07-01, 2023-07-01);

但这不是 MP 控制的,而是 JDBC 驱动在满足条件后自动帮你合并。
要让驱动开启 SQL 重写,需要在 MySQL 连接参数中设置:

rewriteBatchedStatements = true(重写批处理的statement语句)。

默认值是false,所以必须手动开启。开启之后,无论是 MyBatisPlus 的 saveBatch 还是你手写批处理,都会被自动“重写”为多值 insert,从而达到最佳插入性能。

参考文档

修改项目中的application.yml文件,在jdbc的url后面添加参数&rewriteBatchedStatements=true:

1
2
3
4
5
6
spring:
datasource:
url: jdbc:mysql://127.0.0.1:3306/mp?useUnicode=true&characterEncoding=UTF-8&autoReconnect=true&serverTimezone=Asia/Shanghai&rewriteBatchedStatements=true
driver-class-name: com.mysql.cj.jdbc.Driver
username: username
password: password

再次测试插入10万条数据,可以发现速度有非常明显的提升:

img

ClientPreparedStatementexecuteBatchInternal中,有判断rewriteBatchedStatements值是否为true并重写SQL的功能:

最终,SQL被重写了:

img

3.扩展功能

3.1.代码生成

在使用MybatisPlus以后,基础的MapperServicePO代码相对固定,重复编写也比较麻烦。因此MybatisPlus官方提供了代码生成器根据数据库表结构生成POMapperService等相关代码。只不过代码生成器同样要编码使用,也很麻烦。

这里推荐大家使用一款MybatisPlus的插件,它可以基于图形化界面完成MybatisPlus的代码生成,非常简单。

3.1.1.安装插件

Idea的plugins市场中搜索并安装MyBatisPlus插件:

img

然后重启你的Idea即可使用。

3.1.2.使用

刚好数据库中还有一张address表尚未生成对应的实体和mapper等基础代码。我们利用插件生成一下。 首先需要配置数据库地址,在Idea顶部菜单中,找到 Other → Config Database

img

在弹出的窗口中填写数据库连接的基本信息:

img

点击OK保存。

再次点击顶部菜单 Other → Code Generator

img

在弹出的表单中填写信息:

img

最终,代码自动生成到指定的位置了。

3.2.静态工具

有的时候Service之间也会相互调用,为了避免出现循环依赖问题,MybatisPlus提供一个静态工具类:Db,其中的一些静态方法与IService中方法签名基本一致,也可以帮助我们实现CRUD功能:

img

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
@Test
void testDbGet() {
User user = Db.getById(1L, User.class);
System.out.println(user);
}

@Test
void testDbList() {
// 利用Db实现复杂条件查询
List<User> list = Db.lambdaQuery(User.class)
.like(User::getUsername, "o")
.ge(User::getBalance, 1000)
.list();
list.forEach(System.out::println);
}

@Test
void testDbUpdate() {
Db.lambdaUpdate(User.class)
.set(User::getBalance, 2000)
.eq(User::getUsername, "Rose")
.update(); // 记得加 update 执行
}

为了在根据用户 id 查询用户时,同时返回用户的收货地址列表,我们需要先准备一个 AddressVO 对象:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
@Data
@ApiModel(description = "收货地址VO")
public class AddressVO {
@ApiModelProperty("id")
private Long id;

@ApiModelProperty("用户ID")
private Long userId;

@ApiModelProperty("省")
private String province;

@ApiModelProperty("市")
private String city;

@ApiModelProperty("县/区")
private String town;

@ApiModelProperty("手机")
private String mobile;

@ApiModelProperty("详细地址")
private String street;

@ApiModelProperty("联系人")
private String contact;

@ApiModelProperty("是否默认 1默认 0否")
private Boolean isDefault;

@ApiModelProperty("备注")
private String notes;
}

同时扩展原来的 UserVO,加上地址列表属性:

img

Controller 添加接口:

1
2
3
4
5
@GetMapping("/{id}")
@ApiOperation("根据id查询用户")
public UserVO queryUserById(@PathVariable("id") Long userId){
return userService.queryUserAndAddressById(userId);
}

IUserService 中定义方法:

1
2
3
4
5
6
public interface IUserService extends IService<User> {

void deduct(Long id, Integer money);

UserVO queryUserAndAddressById(Long userId);
}

Service 实现:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
@Override
public UserVO queryUserAndAddressById(Long userId) {
// 1. 查询用户
User user = getById(userId);
if (user == null) {
return null;
}

// 2. 查询收货地址(使用 Db 静态方法,无需注入 addressService)
List<Address> addresses = Db.lambdaQuery(Address.class)
.eq(Address::getUserId, userId)
.list();

// 3. 封装 VO
UserVO userVO = BeanUtil.copyProperties(user, UserVO.class);
userVO.setAddresses(BeanUtil.copyToList(addresses, AddressVO.class));
return userVO;
}

实现“根据多个 id 查询用户及其全部地址”的写法可以这样:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
public List<UserVO> queryUsersAndAddresses(List<Long> userIds) {
if (CollUtil.isEmpty(userIds)) {
return Collections.emptyList();
}

// 1. 一次查出所有用户
List<User> users = listByIds(userIds);

// 2. 一次查出所有用户地址
List<Address> allAddresses = Db.lambdaQuery(Address.class)
.in(Address::getUserId, userIds)
.list();

// 3. 将地址按 userId 分组
Map<Long, List<Address>> addressMap = allAddresses.stream()
.collect(Collectors.groupingBy(Address::getUserId));

// 4. 组装结果
return users.stream().map(user -> {
UserVO vo = BeanUtil.copyProperties(user, UserVO.class);
List<Address> addrList = addressMap.get(user.getId());
vo.setAddresses(BeanUtil.copyToList(addrList, AddressVO.class));
return vo;
}).collect(Collectors.toList());
}

这个实现一次性查用户、一次性查地址,避免循环查询,性能好也更符合 MP 的使用方式。

3.3.逻辑删除

对于一些比较重要的数据,我们往往会采用逻辑删除的方案,即:

  • 在表中添加一个字段标记数据是否被删除
  • 当删除数据时把标记置为true
  • 查询时过滤掉标记为true的数据

一旦采用了逻辑删除,所有的查询和删除逻辑都要跟着变化,非常麻烦。

为了解决这个问题,MybatisPlus就添加了对逻辑删除的支持。

注意,只有MybatisPlus生成的SQL语句才支持自动的逻辑删除,自定义SQL需要自己手动处理逻辑删除。

例如,我们给address表添加一个逻辑删除字段:

1
alter table address add deleted bit default b'0' null comment '逻辑删除';

然后给Address实体添加deleted字段:

img

接下来,我们要在application.yml中配置逻辑删除字段:

1
2
3
4
5
6
mybatis-plus:
global-config:
db-config:
logic-delete-field: deleted # 全局逻辑删除的实体字段名(since 3.3.0,配置后可以忽略不配置步骤2)
logic-delete-value: 1 # 逻辑已删除值(默认为 1)
logic-not-delete-value: 0 # 逻辑未删除值(默认为 0)

测试: 首先,我们执行一个删除操作:

1
2
3
4
5
@Test
void testDeleteByLogic() {
// 删除方法与以前没有区别
addressService.removeById(59L);
}

方法与普通删除一模一样,但是底层的SQL逻辑变了:

img

查询一下:

1
2
3
4
5
@Test
void testQuery() {
List<Address> list = addressService.list();
list.forEach(System.out::println);
}

会发现id为59的确实没有查询出来,而且SQL中也对逻辑删除字段做了判断:

img

综上, 开启了逻辑删除功能以后,我们就可以像普通删除一样做CRUD,基本不用考虑代码逻辑问题。还是非常方便的。

注意: 逻辑删除本身也有自己的问题,比如:

  • 会导致数据库表垃圾数据越来越多,从而影响查询效率
  • SQL中全都需要对逻辑删除字段做判断,影响查询效率

因此,不太推荐采用逻辑删除功能,如果数据不能删除,可以采用把数据迁移到其它表的办法。

3.3.通用枚举

User类中有一个用户状态字段:

img

像这种字段我们一般会定义一个枚举,做业务判断的时候就可以直接基于枚举做比较。但是我们数据库采用的是int类型,对应的PO也是Integer。因此业务操作时必须手动把枚举Integer转换,非常麻烦。

因此,MybatisPlus提供了一个处理枚举的类型转换器,可以帮我们把枚举类型与数据库类型自动转换

3.3.1.定义枚举

我们定义一个用户状态的枚举:

img

代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
@Getter
public enum UserStatus {
NORMAL(1, "正常"),
FREEZE(2, "冻结")
;
private final int value;
private final String desc;

UserStatus(int value, String desc) {
this.value = value;
this.desc = desc;
}
}

然后把User类中的status字段改为UserStatus 类型:

img

要让Mybatis Plus处理枚举与数据库类型自动转换,必须告诉Mybatis Plus,枚举中的哪个字段的值作为数据库值。 Mybatis Plus提供了@EnumValue注解来标记枚举属性:

img

3.3.2.配置枚举处理器

在application.yaml文件中添加配置:

1
2
3
mybatis-plus:
configuration:
default-enum-type-handler: com.baomidou.mybatisplus.core.handlers.MybatisEnumTypeHandler

3.3.3.测试

1
2
3
4
5
@Test
void testService() {
List<User> list = userService.list();
list.forEach(System.out::println);
}

最终,查询出的User类的status字段会是枚举类型:

img

同时,为了使页面查询结果也是枚举格式,我们需要修改UserVO中的status属性:

img

并且,在UserStatus枚举中通过@JsonValue注解标记JSON序列化时展示的字段:

img

最后,在页面查询,结果如下:

img

3.4.JSON类型处理器

数据库的user表中有一个info字段,是JSON类型:

img

格式像这样:

1
{"age": 20, "intro": "佛系青年", "gender": "male"}

而目前User实体类中却是String类型:

img

这样一来,我们要读取info中的属性时就非常不方便。如果要方便获取,info的类型最好是一个Map或者实体类。

而一旦我们把info改为对象类型,就需要在写入数据库时手动转为String,再读取数据库时,手动转换为对象,这会非常麻烦。

因此MybatisPlus提供了很多特殊类型字段的类型处理器,解决特殊字段类型与数据库类型转换的问题。例如处理JSON就可以使用JacksonTypeHandler处理器。

接下来,我们就来看看这个处理器该如何使用。

3.4.1.定义实体

首先,我们定义一个单独实体类来与info字段的属性匹配:

img

代码如下:

1
2
3
4
5
6
@Data
public class UserInfo {
private Integer age;
private String intro;
private String gender;
}

3.4.2.使用类型处理器

接下来,将User类的info字段修改为UserInfo类型,并声明类型处理器:

img

同时,在User类上添加一个注解,声明自动映射:

img

测试可以发现,所有数据都正确封装到UserInfo当中了:

img

同时,为了让页面返回的结果也以对象格式返回,我们要修改UserVO中的info字段:

img

此时,在页面查询结果如下:

img

3.5.配置加密(选学)

目前我们配置文件中的很多参数都是明文,如果开发人员发生流动,很容易导致敏感信息的泄露。所以MybatisPlus支持配置文件的加密和解密功能。

我们以数据库的用户名和密码为例。

3.5.1.生成秘钥

首先,我们利用AES工具生成一个随机秘钥,然后对用户名、密码加密:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
class MpDemoApplicationTests {
@Test
void contextLoads() {
// 生成 16 位随机 AES 密钥
String randomKey = AES.generateRandomKey();
System.out.println("randomKey = " + randomKey);

// 利用密钥对用户名加密
String username = AES.encrypt("root", randomKey);
System.out.println("username = " + username);

// 利用密钥对用户名加密
String password = AES.encrypt("MySQL123", randomKey);
System.out.println("password = " + password);

}
}

打印结果如下:

1
2
3
randomKey = 6234633a66fb399f
username = px2bAbnUfiY8K/IgsKvscg==
password = FGvCSEaOuga3ulDAsxw68Q==

3.5.2.修改配置

修改application.yaml文件,把jdbc的用户名、密码修改为刚刚加密生成的密文:

1
2
3
4
5
6
spring:
datasource:
url: jdbc:mysql://127.0.0.1:3306/mp?useUnicode=true&characterEncoding=UTF-8&autoReconnect=true&serverTimezone=Asia/Shanghai&rewriteBatchedStatements=true
driver-class-name: com.mysql.cj.jdbc.Driver
username: mpw:QWWVnk1Oal3258x5rVhaeQ== # 密文要以 mpw:开头
password: mpw:EUFmeH3cNAzdRGdOQcabWg== # 密文要以 mpw:开头

3.5.3.测试

在启动项目的时候,需要把刚才生成的秘钥添加到启动参数中,像这样:

–mpw.key=6234633a66fb399f

单元测试的时候不能添加启动参数,所以要在测试类的注解上配置:

img

然后随意运行一个单元测试,可以发现数据库查询正常。

4.插件功能

MybatisPlus提供了很多的插件功能,进一步拓展其功能。目前已有的插件有:

  • PaginationInnerInterceptor:自动分页
  • TenantLineInnerInterceptor:多租户
  • DynamicTableNameInnerInterceptor:动态表名
  • OptimisticLockerInnerInterceptor:乐观锁
  • IllegalSQLInnerInterceptor:sql 性能规范
  • BlockAttackInnerInterceptor:防止全表更新与删除

注意: 使用多个分页插件的时候需要注意插件定义顺序,建议使用顺序如下:

  • 多租户,动态表名
  • 分页,乐观锁
  • sql 性能规范,防止全表更新与删除

这里我们以分页插件为里来学习插件的用法。

4.1.分页插件

在未引入分页插件的情况下,MybatisPlus是不支持分页功能的,IServiceBaseMapper中的分页方法都无法正常起效。 所以,我们必须配置分页插件。

4.1.1.配置分页插件

在项目中新建一个配置类:

img

其代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
@Configuration
public class MybatisConfig {

@Bean
public MybatisPlusInterceptor mybatisPlusInterceptor() {
// 初始化核心插件
MybatisPlusInterceptor interceptor = new MybatisPlusInterceptor();
// 添加分页插件
interceptor.addInnerInterceptor(new PaginationInnerInterceptor(DbType.MYSQL));
return interceptor;
}
}

4.1.2.分页API

编写一个分页查询的测试:

1
2
3
4
5
6
7
8
9
10
11
12
@Test
void testPageQuery() {
// 1.分页查询,new Page()的两个参数分别是:页码、每页大小
Page<User> p = userService.page(new Page<>(2, 2));
// 2.总条数
System.out.println("total = " + p.getTotal());
// 3.总页数
System.out.println("pages = " + p.getPages());
// 4.数据
List<User> records = p.getRecords();
records.forEach(System.out::println);
}

运行的SQL如下:

img

这里用到了分页参数,Page,即可以支持分页参数,也可以支持排序参数。常见的API如下:

1
2
3
4
5
6
7
int pageNo = 1, pageSize = 5;
// 分页参数
Page<User> page = Page.of(pageNo, pageSize);
// 排序参数, 通过OrderItem来指定
page.addOrder(new OrderItem("balance", false));

userService.page(page);

4.2.通用分页实体

现在要实现一个用户分页查询的接口,接口规范如下:

参数说明
请求方式GET
请求路径/users/page
请求参数{ "pageNo": 1, "pageSize": 5, "sortBy": "balance", "isAsc": false, "name": "o", "status": 1 }
返回值{ "total": 100006, "pages": 50003, "list": [ { "id": 1685100878975279298, "username": "user_9****", "info": { "age": 24, "intro": "英文老师", "gender": "female" }, "status": "正常", "balance": 2000 } ] }
特殊说明如果排序字段为空,默认按照更新时间排序排序字段不为空,则按照排序字段排序

这里需要定义3个实体:

  • UserQuery:分页查询条件的实体,包含分页、排序参数、过滤条件
  • PageDTO:分页结果实体,包含总条数、总页数、当前页数据
  • UserVO:用户页面视图实体

4.2.1.实体

由于UserQuery之前已经定义过了,并且其中已经包含了过滤条件,具体代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
@Data
@ApiModel(description = "用户查询条件实体")
public class UserQuery {
@ApiModelProperty("用户名关键字")
private String name;
@ApiModelProperty("用户状态:1-正常,2-冻结")
private Integer status;
@ApiModelProperty("余额最小值")
private Integer minBalance;
@ApiModelProperty("余额最大值")
private Integer maxBalance;
}

其中缺少的仅仅是分页条件,而分页条件不仅仅用户分页查询需要,以后其它业务也都有分页查询的需求。因此建议将分页查询条件单独定义为一个PageQuery实体:

img

PageQuery是前端提交的查询参数,一般包含四个属性:

  • pageNo:页码
  • pageSize:每页数据条数
  • sortBy:排序字段
  • isAsc:是否升序
1
2
3
4
5
6
7
8
9
10
11
12
@Data
@ApiModel(description = "分页查询实体")
public class PageQuery {
@ApiModelProperty("页码")
private Long pageNo;
@ApiModelProperty("页码")
private Long pageSize;
@ApiModelProperty("排序字段")
private String sortBy;
@ApiModelProperty("是否升序")
private Boolean isAsc;
}

然后,让我们的UserQuery继承这个实体:

1
2
3
4
5
6
7
8
9
10
11
12
13
@EqualsAndHashCode(callSuper = true)
@Data
@ApiModel(description = "用户查询条件实体")
public class UserQuery extends PageQuery {
@ApiModelProperty("用户名关键字")
private String name;
@ApiModelProperty("用户状态:1-正常,2-冻结")
private Integer status;
@ApiModelProperty("余额最小值")
private Integer minBalance;
@ApiModelProperty("余额最大值")
private Integer maxBalance;
}

返回值的用户实体沿用之前定一个UserVO实体:

img

最后,则是分页实体PageDTO:

img

代码如下:

1
2
3
4
5
6
7
8
9
10
@Data
@ApiModel(description = "分页结果")
public class PageDTO<T> {
@ApiModelProperty("总条数")
private Long total;
@ApiModelProperty("总页数")
private Long pages;
@ApiModelProperty("集合")
private List<T> list;
}

4.2.2.开发接口

我们在UserController中定义分页查询用户的接口:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
@RestController
@RequestMapping("users")
@RequiredArgsConstructor
public class UserController {

private final UserService userService;

@GetMapping("/page")
public PageDTO<UserVO> queryUsersPage(UserQuery query){
return userService.queryUsersPage(query);
}

// 。。。 略
}

然后在IUserService中创建queryUsersPage方法:

1
PageDTO<UserVO> queryUsersPage(PageQuery query);

接下来,在UserServiceImpl中实现该方法:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
@Override
public PageDTO<UserVO> queryUsersPage(PageQuery query) {
// 1.构建条件
// 1.1.分页条件
Page<User> page = Page.of(query.getPageNo(), query.getPageSize());
// 1.2.排序条件
if (query.getSortBy() != null) {
page.addOrder(new OrderItem(query.getSortBy(), query.getIsAsc()));
}else{
// 默认按照更新时间排序
page.addOrder(new OrderItem("update_time", false));
}
// 2.查询
page(page);
// 3.数据非空校验
List<User> records = page.getRecords();
if (records == null || records.size() <= 0) {
// 无数据,返回空结果
return new PageDTO<>(page.getTotal(), page.getPages(), Collections.emptyList());
}
// 4.有数据,转换
List<UserVO> list = BeanUtil.copyToList(records, UserVO.class);
// 5.封装返回
return new PageDTO<UserVO>(page.getTotal(), page.getPages(), list);
}

启动项目,在页面查看:

img

4.2.3.改造PageQuery实体

在刚才的代码中,从PageQueryMybatisPlusPage之间转换的过程还是比较麻烦的。

我们完全可以在PageQuery这个实体中定义一个工具方法,简化开发。 像这样:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
@Data
public class PageQuery {
private Integer pageNo;
private Integer pageSize;
private String sortBy;
private Boolean isAsc;

public <T> Page<T> toMpPage(OrderItem ... orders){
// 1.分页条件
Page<T> p = Page.of(pageNo, pageSize);
// 2.排序条件
// 2.1.先看前端有没有传排序字段
if (sortBy != null) {
p.addOrder(new OrderItem(sortBy, isAsc));
return p;
}
// 2.2.再看有没有手动指定排序字段
if(orders != null){
p.addOrder(orders);
}
return p;
}

public <T> Page<T> toMpPage(String defaultSortBy, boolean isAsc){
return this.toMpPage(new OrderItem(defaultSortBy, isAsc));
}

public <T> Page<T> toMpPageDefaultSortByCreateTimeDesc() {
return toMpPage("create_time", false);
}

public <T> Page<T> toMpPageDefaultSortByUpdateTimeDesc() {
return toMpPage("update_time", false);
}
}

这样我们在开发也时就可以省去对从PageQueryPage的的转换:

1
2
// 1.构建条件
Page<User> page = query.toMpPageDefaultSortByCreateTimeDesc();

4.2.4.改造PageDTO实体

在查询出分页结果后,数据的非空校验,数据的vo转换都是模板代码,编写起来很麻烦。

我们完全可以将其封装到PageDTO的工具方法中,简化整个过程:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
@Data
@NoArgsConstructor
@AllArgsConstructor
public class PageDTO<V> {
private Long total;
private Long pages;
private List<V> list;

/**
* 返回空分页结果
* @param p MybatisPlus的分页结果
* @param <V> 目标VO类型
* @param <P> 原始PO类型
* @return VO的分页对象
*/
public static <V, P> PageDTO<V> empty(Page<P> p){
return new PageDTO<>(p.getTotal(), p.getPages(), Collections.emptyList());
}

/**
* 将MybatisPlus分页结果转为 VO分页结果
* @param p MybatisPlus的分页结果
* @param voClass 目标VO类型的字节码
* @param <V> 目标VO类型
* @param <P> 原始PO类型
* @return VO的分页对象
*/
public static <V, P> PageDTO<V> of(Page<P> p, Class<V> voClass) {
// 1.非空校验
List<P> records = p.getRecords();
if (records == null || records.size() <= 0) {
// 无数据,返回空结果
return empty(p);
}
// 2.数据转换
List<V> vos = BeanUtil.copyToList(records, voClass);
// 3.封装返回
return new PageDTO<>(p.getTotal(), p.getPages(), vos);
}

/**
* 将MybatisPlus分页结果转为 VO分页结果,允许用户自定义PO到VO的转换方式
* @param p MybatisPlus的分页结果
* @param convertor PO到VO的转换函数
* @param <V> 目标VO类型
* @param <P> 原始PO类型
* @return VO的分页对象
*/
public static <V, P> PageDTO<V> of(Page<P> p, Function<P, V> convertor) {
// 1.非空校验
List<P> records = p.getRecords();
if (records == null || records.size() <= 0) {
// 无数据,返回空结果
return empty(p);
}
// 2.数据转换
List<V> vos = records.stream().map(convertor).collect(Collectors.toList());
// 3.封装返回
return new PageDTO<>(p.getTotal(), p.getPages(), vos);
}
}

最终,业务层的代码可以简化为:

1
2
3
4
5
6
7
8
9
@Override
public PageDTO<UserVO> queryUserByPage(PageQuery query) {
// 1.构建条件
Page<User> page = query.toMpPageDefaultSortByCreateTimeDesc();
// 2.查询
page(page);
// 3.封装返回
return PageDTO.of(page, UserVO.class);
}

如果是希望自定义PO到VO的转换过程,可以这样做:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
@Override
public PageDTO<UserVO> queryUserByPage(PageQuery query) {
// 1.构建条件
Page<User> page = query.toMpPageDefaultSortByCreateTimeDesc();
// 2.查询
page(page);
// 3.封装返回
return PageDTO.of(page, user -> {
// 拷贝属性到VO
UserVO vo = BeanUtil.copyProperties(user, UserVO.class);
// 用户名脱敏
String username = vo.getUsername();
vo.setUsername(username.substring(0, username.length() - 2) + "**");
return vo;
});
}

最终查询的结果如下:

img

5.作业

尝试改造项目一中的Service层和Mapper层实现,用MybatisPlus代替单表的CRUD