原创 Shepherd Shepherd进阶笔记 2023-11-17 15:37 发表于浙江
Shepherd进阶笔记
专注分享Java技术进阶知识点,日常工作点点滴滴,滴水穿石
47篇原创内容
公众号
今天接着之前总结的入门教程分析:MyBatis-Plus最详细的入门教程,首先还是同样地需要准备一张表tb_user
:
CREATE TABLE `tb_user` (
`id` bigint(20) NOT NULL AUTO_INCREMENT COMMENT '主键',
`user_no` varchar(255) NOT NULL COMMENT '编号',
`nickname` varchar(255) DEFAULT NULL COMMENT '昵称',
`email` varchar(255) DEFAULT NULL COMMENT '邮箱',
`phone` varchar(255) NOT NULL COMMENT '手机号',
`gender` tinyint(4) NOT NULL DEFAULT '0' COMMENT '性别 0:男生 1:女生',
`birthday` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '出生日期',
`is_delete` tinyint(4) NOT NULL DEFAULT '0' COMMENT '删除标志 0:否 1:是',
`create_time` datetime DEFAULT NULL COMMENT '创建时间',
`update_time` datetime DEFAULT NULL COMMENT '更新时间',
`create_by` bigint(20) DEFAULT NULL COMMENT '创建人',
`update_by` bigint(20) DEFAULT NULL COMMENT '更新人',
`address` varchar(1024) DEFAULT NULL COMMENT '地址',
PRIMARY KEY (`id`) USING BTREE
) ENGINE=InnoDB AUTO_INCREMENT=1 DEFAULT CHARSET=utf8mb4 ROW_FORMAT=DYNAMIC;
在项目服务中对应的实体类User
:
@Data
@Builder
@AllArgsConstructor
@NoArgsConstructor
@TableName(value = "tb_user")
public class User {
@TableId(type = IdType.AUTO)
private Long id;
private String userNo;
private String nickname;
private String email;
private String phone;
private Integer gender;
private Date birthday;
private Integer isDelete;
private Date createTime;
private Date updateTime;
}
下面高级功能的示例都是基于上面的表进行展开的。
1.批量插入
我们在日常开发中知道大批量插入数据可能造成性能瓶颈,所以需要格外关注。在之前的入门教程讲过mp(MyBatis-Plus简称,下文都用简称)
对数据库的CRUD操作提供了service层和mapper层的接口方法
封装,两者的一大区别就是service CRUD接口
提供了批量保存的操作,下面就分别来看看批量保存1000条user数据,然后对执行时间进行统计对比和性能评估,这里我连接的数据库是4核8G10M轻量服务器部署的,不同的数据库服务环境配置,执行效率是不一样的,所以下面的执行时间仅供对比参考。
mapper层的CRUD接口
/**
* mapper层的crud接口方法批量插入
*/
@Test
public void testMapperBatchAdd() {
List<User> users = new ArrayList<>();
for(long i = 1; i <= 1000; i++) {
User user = User.builder()
.id(i)
.userNo("No-" + i)
.nickname("哈哈")
.phone("12345678901")
.email("shepherd_123@qq.com")
.birthday(new Date())
.gender(0)
.isDelete(0)
.build();
users.add(user);
}
long start = System.currentTimeMillis();
users.forEach(user -> {
userDAO.insert(user);
});
long end = System.currentTimeMillis();
System.out.println("执行时长:" + (end-start) + "毫秒");
}
控制台输出如下:
执行时长:42516毫秒
service层CRUD接口
/**
* service层的crud接口方法批量插入
*/
@Test
public void testServiceBatchAdd() {
List<User> users = new ArrayList<>();
for(long i = 1; i <= 1000; i++) {
User user = User.builder()
.id(i)
.userNo("No-" + i)
.nickname("哈哈")
.phone("12345678901")
.email("shepherd_123@qq.com")
.birthday(new Date())
.gender(0)
.isDelete(0)
.build();
users.add(user);
}
long start = System.currentTimeMillis();
userService.saveBatch(users);
long end = System.currentTimeMillis();
System.out.println("执行时长:" + (end-start) + "毫秒");
}
执行结果:
执行时长:19385毫秒
可以看出,使用service层提供的批量保存接口userService.saveBatch(users)
虽然快了很多,却仍需要19s,耗时比起mapper层的一条一条地插入快了23s,但是对于服务接口响应来说还是不可接受的。那为什么批量插入保存还是比较慢呢?
MySQL 的 JDBC 连接的 url 中要加 rewriteBatchedStatements 参数,并保证 5.1.13 以上版本的驱动,才能实现高性能的批量插入。
spring:
datasource:
driver-class-name: com.mysql.cj.jdbc.Driver
username: root
password: root
url: jdbc:mysql://ip:3306/db_test?&serverTimezone=Asia/Shanghai&useUnicode=true&characterEncoding=UTF8&autoReconnect=true&useSSL=false&allowPublicKeyRetrieval=true&rewriteBatchedStatements=true
再次执行上面的service层crud接口批量插入方法,执行结果如下:
执行时长:1364毫秒
可以看到 jdbcurl
添加了 rewriteBatchedStatements=true
参数后,批量操作的执行耗时已经只有 1364 毫秒快的飞起,具体缘由分析请看:https://juejin.cn/post/7295688187752562751
2.逻辑删除
现今互联网系统数据安全越发重要,逻辑删表是指在删除表中数据时,并不是直接将数据从表中删除,而是将数据的状态标记为已删除。这种方式被称为逻辑删除,与之相对的是物理删除。逻辑删除可以保留数据的完整性,同时也方便数据恢复。
添加配置如下:
mybatis-plus:
global-config:
db-config:
logic-delete-field: isDelete # 全局逻辑删除的实体字段名
logic-delete-value: 1 # 逻辑已删除值(默认为 1)
logic-not-delete-value: 0 # 逻辑未删除值(默认为 0)
测试样例:
/**
* 逻辑删除
*/
@Test
public void testLogicDelete() {
userDAO.deleteById(1L);
}
控制台输出如下:
2023-11-16 17:01:47.656 DEBUG 10649 --- [ main] c.s.m.demo.dao.UserDAO.deleteById : ==> Preparing: UPDATE tb_user SET is_delete=1 WHERE id=? AND is_delete=0
2023-11-16 17:01:47.702 DEBUG 10649 --- [ main] c.s.m.demo.dao.UserDAO.deleteById : ==> Parameters: 1(Long)
2023-11-16 17:01:47.744 DEBUG 10649 --- [ main] c.s.m.demo.dao.UserDAO.deleteById : <== Updates: 1
从日志可以看出做了更新,这就是逻辑删除,接下来我们看看查询:
@Test
public void testQuery() {
User user = userDAO.selectById(1L);
}
2023-11-16 17:06:19.802 DEBUG 10687 --- [ main] c.s.m.demo.dao.UserDAO.selectById : ==> Preparing: SELECT id,user_no,nickname,email,phone,gender,birthday,is_delete,create_time,update_time FROM tb_user WHERE id=? AND is_delete=0
2023-11-16 17:06:19.851 DEBUG 10687 --- [ main] c.s.m.demo.dao.UserDAO.selectById : ==> Parameters: 1(Long)
2023-11-16 17:06:19.896 DEBUG 10687 --- [ main] c.s.m.demo.dao.UserDAO.selectById : <== Total: 0
可以知道加上了未删除标志位这个条件is_delete=0
,注意逻辑删除会造成唯一索引冲突
建立唯一索引时,需要额外增加 delete_time
字段,添加到唯一索引字段中,避免唯一索引冲突。例如说,tb_user
使用 user_no
作为唯一索引:
-
未添加前:先逻辑删除了一条
user_no = 001
的记录,然后又插入了一条user_no = 001
的记录时,会报索引冲突的异常。 -
已添加后:先逻辑删除了一条
user_no = 001
的记录并更新delete_time
为当前时间,然后又插入一条user_no = 001
并且delete_time
为 0 的记录,不会导致唯一索引冲突。
3.默认字段填充
按照一般设计规范,数据库表都有这几个字段创建时间(create_time)、更新时间(update_time)、创建人(create_by)、更新人(update_by)
,所以我抽取一个基础的DO类:BaseDO
/**
* 数据库表字段公共属性抽象类
*/
@Data
public class BaseDO implements Serializable {
/**
* 创建时间
*/
@TableField(fill = FieldFill.INSERT)
private Date createTime;
/**
* 最后更新时间
*/
@TableField(fill = FieldFill.INSERT_UPDATE)
private Date updateTime;
/**
* 创建者
*/
@TableField(fill = FieldFill.INSERT)
private Long createBy;
/**
* 更新者
*/
@TableField(fill = FieldFill.INSERT_UPDATE)
private Long updateBy;
/**
* 是否删除
*/
// @TableLogic
// private Integer deleted;
}
定义一个类实现MetaObjectHandler
接口完成自动填充逻辑:DefaultDBFieldHandler
/**
* 公共字段属性值自动填充
*/
public class DefaultDBFieldHandler implements MetaObjectHandler {
@Override
public void insertFill(MetaObject metaObject) {
if (Objects.nonNull(metaObject) && metaObject.getOriginalObject() instanceof BaseDO) {
BaseDO baseDO = (BaseDO) metaObject.getOriginalObject();
Date current = new Date();
// 创建时间为空,则以当前时间为插入时间
if (Objects.isNull(baseDO.getCreateTime())) {
baseDO.setCreateTime(current);
}
// 更新时间为空,则以当前时间为更新时间
if (Objects.isNull(baseDO.getUpdateTime())) {
baseDO.setUpdateTime(current);
}
baseDO.setCreateBy(1001L);
baseDO.setUpdateBy(1002L);
// // 根据登录上下文信息设置创建人和更新人
// LoginUser currentUser = RequestUserHolder.getCurrentUser();
// // 当前登录用户不为空,创建人为空,则当前登录用户为创建人
// if (Objects.nonNull(currentUser) && Objects.isNull(baseDO.getCreator())) {
// baseDO.setCreator(currentUser.getId());
// }
// // 当前登录用户不为空,更新人为空,则当前登录用户为更新人
// if (Objects.nonNull(currentUser) && Objects.isNull(baseDO.getUpdater())) {
// baseDO.setUpdater(currentUser.getId());
// }
}
}
@Override
public void updateFill(MetaObject metaObject) {
// 更新时间为空,则以当前时间为更新时间
Object modifyTime = getFieldValByName("updateTime", metaObject);
if (Objects.isNull(modifyTime)) {
setFieldValByName("updateTime", new Date(), metaObject);
}
Object modifier = getFieldValByName("updateBy", metaObject);
if (Objects.isNull(modifier)) {
setFieldValByName("updateBy", 1002L, metaObject);
}
// LoginUser currentUser = RequestUserHolder.getCurrentUser();
// // 当前登录用户不为空,更新人为空,则当前登录用户为更新人
// if (Objects.nonNull(currentUser) && Objects.isNull(modifier)) {
// setFieldValByName("updater", currentUser.getId(), metaObject);
// }
}
}
注入容器:
@Bean
public MetaObjectHandler defaultMetaObjectHandler(){
return new DefaultDBFieldHandler();
}
测试示例:让上面的User
类继承BaseDO
类
@Test
public void testAutoFieldFill() {
User user = User.builder()
.id(1001L)
.userNo("No-001")
.nickname("哈哈")
.phone("12345678901")
.email("shepherd_123@qq.com")
.birthday(new Date())
.gender(0)
.isDelete(0)
.build();
userDAO.insert(user);
}
上面我们并没有对创建时间,创建人等字段设置值,执行控制台输出如下:
2023-11-16 18:16:41.225 DEBUG 11359 --- [ main] c.s.mybatisplus.demo.dao.UserDAO.insert : ==> Preparing: INSERT INTO tb_user ( id, user_no, nickname, email, phone, gender, birthday, is_delete, create_time, update_time, create_by, update_by ) VALUES ( ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ? )
2023-11-16 18:16:41.267 DEBUG 11359 --- [ main] c.s.mybatisplus.demo.dao.UserDAO.insert : ==> Parameters: 1001(Long), No-001(String), 哈哈(String), shepherd_123@qq.com(String), 12345678901(String), 0(Integer), 2023-11-16 18:16:40.968(Timestamp), 0(Integer), 2023-11-16 18:16:41.018(Timestamp), 2023-11-16 18:16:41.018(Timestamp), 1001(Long), 1002(Long)
2023-11-16 18:16:41.272 DEBUG 11359 --- [ main] c.s.mybatisplus.demo.dao.UserDAO.insert : <== Updates: 1
可以看到四个基础字段都进行了值填充。
项目推荐:基于SpringBoot2.x、SpringCloud和SpringCloudAlibaba企业级系统架构底层框架封装,解决业务开发时常见的非功能性需求,防止重复造轮子,方便业务快速开发和企业技术栈框架统一管理。引入组件化的思想实现高内聚低耦合并且高度可配置化,做到可插拔。严格控制包依赖和统一版本管理,做到最少化依赖。注重代码规范和注释,非常适合个人学习和企业使用
Github地址:https://github.com/plasticene/plasticene-boot-starter-parent
Gitee地址:https://gitee.com/plasticene3/plasticene-boot-starter-parent
微信公众号:Shepherd进阶笔记
交流探讨qun:Shepherd_126
4.字段类型处理
我们平时会碰到有些表字段是一个"复杂"字段类型,比如说是个JSON或者数组,这时候需要手动处理之后保存,查询之后也需要自己处理转换,虽然不难但是繁杂,所以mp针对这一情况也做了对应功能,比如每个人都包含一个户籍地址,如果地址对象类是Address
,那个user就包含一个address属性:
首先数据库tb_user
表添加添加一个地址字段address
,存的是JSON字符串,所以长度大一些varchar(1024)
,接着定义地址类Address
@Data
public class Address {
private Long id;
private String province;
private String city;
private String region;
private String address;
}
实体类中添加地址address
属性:
@Data
@Builder
@AllArgsConstructor
@NoArgsConstructor
@TableName(value = "tb_user", autoResultMap = true)
public class User extends BaseDO {
@TableId(type = IdType.AUTO)
private Long id;
private String userNo;
private String nickname;
private String email;
private String phone;
private Integer gender;
@DateTimeFormat(pattern = "yyyy-MM-dd HH:mm:ss")
private Date birthday;
private Integer isDelete;
@TableField(typeHandler = JacksonTypeHandler.class)
private Address address;
}
测试示例:
@Test
public void testTypeHandler() {
Address address = new Address();
address.setProvince("浙江省");
address.setCity("杭州市");
address.setRegion("余杭区");
address.setAddress("城北万象城");
address.setId(1L);
User user = User.builder()
.id(100L)
.userNo("No-001")
.nickname("哈哈")
.phone("12345678901")
.email("shepherd_123@qq.com")
.birthday(new Date())
.gender(0)
.isDelete(0)
.address(address)
.build();
userDAO.insert(user);
}
2023-11-16 18:37:58.251 DEBUG 11546 --- [ main] c.s.mybatisplus.demo.dao.UserDAO.insert : ==> Preparing: INSERT INTO tb_user ( id, user_no, nickname, email, phone, gender, birthday, is_delete, address, create_time, update_time, create_by, update_by ) VALUES ( ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ? )
2023-11-16 18:37:58.333 DEBUG 11546 --- [ main] c.s.mybatisplus.demo.dao.UserDAO.insert : ==> Parameters: 100(Long), No-001(String), 哈哈(String), shepherd_123@qq.com(String), 12345678901(String), 0(Integer), 2023-11-16 18:37:57.997(Timestamp), 0(Integer), {"id":1,"province":"浙江省","city":"杭州市","region":"余杭区","address":"城北万象城"}(String), 2023-11-16 18:37:58.039(Timestamp), 2023-11-16 18:37:58.039(Timestamp), 1001(Long), 1002(Long)
2023-11-16 18:37:58.336 DEBUG 11546 --- [ main] c.s.mybatisplus.demo.dao.UserDAO.insert : <== Updates: 1
查询示例:
@Test
public void testQuery() {
User user = userDAO.selectById(100L);
System.out.println(user);
}
2023-11-16 18:42:47.023 DEBUG 11564 --- [ main] c.s.m.demo.dao.UserDAO.selectById : ==> Preparing: SELECT id,user_no,nickname,email,phone,gender,birthday,is_delete,address,create_time,update_time,create_by,update_by FROM tb_user WHERE id=?
2023-11-16 18:42:47.051 DEBUG 11564 --- [ main] c.s.m.demo.dao.UserDAO.selectById : ==> Parameters: 100(Long)
2023-11-16 18:42:47.164 DEBUG 11564 --- [ main] c.s.m.demo.dao.UserDAO.selectById : <== Total: 1
User(id=100, userNo=No-001, nickname=哈哈, email=shepherd_123@qq.com, phone=12345678901, gender=0, birthday=Thu Nov 16 18:37:58 CST 2023, isDelete=0, address=Address(id=1, province=浙江省, city=杭州市, region=余杭区, address=城北万象城))
可以看出复杂类型对象字段都自动转换了,注意在实体类上一定要指定@TableName(autoResultMap = true)
之前我们分析过的字段加密存储,也是通过类型转换器实现,详见:Spring Boot如何优雅实现数据加密存储
5.动态表名
业务系统随着使用时间的推移数据量越来越大,所以我们会对数据量大的表进行归档处理,比如说通过日期进行了水平分表,需要通过日期参数,动态的查询数据,Mybatis-plus可以通过此插件解析替换设定表名为处理器的返回表名
插件配置
@Bean
public MybatisPlusInterceptor paginationInterceptor()
{
MybatisPlusInterceptor interceptor = new MybatisPlusInterceptor();
//动态表名
interceptor.addInnerInterceptor(dynamicTableNameInnerInterceptor());
}
@Bean
public DynamicTableNameInnerInterceptor dynamicTableNameInnerInterceptor()
{
DynamicTableNameInnerInterceptor dynamicTableNameInnerInterceptor =new DynamicTableNameInnerInterceptor();
//
TableNameHandler tableNameHandler=new MyTableNameHandler();
dynamicTableNameInnerInterceptor.setTableNameHandler(tableNameHandler);
return dynamicTableNameInnerInterceptor;
}
动态表名规则处理类
public class MyTableNameHandler implements TableNameHandler
{
private Logger logger = LoggerFactory.getLogger(MyTableNameHandler.class);
@Override
public String dynamicTableName(String sql, String tableName)
{
logger.info("dynamicTableName sql:{},tableName:{}",sql,tableName);
//如果参数为空,则以不要动态动态查询表名
Map<String, Object> paramMap = RequestDataHelper.getRequestData();
if(paramMap==null || paramMap.isEmpty())
{
logger.info("dynamicTableName paramMap is null");
return tableName;
}
// 获取参数方法
paramMap.forEach((k, v) -> logger.info(k + "----" + v));
int random = (int) paramMap.get("tableNo");
String tableNo = "_1";
if (random % 2 == 1)
{
tableNo = "_2";
}
String queryTableName=tableName + tableNo;
logger.info("---------> queryTableName:{}",queryTableName);
return queryTableName;
}
}
参数上下文传递
public class RequestDataHelper
{
/**
* 请求参数存取
*/
private static final ThreadLocal<Map<String, Object>> REQUEST_DATA = new ThreadLocal<>();
/**
* 设置请求参数
*
* @param requestData 请求参数 MAP 对象
*/
public static void setRequestData(Map<String, Object> requestData) {
REQUEST_DATA.set(requestData);
}
/**
* 获取请求参数
*
* @return 请求参数 MAP 对象
*/
public static Map<String, Object> getRequestData()
{
return REQUEST_DATA.get();
}
}
6.多租户插件
关于多租户的使用场景和功能实现,插件使用,之前我们已经总结分析过了,传送门:浅析SaaS多租户系统数据隔离实现方案
7.总结
以上全部就是mp提供的一些高级扩展功能,在日常开发中比较实用,都有相应的场景去使用,提高代码高效性,防止重复编码。