MyBatis Plus:快速上手指南
上一篇我们讲了 MyBatis 的架构和 Mapper 代理机制,知道了 Mapper 接口通过动态代理把方法调用转成 SQL 执行。但在实际开发中,大部分 Mapper 方法做的事情都很基础:按 ID 查、按条件查、插入一条、更新一条、删一条。这些 SQL 写法几乎一模一样,每个 Mapper 都要重复一遍。
如果你写过几个 Mapper,很快就会发现,大部分代码其实都在重复:增删改查、按 ID 查询、分页、条件查询……每个 Mapper 都要重新写一遍。MyBatis Plus 干的就是这件事——把这些重复劳动封装掉。
目录
- MyBatis Plus 是什么
- 快速接入 Spring Boot
- BaseMapper:内置的 CRUD 方法
- 条件构造器:Wrapper 怎么用
- Service 层封装
- 自动填充:时间字段不用手动 set
- 分页查询
- 逻辑删除
- 代码生成器
- MP 和 MyBatis 的关系
- 小结
MyBatis Plus 是什么
MyBatis Plus(简称 MP)是 MyBatis 的增强工具,只做增强,不做改变。引入 MP 后,MyBatis 的所有功能照常使用,XML 映射文件、动态 SQL、自定义方法都不受影响。
日常开发最常用的也就五件事:CRUD、条件构造、分页、自动填充和逻辑删除,后面基本都会用到。
快速接入 Spring Boot
Spring Boot 项目引入依赖:
<dependency>
<groupId>com.baomidou</groupId>
<artifactId>mybatis-plus-boot-starter</artifactId>
<version>3.5.5</version>
</dependency>
启动类加上 MapperScan:
@SpringBootApplication
@MapperScan("com.example.mapper")
public class Application {
public static void main(String[] args) {
SpringApplication.run(Application.class, args);
}
}
定义实体类:
@TableName("user") // 指定表名
public class User {
@TableId(type = IdType.AUTO) // 主键自增
private Long id;
private String name;
private Integer age;
private String email;
@TableField(fill = FieldFill.INSERT) // 插入时自动填充
private LocalDateTime createTime;
@TableField(fill = FieldFill.INSERT_UPDATE) // 插入和更新时自动填充
private LocalDateTime updateTime;
// getter / setter 省略
}
定义 Mapper:
public interface UserMapper extends BaseMapper<User> {
// 不需要写任何方法,BaseMapper 已经提供了通用 CRUD
}
到这里就能用了。BaseMapper<User> 提供了 17 个内置方法,直接调用就行。
BaseMapper:内置的 CRUD 方法
BaseMapper 是 MP 提供的核心接口,里面定义了通用的 CRUD 方法。你的 Mapper 只要继承它,就自动拥有这些能力。
日常开发真正经常用的其实就下面这些:
| insert | 插入一条记录 | mapper.insert(user) |
| deleteById | 按主键删除 | mapper.deleteById(1L) |
| deleteBatchIds | 批量删除 | mapper.deleteBatchIds(Arrays.asList(1L, 2L)) |
| delete | 条件删除 | mapper.delete(wrapper) |
| updateById | 按主键更新(null 字段不更新) | mapper.updateById(user) |
| update | 条件更新 | mapper.update(user, wrapper) |
| selectById | 按主键查询 | mapper.selectById(1L) |
| selectBatchIds | 批量查询 | mapper.selectBatchIds(Arrays.asList(1L, 2L)) |
| selectOne | 查询一条 | mapper.selectOne(wrapper) |
| selectList | 条件查询列表 | mapper.selectList(wrapper) |
| selectCount | 条件计数 | mapper.selectCount(wrapper) |
| selectPage | 分页查询 | mapper.selectPage(page, wrapper) |
光看方法名没什么感觉,直接看几个最常用的。
插入
User user = new User();
user.setName("张三");
user.setAge(25);
user.setEmail("zhangsan@example.com");
// 返回值是受影响的行数
int rows = userMapper.insert(user);
// 插入后,MP 会自动把生成的主键回填到对象里
System.out.println(user.getId()); // 自增主键的值
MP 默认会把 null 字段也拼进 SQL 里。如果你想跳过 null 字段(比如更新时只更新非空字段),MP 的 updateById 方法已经做了这个处理:为 null 的字段不会出现在 SET 子句里。
查询
// 按 ID 查
User user = userMapper.selectById(1L);
// 批量查
List<User> users = userMapper.selectBatchIds(Arrays.asList(1L, 2L, 3L));
// 查全部
List<User> all = userMapper.selectList(null);
更新
User user = new User();
user.setId(1L);
user.setName("李四"); // 只更新 name
// age 为 null,不会出现在 SET 子句里
int rows = userMapper.updateById(user);
// 实际执行:UPDATE user SET name='李四' WHERE id=1
删除
// 按 ID 删
userMapper.deleteById(1L);
// 批量删
userMapper.deleteBatchIds(Arrays.asList(1L, 2L, 3L));
这些方法覆盖了大部分单表操作场景。但实际业务中,查询条件往往不只是"等于",还有模糊查询、范围查询、排序、指定返回字段等需求。这就需要条件构造器了。
条件构造器:Wrapper 怎么用
MP 的条件构造器有两种:QueryWrapper 和 LambdaQueryWrapper。它们的作用是用 Java 代码拼出 WHERE 条件。
QueryWrapper
QueryWrapper<User> wrapper = new QueryWrapper<>();
wrapper.eq("age", 25) // age = 25
.like("name", "张") // name LIKE '%张%'
.between("age", 20, 30) // age BETWEEN 20 AND 30
.orderByDesc("create_time") // ORDER BY create_time DESC
.select("id", "name", "age"); // 只返回指定字段
List<User> users = userMapper.selectList(wrapper);
生成的 SQL:
SELECT id, name, age FROM user
WHERE age = 25 AND name LIKE '%张%' AND age BETWEEN 20 AND 30
ORDER BY create_time DESC
QueryWrapper 的问题是字段名用字符串硬编码,拼错了编译不报错,运行时才发现。LambdaQueryWrapper 解决了这个问题。
LambdaQueryWrapper
LambdaQueryWrapper<User> wrapper = new LambdaQueryWrapper<>();
wrapper.eq(User::getAge, 25)
.like(User::getName, "张")
.between(User::getAge, 20, 30)
.orderByDesc(User::getCreateTime)
.select(User::getId, User::getName, User::getAge);
List<User> users = userMapper.selectList(wrapper);
效果一样,但字段通过方法引用(User::getName)指定,重构时改了字段名会自动跟着变,编译期就能发现问题。
我现在基本不会再写 QueryWrapper 了,全部换成 LambdaQueryWrapper。虽然代码稍微长一点,但重构的时候不用担心字段名写错,IDE 还能直接跟踪引用,长期维护舒服很多。
常用条件方法
| eq | = | .eq(User::getName, "张三") |
| ne | <> | .ne(User::getStatus, 0) |
| like | LIKE '%x%' | .like(User::getName, "张") |
| likeLeft | LIKE '%x' | .likeLeft(User::getName, "三") |
| likeRight | LIKE 'x%' | .likeRight(User::getName, "张") |
| between | BETWEEN x AND y | .between(User::getAge, 20, 30) |
| in | IN (…) | .in(User::getId, Arrays.asList(1, 2, 3)) |
| isNull | IS NULL | .isNull(User::getEmail) |
| isNotNull | IS NOT NULL | .isNotNull(User::getEmail) |
| orderByDesc | ORDER BY x DESC | .orderByDesc(User::getCreateTime) |
| select | 指定返回字段 | .select(User::getId, User::getName) |
条件构造器还支持嵌套条件:
wrapper.eq(User::getStatus, 1)
.and(w -> w.gt(User::getAge, 20).or().like(User::getName, "张"));
// WHERE status = 1 AND (age > 20 OR name LIKE '%张%')
更新条件
条件构造器不只能用于查询,更新也能用:
LambdaUpdateWrapper<User> wrapper = new LambdaUpdateWrapper<>();
wrapper.eq(User::getId, 1L)
.set(User::getName, "王五")
.set(User::getAge, null); // set null
userMapper.update(null, wrapper);
// UPDATE user SET name='王五', age=NULL WHERE id=1
Service 层封装
MP 还提供了 IService 接口和 ServiceImpl 实现类,把 Service 层的通用 CRUD 也封装了。
// 接口继承 IService
public interface UserService extends IService<User> {
}
// 实现类继承 ServiceImpl
@Service
public class UserServiceImpl extends ServiceImpl<UserMapper, User>
implements UserService {
}
IService 提供的方法比 BaseMapper 更丰富,常用的:
// 批量插入(自动分批,避免 SQL 过长)
userService.saveBatch(userList, 1000);
// 按条件查询一条
User user = userService.getOne(
new LambdaQueryWrapper<User>().eq(User::getName, "张三")
);
// 按条件计数
long count = userService.count(
new LambdaQueryWrapper<User>().gt(User::getAge, 20)
);
// 批量查询
List<User> users = userService.listByIds(Arrays.asList(1L, 2L));
// 链式查询
List<User> result = userService.lambdaQuery()
.eq(User::getStatus, 1)
.gt(User::getAge, 20)
.orderByDesc(User::getCreateTime)
.list();
lambdaQuery() 和 lambdaUpdate() 返回链式调用的构造器,写起来更简洁。
自动填充:时间字段不用手动 set
表里的 create_time、update_time 这类字段,每次插入或更新都要手动赋值,容易遗漏。MP 支持自动填充。
第一步,实体类加注解:
@TableField(fill = FieldFill.INSERT)
private LocalDateTime createTime;
@TableField(fill = FieldFill.INSERT_UPDATE)
private LocalDateTime updateTime;
FieldFill 的取值:
| DEFAULT | 不自动填充 |
| INSERT | 插入时填充 |
| UPDATE | 更新时填充 |
| INSERT_UPDATE | 插入和更新时都填充 |
第二步,实现 MetaObjectHandler:
@Component
public class MyMetaObjectHandler implements MetaObjectHandler {
@Override
public void insertFill(MetaObject metaObject) {
this.strictInsertFill(metaObject, "createTime", LocalDateTime.class, LocalDateTime.now());
this.strictInsertFill(metaObject, "updateTime", LocalDateTime.class, LocalDateTime.now());
}
@Override
public void updateFill(MetaObject metaObject) {
this.strictUpdateFill(metaObject, "updateTime", LocalDateTime.class, LocalDateTime.now());
}
}
之后调用 insert 或 updateById 时,createTime 和 updateTime 会自动赋值,不需要手动 set。
不过这里有个容易踩坑的地方:只有通过 MP 提供的方法(如 insert、updateById)执行时,自动填充才会生效。如果自己写 XML 更新 SQL,MetaObjectHandler 是不会执行的。很多人以为配了自动填充就万事大吉,结果自定义 SQL 里的时间字段全是 null,排查半天才发现是这个问题。
分页查询
MP 内置了分页功能,需要先注册分页插件:
@Configuration
public class MybatisPlusConfig {
@Bean
public MybatisPlusInterceptor mybatisPlusInterceptor() {
MybatisPlusInterceptor interceptor = new MybatisPlusInterceptor();
interceptor.addInnerInterceptor(new PaginationInnerInterceptor(DbType.MYSQL));
return interceptor;
}
}
分页插件一定要注册,不然 selectPage() 虽然能调用,但不会自动拼 LIMIT,很多人第一次接 MP 都会踩这个坑。
使用方式:
// 第 1 页,每页 10 条
Page<User> page = new Page<>(1, 10);
LambdaQueryWrapper<User> wrapper = new LambdaQueryWrapper<>();
wrapper.eq(User::getStatus, 1)
.orderByDesc(User::getCreateTime);
// 分页查询
Page<User> result = userMapper.selectPage(page, wrapper);
// 获取结果
List<User> records = result.getRecords(); // 当前页数据
long total = result.getTotal(); // 总记录数
long pages = result.getPages(); // 总页数
MP 的分页是物理分页,生成的 SQL 带 LIMIT:
SELECT * FROM user WHERE status = 1 ORDER BY create_time DESC LIMIT 0, 10
同时会自动执行一条 SELECT COUNT(*) 获取总记录数。
如果不想查总数,可以关闭:
Page<User> page = new Page<>(1, 10, false); // 第三个参数 false 表示不查 count
逻辑删除
逻辑删除不是真正删除数据,而是把记录标记为"已删除"。比如加一个 deleted 字段,0 表示正常,1 表示已删除。
配置文件:
mybatis-plus:
global-config:
db-config:
logic-delete-field: deleted # 逻辑删除字段
logic-delete-value: 1 # 已删除的值
logic-not-delete-value: 0 # 未删除的值
实体类加上字段:
@TableLogic
private Integer deleted;
之后调用 deleteById 时,执行的不是 DELETE,而是 UPDATE:
userMapper.deleteById(1L);
// 实际执行:UPDATE user SET deleted=1 WHERE id=1 AND deleted=0
查询时也会自动过滤已删除的记录:
userMapper.selectById(1L);
// 实际执行:SELECT * FROM user WHERE id=1 AND deleted=0
逻辑删除对开发者是透明的,查询、更新、删除的方法调用方式不变,MP 自动在 SQL 里加上 deleted=0 条件。
但有个坑需要提前知道:逻辑删除只是帮你把 DELETE 改成了 UPDATE,数据库里的数据其实一直都在。所以涉及唯一索引时需要额外注意,否则可能出现"删了却不能重新插入"的问题。比如用户名加了唯一索引,逻辑删除"张三"后再注册一个"张三",数据库会报唯一键冲突,因为那条"已删除"的记录还在。
代码生成器
MP 提供了代码生成器,根据数据库表自动生成 Entity、Mapper、Service、Controller。
FastAutoGenerator.create(
"jdbc:mysql://localhost:3306/test", "root", "password")
.globalConfig(builder -> {
builder.author("developer") // 作者
.outputDir("src/main/java") // 输出目录
.disableOpenDir(); // 生成后不自动打开目录
})
.packageConfig(builder -> {
builder.parent("com.example") // 父包名
.moduleName("demo") // 模块名
.mapper("mapper") // Mapper 包名
.service("service") // Service 包名
.controller("controller"); // Controller 包名
})
.strategyConfig(builder -> {
builder.addInclude("user", "order") // 要生成的表
.entityBuilder()
.enableLombok() // Entity 用 Lombok
.serviceBuilder()
.formatServiceFileName("%sService");
})
.templateEngine(new FreemarkerTemplateEngine())
.execute();
生成的目录结构:
src/main/java/com/example/demo/
├── entity/
│ └── User.java
├── mapper/
│ └── UserMapper.java
├── service/
│ ├── UserService.java
│ └── impl/
│ └── UserServiceImpl.java
└── controller/
└── UserController.java
生成的代码可以直接跑,但通常需要根据业务需求做调整,比如加自定义方法、改查询逻辑等。
MP 和 MyBatis 的关系
MP 是 MyBatis 的增强,不是替代。它们的关系:
| 单表 CRUD | 手写 XML 或注解 | BaseMapper 内置方法 |
| 条件查询 | XML 里写 <where> <if> | Wrapper 链式调用 |
| 分页 | 需要第三方插件(PageHelper) | 内置分页插件 |
| 逻辑删除 | 手动实现 | 注解 + 配置即可 |
| 自动填充 | 手动实现 | MetaObjectHandler |
| 复杂查询 | XML 动态 SQL | 同样支持 XML 动态 SQL |
| 多表联查 | XML 写 resultMap 和 JOIN | 同样用 XML 写 |
小结
如果让我总结 MyBatis Plus,我觉得它最大的价值不是"功能多",而是把那些机械重复的 CRUD 全部接管了,让开发者把精力放到真正的业务逻辑上。但是,多表联查、复杂 SQL这些还是老老实实写 MyBatis 更舒服。项目里一般也是两者一起用:简单 CRUD 交给 MP,复杂查询继续写 XML,这也是大多数开发者比较常见的用法。


