MyBatis-Plus 学习笔记

特别鸣谢:黑马程序员 — 虎哥《SpringCloud微服务开发与实战》课程

一、基本使用

① 引入依赖
<dependency>
    <groupId>com.baomidou</groupId>
    <artifactId>mybatis-plus-boot-starter</artifactId>
    <version>3.5.3.1</version>
</dependency>
② 继承 BaseMapper<T>
package com.itheima.mp.mapper;

import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.itheima.mp.domain.po.User;

public interface UserMapper extends BaseMapper<User> {

}

MyBatisPlus实现原理:通过扫描实体类,基于反射机制获取实体类信息作为数据库表信息。

当实体类与数据库表满足以下约定时,实体类和数据库可正确映射,无需额外配置,使用MyBatisPlus一定要设置主键。

(1)类名驼峰转下划线作为表名;

(2)名为id的字段作为主键;

(3)变量名驼峰转下划线作为表中的字段名。

若不满足以上约定,则需要使用以下注解:

注解名用途
@TableName指定表名
@TableId指定表中的主键字段信息
@TableField指定表中的普通字段信息

其中@TableId(value="数据库中的主键名", type= IdType.*),IdType枚举类:

枚举字段含义
IdType.AUTO数据库自增长
IdType.INPUT通过set方法自行输入(自己指定ID)
IdType.ASSIGN_ID分配的ID·接口IdentifierGenerator的方法nextId()来生成id,默认实现为DefaultIdentifierGenerator雪花算法

如果需要ID自增长,需要显式设置@TableId(type= IdType.AUTO),否则默认使用雪花算法(生成一个Long类型的长ID)

使用@TableField的常见场景:

  • 成员变量名数据库字段名不一致
  • 成员变量名is开头,且是boolean类型
  • 成员变量名数据库关键字冲突
  • 成员变量不是数据库字段 --> 加上@TableField(exist = false)

例如:① Boolean isMarried 由于是布尔类型,会默认映射到数据库的married字 ② Integer order 与数据库的order排序关键字冲突

注解使用范例

注解的作用:告诉MybatisPlus,数据库中的字段是怎样定义的。

更多注解信息,请参考官方文档:注解配置 | MyBatis-Plus

③ 常见配置

mybatis-plus的配置项继承了mybatis原生配置,和自己一些特有的配置

其中 global-config 是 mybatis-plus 特有的配置。其中:

  • id-type 默认值为 ASSIGN_ID ,当和注解配置@TableId同时存在时,注解的优先级更高。如果没有注解,默认使用这里的 id-type 配置。
  • update-strategy 默认值就是 not_null,在更新数据时,只更新非 null 的字段。

更多配置信息,请参考官方文档:使用配置 | MyBatis-Plus

二、核心功能

2.1 条件构造器(Wrapper)

注意到 BaseMapper 中有一部分方法的传参为 Wrapper 类型:

继承体系:

QueryWrapper 和 LambdaQueryWrapper 通常用来构建 select、delete、update 和 where 条件
UpdateWrapper 和 LambdaUpdateWrapper 通常只有在 set 语句比较特殊才会使用

尽量使用 LambdaQueryWrapper 和 LambdaUpdateWrapper 来避免硬编码

2.1.1 QueryWrapper 范例

使用范例1:

/**
 * 查询出名字中带 o 的,存款大于等于 1000 的人的id、username、
 * info 和 balance
 */
@Test
void testQueryWrapper() {
    // 1、构建查询条件
    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);
}
// 对应的 SQL
SELECT id, username, info, balance FROM user
WHERE username LIKE "%o%" AND balance >= 1000

运行结果:

Preparing: SELECT id,username,info,balance FROM user WHERE (username LIKE ? AND balance >= ?)
Parameters: %o%(String), 1000(Integer)
<==      Total: 2
User(id=3, username=Hope, password=null, phone=null, info={"age": 25, "intro": "上进青年", "gender": "male"}, status=null, balance=100000, createTime=null, updateTime=null)
User(id=4, username=Thomas, password=null, phone=null, info={"age": 29, "intro": "伏地魔", "gender": "male"}, status=null, balance=1200, createTime=null, updateTime=null)

使用范例2:

/**
 * 使用 QueryWrapper 更新 jack 的余额为 2000
 */
@Test
void testUpdateByQueryWrapper() {
    // 1、要更新的数据
    User user = new User();
    user.setBalance(2000);
    // 2、更新的条件
    QueryWrapper<User> wrapper = new QueryWrapper<User>()
            .eq("username", "jack");
    // 3、执行更新
    userMapper.update(user, wrapper);
}
// 对应SQL
UPDATE user SET balance=2000 WHERE username = "Jack"
2.1.2 UpdateWrapper 范例
/**
 * 使用 UpdateWrapper 更新 id 在 1,2,4 中的用户的余额减 200
 */
@Test
void testUpdateWrapper() {
    List<Long> ids = List.of(1L, 2L, 4L);
    UpdateWrapper<User> wrapper = new UpdateWrapper<User>()
            .setSql("balance = balance - 200")
            .in("id", ids);
    userMapper.update(null, wrapper);
}
// 对应SQL
UPDATE user SET balance = balance - 200 WHERE id IN (1, 2, 4)

运行结果:

Preparing: UPDATE user SET balance = balance - 200 WHERE (id IN (?,?,?))
Parameters: 1(Long), 2(Long), 4(Long)
<==    Updates: 3
2.1.3 LambdaQueryWrapper
/**
 * 使用 LambdaQueryWrapper 查询出名字中带 o 的,存款大于等于 1000 的人的 username、
 * info 和 balance
 */
@Test
void testLambdaQueryWrapper() {
    // 以下两种创建 LambdaQueryWrapper 的方式均可
    // LambdaQueryWrapper<User> wrapper = new QueryWrapper<User>().lambda()
    LambdaQueryWrapper<User> wrapper = new LambdaQueryWrapper<User>()
            .select(User::getUsername, User::getInfo, User::getBalance)
            .like(User::getUsername, "o")
            .ge(User::getBalance, 1000);
    List<User> users = userMapper.selectList(wrapper);
    users.forEach(System.out::println);
}

运行结果:

Preparing: SELECT username,info,balance FROM user WHERE (username LIKE ? AND balance >= ?)
Parameters: %o%(String), 1000(Integer)
<==      Total: 2
User(id=null, username=Hope, password=null, phone=null, info={"age": 25, "intro": "上进青年", "gender": "male"}, status=null, balance=100000, createTime=null, updateTime=null)
User(id=null, username=Thomas, password=null, phone=null, info={"age": 29, "intro": "伏地魔", "gender": "male"}, status=null, balance=1200, createTime=null, updateTime=null)

2.2 自定义SQL

to do

三、使用IService接口

使用mybatis-plus,不仅基本的增删改查mapper不用写了,就连简单的service也不用自己写了,以下是常见IService接口提供的增删改查方法:

3.1 基本使用

但是接口定义的方法都需要实现,自定义的Service接口,如果继承了IService接口,那么这些方法都需要实现。为此,mybatis-plus也提供了ServiceImpl实现类,实现了IService中规定的方法。使用步骤如下:
① 自定义接口继承IService接口:
package com.itheima.mp.service;

import com.baomidou.mybatisplus.extension.service.IService;
import com.itheima.mp.domain.po.User;

public interface IUserService extends IService<User> {
}
② 自定义实现类,继承ServiceImpl,并实现自定义的接口

ServiceImpl<对应的Mapper, 对应的实体类>

package com.itheima.mp.service.impl;

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  {
}

创建单元测试:

一定要加上 @SpringBootTest 注解!

package com.itheima.mp.service;
import com.itheima.mp.domain.po.User;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;

import java.time.LocalDateTime;

@SpringBootTest
class IUserServiceTest {

    @Autowired
    private IUserService userService;

    @Test
    public void testSave() {
        User user = new User();
        user.setUsername("LiLei");
        user.setPassword("123");
        user.setPhone("18688990011");
        user.setBalance(200);
        user.setInfo("{\"age\": 24, \"intro\": \"英文老师\", \"gender\": \"female\"}");
        user.setCreateTime(LocalDateTime.now());
        user.setUpdateTime(LocalDateTime.now());
        
        userService.save(user);
    }

    @Test
    void testQuery() {
        List<User> users = userService.listByIds(List.of(1L, 2L, 4L));
        users.forEach(System.out::println);
    }
}

3.2 Mybatis 实现 Restful CRUD 案例

基于Restful风格实现下面的接口:

3.2.1 引入相关依赖
<!--swagger 方便测试 api-->
<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>
knife4j:
  enable: true
  openapi:
    title: 用户管理接口文档
    description: "用户管理接口文档"
    email: zhanghuyi@itcast.cn
    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
3.2.2 编写 UserController
package com.itheima.mp.domain.controller;

import cn.hutool.core.bean.BeanUtil;
import com.itheima.mp.domain.dto.UserFormDTO;
import com.itheima.mp.domain.po.User;
import com.itheima.mp.domain.vo.UserVO;
import com.itheima.mp.service.IUserService;
import io.swagger.annotations.Api;
import io.swagger.annotations.ApiOperation;
import io.swagger.annotations.ApiParam;
import lombok.RequiredArgsConstructor;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.web.bind.annotation.*;

import java.util.List;

@Api(tags = "用户管理接口")
@RestController
@RequestMapping("/users")
// Generates a constructor with required arguments.
// Required arguments are final fields and fields with constraints such as @NonNull
@RequiredArgsConstructor
public class UserController {
    // 配合 @RequiredArgsConstructor 生成构造函数注入
    private final IUserService userService;

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

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

    @ApiOperation("根据id查询用户")
    @GetMapping("/{id}")
    public UserVO getUserById(@ApiParam("用户id") @PathVariable("id") Long id) {
        User user = userService.getById(id);
        return BeanUtil.copyProperties(user, UserVO.class);
    }

    @ApiOperation("根据id批量查询用户")
    @GetMapping
    public List<UserVO> listUsersByIds(
            @ApiParam("用户id列表") @RequestParam("ids") List<Long> ids) {
        List<User> users = userService.listByIds(ids);
        return BeanUtil.copyToList(users, UserVO.class);
    }

    @ApiOperation("扣减用余额")
    @PutMapping("/{id}/deduction/{money}")
    public void deleteUser(
            @ApiParam("用户id") @PathVariable("id") Long id,
            @ApiParam("扣减的金额") @PathVariable("money") Integer money) {
        // Alt + Enter 换出并在IUserService中创建方法
        userService.deductBalance(id, money); 
    }
}

注意:“根据id批量查询用户”接口,接收GET请求,请求参数为 List<Long> ids 类型,请求方式有以下几种:

方式一:
GET http://localhost:8080/users?ids=1&ids=2&ids=3 HTTP/1.1

方式二:
GET http://localhost:8080/users?ids=1,2,3 HTTP/1.1
axios.get('/users/listUsersByIds', {
  params: {
    ids: [1, 2, 3] // Axios 会自动转换为 ?ids=1&ids=2&ids=3
  }
});
3.2.3 编写 UserService
package com.itheima.mp.service;

import com.baomidou.mybatisplus.extension.service.IService;
import com.itheima.mp.domain.po.User;

public interface IUserService extends IService<User> {
    void deductBalance(Long id, Integer money);
}
package com.itheima.mp.service.impl;

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  {
    @Override
    public void deductBalance(Long id, Integer money) {
        // 1、查询用户
        User user = getById(id);
        // 或者使用 baseMapper,它实际上就是UserMapper
        // User user = baseMapper.selectById(id);
        // 2、校验用户状态
        if (user == null || user.getStatus() == 2) {
            throw new RuntimeException("用户状态异常!");
        }
        // 3、校验余额是否充足
        if (user.getBalance() < money) {
            throw new RuntimeException("用户余额不足!");
        }
        // 4、扣减余额 update user set balance = balance - ? where id = ?
        baseMapper.deductBalance(id, money);
    }
}
3.2.4 编写 UserMapper
package com.itheima.mp.mapper;

import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.itheima.mp.domain.po.User;
import org.apache.ibatis.annotations.Param;
import org.apache.ibatis.annotations.Update;

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);
}
3.2.5 测试接口

http://localhost:8080/doc.html   接口 -> 调试

3.3 IService 的 Lambda 查询方式

3.3.1 案例一:使用 Lambda 实现复杂查询
需求:实现一个根据复杂条件查询用户的接口,查询条件如下:
① name:用户名,可以为空
② status:账户状态,可以为空
③ minBalance:最小余额,可以为空
④ maxBalance:最大余额,可以为空

可以理解成一个用户的后台管理界面,管理员可以自己选择条件来筛选用户,因此上述条件不一定存在,需要做判断。如果是xml文件,应该长这样:

① 用于接收查询参数的Entity
package com.itheima.mp.domain.query;

import io.swagger.annotations.ApiModel;
import io.swagger.annotations.ApiModelProperty;
import lombok.Data;

@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
@ApiOperation("根据复杂条件批量查询用户")
@GetMapping("/list")
public List<UserVO> queryUsers(UserQuery query) {
    List<User> users = userService.queryUsers(
            query.getName(), query.getStatus(),
            query.getMinBalance(), query.getMaxBalance());
    return BeanUtil.copyToList(users, UserVO.class);
}
③ 编写 UserService
public interface IUserService extends IService<User> {
    ...
    List<User> queryUsers(String name, Integer status, Integer minBalance, Integer maxBalance);
}
@Service
public class UserServiceImpl extends ServiceImpl<UserMapper, User> implements IUserService  {
    ...
    @Override
    public List<User> queryUsers(String name, Integer status, Integer minBalance, Integer maxBalance) {
        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();
    }
}

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

函数名描述
.one()最多1个结果
.list()返回集合结果
.count()返回计数结果
.page()用来做分页
3.3.2 案例二:使用 Lambda Update 修改 3.2.3 扣减余额逻辑
需求:实现一个根据复杂条件查询用户的接口,查询条件如下:
① 对用户状态校验
② 对用户余额校验
③ 如果扣减后余额为0,则修改用户status为冻结状态(2)
@Override
@Transactional
public void deductBalance(Long id, Integer money) {
    // 1、查询用户
    User user = getById(id);
    // 或者使用 baseMapper,它实际上就是UserMapper
    // User user = baseMapper.selectById(id);
    // 2、校验用户状态
    if (user == null || user.getStatus() == 2) {
        throw new RuntimeException("用户状态异常!");
    }
    // 3、校验余额是否充足
    if (user.getBalance() < money) {
        throw new RuntimeException("用户余额不足!");
    }
    // 4、扣减余额 update user set balance = balance - ? where id = ?
    // baseMapper.deductBalance(id, money);
    int remainBalance = user.getBalance() - money;
    // 这里的操作是有并发安全问题的,比如多个线程同时通过了前两个校验,
    // 要执行接下来的余额扣减。所以需要加锁(悲观锁,或乐观锁)
    lambdaUpdate()
            .set(User::getBalance, remainBalance)
            .set(remainBalance == 0, User::getStatus, 2)
            .eq(User::getId, id)
            // 乐观锁
            .eq(User::getBalance, user.getBalance())
            // 这里必须加 update 才会执行
            .update();
}

① lambdaQuery() 主要是用来构建复杂查询的,简单查询推荐使用 IService 中以及定义好的方法;
② lambdaUpdate() 用来构建复杂更新,这两个 lambda 都可以用第一个 condition 参数,来决定对应的参数是否参与SQL拼接;
③ lambdaUpdate() 最后必须加 update() 才会执行。

四、批处理&性能测试

4.1 单条循环插入

@SpringBootTest
class IUserServiceTest {

    @Autowired
    private IUserService userService;

    @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;
    }
}

10万条数据插入速度超级慢(没耐心等了直接终止了),黑马虎哥测试是20万ms

-- 删除刚刚插入的大量数据
DELETE FROM user WHERE id > 5 

4.2 批处理方式

批处理需要先new出来,放到内存里,占用内存太多,所以需要分批次,每批次1000条数据。而且向MySQL发送网络请求的数据包,大小是有限制的。

@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));
}
-- 查询插入是否成功
SELECT count(*) FROM user WHERE id > 5 

耗时:6136ms(mybatis的foreach拼接成一条SQL是最快的)

其实MybatisPlus的批处理是基于PrepareStatement的预编译模式,然后批量提交,最终在数据库执行时还是会有多条insert语句,逐条插入数据。SQL类似这样:

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合并为一条,像这样:
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

MySQL的客户端连接参数中有这样的一个参数:rewriteBatchedStatements。顾名思义,就是重写批处理的statement语句。参考文档:MySQL :: Connectors and APIs Manual :: 3.5.3.13 Performance Extensions 的 rewriteBatchedStatements

这个参数的默认值是false,我们需要修改连接参数,将其配置为true

spring:
  datasource:
    url: jdbc:mysql://127.0.0.1:3306/mp?...&rewriteBatchedStatements=true

耗时:4117ms

五、扩展功能

5.1 代码生成

新款 IDEA 2024 使用方法:

① 工具 --> Config Database

如果测试链接还是连不上,需要加&useSSL=false

② 工具 --> Code Generator

直接点 Code Generate 没反应的,应该先点 check field 然后点 OK,然后再生成

5.2 静态工具

有的时候Service之间也会相互调用,如果使用@Autowired相互注入,就会产生循环依赖问题。为了避免这种情况,MybatisPlus提供一个静态工具类:Db,其中的一些静态方法与IService中方法签名基本一致,也可以帮助我们实现CRUD功能。使用的时候有些方法需要提供字节码类(SomeClassName.class)。

当然也可以直接去使用mapper,能不用Service就不用。

—— 摘自黑马 day01-MybatisPlus - 飞书云文档

5.2.1 案例——静态工具查询
需求介绍:
① 改造根据id查询用户的接口,查询用户的同时,给出用户对应的所有地址;
② 改造根据id批量查询用户的接口,查询用户的同时,查询出用户对应的所有地址;
③ 实现根据用户id查询收货地址功能,需要验证用户状态,冻结用户抛出异常。

① 改造根据id查询用户的接口,查询用户的同时,给出用户对应的所有地址。

AddressVO 实体类:

package com.itheima.mp.domain.vo;

import io.swagger.annotations.ApiModel;
import io.swagger.annotations.ApiModelProperty;
import lombok.Data;

/**
 * @author 虎哥
 * @since 2023-07-01
 */
@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 加字段(无需加到User实体类,也就是说User类中不含address):

@ApiModelProperty("用户收货地址列表")
private List<AddressVO> addresses;

UserController 直接调 UserService

@ApiOperation("根据id查询用户(含地址)")
@GetMapping("/{id}")
public UserVO getUserById(@ApiParam("用户id") @PathVariable("id") Long id) {
    return userService.queryUserAndAddressById(id);
}

IUserService 添加对应的方法声明:

UserVO queryUserAndAddressById(Long id);

UserServiceImpl 实现对应方法:

@Override
public UserVO queryUserAndAddressById(Long id) {
    // 1、查询用户
    User user = getById(id);
    if (user == null || user.getStatus() == 2) {
        throw new RuntimeException("用户状态异常!");
    }
    // 2、查询地址
    List<Address> addresses = Db.lambdaQuery(Address.class)
            .eq(Address::getUserId, id).list();
    // 3、封装 VO
    // 3.1 转 User 的 PO 为 VO
    UserVO userVO = BeanUtil.copyProperties(user, UserVO.class);
    // 3.2 转 Address 的 PO 为 VO
    if (!CollUtil.isEmpty(addresses)) {
        userVO.setAddresses(BeanUtil.copyToList(addresses, AddressVO.class));
    }
    return userVO;
}

② 改造根据id批量查询用户的接口,查询用户的同时,查询出用户对应的所有地址;

UserController:

@ApiOperation("用id批量查用户(含地址)")
@GetMapping
public List<UserVO> listUsersByIds(
        @ApiParam("用户id列表") @RequestParam("ids") List<Long> ids) {
    return userService.queryUserAndAddressByIds(ids);
}

 IUserService 添加对应的方法声明:

List<UserVO> queryUserAndAddressByIds(List<Long> ids);

 UserServiceImpl 实现对应方法:

场景:要查多个User,每个User又对应多个地址

相当好的案例,之前不知道如何优雅地处理这种情况,建议熟读并背诵 🤣

@Override
public List<UserVO> queryUserAndAddressByIds(List<Long> ids) {
    // 1、查询用户
    List<User> users = listByIds(ids);
    if (CollUtil.isEmpty(users)) {
        return Collections.emptyList();
    }
    // 2、查询地址
    // 2.1 获取用户id集合
    List<Long> userIds = users.stream().map(User::getId).collect(Collectors.toList());
    // 2.2 根据用户id查询地址
    List<Address> addresses = Db.lambdaQuery(Address.class)
            .in(Address::getUserId, userIds).list();
    // 2.3 转换地址 VO
    List<AddressVO> addressVOList = BeanUtil.copyToList(addresses, AddressVO.class);
    // 2.4 对用户地址集合分组,相同用户的放入一个集合中
    Map<Long, List<AddressVO>> addressMap = new HashMap<>(0);
    if (CollUtil.isNotEmpty(addressVOList)) {
        addressMap = addressVOList
                .stream().collect(Collectors.groupingBy(AddressVO::getUserId));
    }
    // 3、转 VO 返回
    List<UserVO> list = new ArrayList<>(users.size());
    for (User user : users) {
        // 3.1 转 User 的 PO 为 VO
        UserVO userVO = BeanUtil.copyProperties(user, UserVO.class);
        // 3.2 转换 Address 的 PO 为 VO
        userVO.setAddresses(addressMap.get(user.getId()));
        // 3.3 添加到集合中
        list.add(userVO);
    }
    return list;
}

5.3 逻辑删除

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

MybatisPlus提供了对逻辑删除的支持,无需改变代码中调用CRUD的方式,只需要修改配置,即可为所有的操作自动拼接

-- 用 deleted = 0 表示未删除
WHERE deleted = 0
5.3.1 配置步骤
mybatis-plus:
  global-config:
    db-config:
      logic-delete-field: deleted # 全局逻辑删除的字段名(类型为boolean、integer)
      logic-delete-value: 1 # 逻辑已删除值(默认为 1)
      logic-not-delete-value: 0 # 逻辑未删除值(默认为 0)
-- 添加逻辑删除字段的SQL
alter table address add deleted bit default b'0' null comment '逻辑删除';
5.3.2 编辑测试类
package com.itheima.mp.service;

import com.itheima.mp.domain.po.Address;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;

@SpringBootTest
class IAddressServiceTest {

    @Autowired
    private IAddressService addressService;

    @Test
    void testLogicDelete() {
        // 1、删除
        addressService.removeById(59L);
        // 2、查询
        Address address = addressService.getById(59L);
        System.out.println("address = " + address);
    }
}

运行结果:

==>  Preparing: UPDATE address SET deleted=1 WHERE id=? AND deleted=0
==> Parameters: 59(Long)
<==    Updates: 1
==>  Preparing: SELECT id,user_id,province,city,town,mobile,street,contact,is_default,notes,deleted FROM address WHERE id=? AND deleted=0
==> Parameters: 59(Long)
<==      Total: 0
address = null

逻辑删除本身也有自己的问题,比如:
① 会导致数据库表垃圾数据越来越多,从而影响查询效率
② SQL中全都需要对逻辑删除字段做判断,影响查询效率
因此,虎哥不太推荐采用逻辑删除功能,如果数据不能删除,可以采用把数据迁移到其它表的办法。

5.4 枚举处理器

掌握Java中的枚举类型,与数据库字段之间的相互转换

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

/**
 * 使用状态(1正常 2冻结)
 */
private Integer status;
5.4.1 创建枚举类
package com.itheima.mp.enums;

import com.baomidou.mybatisplus.annotation.EnumValue;
import lombok.Getter;

@Getter
public enum UserStatus {
    NORMAL(1, "正常"),
    FROZEN(2, "冻结");

    @EnumValue  // 告诉 mybatis-plus 这个字段是枚举值
    private final int value;
    private final String desc;

    UserStatus(int value, String desc) {
        this.value = value;
        this.desc = desc;
    }
}
5.4.2 配置枚举处理器

配置枚举处理器类型为:MybatisEnumTypeHandler

mybatis-plus:
  configuration:
    default-enum-type-handler: com.baomidou.mybatisplus.core.handlers.MybatisEnumTypeHandler
5.4.3 修改User和UserVO
/**
 * 使用状态(1正常 2冻结)
 */
private Integer status;  -->  private UserStatus status;
public class UserServiceImpl extends ServiceImpl<UserMapper, User> implements IUserService  {
    @Override
    @Transactional
    public void deductBalance(Long id, Integer money) {
        ...
        [-] if (user == null || user.getStatus() == 2) {
        [+] if (user == null || user.getStatus() == UserStatus.FROZEN) {
        ...
    }
}

其余的魔法值也都改成 UserStatus.NORMAL 或者 UserStatus.FROZEN

前端查询到的结果:

{
  "id": 1,
  "username": "Jack",
  "info": "{\"age\": 20, \"intro\": \"佛系青年\", \"gender\": \"male\"}",
  "status": "NORMAL",
  "balance": 2400,
  "addresses": [ ]
}

可以看到默认返回的是枚举变量名,而非枚举值!

SpringMVC 处理 JSON 默认使用的是 Jackson 包,配置方式如下:

@Getter
public enum UserStatus {
    NORMAL(1, "正常"),
    FROZEN(2, "冻结");

    @EnumValue  // 告诉 mybatis-plus 这个字段是枚举值
[+] @JsonValue 
    private final int value;
    private final String desc;
    ...
}
@JsonValue 加在 value 上,返回的就是value值(1或2);加在 desc 上,返回的就是"正常"或"冻结"

5.5 JSON处理器

用于处理对象嵌套对象查询的自动映射,例如User类有个UserInfo的属性🤠

数据库的user表中有一个info字段,是JSON类型,而User实体类中是String类型,这样一来,我们要读取info中的属性时就非常不方便。这时就可以使用JSON处理器

 5.5.1 定义UserInfo实体类
package com.itheima.mp.domain.po;

import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;

@Data
@NoArgsConstructor
// 提供了静态方法,用于创建对象
@AllArgsConstructor(staticName = "of")
public class UserInfo {
    private Integer age;
    private String intro;
    private String gender;
}

这里的 "of" 注解可以实现:UserInfo.of(24, "英文老师", "female") 直接创建对象

5.5.2 修改User和UserVO实体类
@Data
[+] @TableName(value = "user", autoResultMap = true)
public class User {
    ...
    [+] @TableField(typeHandler = JacksonTypeHandler.class)
    private UserInfo info;
    ...
}

六、插件功能

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

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

6.1 分页插件

分页功能是业务开发中最常见的功能,MybatisPlus 没有限制分页的方式。这意味着我们可以使用 PageHelper 来实现。当然 MybatisPlus 也提供了分页实现,我们将在下文探索。

6.1.1 配置分页插件
package com.itheima.mp.config;

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;

@Configuration
public class MybatisPlusConfig {
    
    @Bean
    public MybatisPlusInterceptor mybatisPlusInterceptor() {
        // 1、初始化核心插件
        MybatisPlusInterceptor interceptor = new MybatisPlusInterceptor();
        // 2、初始化分页插件
        PaginationInnerInterceptor pageInterceptor = 
                             new PaginationInnerInterceptor(DbType.MYSQL);
        pageInterceptor.setMaxLimit(1000L); // 单页最大数量
        // 3、添加分页插件到核心插件(如需使用其他插件,同理,new出来然后add即可)
        interceptor.addInnerInterceptor(pageInterceptor);
        return interceptor;
    }
}

MybatisPlus 3.5.9 版本及以上需要添加 mybatis-plus-jsqlparser 依赖

6.1.2 测试分页查询
...
import com.baomidou.mybatisplus.core.metadata.OrderItem;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;

@SpringBootTest
class IUserServiceTest {
    ...
    @Test
    void testPageQuery() {
        int pageNo = 1, pageSize = 2;
        // 1、准备分页条件
        // 1.1 分页条件
        Page<User> page = Page.of(pageNo, pageSize);
        // 1.2 排序条件
        // 条件1:余额排序
        page.addOrder(new OrderItem("balance", true));
        // 条件2:条件1满足后再按id排
        page.addOrder(new OrderItem("id", true));
        // 2、利用条件查询
        Page<User> resultPage = userService.page(page);
        // 3、解析查询结果(其实page和resultPage是同一个对象)
        long total = resultPage.getTotal();
        System.out.println("total = " + total);
        long pages = resultPage.getPages();
        System.out.println("pages = " + pages);
        List<User> records = resultPage.getRecords();
        records.forEach(System.out::println);
    }
}

运行结果:

==>  Preparing: SELECT COUNT(*) AS total FROM user
==> Parameters: 
<==      Total: 1
==>  Preparing: SELECT id, username, password, phone, info, status, balance, create_time, update_time FROM user ORDER BY balance ASC, id ASC LIMIT ?
==> Parameters: 2(Long)
<==      Total: 2
total = 5
pages = 3
User(id=5, username=Lucy, password=123, phone=18688990011, info=UserInfo(age=24, intro=英文老师, gender=female), status=NORMAL, balance=200, createTime=2025-05-06T10:40:04, updateTime=2025-05-06T10:40:04)
User(id=2, username=Rose, password=123, phone=13900112223, info=UserInfo(age=19, intro=青涩少女, gender=female), status=NORMAL, balance=800, createTime=2023-05-19T21:00:23, updateTime=2025-05-06T10:25:37)

6.2 通用分页实体

分页接口规范如下:

参数说明
请求参数

见:6.2.1、6.2.2
{
    "pageNo": 1,
    "pageSize": 5,
    "sortBy": "balance",
    "isAsc": false,
     ...
}

结果参数


见:6.2.3

{
    "total": 100006,
    "pages": 50003,
    "list": [
        {
            "id": 1685100878975279298,
            "username": "user_9****",
             ...
        }, ...
    ]
}
特殊说明如果排序字段为空,默认按照更新时间排序
6.2.1 新建查询条件实体
package com.itheima.mp.domain.query;

import io.swagger.annotations.ApiModel;
import io.swagger.annotations.ApiModelProperty;
import lombok.Data;

/**
 * 用于接收前端的传参
 */
@ApiModel(description = "分页查询条件实体")
@Data
public class PageQuery {
    @ApiModelProperty("当前页码")
    private Integer pageNo;
    @ApiModelProperty("每页记录数")
    private Integer pageSize;
    @ApiModelProperty("排序字段")
    private String sortBy;
    @ApiModelProperty("是否正序")
    private Boolean isAsc;
}
6.2.2 用户查询实体扩展
package com.itheima.mp.domain.query;

import io.swagger.annotations.ApiModel;
import io.swagger.annotations.ApiModelProperty;
import lombok.Data;
import lombok.EqualsAndHashCode;

/**
 * 用户查询条件实体
 */
[+] @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;
}

@EqualsAndHashCode(callSuper = true) 的核心价值:在继承场景中,确保子类的 equals()hashCode() 方法能够正确比较父类字段,避免因字段遗漏导致的逻辑错误。例如以下示例:

@Data @EqualsAndHashCode(callSuper = false) // 默认值
class SubClass extends SuperClass {
    private String field;
}

@Data
class SuperClass {
    private int id;
}

如果 SubClass 的 equals()hashCode() 未调用父类方法,则比较仅基于 field,而忽略 id。这可能导致两个 id 不同的 SubClass 对象被错误地认为相等。

6.2.3 请求结果实体
package com.itheima.mp.domain.dto;

import ...

/**
 *  分页查询结果
 */
@Data
@ApiModel(description = "分页查询结果")
public class PageResultDTO<T> {
    @ApiModelProperty("总记录数")
    private Long total;
    @ApiModelProperty("总页数")
    private Long pages;
    @ApiModelProperty("当前页数据")
    private List<T> list;
}
6.2.4 业务逻辑编写
public class UserController {
    ...
    @ApiOperation("根据条件分页查询用户")
    @GetMapping("/page")
    public List<UserVO> queryUsersPage(UserQuery query) {
        return userService.queryUsersWithPage(query);
    }
}
package com.itheima.mp.service.impl;

import ...

@Service
public class UserServiceImpl extends ServiceImpl<UserMapper, User> implements IUserService  {
    ...
    @Override
    public PageResultDTO<UserVO> queryUsersWithPage(UserQuery query) {
        Integer status = query.getStatus();
        String name = query.getName();
        // 1、构建分页条件
        // 1.1 分页条件
        Page<User> page = Page.of(query.getPageNo(), query.getPageSize());
        // 1.2 排序条件
        if (StrUtil.isNotBlank(query.getSortBy())) {
            // 如果条件不为空
            page.addOrder(new OrderItem(query.getSortBy(), query.getIsAsc()));
        } else {
            // 为空,则默认按时间排序(降序)
            page.addOrder(new OrderItem("update_time", false));
        }
        // 2、分页查询
        Page<User> pageResult = lambdaQuery()
                .like(name != null, User::getUsername, name)
                .eq(status != null, User::getStatus, status)
                .page(page);
        // 3、封装分页结果
        PageResultDTO<UserVO> dto = new PageResultDTO<>();
        List<User> result = pageResult.getRecords();
        if (CollUtil.isEmpty(result)) {
            dto.setList(Collections.emptyList());
            return dto;
        }
        dto.setPages(pageResult.getPages());
        dto.setTotal(pageResult.getTotal());
        dto.setList(BeanUtil.copyToList(result, UserVO.class));
        // 4、返回
        return dto;
    }
}

6.3 分页代码封装

6.3.1 改造分页查询实体
package com.itheima.mp.domain.query;

import ...

/**
 * 用于接收前端的传参
 */
@ApiModel(description = "分页查询条件实体")
@Data
public class PageQuery {
    @ApiModelProperty("当前页码")
    private Integer pageNo = 1;
    @ApiModelProperty("每页记录数")
    private Integer pageSize = 5;
    @ApiModelProperty("排序字段")
    private String sortBy;
    @ApiModelProperty("是否正序")
    private Boolean isAsc = true;

    public <T> Page<T> toMpPage(OrderItem ... items) {
        // 1、构建分页条件
        // 1.1 分页条件
        Page<T> page = Page.of(pageNo, pageSize);
        // 1.2 排序条件
        if (StrUtil.isNotBlank(sortBy)) {
            // 如果条件不为空
            page.addOrder(new OrderItem(sortBy, isAsc));
        } else if (items != null) {
            // 为空,则按传入OrderItem参数排序
            page.addOrder(items);
        }
        return page;
    }

    public <T> Page<T> toMpPage(String defaultSortBy, Boolean defaultAsc) {
        return toMpPage(new OrderItem(defaultSortBy, defaultAsc));
    }

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

    public <T> Page<T> toMpPageOrderByUpdateTime() {
        return toMpPage(new OrderItem("update_time", false));
    }
}
6.3.2 改造分页结果实体
方法1:PO转VO的逻辑写死为 BeanUtil.copyToList(数据结果集, 目标类);
方法2:使用函数式接口,由调用者提供PO转VO的逻辑。
package com.itheima.mp.domain.dto;

import ...

/**
 *  分页查询结果
 */
@Data
@ApiModel(description = "分页查询结果")
public class PageResultDTO<T> {
    @ApiModelProperty("总记录数")
    private Long total;
    @ApiModelProperty("总页数")
    private Long pages;
    @ApiModelProperty("当前页数据")
    private List<T> list;

    public <PO, VO> PageResultDTO<VO> getPageResult(Page<PO> page, 
                                                    Class<VO> clazz) {
        // 3、封装分页结果
        PageResultDTO<VO> dto = new PageResultDTO<>();
        List<PO> result = page.getRecords();
        if (CollUtil.isEmpty(result)) {
            dto.setList(Collections.emptyList());
            return dto;
        }
        dto.setPages(page.getPages());
        dto.setTotal(page.getTotal());
        dto.setList(BeanUtil.copyToList(result, clazz));
        return dto;
    }

    public <PO, VO> PageResultDTO<VO> getPageResult(Page<PO> page,
                                                    Function<PO, VO> convertor) {
        // 3、封装分页结果
        PageResultDTO<VO> dto = new PageResultDTO<>();
        List<PO> result = page.getRecords();
        if (CollUtil.isEmpty(result)) {
            dto.setList(Collections.emptyList());
            return dto;
        }
        dto.setPages(page.getPages());
        dto.setTotal(page.getTotal());
        dto.setList(result.stream().map(convertor).collect(Collectors.toList()));
        return dto;
    }
}
6.3.3 改造业务逻辑
package com.itheima.mp.service.impl;

import ...

@Service
public class UserServiceImpl extends ... implements ... {
    ...
    @Override
    public PageResultDTO<UserVO> queryUsersWithPage(UserQuery query) {
        Integer status = query.getStatus();
        String name = query.getName();
        // 使用重构的方法:
        Page<User> page = query.toMpPage(query.getSortBy(), query.getIsAsc());
        // 2、分页查询
        Page<User> pageResult = lambdaQuery()
                .like(name != null, User::getUsername, name)
                .eq(status != null, User::getStatus, status)
                .page(page);
        // 调用方法1:直接复制属性
        //PageResultDTO<UserVO> dto = new PageResultDTO<>()
        //              .getPageResult(pageResult, UserVO.class);
        // 调用方法2:使用转换器,处理复杂转换
        PageResultDTO<UserVO> dto = new PageResultDTO<>()
                .getPageResult(pageResult, user -> {
                    // 拷贝基础属性
                    UserVO userVO = BeanUtil.copyProperties(user, UserVO.class);
                    userVO.setUsername(user.getUsername()
                            .substring(0, user.getUsername().length() - 2) + "**");
                    return userVO;
                });
        return dto;
    }
}

当前封装的两个方法(①根据前端查询条件构建Page ②根据page()的查询结果构建PageResultDTO)都是放到了实体的内部,和实体耦合在一起,有些情况下可能会出问题,例如公司里面用的不是MybatisPlus。可以采用单独另外封装工具类,而不是直接写在Query和VO的实体里面。

完结撒花 🎉

This article was updated on