学习目标:
- 能利用MybatisPlus实现基本的CRUD
- 会使用条件构造器构建查询和更新语句
- 会使用MybatisPlus中的常用注解
- 会使用MybatisPlus处理枚举、JSON类型字段
- 会使用MybatisPlus实现分页
<br>
<br>
一、MyBatis-Plus简介
MyBatis-Plus 是一个 MyBatis 的增强工具,在 MyBatis 的基础上只做增强不做改变,为简化开发、提高效率而生。
- 支持的数据库
- 框架结构
MyBatis-Plus官网:https://baomidou.com/
参考文档:https://mybatis.plus/ (访问速度稍慢,建议直接看官网文档)
<br>
<br>
二、快速入门
1、环境准备
- 导入数据库表结构
mp.sql
,一共三张表user
和address
,还有测试MP注解的表tb_user
。
-- -------------------------------------------------------- -- 主机: 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 '备注', `deleted` bit(1) DEFAULT b'0' COMMENT '逻辑删除', PRIMARY KEY (`id`) USING BTREE, KEY `user_id` (`user_id`) USING BTREE ) ENGINE=InnoDB AUTO_INCREMENT=71 DEFAULT CHARSET=utf8mb3 ROW_FORMAT=COMPACT; -- 正在导出表 mp.address 的数据:~11 rows (大约) INSERT INTO `address` (`id`, `user_id`, `province`, `city`, `town`, `mobile`, `street`, `contact`, `is_default`, `notes`, `deleted`) VALUES (59, 2, '北京', '北京', '朝阳区', '13900112222', '金燕龙办公楼', 'Rose', b'1', NULL, b'0'), (60, 1, '北京', '北京', '朝阳区', '13700221122', '修正大厦', 'Jack', b'0', NULL, b'0'), (61, 1, '上海', '上海', '浦东新区', '13301212233', '航头镇航头路', 'Jack', b'1', NULL, b'0'), (63, 2, '广东', '佛山', '永春', '13301212233', '永春武馆', 'Rose', b'0', NULL, b'0'), (64, 3, '浙江', '杭州', '拱墅区', '13567809102', '浙江大学', 'Hope', b'1', NULL, b'0'), (65, 3, '浙江', '杭州', '拱墅区', '13967589201', '左岸花园', 'Hope', b'0', NULL, b'0'), (66, 4, '湖北', '武汉', '汉口', '13967519202', '天天花园', 'Thomas', b'1', NULL, b'0'), (67, 3, '浙江', '杭州', '拱墅区', '13967589201', '左岸花园', 'Hopey', b'0', NULL, b'0'), (68, 4, '湖北', '武汉', '汉口', '13967519202', '天天花园', 'Thomas', b'1', NULL, b'0'), (69, 3, '浙江', '杭州', '拱墅区', '13967589201', '左岸花园', 'Hopey', b'0', NULL, b'0'), (70, 4, '湖北', '武汉', '汉口', '13967519202', '天天花园', 'Thomas', b'1', NULL, b'0'); -- 导出 表 mp.user 结构 CREATE TABLE `user` ( `id` BIGINT(19) NOT NULL AUTO_INCREMENT COMMENT '用户id', `username` VARCHAR(50) NOT NULL COMMENT '用户名' COLLATE 'utf8_general_ci', `password` VARCHAR(128) NOT NULL COMMENT '密码' COLLATE 'utf8_general_ci', `phone` VARCHAR(20) NULL DEFAULT NULL COMMENT '注册手机号' COLLATE 'utf8_general_ci', `info` JSON NOT NULL COMMENT '详细信息', `status` INT(10) NULL DEFAULT '1' COMMENT '使用状态(1正常 2冻结)', `balance` INT(10) NULL DEFAULT NULL COMMENT '账户余额', `create_time` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', `update_time` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间', PRIMARY KEY (`id`) USING BTREE, UNIQUE INDEX `username` (`username`) USING BTREE ) COMMENT='用户表' COLLATE='utf8_general_ci' ENGINE=InnoDB ROW_FORMAT=COMPACT AUTO_INCREMENT=5 ; -- 正在导出表 mp.user 的数据:~4 rows (大约) 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, 600, '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) */; create table tb_user ( user_id bigint auto_increment comment '用户id', username varchar(20) null comment '用户名', password varchar(20) null comment '密码', is_deleted TINYINT default 0 null comment '逻辑删除', `order` TINYINT null comment '排序序号', constraint tb_user_pk primary key (user_id), constraint tb_user_pk2 unique (username) ) comment '用户表(测试mp注解)';
数据库表结构如下:
- 导入项目结构mp-demo
在application.yaml
中修改jdbc参数为你自己的数据库参数:
spring: datasource: url: jdbc:mysql://127.0.0.1:3307/mp?useUnicode=true&characterEncoding=UTF-8&autoReconnect=true&serverTimezone=Asia/Shanghai driver-class-name: com.mysql.cj.jdbc.Driver username: root password: 123456 logging: level: com.itheima: debug pattern: dateformat: HH:mm:ss mybatis: mapper-locations: classpath*:mapper/*.xml
<br>
2、将mybatis项目改造成mybatis-plus项目
基于现有的mybatis项目将其改造成mybatis-plus实现如下功能:
- 新增用户功能
- 根据id查询用户
- 根据id批量查询用户
- 根据id更新用户
- 根据id删除用户
比如我们要实现User表的CRUD,只需要下面几步。
(1)引入MybatisPlus依赖,代替MyBatis依赖
MyBatisPlus官方提供了starter,其中集成了Mybatis和MybatisPlus的所有功能,并且实现了自动装配效果。
<!-- springboot2的mybatis-plus依赖 --> <dependency> <groupId>com.baomidou</groupId> <artifactId>mybatis-plus-boot-starter</artifactId> <version>3.5.9</version> </dependency>
如果是springboot3,引入的是mybatis-plus-spring-boot3-starter依赖。
<!-- springboot3的mybatis-plus依赖 --> <dependency> <groupId>com.baomidou</groupId> <artifactId>mybatis-plus-spring-boot3-starter</artifactId> <version>3.5.9</version> </dependency>
由于这个starter包含对mybatis的自动装配,因此完全可以替换掉Mybatis的starter。 最终,项目的依赖如下:
<!-- mysql --> <dependency> <groupId>com.mysql</groupId> <artifactId>mysql-connector-j</artifactId> <scope>runtime</scope> </dependency> <!-- lombok --> <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> <!-- mybatis-plus --> <dependency> <groupId>com.baomidou</groupId> <artifactId>mybatis-plus-boot-starter</artifactId> <version>3.5.9</version> </dependency>
(2)配置Mapper包扫描路径
- SpringBoot启动类上添加@MapperScan注解
import org.mybatis.spring.annotation.MapperScan; import org.springframework.boot.SpringApplication; import org.springframework.boot.autoconfigure.SpringBootApplication; @MapperScan("com.itheima.mp.mapper") @SpringBootApplication public class MpDemoApplication { public static void main(String[] args) { SpringApplication.run(MpDemoApplication.class, args); } }
(3)定义Mapper接口并继承BaseMapper
- 升级前的MyBatis版本增删改查
之前MyBatis的Mapper接口:
public interface UserMapper { void saveUser(User user); void deleteUser(Long id); void updateUser(User user); User queryUserById(@Param("id") Long id); List<User> queryUserByIds(@Param("ids") List<Long> ids); }
之前MyBatis的Mapper接口的xml映射文件:
<?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.itheima.mp.mapper.UserMapper"> <!-- 新增用户 --> <insert id="saveUser" parameterType="com.itheima.mp.domain.po.User"> INSERT INTO `user` (`id`, `username`, `password`, `phone`, `info`, `balance`) VALUES (#{id}, #{username}, #{password}, #{phone}, #{info}, #{balance}); </insert> <!-- 修改用户 --> <update id="updateUser" parameterType="com.itheima.mp.domain.po.User"> UPDATE `user` <set> <if test="username != null"> `username`=#{username} </if> <if test="password != null"> `password`=#{password} </if> <if test="phone != null"> `phone`=#{phone} </if> <if test="info != null"> `info`=#{info} </if> <if test="status != null"> `status`=#{status} </if> <if test="balance != null"> `balance`=#{balance} </if> </set> WHERE `id`=#{id}; </update> <!-- 删除用户 --> <delete id="deleteUser" parameterType="com.itheima.mp.domain.po.User"> DELETE FROM user WHERE id = #{id} </delete> <!-- 根据用户id查询单个用户 --> <select id="queryUserById" resultType="com.itheima.mp.domain.po.User"> SELECT * FROM user WHERE id = #{id} </select> <!-- 根据用户id数组批量查询多个用户 --> <select id="queryUserByIds" resultType="com.itheima.mp.domain.po.User"> SELECT * FROM user <if test="ids != null"> WHERE id IN <foreach collection="ids" open="(" close=")" item="id" separator=","> #{id} </foreach> </if> LIMIT 10 </select> </mapper>
之前MyBatis的CRUD测试类:
@SpringBootTest class MyBatisUserMapperTests { @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.saveUser(user); } @Test void testSelectById() { User user = userMapper.queryUserById(5L); System.out.println("user = " + user); } @Test void testQueryByIds() { List<User> users = userMapper.queryUserByIds(List.of(1L, 2L, 3L, 4L)); users.forEach(System.out::println); } @Test void testUpdateById() { User user = new User(); user.setId(5L); user.setBalance(20000); userMapper.updateUser(user); } @Test void testDeleteUser() { userMapper.deleteUser(5L); } }
- 升级后的MyBatisPlus版本的增删改查
为了简化单表CRUD,MybatisPlus提供了一个基础的BaseMapper
接口,其中已经实现了单表的CRUD:
MyBatisPlus的Mapper接口:
import com.baomidou.mybatisplus.core.mapper.BaseMapper; import com.itheima.mp.domain.po.User; // 只需要继承BaseMapper就能省去所有的单表CRUD public interface UserMapper extends BaseMapper<User> { }
MyBatisPlus的CRUD测试类:
@SpringBootTest class MyBatisPlusUserMapperTests { @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日志,以字段名进行查询而不是用*
,非常标准。
11:52:03 DEBUG 22712 --- [ main] com.itheima.mp.mapper.UserMapper.insert : ==> Preparing: INSERT INTO user ( id, username, password, phone, info, balance, create_time, update_time ) VALUES ( ?, ?, ?, ?, ?, ?, ?, ? ) 11:52:03 DEBUG 22712 --- [ main] com.itheima.mp.mapper.UserMapper.insert : ==> Parameters: 5(Long), Lucy(String), 123(String), 18688990011(String), {"age": 24, "intro": "英文老师", "gender": "female"}(String), 200(Integer), 2024-11-13T11:52:02.904878200(LocalDateTime), 2024-11-13T11:52:02.904878200(LocalDateTime) 11:52:03 DEBUG 22712 --- [ main] com.itheima.mp.mapper.UserMapper.insert : <== Updates: 1 11:54:23 DEBUG 8964 --- [ main] c.i.mp.mapper.UserMapper.selectBatchIds : ==> Preparing: SELECT id,username,password,phone,info,status,balance,create_time,update_time FROM user WHERE id IN ( ? , ? , ? , ? , ? ) 11:54:23 DEBUG 8964 --- [ main] c.i.mp.mapper.UserMapper.selectBatchIds : ==> Parameters: 1(Long), 2(Long), 3(Long), 4(Long), 5(Long) 11:54:23 DEBUG 8964 --- [ main] c.i.mp.mapper.UserMapper.selectBatchIds : <== Total: 5 User(id=1, username=Jack, password=123, phone=13900112224, info={"age": 20, "intro": "佛系青年", "gender": "male"}, status=1, balance=1600, createTime=2023-05-19T20:50:21, updateTime=2023-06-19T20:50:21) User(id=2, username=Rose, password=123, phone=13900112223, info={"age": 19, "intro": "青涩少女", "gender": "female"}, status=1, balance=600, createTime=2023-05-19T21:00:23, updateTime=2023-06-19T21:00:23) User(id=3, username=Hope, password=123, phone=13900112222, info={"age": 25, "intro": "上进青年", "gender": "male"}, status=1, balance=100000, createTime=2023-06-19T22:37:44, updateTime=2023-06-19T22:37:44) User(id=4, username=Thomas, password=123, phone=17701265258, info={"age": 29, "intro": "伏地魔", "gender": "male"}, status=1, balance=800, createTime=2023-06-19T23:44:45, updateTime=2023-06-19T23:44:45) User(id=5, username=Lucy, password=123, phone=18688990011, info={"age": 24, "intro": "英文老师", "gender": "female"}, status=1, balance=200, createTime=2024-11-13T11:52:03, updateTime=2024-11-13T11:52:03)
<br>
3、常见注解
在刚刚的入门案例中,我们仅仅引入了依赖,继承了BaseMapper就能使用MybatisPlus,非常简单。但是问题来了: MybatisPlus如何知道我们要查询的是哪张表?表中有哪些字段呢?
UserMapper在继承BaseMapper的时候指定了一个泛型:
泛型中的User就是与数据库对应的PO实体类。
MybatisPlus底层通过反射,根据PO实体的信息来推断出表的信息,从而生成SQL的。默认情况下(约定):
- MybatisPlus会把PO实体的类名驼峰转下划线作为表名
- MybatisPlus会把PO实体的所有变量名驼峰转下划线作为表的字段名,并根据变量类型推断字段类型
- MybatisPlus会把名为id的字段作为主键
但很多情况下,默认的实现与实际场景不符(实际情况与MP的约定不符合时使用),因此MybatisPlus提供了一些注解便于我们声明表信息。
- @TableName:用来指定表名
- @Tableld:用来指定表中的主键字段信息
- @TableField:用来指定表中的普通字段信息
(1)@TableName
- 描述:表名注解,标识实体类对应的表
- 使用位置:实体类
TableName注解除了指定表名以外,还可以指定很多其它属性:
属性 |
类型 |
必须指定 |
默认值 |
描述 |
value |
String |
否 |
"" |
表名 |
schema |
String |
否 |
"" |
schema |
keepGlobalPrefix |
boolean |
否 |
false |
是否保持使用全局的 tablePrefix 的值(当全局 tablePrefix 生效时) |
resultMap |
String |
否 |
"" |
xml 中 resultMap 的 id(用于满足特定类型的实体类对象绑定) |
autoResultMap |
boolean |
否 |
false |
是否自动构建 resultMap 并使用(如果设置 resultMap 则不会进行 resultMap 的自动构建与注入) |
excludeProperty |
String[] |
否 |
{} |
需要排除的属性名 @since 3.3.1 |
(2)@TableId
- 描述:主键注解,标识实体类中的主键字段
- 使用位置:实体类的主键字段
TableId
注解支持两个属性:
属性 |
类型 |
必须指定 |
默认值 |
描述 |
value |
String |
否 |
"" |
表名 |
type |
Enum |
否 |
IdType.NONE |
指定主键类型 |
枚举IdType
支持的类型有:
值 |
描述 |
AUTO |
数据库 ID 自增 |
NONE |
无状态,该类型为未设置主键类型(如果全局配置中有 IdType 相关的配置,则会跟随全局配置。)当我们设置 @TableId 类型为NONE 时,且不手动设置主键值,MyBatisPlus将默认给出一个 Long 类型的字符串,因为全局配置默认为ASSIGN_ID。 |
INPUT |
insert 前自行 set 主键值。当我们没有设置主键值时,MyBatisPlus并不设置 Long 类型的值,而是插入为null。 |
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 长整型类型 (please use ASSIGN_ID) |
|
32 位 UUID 字符串 (please use ASSIGN_UUID) |
|
分布式全局唯一 ID 字符串类型 (please use ASSIGN_ID) |
这里比较常见的有三种:
AUTO
:利用数据库的id自增长INPUT
:手动生成idASSIGN_ID
:雪花算法生成Long类型的全局唯一id,这是默认的ID策略
(3)@TableField
- 描述:普通字段注解
一般情况下我们并不需要给字段添加@TableField
注解,一些特殊情况除外:
- 成员变量名与数据库字段名不一致
- 成员变量是以
isXXX
命名,按照JavaBean
的规范,MybatisPlus识别字段时会把is
去除,这就导致与数据库不符。 - 成员变量名与数据库一致,但是与数据库的关键字冲突。使用
@TableField
注解给字段名添加转义字符(两个反引号):``
支持的其它属性如下:
属性 |
类型 |
必填 |
默认值 |
描述 |
value |
String |
否 |
"" |
数据库字段名 |
exist |
boolean |
否 |
true |
是否为数据库表字段 |
condition |
String |
否 |
"" |
字段 where 实体查询比较条件,有值设置则按设置的值为准,没有则为默认全局的 %s=#{%s},参考(opens new window) |
update |
String |
否 |
"" |
字段 update set 部分注入,例如:当在version字段上注解update="%s+1" 表示更新时会 set version=version+1 (该属性优先级高于 el 属性) |
insertStrategy |
Enum |
否 |
FieldStrategy.DEFAULT |
举例:NOT_NULL insert into table_a(<if test="columnProperty != null">column</if>) values (<if test="columnProperty != null">#{columnProperty}</if>) |
updateStrategy |
Enum |
否 |
FieldStrategy.DEFAULT |
举例:IGNORED update table_a set column=#{columnProperty} |
whereStrategy |
Enum |
否 |
FieldStrategy.DEFAULT |
举例:NOT_EMPTY where <if test="columnProperty != null and columnProperty!=''">column=#{columnProperty}</if> |
fill |
Enum |
否 |
FieldFill.DEFAULT |
字段自动填充策略 |
select |
boolean |
否 |
true |
是否进行 select 查询 |
keepGlobalFormat |
boolean |
否 |
false |
是否保持使用全局的 format 进行处理 |
jdbcType |
JdbcType |
否 |
JdbcType.UNDEFINED |
JDBC 类型 (该默认值不代表会按照该值生效) |
typeHandler |
TypeHander |
否 |
类型处理器 (该默认值不代表会按照该值生效) |
|
numericScale |
String |
否 |
"" |
指定小数点后保留的位数 |
(4)使用案例
tb_user
为MP这三个常用注解的演示表
MpUser
为PO实体类
@Data @TableName("tb_user") public class MpUser { // 用户id @TableId(value = "user_id", type = IdType.AUTO) private Long id; // 用户名 @TableField("username") private String name; // 密码 private String password; // 是否被逻辑删除 @TableField("is_deleted") private Boolean isDeleted; // 排序字段 @TableField("`order`") private Integer order; // 地址 @TableField(exist = false) private String address; }
- MpUserMapper
public interface MpUserMapper extends BaseMapper<MpUser> { }
- MpUserMapperTests测试类
@SpringBootTest public class MpUserMapperTests { @Autowired private MpUserMapper mpUserMapper; @Test void testInsert() { MpUser mpUser = new MpUser(); //mpUser.setId(1L); mpUser.setName("MP用户1"); mpUser.setPassword("123"); mpUser.setIsDeleted(false); mpUser.setOrder(1); mpUser.setAddress("https://baomidou.com/"); mpUserMapper.insert(mpUser); } @Test void testSelectById() { MpUser mpUser = mpUserMapper.selectById(1L); System.out.println("mpUser = " + mpUser); } @Test void testSelectAll() { // selectList()方法的参数为MP内置的条件封装器Wrapper,所以不填写就是无任何条件,即查询全部 List<MpUser> mpUserList = mpUserMapper.selectList(null); mpUserList.forEach(System.out::println); } @Test void testUpdateById() { MpUser mpUser = new MpUser(); mpUser.setId(1L); mpUser.setIsDeleted(true); mpUserMapper.updateById(mpUser); } @Test void testDelete() { mpUserMapper.deleteById(1L); } }
在新增时无论是否手动设置id,主键字段都被忽略,由数据库自增长
在查询时,字段名会添加别名,冲突字段加了转移符,并且查数据库不存在的字段也不会报错,而是null
<br>
4、常见配置
MybatisPlus也支持基于yaml文件的自定义配置,详见官方文档使用配置。
mybatis-plus: type-aliases-package: com.itheima.mp.domain.po # 别名包扫描,这项无默认值,需要自己指定 mapper-locations: "classpath*:/mapper/**/*.xml" # mapper.xml映射文件地址,默认值 configuration: map-underscore-to-camel-case: true # 是否开启下划线和驼峰命名的映射,默认开启 cache-enabled: true # mybatis二级缓存,默认开启 global-config: db-config: id-type: assign_id # 默认全局id生成策略为雪花算法 update-strategy: not_null # 默认更新策略:只更新非null字段
mapper-locations:指定 MyBatis Mapper 对应的 XML 文件位置。如果在 Mapper 中有自定义方法(手写SQL或多表联查),需要配置此项。默认值为:"classpath*:/mapper/**/*.xml"
,也就是说我们只要把mapper.xml文件放置这个目录下就一定会被加载。
对于 Maven 多模块项目,扫描路径应以 classpath*:
开头,以加载多个 JAR 包中的 XML 文件。
大多数的配置都有默认值,因此我们都无需配置。但还有一些是没有默认值的,例如实体类的别名扫描包type-aliases-package
(默认值为null),用于给包中的类注册别名,注册后,在Mapper对应的XML文件中可以直接使用类名,无需使用全限定类名。
另外如果数据库的表大多数为主键自增,可以在全局配置中设置id-type
为auto
,之后如果有实体类的id属性是其他主键生成策略,再通过@TableId
注解配置即可(优先级:指定注解 > 全局配置)。
mybatis-plus: type-aliases-package: com.itheima.mp.domain.po global-config: db-config: id-type: auto # 全局id类型为自增长
<br>
<br>
三、核心功能
刚才的案例中都是以id为条件的简单CRUD,一些复杂条件的SQL语句就要用到一些更高级的功能了。
1、条件构造器
除了新增以外,修改、删除、查询的SQL语句都需要指定where条件。因此BaseMapper中提供的相关方法除了以id
作为where
条件以外,还支持更加复杂的where
条件。
参数中的Wrapper
就是条件构造的抽象类,其下有很多默认实现,继承关系如图:
Wrapper
的子类AbstractWrapper
提供了where中包含的所有条件构造方法:
而QueryWrapper在AbstractWrapper的基础上拓展了一个select方法,允许指定查询字段:
而UpdateWrapper在AbstractWrapper的基础上拓展了一个set方法,允许指定SQL中的SET部分:
接下来,我们就来看看如何利用Wrapper
实现复杂查询。
(1)QueryWrapper
无论是修改、删除、查询,都可以使用QueryWrapper来构建查询条件。接下来看一些例子:
查询:查询出名字中带o
的,存款大于等于1000元的人的id、username、info、balance字段。
// 查询出名字中带o的,存款大于等于1000元的人的id、username、info、balance字段 @Test void testQueryWrapper() { // 1.构建查询条件 where username like "%o%" AND balance >= 1000 QueryWrapper<User> queryWrapper = new QueryWrapper<>(); queryWrapper.select("id", "username", "info", "balance") .like("username", "o") .ge("balance", 1000); // 2.查询数据 List<User> userList = userMapper.selectList(queryWrapper); userList.forEach(System.out::println); }
更新:更新用户名为jack的用户的余额为2000。
// 更新用户名为jack的用户的余额为2000。 @Test void testUpdateByQueryWrapper() { // 1.设置要更新的数据 User user = new User(); user.setBalance(2000); // 2.构建更新条件 where username = "Jack" QueryWrapper<User> queryWrapper = new QueryWrapper<User>().eq("username", "Jack"); // 3.执行更新,user中非null字段都会作为set语句 System.out.println(userMapper.update(user, queryWrapper) > 0); }
(2)UpdateWrapper
基于BaseMapper中的update方法更新时只能直接赋值,对于一些复杂的需求就难以实现。 例如:更新id为1,2,4
的用户的余额,扣200,对应的SQL应该是:
UPDATE user SET balance = balance - 200 WHERE id in (1, 2, 4)
SET的赋值结果是基于字段现有值的,这个时候就要利用UpdateWrapper中的setSql功能了:
@Test void testUpdateWrapper() { List<Long> ids = List.of(1L, 2L, 4L); // 1.生成SQL UpdateWrapper<User> updateWrapper = new UpdateWrapper<User>() .setSql("balance = balance - 200") // SET balance = balance - 200 .in("id", ids); // WHERE id in (1, 2, 4) // 2.基于UpdateWrapper中的setSql来更新 System.out.println(userMapper.update(updateWrapper) > 0); }
(3)基于Lambda的Wrapper
无论是QueryWrapper
还是UpdateWrapper
在构造条件的时候都需要写死字段名称,会出现字符串魔法值。这在编程规范中显然是不推荐的。那怎么样才能不写字段名,又能知道字段名呢?
其中一种办法是基于变量的getter方法结合反射技术,我们只要将条件对应的字段的getter方法传递给MybatisPlus,它就能计算出对应的变量名了。而传递方法可以使用JDK8中的方法引用和Lambda表达式。
因此MybatisPlus又提供了一套基于Lambda的Wrapper,包含两个:
LambdaQueryWrapper
,对应QueryWrapperLambdaUpdateWrapper
,对应UpdateWrapper
其使用方式如下:
@Test void testLambdaUpdateWrapper() { List<Long> ids = List.of(1L, 2L, 4L); // 1.生成SQL LambdaUpdateWrapper<User> lambdaUpdateWrapper = new LambdaUpdateWrapper<User>() .setSql("balance = balance - 200") // SET balance = balance - 200 .in(User::getId, ids); // WHERE id in (1, 2, 4) // 2.基于UpdateWrapper中的setSql来更新 System.out.println(userMapper.update(lambdaUpdateWrapper) > 0); } @Test void testLambdaQueryWrapper() { // 1.构建查询条件 where username like "%o%" AND balance >= 1000 LambdaQueryWrapper<User> queryWrapper = new LambdaQueryWrapper<>(); queryWrapper.select(User::getId, User::getUsername, User::getInfo, User::getBalance) .like(User::getUsername, "o") .ge(User::getBalance, 1000); // 2.查询数据 List<User> userList = userMapper.selectList(queryWrapper); userList.forEach(System.out::println); }
总结:
- QueryWrapper和LambdaQueryWrapper通常用来构建select、delete、update的where条件部分
- UpdateWrapper和LambdaUpdateWrapper通常只有在set语句比较特殊的情况才使用
- 尽量使用LambdaQueryWrapper和LambdaUpdateWrapper避免硬编码
<br>
2、自定义SQL
(1)自定义SQL片段
- 问题引出
在演示的UpdateWrapper和LambdaUpdateWrapper的案例中,我们在代码中编写了更新的SQL语句:
其中balance = balance - 200
现在这种写法相当于把Mapper层的sql语句写在Service层了,这在某些企业也是不允许的,因为SQL语句最好都维护在持久层,而不是业务层。就当前案例来说,由于条件是in语句,只能将SQL写在Mapper.xml文件,利用foreach来生成动态SQL。 假如查询条件更复杂,动态SQL的编写也会更加复杂。
所以,MybatisPlus提供了自定义SQL片段功能,可以让我们利用Wrapper生成查询条件,再结合Mapper.xml编写SQL。
以当前案例来说,我们可以这样写:
@Test void testCustomSQLUpdate() { // 更新条件 List<Long> ids = List.of(1L, 2L, 4L); int amount = 200; // 定义条件 LambdaQueryWrapper<User> wrapper = new LambdaQueryWrapper<User>().in(User::getId, ids); // 调用自定义SQL方法 userMapper.updateBalanceByIds(wrapper, amount); }
然后在UserMapper中自定义SQL:
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; import com.baomidou.mybatisplus.core.mapper.BaseMapper; import com.baomidou.mybatisplus.core.toolkit.Constants; import com.itheima.mp.domain.po.User; import org.apache.ibatis.annotations.Param; import org.apache.ibatis.annotations.Select; import java.util.List; public interface UserMapper extends BaseMapper<User> { @Select("UPDATE user SET balance = balance - #{amount} ${ew.customSqlSegment}") //void updateBalanceByIds(@Param("ew") LambdaQueryWrapper<User> wrapper, @Param("amount") int amount); void updateBalanceByIds(@Param(Constants.WRAPPER) LambdaQueryWrapper<User> wrapper, @Param("amount") int amount); }
${ew.customSqlSegment}
:为自定义SQL片段,@Param("ew")
其中参数ew
必须叫这个,如果忘记了也可以用baomidou
包下的常量类Constants.WRAPPER
,其值等于"ew"
。
这样就省去了编写复杂查询条件的烦恼了,总结一下自定义SQL片段的使用场景:
- 更新时的特殊场景,不是更新具体不变的值,而是在原有值的基础上动态做增减(例如
balance = balance - amount
),完全使用MP只能在业务层拼接这条SQL语句。此时可以使用自定义SQL传值更新,更新的SQL定义在Mapper接口或Mapper.xml中,MP则更擅长处理where更新条件。 - 查询时的特殊场景(如下图),查询的字段结果是个别字段,而MP默认查询所有字段,只能通过QueryWrapper或LambdaQueryWrapper的select()方法去在业务层拼出查询字段,有时为了不违背企业开发规范,此时也可以使用自定义SQL片段
(2)多表联查
理论上来讲MyBatisPlus是不支持多表查询的,不过我们可以利用Wrapper中自定义条件结合自定义SQL来实现多表查询的效果。 例如,我们要查询出所有收货地址在北京的并且用户id在1、2、4之中的用户信息,要是自己基于mybatis实现SQL,大概是这样的:
<select id="queryUserByIdAndAddr" resultType="com.itheima.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部分,实现多表查询。
查询条件这样来构建:
@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中自定义方法:
@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:
<select id="queryUserByIdAndAddr" resultType="com.itheima.mp.domain.po.User"> SELECT * FROM user u INNER JOIN address a ON u.id = a.user_id ${ew.customSqlSegment} </select>
<br>
3、IService接口
MybatisPlus不仅提供了BaseMapper
,还提供了通用的Service接口及默认实现,封装了一些常用的service模板方法。
通用接口为IService
,默认实现为ServiceImpl
,其中封装的方法可以分为以下几类:
- save:新增
- remove:删除
- update:更新
- get:查询单个结果
- list:查询集合结果
- count:计数
- page:分页查询
(1)常用方法介绍
新增:
save
:新增单个元素saveBatch
:批量新增saveOrUpdate
:根据id判断,如果实体类中存在id就更新,不存在则新增saveOrUpdateBatch
:批量的新增或修改
删除:
removeById
:根据id删除removeByIds
:根据ids集合批量删除removeByMap
:根据Map中的键值对为条件删除remove(Wrapper<T>)
:根据Wrapper条件删除removeBatchByIds:暂不支持
修改:
updateById
:根据id修改,只更新不为null的值update(Wrapper<T>)
:根据UpdateWrapper修改,Wrapper中包含set和where部分update(T,Wrapper)
:按照T内的数据修改与Wrapper匹配到的数据updateBatchById
:根据id批量修改
查一条:
getById
:根据id查询1条数据getOne(Wrapper<T>)
:根据Wrapper查询1条数据getBaseMapper
:获取Service内的BaseMapper实现,某些时候需要直接调用Mapper内的自定义SQL时可以用这个方法获取到Mapper
查多条:
listByIds
:根据id批量查询list(Wrapper<T>)
:根据Wrapper条件查询多条数据list()
:查询所有
查条数:
count()
:统计所有数量count(Wrapper<T>)
:统计符合Wrapper条件的数据数量
(2)基本用法
由于Service
中经常需要定义与业务有关的自定义方法,因此我们不能直接使用IService
,而是自定义Service
接口,然后继承MP的IService
接口以拓展方法。让自定义的ServiceImpl实现类实现自定义的Service接口,同时继承MP的默认实现类ServiceImpl
,同时,这样就不用自己实现IService
接口中的方法了。
- 自定义Service接口继承
IService
接口。定义IUserService
继承IService
:
import com.baomidou.mybatisplus.extension.service.IService; import com.itheima.mp.domain.po.User; public interface IUserService extends IService<User> { }
- 自定义Service实现类,实现自定义接口并继承
ServiceImpl
类。创建UserServiceImpl
类,继承ServiceImpl
,实现UserService
:
import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl; import com.itheima.mp.domain.po.User; import com.itheima.mp.mapper.UserMapper; import com.itheima.mp.service.IUserService; import org.springframework.stereotype.Service; @Service public class UserServiceImpl extends ServiceImpl<UserMapper, User> implements IUserService { }
ServiceImpl<M, T>接口的泛型参数中,M是继承了BaseMapper的Mapper接口,T是PO实体类。
(3)Restful案例
案例:基于Restful风格实现下面的接口
编号 |
接口 |
请求方式 |
请求路径 |
请求参数 |
返回值 |
1 |
新增用户 |
POST |
/users |
用户表单实体 |
无 |
2 |
删除用户 |
DELETE |
/users/{id} |
用户id |
无 |
3 |
根据id查询用户 |
GET |
/users/{id} |
用户id |
用户VO |
4 |
根据id批量查询 |
GET |
/users |
用户id集合 |
用户VO集合 |
5 |
根据id扣减余额 |
PUT |
/users/{id}/deduction/{money} |
|
无 |
首先,我们在项目中引入Swagger和Web依赖:
<!-- 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>
在yaml中配置swagger信息:
knife4j: enable: true openapi: title: 用户管理接口文档 description: "用户管理接口文档" email: Aizen@qq.com concat: 蓝染 url: https://www.itcast.cn version: v1.0.0 group: default: group-name: default api-rule: package api-rule-resources: - com.itheima.mp.controller
然后,接口的接收和返回值分别需要定义两个实体:
- UserFormDTO:代表新增时的用户表单
package com.itheima.mp.domain.dto; import com.baomidou.mybatisplus.annotation.TableField; import com.baomidou.mybatisplus.extension.handlers.JacksonTypeHandler; import io.swagger.annotations.ApiModel; import io.swagger.annotations.ApiModelProperty; import lombok.Data; @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; }
- UserVO:代表查询的返回结果
package com.itheima.mp.domain.vo; import io.swagger.annotations.ApiModel; import io.swagger.annotations.ApiModelProperty; import lombok.Data; @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; }
最后,按照Restful风格编写Controller接口方法
@Api(tags = "用户管理接口") @RestController @RequestMapping("/users") @RequiredArgsConstructor // lombok注解:在构造方法中只会注入必须需要初始化的成员变量,例如加了final且未初始化的变量,将来不需要做注入的变量不加final即可 public class UserController { // Spring不推荐我们使用@Autowired进行属性注入,推荐我们使用构造器注入,但当需要注入的成员变量很多的时候,构造方法会显得特别长,因此我们可以将需要注入的变量加上final,并且使用lombok的@RequiredArgsConstructor注解提供必要参数构造器 private final IUserService userService; @ApiOperation("新增用户接口") @PostMapping public void saveUser(@RequestBody UserFormDTO userDTO) { // 将DTO拷贝到PO User user = BeanUtil.copyProperties(userDTO, User.class); // 新增 userService.save(user); } @ApiOperation("删除用户接口") @DeleteMapping("/{id}") public void deleteUserById(@ApiParam("用户id") @PathVariable("id") Long id) { userService.removeById(id); } @ApiOperation("根据id查询用户接口") @GetMapping("/{id}") public UserVO queryUserById(@ApiParam("用户id") @PathVariable("id") Long id) { // 查询用户PO User user = userService.getById(id); // 将PO拷贝到VO return BeanUtil.copyProperties(user, UserVO.class); } @ApiOperation("根据id批量查询用户接口") @GetMapping public List<UserVO> queryUserById(@ApiParam("用户id集合") @RequestParam("ids") List<Long> ids) { // 查询用户PO集合 List<User> users = userService.listByIds(ids); // 将PO集合拷贝到VO集合 return BeanUtil.copyToList(users, UserVO.class); } @ApiOperation("根据id扣减用户余额接口") @PutMapping("/{id}/deduction/{money}") public void deductBalanceById( @ApiParam("用户id") @PathVariable("id") Long id, @ApiParam("扣减的金额") @PathVariable("money") Integer money) { userService.deductBalanceById(id, money); } }
可以看到前四个接口都直接在Controller实现即可,无需编写任何Service代码,非常方便。不过,一些带有业务逻辑的接口,比如第五个deductBalanceById接口,MP的Service没有提供业务逻辑,所以这些业务逻辑都要在Service层来做。另外更新余额需要自定义SQL,要在mapper中来实现。
- UserService接口和UserServiceImpl实现类
public interface IUserService extends IService<User> { void deductBalanceById(Long id, Integer money); } @Service public class UserServiceImpl extends ServiceImpl<UserMapper, User> implements IUserService { @Override public void deductBalanceById(Long id, Integer money) { // 查询用户 User user = getById(id); // 校验用户状态 if (user == null || user.getStatus() == 2) { throw new RuntimeException("用户状态异常!"); } // 校验余额是否充足 if (user.getBalance() < money) { throw new RuntimeException("用户余额不足!"); } // 扣减余额 baseMapper.deductBalance(id, money); } }
- mapper接口
public interface UserMapper extends BaseMapper<User> { @Update("UPDATE user SET balance = balance - #{money} WHERE id = #{id}") void deductBalance(@Param("id") Long id, @Param("money") Integer money); }
- 访问http://localhost:8080/doc.html,测试接口
(4)LambdaQuery和LambdaUpdate
IService中还提供了Lambda功能来简化我们的复杂查询及更新功能。我们通过两个案例来学习一下。
案例一:实现一个根据复杂条件查询用户的接口,查询条件如下:
- name:用户名关键字,可以为空
- status:用户状态,可以为空
- minBalance:最小余额,可以为空
- maxBalance:最大余额,可以为空
可以理解成一个用户的后台管理界面,管理员可以自己选择条件来筛选用户,因此上述条件不一定存在,需要动态做判断。
我们首先需要定义一个查询条件实体,UserQueryDTO实体:
@Data @ApiModel(description = "用户查询条件实体") public class UserQueryDTO { @ApiModelProperty("用户名关键字") private String name; @ApiModelProperty("用户状态:1-正常,2-冻结") private Integer status; @ApiModelProperty("余额最小值") private Integer minBalance; @ApiModelProperty("余额最大值") private Integer maxBalance; }
- 在UserController中定义方法
@ApiOperation("根据条件查询用户接口") @GetMapping("/condition") public List<UserVO> queryUserByCondition(UserQueryDTO queryDTO) { // 查询用户PO集合 List<User> users = userService.queryUserByCondition(queryDTO); // 将PO集合拷贝到VO集合 return BeanUtil.copyToList(users, UserVO.class); }
- UserService接口和UserServiceImpl实现类,基于lambdaQuery实现
public interface IUserService extends IService<User> { List<User> queryUserByCondition(UserQueryDTO queryDTO); } // 基于Lambda查询 @Override public List<User> queryUserByCondition(UserQueryDTO queryDTO) { String name = queryDTO.getName(); Integer status = queryDTO.getStatus(); Integer minBalance = queryDTO.getMinBalance(); Integer maxBalance = queryDTO.getMaxBalance(); return lambdaQuery() .like(name != null, User::getUsername, name) .eq(status != null, User::getStatus, status) .ge(minBalance != null, User::getBalance, minBalance) .le(maxBalance != null, User::getBalance, maxBalance) .list(); }
MP对LambdaQueryWrapper
和LambdaUpdateWrapper
的用法进一步做了简化。我们无需自己通过new
的方式来创建Wrapper
,而是直接调用lambdaQuery
和lambdaUpdate
方法。在组织查询条件的时候,我们加入了name != null
这样的参数,意思就是当条件成立时才会添加这个查询条件,类似Mybatis的mapper.xml文件中的<if>
标签。这样就实现了动态查询条件效果了。
MybatisPlus会根据链式编程的最后一个方法来判断最终的返回结果。lambdaQuery方法中除了可以构建条件,还需要在链式编程的最后添加一个查询结果,list()
表示查询结果返回一个List集合。可选的常用方法有:
one()
:最多1个结果list()
:返回集合结果count()
:返回计数结果exist()
:返回查询的结果是否存在
与lambdaQuery方法类似,IService中的lambdaUpdate方法可以非常方便的实现复杂更新业务。
案例二:改造根据id修改用户余额的接口,要求如下
- 完成对用户状态校验
- 完成对用户余额校验
- 如果扣减后余额为0,则将用户status修改为2,表示冻结状态(update语句的set部分是动态的)
- 基于lambdaUpdate实现
@Override @Transactional public void deductBalanceById(Long id, Integer money) { // 查询用户 User user = getById(id); // 校验用户状态 if (user == null || user.getStatus() == 2) { throw new RuntimeException("用户状态异常!"); } // 校验余额是否充足 if (user.getBalance() < money) { throw new RuntimeException("用户余额不足!"); } // 扣减余额 //baseMapper.deductBalance(id, money); int remainBalance = user.getBalance() - money; lambdaUpdate() .set(User::getBalance, remainBalance) // 更新余额 .set(remainBalance == 0, User::getStatus, 2) // 动态判断是否更新status .eq(User::getBalance, user.getBalance()) // CAS乐观锁 .eq(User::getId, id) // 根据id扣减对应用户的余额 .update(); // 注意:LambdaUpdate做复杂更新时,最后必须记得加上.update()进行更新操作 }
(5)批量新增 & 批处理方案性能测试
IService中的批量新增功能使用起来非常方便,但有一点注意事项。
需求:批量插入10万条用户数据,并作出对比。
- 方式一:普通for循环逐条插入
- 方式二:IService的批量插入(默认不开启 jdbc 批处理参数)
- 方式三:开启rewriteBatchedStatements=true参数
首先我们测试方式一,逐条插入数据
/** * 10w次插入意味着10w次网络请求,耗时最慢 */ @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; }
执行结果耗时大约为551.9秒
可以看到速度非常慢。
再测试一下方式二,MybatisPlus的批处理:
/** * MP批处理采用的是JDBC底层的预编译方案PreparedStatement,将1000条数据统一打包执行save一并提交到MySQL,每1000条发送一次网络请求,插入100次共发送100次网络请求 * MP如果不加JDBC连接参数rewriteBatchedStatements=true,底层还是打包逐条插入,只不过是从网络请求数量上减少了耗时 * 而加上了MySQL的这个开启批处理参数后,MP调用的JDBC底层的批处理才能真正变成一次性批量插入多条数据 */ @Test void testSaveBatch() { // 因为一次性new 10万条数据占用内存太多,并且向数据库请求的数据包有上限大小限制(一次网络传输的数据量是有限的) // 所以我们每次批量插入1000条件,插入100次即10万条数据 // 准备一个容量为1000的集合 List<User> list = new ArrayList<>(1000); long b = System.currentTimeMillis(); for (int i = 1; i <= 100000; i++) { // 添加一个user list.add(buildUser(i)); // 每1000条批量插入一次 if (i % 1000 == 0) { // 批量插入 userService.saveBatch(list); // 清空集合,准备下一批数据 list.clear(); } } long e = System.currentTimeMillis(); System.out.println("耗时:" + (e - b)); }
执行结果耗时大约为27.6秒,打包逐条插入,从网络请求层面大大减少了耗时。
虽然上面的方式二减少了网络请求次数,但是底层还是打包逐条SQL插入。如果想要真正实现批量处理,有下面两种办法。
第一种实现就是利用MyBatis自定义SQL语句,用<foreach>
标签遍历组装成下面一条SQL的形式
<!-- MyBatis批量插入 --> <insert id="batchInsertUsers"> INSERT INTO user (username, password, phone, info, balance, create_time, update_time) VALUES <foreach collection="list" item="user" separator=","> (#{name}, #{password}, #{phone}, #{info}, #{balance}, #{createTime}, #{updateTime}) </foreach> </insert>
第二种实现,就是还是利用MP的jdbc批处理,只不过MySQL本身默认没有开启这个批处理参数rewriteBatchedStatements=true
,该参数在MySQL 3.1.13版本开始引入,默认值为false不开启。因此这个批处理操作其实底层是由MySQL驱动去做的,不是由MP来做的。所以我们只需在jdbc的url连接参数后添加该参数,MP的批处理才能成效。
spring: datasource: url: jdbc:mysql://127.0.0.1:3307/mp?useUnicode=true&characterEncoding=UTF-8&autoReconnect=true&serverTimezone=Asia/Shanghai&rewriteBatchedStatements=true driver-class-name: com.mysql.cj.jdbc.Driver username: root password: 123456
jdbc批处理相比于Mybatis批处理效率更高
- 开启参数后,测试耗时大约为6.5秒
拓展:rewriteBatchedStatements=true
和allowMultiQueries=true
的区别
- rewriteBatchedStatements是重写sql语句达到发送一次sql的请求效果;allowMultiQueries是在mapper.xml中使用
;
分号分隔多条sql,允许多条语句一起发送执行的。 - 对于insert批处理操作,开启rewriteBatchedStatements=true,驱动则会把多条sql语句重写成一条sql语句然后再发出去;而对于update和delete批处理操作,开启allowMultiQueries=true,驱动所做的事就是把多条sql语句累积起来再一次性发出去。
批处理方案总结:
- 普通for循环逐条插入速度极差,不推荐
- MP的批量新增,基于预编译的批处理,性能不错
- 配置jdbc参数,开启rewriteBatchedStatements,性能最好
<br>
<br>
四、扩展功能
1、代码生成
在使用MybatisPlus以后,基础的Mapper
、Service
、PO
代码相对固定,重复编写也比较麻烦。因此MybatisPlus官方提供了代码生成器根据数据库表结构生成PO
、Mapper
、Service
等相关代码。只不过代码生成器同样要编码使用,也很麻烦。
这里推荐大家使用一款MybatisPlus
的插件,它可以基于图形化界面完成MybatisPlus
的代码生成,非常简单。
(1)安装插件
在idea的plugins市场中搜索并安装MyBatisPlus
插件:
(2)使用步骤
刚好数据库中还有一张address表尚未生成对应的实体和mapper等基础代码。我们利用插件生成一下。 首先需要配置数据库地址,在Idea顶部菜单中,找到other
,选择Config Database
,在弹出的窗口中填写数据库连接的基本信息:
点击OK保存。然后再次点击Idea顶部菜单中的other,然后选择Code Generator
,在弹出的表单中填写信息:
最终,代码自动生成到指定的位置了
(3)代码生成器配置
如果不想用图形化界面方式配置生成代码,使用MyBatis-Plus官网提供的代码生成器模板也是可以的,但需要自己填写配置信息。
因为MP代码生成更新迭代速度很快,若本文的API被弃用,请以官网最新版本API为准:
MyBatis-Plus新代码生成器:https://baomidou.com/guides/new-code-generator/
代码生成器配置:https://baomidou.com/reference/new-code-generator-configuration/
- 引入依赖
<!-- MP代码生成器 --> <dependency> <groupId>com.baomidou</groupId> <artifactId>mybatis-plus-generator</artifactId> <version>3.5.9</version> </dependency>
- 代码生成模板配置示例
import com.baomidou.mybatisplus.core.mapper.BaseMapper; import com.baomidou.mybatisplus.generator.FastAutoGenerator; import com.baomidou.mybatisplus.generator.config.OutputFile; import java.util.Collections; import java.util.List; public class CodeGenerator { /** * 数据库链接地址 **/ private static final String JDBC_URL_MAN = "jdbc:mysql://xxxxx:3306/xxx?useUnicode=true&characterEncoding=UTF-8"; /** * 数据库登录账号 **/ private static final String JDBC_USER_NAME = "xx"; /** * 数据库登录密码 **/ private static final String JDBC_PASSWORD = "xxxx"; public static void main(String[] args) { String dir = "\\xx\\xxx"; String tablePrefix = "tb_"; List<String> tables = List.of("tb_user"); FastAutoGenerator.create(JDBC_URL_MAN, JDBC_USER_NAME, JDBC_PASSWORD) .globalConfig(builder -> { builder.author("Aizen") // 作者 .outputDir(System.getProperty("user.dir") + dir + "\\src\\main\\java") // 输出路径(写到java目录) .enableSwagger() // 开启swagger .commentDate("yyyy-MM-dd"); // 设置注释日期格式,默认值: yyyy-MM-dd }) .packageConfig(builder -> { builder.parent("com.{company}") // 设置父包名 .moduleName("{model}") // 设置父包模块名 .entity("domain") // 设置实体类包名 .service("service") // 设置Service接口包名 .serviceImpl("service.impl")// 设置Service实现类包名 .controller("controller") // 设置Controller包名 .mapper("mapper") // 设置Mapper接口文件包名 .xml("mappers") // 设置Mapper XML文件包名 .pathInfo(Collections.singletonMap(OutputFile.xml, System.getProperty("user.dir") + dir + "\\src\\main\\resources\\mapper")); }) .strategyConfig(builder -> { builder.addInclude(tables) // 设置需要生成的表名 .addTablePrefix(tablePrefix) // 设置表前缀 .serviceBuilder() // 设置 Service 层模板 .formatServiceFileName("%sService") .formatServiceImplFileName("%sServiceImpl") .entityBuilder() // 设置实体类模板 .enableLombok() // 启用 Lombok .logicDeleteColumnName("deleted") // 逻辑删除字段名(数据库字段) .enableTableFieldAnnotation() // 开启生成实体时生成字段注解 .controllerBuilder() .formatFileName("%sController") .enableRestStyle() // 启用 REST 风格 .mapperBuilder() // Mapper 策略配置 .enableBaseResultMap() // 生成通用的resultMap .superClass(BaseMapper.class) // 设置父类 .formatMapperFileName("%sMapper") .enableMapperAnnotation() .formatXmlFileName("%sMapper"); }) //.templateEngine(new FreemarkerTemplateEngine()) // 使用Freemarker引擎模板,默认的是Velocity引擎模板 .execute(); // 执行生成 } }
<br>
2、静态工具
有的时候Service之间也会相互调用,为了避免出现循环依赖问题,MybatisPlus提供一个静态工具类:Db
,其中的一些静态方法与IService
中方法签名基本一致,也可以帮助我们实现CRUD功能:
因为静态方法无法读取类上的泛型,所以MP在使用静态工具读取表信息时,需要传入PO实体类的Class字节码,MP再通过反射获取到表信息。其中新增和修改的方法由于需要传入实体类对象,因此不用传入实体类的Class字节码。下面是使用示例:
@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"); }
(1)案例一
- 案例一:改造根据id用户查询的接口,查询用户的同时返回用户收货地址列表
首先,我们要添加一个收货地址的VO对象AddressVO
@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,添加一个用户的收获地址集合(对多)属性
@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; @ApiModelProperty("用户的收获地址") private List<AddressVO> addresses; }
修改UserController中根据id查询用户的业务接口
@ApiOperation("根据id查询用户接口") @GetMapping("/{id}") public UserVO queryUserById(@ApiParam("用户id") @PathVariable("id") Long id) { return userService.queryUserAndAddressById(id); }
IUserService和UserServiceImpl
public interface IUserService extends IService<User> { UserVO queryUserAndAddressById(Long id); } @Override public UserVO queryUserAndAddressById(Long id) { // 查询用户PO User user = this.getById(id); if (user == null || user.getStatus() == 2) { throw new RuntimeException("用户不存在或用户状态异常!"); } // 查询地址PO List<Address> addresses = Db.lambdaQuery(Address.class).eq(Address::getUserId, id).list(); // 封装VO,将用户PO转为VO UserVO userVO = BeanUtil.copyProperties(user, UserVO.class); // 如果地址PO不为空,将地址PO集合转化为地址VO集合,设置到用户VO中 if (CollUtil.isNotEmpty(addresses)) { List<AddressVO> addressVOList = BeanUtil.copyToList(addresses, AddressVO.class); userVO.setAddresses(addressVOList); } return userVO; }
在查询地址时,我们采用了Db的静态方法,因此避免了注入AddressService,减少了循环依赖的风险。
(2)案例二
- 案例二:改造根据id批量查询用户的接口,查询用户的同时,查询出用户对应的所有地址
代码实现:
@ApiOperation("根据id批量查询用户接口") @GetMapping public List<UserVO> queryUserByIds(@ApiParam("用户id集合") @RequestParam("ids") List<Long> ids) { return userService.queryUserAndAddressByIds(ids); } public interface IUserService extends IService<User> { List<UserVO> queryUserAndAddressByIds(List<Long> ids); } @Override public List<UserVO> queryUserAndAddressByIds(List<Long> ids) { // 查询用户 List<User> users = this.listByIds(ids); if (CollUtil.isEmpty(users)) { return Collections.emptyList(); } // 查询地址 // 获取用户id集合 List<Long> userIds = users.stream().map(User::getId).collect(Collectors.toList()); // 根据用户id集合查询地址PO集合 List<Address> addresses = Db.lambdaQuery(Address.class).in(Address::getUserId, userIds).list(); // 地址PO集合转地址VO集合 List<AddressVO> addressVOList = BeanUtil.copyToList(addresses, AddressVO.class); // 用户地址集合分组处理,相同用户的放入一个集合(组)中 Map<Long, List<AddressVO>> addressMap = new HashMap<>(0); if (CollUtil.isNotEmpty(addressVOList)) { addressMap = addressVOList.stream().collect(Collectors.groupingBy(AddressVO::getUserId)); } // PO集合转VO集合返回 List<UserVO> userVOList = new ArrayList<>(users.size()); for (User user : users) { // 转换用户PO为VO UserVO userVO = BeanUtil.copyProperties(user, UserVO.class); // 为每个用户VO设置自己的地址集合 userVO.setAddresses(addressMap.get(user.getId())); userVOList.add(userVO); } return userVOList; }
(3)案例三
- 案例三:实现根据用户id查询收货地址功能,需要验证用户状态,冻结用户抛出异常
代码实现:
@Api(tags = "地址管理接口") @RestController @RequestMapping("/address") @RequiredArgsConstructor public class AddressController { private final IAddressService addressService; @ApiOperation("根据用户id查询地址接口") @GetMapping("/{userId}") public List<AddressVO> queryAddressByUserId(@ApiParam("用户id") @PathVariable("userId") Long userId) { return addressService.queryAddressByUserId(userId); } } public interface IAddressService extends IService<Address> { List<AddressVO> queryAddressByUserId(Long userId); } @Service public class AddressServiceImpl extends ServiceImpl<AddressMapper, Address> implements IAddressService { @Override public List<AddressVO> queryAddressByUserId(Long userId) { // 查询用户,验证用户状态,冻结用户抛出异常 User user = Db.getById(userId, User.class); if (user == null || user.getStatus() == 2) { throw new RuntimeException("用户不存在或用户状态异常!"); } // 查询该用户的收获地址 List<Address> addresses = lambdaQuery().eq(Address::getUserId, userId).list(); return BeanUtil.copyToList(addresses, AddressVO.class); } }
<br>
3、逻辑删除
(1)介绍
对于一些比较重要的数据,我们往往会采用逻辑删除的方案,即:
- 在表中添加一个字段标记数据是否被删除,逻辑删除字段的属性通常是
Integer
或Boolean
类型。 - 当删除数据时把标记置为1,1表示已删除
- 查询时只查询标记为0的数据,0表示未删除
同理更新操作也需要加上deleted = 0
,所以一旦采用了逻辑删除,所有的查询、删除、更新逻辑都要跟着变化,非常麻烦。
为了解决这个问题,MybatisPlus就添加了对逻辑删除的支持。无需改变方法调用的方式,而是在底层帮我们自动修改CRUD的语句。我们只需要在application.yaml
文件中配置逻辑删除的字段名称和值即可。
注意:只有MybatisPlus生成的SQL语句才支持自动的逻辑删除,自定义SQL需要自己手动处理逻辑删除。
(2)使用步骤
例如,给address
表添加一个逻辑删除字段
alter table address add deleted bit default b'0' null comment '逻辑删除';
给Address
实体类添加deleted
属性
在application.yml
中配置MP逻辑删除字段
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)
- 测试删除和查询
@SpringBootTest class IAddressServiceTest { @Autowired private IAddressService addressService; @Test void testLogicDelete() { // 删除 addressService.removeById(59L); // 查询 Address address = addressService.getById(59L); System.out.println("address = " + address); } }
对于没有逻辑删除字段的表不受影响,删除和查询还和之前一样。
(3)@TableLogic
@TableLogic
注解用于标记实体类中的逻辑删除字段。
@Data @EqualsAndHashCode(callSuper = false) @Accessors(chain = true) @TableName("address") public class Address implements Serializable { private static final long serialVersionUID = 1L; @TableId(value = "id", type = IdType.AUTO) private Long id; // 省略... /** * 逻辑删除 */ @TableLogic // @TableLogic注解用于标记实体类中的逻辑删除字段 //@TableLogic(value = "0", delval = "1") // value表示默认逻辑未删除值,delval表示默认逻辑删除值 (这两个值可无、会自动获取全局配置) @TableField("deleted") private Boolean deleted; }
使用这种方式就不用在application.yml
中配置MP逻辑删除字段了,直接在逻辑删除字段属性上加该注解即可。
总结:逻辑删除本身也有自己的问题,比如
- 会导致数据库表垃圾数据越来越多,从而影响查询效率
- SQL中全都需要对逻辑删除字段做判断,影响查询效率
因此,不太推荐采用逻辑删除功能,如果数据不能删除,可以采用把数据迁移到其它表的办法。
<br>
4、枚举处理器
当实体类属性是枚举类型,在与数据库的字段类型做转换时,底层默认使用的是MyBatis提供的EnumOrdinalTypeHandler
枚举类型处理器。
但是这个并不好用,所以MP对类型处理器做了增强,其中增强后的枚举处理器叫MybatisEnumTypeHandler
,JSON处理器叫AbstractJsonTypeHandler
。
(1)定义枚举,标记@EnumValue
定义一个用户状态的枚举
import com.baomidou.mybatisplus.annotation.EnumValue; import lombok.Getter; @Getter public enum UserStatus { NORMAL(1, "正常"), FREEZE(2, "冻结"); @EnumValue private final int value; private final String desc; UserStatus(int value, String desc) { this.value = value; this.desc = desc; } }
要让MybatisPlus
处理枚举与数据库类型自动转换,我们必须告诉MybatisPlus
,枚举中的哪个字段的值作为数据库值。 MybatisPlus
提供了@EnumValue
注解来标记枚举属性值。因此我们需要给枚举中与数据库字段类型对应的属性值添加@EnumValue
注解。
把User
和UserVO
类中的status
字段类型改为UserStatus
枚举类型。
(2)配置枚举处理器
在application.yaml文件中配置枚举处理器
mybatis-plus: configuration: default-enum-type-handler: com.baomidou.mybatisplus.core.handlers.MybatisEnumTypeHandler
- 测试发现查询出的
status
字段会是枚举类型,默认显示的是枚举项的名称
如果我们想向前端返回指定的枚举属性,例如value状态值或desc描述,SpringMVC负责处理响应数据,它在底层处理Json时用的是jackson,所以我们只需要使用jackson提供的@JsonValue
注解,来标记JSON序列化后展示的字段。
import com.baomidou.mybatisplus.annotation.EnumValue; import com.fasterxml.jackson.annotation.JsonValue; import lombok.Getter; @Getter public enum UserStatus { NORMAL(1, "正常"), FREEZE(2, "冻结"); @EnumValue private final int value; @JsonValue private final String desc; UserStatus(int value, String desc) { this.value = value; this.desc = desc; } }
- 响应效果
<br>
5、JSON类型处理器
数据库的user表中有一个info
字段,是JSON类型
info的格式像这样
{"age": 20, "intro": "佛系青年", "gender": "male"}
这样一来,我们要读取info中的属性时就非常不方便。如果要方便获取,info的类型最好是一个Map
或者实体类。而一旦我们把info
改为对象
类型,就需要在写入数据库时手动转为String
,再读取数据库时,手动转换为对象
,这会非常麻烦。
因此MybatisPlus提供了很多特殊类型字段的类型处理器,解决特殊字段类型与数据库类型转换的问题。例如处理JSON就可以使用JacksonTypeHandler
处理器(SpringMVC底层默认也是使用的这个类)。
(1)定义接收Json的实体类
首先定义一个单独实体类UserInfo
来与info字段的属性匹配
@Data @NoArgsConstructor @AllArgsConstructor(staticName = "of") // 为了方便构建对象,为有参构造提供静态方法,名为of,UserInfo.of() public class UserInfo { private Integer age; private String intro; private String gender; }
(2)指定类型处理器
将User
和UserVO
类的info字段修改为UserInfo
类型,并声明类型处理器@TableField(typeHandler = JacksonTypeHandler.class)
。另外,将info改为对象类型后出现对象嵌套,在复杂嵌套查询时需要使用resultMap
结果集映射,否则无法映射。所以还需要再@TableName
注解中添加autoResultMap=true
确保能够正常映射。
@Data @TableName(value = "user", autoResultMap = true) public class User { /** * 用户id */ //@TableId(type = IdType.AUTO) private Long id; /** * 用户名 */ private String username; /** * 密码 */ private String password; /** * 注册手机号 */ private String phone; /** * 详细信息 */ @TableField(typeHandler = JacksonTypeHandler.class) private UserInfo info; /** * 使用状态(1正常 2冻结) */ private UserStatus status; /** * 账户余额 */ private Integer balance; /** * 创建时间 */ private LocalDateTime createTime; /** * 更新时间 */ private LocalDateTime updateTime; }
如果启动mapper.xml报错,在info字段后加上, typeHandler = com.baomidou.mybatisplus.extension.handlers.JacksonTypeHandler
就解决了。
- 测试效果
<br>
6、yaml配置加密
目前我们配置文件中的很多参数都是明文,如果开发人员发生流动,很容易导致敏感信息的泄露。所以MybatisPlus支持配置文件的加密和解密功能。
我们以数据库的用户名和密码为例。
(1)生成密钥
首先,我们利用MP提供的AES工具生成一个随机秘钥,然后对用户名、密码加密
import com.baomidou.mybatisplus.core.toolkit.AES; import org.junit.jupiter.api.Test; import org.springframework.boot.test.context.SpringBootTest; import static org.junit.jupiter.api.Assertions.*; @SpringBootTest class MpDemoApplicationTest { public static final String USERNAME = "root"; public static final String PASSWORD = "123456"; @Test void testEncrypt() { // 生成 16 位随机 AES 密钥 String randomKey = AES.generateRandomKey(); System.out.println("randomKey = " + randomKey); // randomKey = 7pSEa6F9TnYacTNJ // 利用密钥对用户名加密 String username = AES.encrypt(USERNAME, randomKey); System.out.println("username = " + username); // username = O4Yq+WKYGlPW5t8QvgrhUQ== // 利用密钥对用户名加密 String password = AES.encrypt(PASSWORD, randomKey); System.out.println("password = " + password); // password = cDYHnWysq07zUIAy1tcbRQ== } }
(2)修改配置
修改application.yaml文件,把jdbc的用户名、密码修改为刚刚加密生成的密文
spring: datasource: url: jdbc:mysql://127.0.0.1:3307/mp?useUnicode=true&characterEncoding=UTF-8&autoReconnect=true&serverTimezone=Asia/Shanghai&rewriteBatchedStatements=true driver-class-name: com.mysql.cj.jdbc.Driver username: mpw:O4Yq+WKYGlPW5t8QvgrhUQ== # 密文要以 mpw:开头 password: mpw:cDYHnWysq07zUIAy1tcbRQ== # 密文要以 mpw:开头
(3)配置密钥运行参数
在启动项目的时候,需要把刚才生成的秘钥添加到Jar启动参数
中,像这样:
--mpw.key=7pSEa6F9TnYacTNJ
,新版本idea添加Program arguments
中设置,界面如下
单元测试的时候不能添加启动参数,所以要在测试类的注解上配置:@SpringBootTest(args = "--mpw.key=7pSEa6F9TnYacTNJ")
然后随意运行一个单元测试,可以发现数据库查询正常。
(4)实现原理
SpringBoot提供修改Spring环境后置处理器【EnvironmentPostProcessor】,允许在应用程序之前操作环境属性值,MyBatisPlus对其进行了重写实现。
package com.baomidou.mybatisplus.autoconfigure; import com.baomidou.mybatisplus.core.toolkit.AES; import com.baomidou.mybatisplus.core.toolkit.CollectionUtils; import com.baomidou.mybatisplus.core.toolkit.StringUtils; import org.springframework.boot.SpringApplication; import org.springframework.boot.env.EnvironmentPostProcessor; import org.springframework.boot.env.OriginTrackedMapPropertySource; import org.springframework.core.env.ConfigurableEnvironment; import org.springframework.core.env.MapPropertySource; import org.springframework.core.env.PropertySource; import org.springframework.core.env.SimpleCommandLinePropertySource; import java.util.HashMap; /** * 安全加密处理器 * * @author hubin * @since 2020-05-23 */ public class SafetyEncryptProcessor implements EnvironmentPostProcessor { @Override public void postProcessEnvironment(ConfigurableEnvironment environment, SpringApplication application) { /** * 命令行中获取密钥 */ String mpwKey = null; for (PropertySource<?> ps : environment.getPropertySources()) { if (ps instanceof SimpleCommandLinePropertySource) { SimpleCommandLinePropertySource source = (SimpleCommandLinePropertySource) ps; mpwKey = source.getProperty("mpw.key"); break; } } /** * 处理加密内容 */ if (StringUtils.isNotBlank(mpwKey)) { HashMap<String, Object> map = new HashMap<>(); for (PropertySource<?> ps : environment.getPropertySources()) { if (ps instanceof OriginTrackedMapPropertySource) { OriginTrackedMapPropertySource source = (OriginTrackedMapPropertySource) ps; for (String name : source.getPropertyNames()) { Object value = source.getProperty(name); if (value instanceof String) { String str = (String) value; if (str.startsWith("mpw:")) { map.put(name, AES.decrypt(str.substring(4), mpwKey)); } } } } } // 将解密的数据放入环境变量,并处于第一优先级上 if (CollectionUtils.isNotEmpty(map)) { environment.getPropertySources().addFirst(new MapPropertySource("custom-encrypt", map)); } } } }
<br>
7、自动填充字段
MyBatis-Plus提供了一个便捷的自动填充功能,用于在插入或更新数据时自动填充某些字段,如创建时间、更新时间等。
(1)配置自动填充处理器
自动填充功能通过实现 com.baomidou.mybatisplus.core.handlers.MetaObjectHandler
接口来实现。我们需要创建一个类来实现这个接口,并在其中定义插入和更新时的填充逻辑。添加@Component
配置自动填充处理器类被Spring管理。
MyMetaObjectHandler
实现MetaObjectHandler
接口
@Slf4j @Component public class MyMetaObjectHandler implements MetaObjectHandler { @Override public void insertFill(MetaObject metaObject) { log.info("开始插入填充..."); // 起始版本 3.3.3(推荐) this.strictInsertFill(metaObject, "createTime", LocalDateTime.class, LocalDateTime.now()); // Date类型填充 //this.strictInsertFill(metaObject, "createTime", () -> new Date(), Date.class); // 起始版本 3.3.0(推荐使用) //this.strictInsertFill(metaObject, "createTime", LocalDateTime.class, LocalDateTime.now()); // 也可以使用(3.3.0 该方法有bug) //this.fillStrategy(metaObject, "createTime", LocalDateTime.now()); } @Override public void updateFill(MetaObject metaObject) { log.info("开始更新填充..."); // 起始版本 3.3.3(推荐) this.strictUpdateFill(metaObject, "updateTime", LocalDateTime.class, LocalDateTime.now()); // Date类型填充 //this.strictUpdateFill(metaObject, "updateTime", Date::new, Date.class); // 起始版本 3.3.0(推荐) //this.strictUpdateFill(metaObject, "updateTime", LocalDateTime.class, LocalDateTime.now()); // 也可以使用(3.3.0 该方法有bug) //this.fillStrategy(metaObject, "updateTime", LocalDateTime.now()); // 也可以使用(3.3.0 该方法有bug) } }
(2)添加@TableField的fill属性
在实体类中,你需要使用 @TableField
注解来标记哪些字段需要自动填充,并通过fill
属性指定填充的策略。
- User实体类
@Data @TableName(value = "user", autoResultMap = true) public class User { // 省略... /** * 创建时间 */ @TableField(fill = FieldFill.INSERT) private LocalDateTime createTime; /** * 更新时间 */ @TableField(fill = FieldFill.INSERT_UPDATE) private LocalDateTime updateTime; }
- FieldFill枚举类
public enum FieldFill { DEFAULT, // 默认不处理 INSERT, // 插入填充字段 UPDATE, // 更新填充字段 INSERT_UPDATE // 插入和更新填充字段 }
注意事项:
- 自动填充是直接给实体类的属性设置值,如果属性没有值,入库时会是
null
。 MetaObjectHandler
提供的默认方法策略是:如果属性有值则不覆盖,如果填充值为null
则不填充。- 字段必须声明
@TableField
注解,并设置fill
属性来选择填充策略。 - 在
update(T entity, Wrapper<T> updateWrapper)
时,entity
不能为空,否则自动填充失效。 - 在
update(Wrapper<T> updateWrapper)
时不会自动填充,需要手动赋值字段条件。 - 使用
strictInsertFill
或strictUpdateFill
方法可以根据注解FieldFill.xxx
、字段名和字段类型来区分填充逻辑。如果不需区分,可以使用fillStrategy
方法。
<br>
<br>
五、插件功能
MybatisPlus提供了很多的插件功能,进一步拓展其功能。目前已有的插件有:
PaginationInnerInterceptor
:自动分页TenantLineInnerInterceptor
:多租户DynamicTableNameInnerInterceptor
:动态表名OptimisticLockerInnerInterceptor
:乐观锁IllegalSQLInnerInterceptor
:sql 性能规范BlockAttackInnerInterceptor
:防止全表更新与删除
注意:使用多个分页插件的时候需要注意插件定义顺序,建议使用顺序如下:
- 多租户,动态表名
- 分页,乐观锁
- sql 性能规范,防止全表更新与删除
这里我们以最常用的分页插件为例来学习插件的用法。
<br>
1、分页插件
在未引入分页插件的情况下,MybatisPlus
是不支持分页功能的,IService
和BaseMapper
中的分页方法都无法正常起效。 所以,我们必须配置分页插件。
(1)引入依赖
⚠注意,MyBatisPlus于 v3.5.9 起,PaginationInnerInterceptor
已分离出来。如需使用,则需单独引入mybatis-plus-jsqlparser
依赖!
<!-- MP分页插件 jdk 11+ 引入可选模块 --> <dependency> <groupId>com.baomidou</groupId> <artifactId>mybatis-plus-jsqlparser</artifactId> <version>3.5.9</version> </dependency> <!-- MP分页插件 jdk 8+ 引入可选模块 --> <dependency> <groupId>com.baomidou</groupId> <artifactId>mybatis-plus-jsqlparser-4.9</artifactId> </dependency>
(2)配置分页内置拦截器
在项目中新建一个配置类MyBatisConfig
import com.baomidou.mybatisplus.annotation.DbType; import com.baomidou.mybatisplus.extension.plugins.MybatisPlusInterceptor; import com.baomidou.mybatisplus.extension.plugins.inner.PaginationInnerInterceptor; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; /** * MyBatis配置类 */ @Configuration public class MybatisConfig { /** * MP拦截器 */ @Bean public MybatisPlusInterceptor mybatisPlusInterceptor() { // 初始化核心插件 MybatisPlusInterceptor interceptor = new MybatisPlusInterceptor(); // 添加分页插件 PaginationInnerInterceptor paginationInnerInterceptor = new PaginationInnerInterceptor(DbType.MYSQL); paginationInnerInterceptor.setMaxLimit(1000L); // 设置单页分页条数最大限制 interceptor.addInnerInterceptor(paginationInnerInterceptor); return interceptor; } }
(3)分页API
- 测试分页查询
@Test void testPageQuery() { // 准备分页条件 int pageNo = 1, pageSize = 2; // 页码、每页查询条数 // 查询条件:无 // 分页条件 Page<User> page = Page.of(pageNo, pageSize); // 排序条件 //page.addOrder(new OrderItem("id", true)); // MP老版本排序条件写法,true为升序,false为降序 page.addOrder(OrderItem.desc("balance")); // 新版MP直接用OrderItem的静态方法 page.addOrder(OrderItem.asc("id")); // 可以添加多个排序条件 // 分页查询 page = userService.page(page); // 这里返回的page对象其实和上面是同一个地址,只不过是封装好分页查询结果的page对象 // 获取page中的查询结果 long total = page.getTotal(); // 总条数 System.out.println("total = " + total); long pages = page.getPages(); // 总页数 System.out.println("pages = " + pages); List<User> records = page.getRecords(); // 分页数据 records.forEach(System.out::println); }
- 分页查询效果
<br>
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 } ] } |
特殊说明 |
|
这里需要用到4个实体:
- PageDTO:通用分页查询条件的实体,包含分页页码、每页查询条数、排序字段、是否升序
- UserQueryDTO:接收用户查询条件实体,为了实现分页功能去继承PageDTO
- PageResult:分页结果实体,包含总条数、总页数、当前页数据
- UserVO:响应用户页面视图实体,将用户VO集合封装到PageResult中返回
(1)实体类设计
分页条件不仅仅用户分页查询需要,以后其它业务也都有分页查询的需求。因此建议将分页查询条件单独定义为一个PageDTO
实体
- PageDTO 通用分页查询条件的实体
@Data @ApiModel(description = "通用分页查询实体") public class PageDTO { @ApiModelProperty("页码") private Integer pageNo; @ApiModelProperty("每页查询条数") private Integer pageSize; @ApiModelProperty("排序字段") private String sortBy; @ApiModelProperty("是否升序") private Boolean isAsc; }
PageDTO
是接收前端提交的查询参数,一般包含四个属性:
pageNo
:页码pageSize
:每页数据条数sortBy
:排序字段isAsc
:是否升序- UserQueryDTO 接收用户查询条件实体,继承PageDTO
@EqualsAndHashCode(callSuper = true) // 当判断相等时先考虑父类属性再考虑子类属性,就是分页的时候把分页条件作为数据请求的的前提,然后再考虑查到了哪些匹配的数据 @Data @ApiModel(description = "用户查询条件实体") public class UserQueryDTO extends PageDTO { @ApiModelProperty("用户名关键字") private String name; @ApiModelProperty("用户状态:1-正常,2-冻结") private Integer status; @ApiModelProperty("余额最小值") private Integer minBalance; @ApiModelProperty("余额最大值") private Integer maxBalance; }
- PageResult 分页结果实体
@Data @ApiModel(description = "分页结果") public class PageResult<T> { @ApiModelProperty("总条数") private Long total; @ApiModelProperty("总页数") private Long pages; @ApiModelProperty("结果集合") private List<T> list; }
- UserVO 响应用户页面视图实体,将用户VO集合封装到PageResult中返回,之前已经定义过了
@Data @ApiModel(description = "用户VO实体") public class UserVO { @ApiModelProperty("用户id") private Long id; @ApiModelProperty("用户名") private String username; @ApiModelProperty("详细信息") private UserInfo info; @ApiModelProperty("使用状态(1正常 2冻结)") private UserStatus status; @ApiModelProperty("账户余额") private Integer balance; @ApiModelProperty("用户的收获地址") private List<AddressVO> addresses; }
(2)开发接口
- UserController中定义分页条件查询用户的接口
// in UserController @ApiOperation("根据条件分页查询用户接口") @GetMapping("/condition/page") public PageResult<UserVO> queryUserByConditionAndPage(UserQueryDTO queryDTO) { return userService.queryUserByConditionAndPage(queryDTO); }
- IUserService接口和UserServiceImpl实现类
// in IUserService PageResult<UserVO> queryUserByConditionAndPage(UserQueryDTO queryDTO); // in UserServiceImpl @Override public PageResult<UserVO> queryUserByConditionAndPage(UserQueryDTO queryDTO) { // 构建分页条件 Page<User> page = Page.of(queryDTO.getPageNo(), queryDTO.getPageSize()); // 构建排序条件 if (StrUtil.isNotBlank(queryDTO.getSortBy())) { // 如果排序字段不为空 page.addOrder(new OrderItem().setColumn(queryDTO.getSortBy()).setAsc(queryDTO.getIsAsc())); }else { // 如果排序字段为空,默认按照更新时间排序 page.addOrder(new OrderItem().setColumn("update_time").setAsc(false)); } // 分页查询 String name = queryDTO.getName(); Integer status = queryDTO.getStatus(); Page<User> p = lambdaQuery() .like(name != null, User::getUsername, name) .eq(status != null, User::getStatus, status) .page(page); // 封装VO结果 PageResult<UserVO> result = new PageResult<>(); result.setTotal(p.getTotal()); // 总条数 result.setPages(p.getPages()); // 总页数 // 当前页数据 List<User> records = p.getRecords(); // 其实也可以不用判断,因为如果查到的是空集合,转换完还是空集合,不影响最后的结果 if (CollUtil.isEmpty(records)) { result.setList(Collections.emptyList()); return result; } // 将用户集合拷贝为用户VO集合 List<UserVO> userVOList = BeanUtil.copyToList(records, UserVO.class); result.setList(userVOList); return result; }
- 测试根据条件和分页查询用户接口
发送的分页请求
响应的分页数据
(3)改造PageDTO实体
在刚才的代码中,从PageDTO
到MybatisPlus
的Page
之间转换的过程还是比较麻烦的。
对于PageDTO构建为MP的分页对象的部分,我们完全可以在PageDTO
这个实体内部中定义一个转换方法,简化开发。
- PageDTO
import com.baomidou.mybatisplus.core.metadata.OrderItem; import com.baomidou.mybatisplus.extension.plugins.pagination.Page; import io.swagger.annotations.ApiModel; import io.swagger.annotations.ApiModelProperty; import lombok.Data; @Data @ApiModel(description = "通用分页查询实体") public class PageDTO { @ApiModelProperty("页码") private Integer pageNo; @ApiModelProperty("每页查询条数") private Integer pageSize; @ApiModelProperty("排序字段") private String sortBy; @ApiModelProperty("是否升序") private Boolean isAsc; /** * 在PageDTO内部将PageDTO构建为MP的分页对象,并设置排序条件 * @param items 排序条件(可以有一个或多个) * @return MP的分页对象 * @param <T> MP的分页对象的泛型 */ public <T> Page<T> toMpPage(OrderItem... items) { // 构建分页条件 Page<T> page = Page.of(pageNo, pageSize); // 构建排序条件 if (sortBy != null && sortBy.trim().length() > 0) { // 如果排序字段不为空 page.addOrder(new OrderItem().setColumn(sortBy).setAsc(isAsc)); }else if (items != null) { // 如果排序字段为空,且传入的默认OrderItem不为空,按照调用者传入的默认OrderItem排序 page.addOrder(items); } return page; } /** * 将PageDTO构建为MP的分页对象 * 如果调用者不想new OrderItem对象,可以调用该方法,传入默认的排序字段和排序规则即可 * @param defaultSortBy 排序字段 * @param defaultAsc 排序规则,true为asc,false为desc * @return MP的分页对象 * @param <T> MP的分页对象的泛型 */ public <T> Page<T> toMpPage(String defaultSortBy, Boolean defaultAsc) { return toMpPage(new OrderItem().setColumn(defaultSortBy).setAsc(defaultAsc)); } /** * 将PageDTO构建为MP的分页对象,排序条件按update_time更新时间降序 * @return MP的分页对象 * @param <T> MP的分页对象的泛型 */ public <T> Page<T> toMpPageDefaultSortByUpdateTimeDesc() { return toMpPage("update_time", false); } /** * 将PageDTO构建为MP的分页对象,排序条件按create_time创建时间降序 * @return MP的分页对象 * @param <T> MP的分页对象的泛型 */ public <T> Page<T> toMpPageDefaultSortByCreateTimeDesc() { return toMpPage("create_time", false); } }
这样我们在开发也时就可以省去对从PageDTO
到Page
的的转换:
// 构建分页条件对象 Page<User> page = queryDTO.toMpPageDefaultSortByUpdateTimeDesc();
(4)改造PageResult实体
在查询出分页结果后,数据的非空校验,数据的VO转换都是模板代码,编写起来很麻烦。
我们完全可以将PO分页对象转换为VO分页结果对象的逻辑,封装到PageResult的内部方法中,简化整个过程。
- PageResult
import cn.hutool.core.bean.BeanUtil; import cn.hutool.core.collection.CollUtil; import com.baomidou.mybatisplus.extension.plugins.pagination.Page; import io.swagger.annotations.ApiModel; import io.swagger.annotations.ApiModelProperty; import lombok.Data; import java.util.Collections; import java.util.List; import java.util.function.Function; import java.util.stream.Collectors; @Data @ApiModel(description = "分页结果") public class PageResult<T> { @ApiModelProperty("总条数") private Long total; @ApiModelProperty("总页数") private Long pages; @ApiModelProperty("结果集合") private List<T> list; /** * 将PO分页对象转换为VO分页结果对象 * @param page PO分页对象 * @param clazz 目标VO的字节码对象 * @return VO分页结果对象 * @param <PO> PO实体 * @param <VO> VO实体 */ public static <PO, VO> PageResult<VO> of(Page<PO> page, Class<VO> clazz) { // PO分页对象封装为VO结果 PageResult<VO> result = new PageResult<>(); result.setTotal(page.getTotal()); // 总条数 result.setPages(page.getPages()); // 总页数 List<PO> records = page.getRecords(); // 当前页数据 // 其实也可以不用判断,因为如果查到的是空集合,转换完还是空集合,不影响最后的结果 if (CollUtil.isEmpty(records)) { result.setList(Collections.emptyList()); return result; } // 将PO集合拷贝为VO集合 List<VO> userVOList = BeanUtil.copyToList(records, clazz); result.setList(userVOList); return result; } /** * 将PO分页对象转换为VO分页结果对象 * @param page PO分页对象 * @param convertor 自定义规则转换器 * @return VO分页结果对象 * @param <PO> PO实体 * @param <VO> VO实体 */ public static <PO, VO> PageResult<VO> of(Page<PO> page, Function<PO, VO> convertor) { // PO分页对象封装为VO结果 PageResult<VO> result = new PageResult<>(); result.setTotal(page.getTotal()); // 总条数 result.setPages(page.getPages()); // 总页数 List<PO> records = page.getRecords(); // 当前页数据 // 其实也可以不用判断,因为如果查到的是空集合,转换完还是空集合,不影响最后的结果 if (CollUtil.isEmpty(records)) { result.setList(Collections.emptyList()); return result; } // 将PO集合转换为VO集合,转换动作由调用者来传递 List<VO> voList = records.stream().map(convertor).collect(Collectors.toList()); result.setList(voList); return result; } }
这两个改造都相当于定义业务工具类。最终,业务层的代码可以简化为:
@Override public PageResult<UserVO> queryUserByConditionAndPage(UserQueryDTO queryDTO) { // 构建分页条件对象 Page<User> page = queryDTO.toMpPageDefaultSortByUpdateTimeDesc(); // 分页查询 String name = queryDTO.getName(); Integer status = queryDTO.getStatus(); Page<User> p = lambdaQuery() .like(name != null, User::getUsername, name) .eq(status != null, User::getStatus, status) .page(page); // PO分页对象封装为VO结果 return PageResult.of(p, UserVO.class); }
如果是希望自定义PO到VO的转换过程,可以调用重载方法of(Page<PO> page, Function<PO, VO> convertor)
,convertor的转换器逻辑由调用者去编写传递:
@Override public PageResult<UserVO> queryUserByConditionAndPage(UserQueryDTO queryDTO) { // 构建分页条件对象 Page<User> page = queryDTO.toMpPageDefaultSortByUpdateTimeDesc(); // 分页查询 String name = queryDTO.getName(); Integer status = queryDTO.getStatus(); Page<User> p = lambdaQuery() .like(name != null, User::getUsername, name) .eq(status != null, User::getStatus, status) .page(page); // PO分页对象封装为VO结果 return PageResult.of(p, user -> { // PO拷贝基础属性得到VO UserVO userVO = BeanUtil.copyProperties(user, UserVO.class); // 对VO进行处理特殊逻辑 String username = userVO.getUsername(); // 例如用户名脱敏处理 userVO.setUsername(username.length() > 2 ? StrUtil.fillAfter(username.substring(0, 2), '*', username.length()) : username.charAt(0) + "*"); return userVO; }); }
自定义转换规则的场景,例如:
- ① PO字段和VO字段不是包含关系,出现字段不一致。
- ② 对VO中的属性做一些过滤、数据脱敏、加密等操作。
- ③ 将VO中的属性继续设置数据,例如VO中的address属性,可以查询出用户所属的收获地址,设置后一并返回。
- 最终的查询结果如下
<br>
<br>