1. 为什么我把Spring Boot和MyBatis-Plus定为一套固定组合
如果你刚开始接触Spring Boot,或者已经写过几个小项目,大概率听过MyBatis-Plus这个框架。它不是一个替代MyBatis的新东西,而是把MyBatis的常用功能做了深度封装,保留了SQL和XML自定义能力的核心,同时把CRUD、分页、条件查询这类重复到想吐的模板代码直接吞掉。也就是说,你不需要再为一张用户表去写insert、delete、update、selectById这些几十行雷同的Mapper XML方法。
先看它解决的具体问题。原生MyBatis最大的优点是SQL控制力强,最大的缺点是几乎所有基础操作都要自己写。一张表对应一套增删改查,10张表就是10套,而且每套代码结构几乎一样,只是字段名和表名不同。MyBatis-Plus的思路是:基础CRUD由框架内置实现,你只需要定义实体类,Mapper接口继承一个名为BaseMapper的父接口,就能直接调用现成的增删改查方法。需要的自定义SQL依然可以在XML或注解里写,两者完全不冲突。
再看Spring Boot这边,它会自动配置数据源、事务管理器、Web容器等一堆底层工作,让项目从启动到运行少了很多“为什么这里要写配置“的疑问。MyBatis-Plus官方提供了专门的starter依赖,引入后配合少量配置就能与Spring Boot无缝联动。我在实际项目中用这套组合已经很久,最大的感受是:团队新成员上手速度明显加快,因为不需要理解繁琐的SQL映射配置,只要会写实体类,就能在半小时内跑通一个模块。
这篇保姆教学会从零开始搭一个可运行的项目,选择Spring Boot 2.7.18与MyBatis-Plus 3.5.3.1,JDK使用1.8。之所以选这些版本,下文会单独解释。整篇内容面向的是“第一次接触整合“的人,每一步都会说明目的。适合的人群包括:正在做课程设计的学生、刚入职需要快速熟悉项目结构的新人、以及想把手动CRUD从代码里赶出去的开发者。你不需要先精通MyBatis,照着步骤走完,再回头理解原理,会顺畅很多。
2. 版本选型:我为什么选了2.7.18和3.5.3.1而不是最新版
很多人在整合时喜欢把依赖版本拉到最新,结果启动就报一堆兼容性问题,然后开始怀疑自己代码写错了。实际上对于Spring Boot和MyBatis-Plus这种生态成熟的框架,稳定版本和LTS版本更适合教学和大多数业务项目,原因下面展开说。
2.1 JDK与Spring Boot的版本匹配关系
JDK 8是当前企业项目里最常见的Java版本,虽然现在已经出了更高版本的JDK,但大量老系统、公司内部规范、云平台运行时仍然是8为主。Spring Boot 2.7系列是官方对JDK 8支持最稳定的最后一个大版本分支。3.x系列虽然已经发布,但它把javax包切换成了jakarta包,很多老依赖的兼容性需要额外处理,对教学场景来说没必要冒险。
表格对比一下当前可选的几个版本范围:
| 组合方案 | JDK要求 | 适合场景 | 备注 |
|---|---|---|---|
| Spring Boot 2.7.18 + MP 3.5.3.1 | JDK 8即可 | 教学、中小项目、公司老环境 | 稳定组合,资料丰富 |
| Spring Boot 3.x + MP 3.5.4+ | JDK 17+ | 新项目、有升级需求 | 需要处理jakarta命名空间变化 |
| Spring Boot 2.5.x + MP 3.4.x | JDK 8 | 老项目维护 | 不推荐新项目使用 |
我教学或做Demo一律用第一行组合,原因很简单:遇到问题时网上能查到的解决方案最多,搜索时不容易被版本差异干扰。
2.2 引入依赖时的细节:数据库驱动也要配对
pom.xml中Spring Boot版本由parent声明统一管理,不需要手动写版本号。MyBatis-Plus的starter依赖单独引入。代码片段如下:
<parent> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-parent</artifactId> <version>2.7.18</version> <relativePath/> </parent> <dependencies> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-web</artifactId> </dependency> <dependency> <groupId>com.baomidou</groupId> <artifactId>mybatis-plus-boot-starter</artifactId> <version>3.5.3.1</version> </dependency> <dependency> <groupId>com.mysql</groupId> <artifactId>mysql-connector-j</artifactId> <scope>runtime</scope> </dependency> <dependency> <groupId>org.projectlombok</groupId> <artifactId>lombok</artifactId> <optional>true</optional> </dependency> </dependencies>这里有个很容易踩的坑:MySQL 8.0.33版本之后的驱动包名从mysql-connector-java变成了mysql-connector-j。如果你看着老教程复制了旧的artifactId,Maven会报找不到依赖,或者拉下来一个很老的版本。Spring Boot 2.7.18提供的依赖管理里已经包含了新驱动名的管理,所以直接写mysql-connector-j不需要带版本号。更老的环境用MySQL 5.7,则建议保留旧驱动名,二选一即可,对应关系不要混。
2.3 Lombok要不要加?我的建议是加
Lombok能省掉实体类的getter、setter、toString这些模板方法,让代码读起来更清爽。MyBatis-Plus的实体类常常配合@Data注解使用,这样框架在做属性映射时能直接调用生成的getter和setter。IDE安装Lombok插件后,编译也不会有问题。如果你所在团队禁止使用Lombok,那实体类就要手动补齐所有getter/setter,工作量会大不少。我的观点是:新项目大胆用,老项目遵循原有规范。
3. 项目创建全流程:从IDEA空工程到能启动的骨架
整合教程最尴尬的一步其实是“项目怎么建”,因为不是所有人都熟悉IDEA的初始化向导。这里把每一步都拆开说。
3.1 新建工程时的关键点选操作
打开IDEA,选择New Project,左侧选择Spring Initializr。Project SDK选择本机JDK 8。如果还没有配置JDK,在Project Structure的SDKs里添加本机JDK路径即可。点击Next以后,会出现Group和Artifact两个输入框,前者一般写成com.example这类公司域名倒写,后者就是项目名。Type选择Maven Project,Packaging选择Jar,Java Version选择8。
这里有个容易被忽略的地方:Spring Initializr默认填入的Spring Boot版本可能是最新的3.x。要把它手动改成2.7.18,确认页面右上角显示的版本号不是3开头,否则后面引入MyBatis-Plus 3.5.3.1会踩到javax和jakarta的坑。
点击Next进入依赖选择页,只需要勾选Spring Web这一个即可,其他依赖后续在pom.xml里手动补充。MyBatis-Plus的starter在初始化向导里通常没有直接选项,用它官方的依赖坐标是一个标准做法。另外建议勾选Lombok,如果列表里有的话。
3.2 目录结构:为什么启动类要放在最外层
Spring Boot启动类要求能被扫描到它所在包及其子包下的所有Spring组件。如果启动类放错位置,Controller和Service会出现“明明写了却注入不了”的诡异问题。约定俗成的结构是:
src/main/java/com/example/demo ├── DemoApplication.java ├── controller ├── service │ └── impl ├── mapper └── entityDemoApplication.java放在最外层包,@SpringBootApplication注解自带组件扫描机制,只会扫描当前包和子包。如果你把启动类挪进了controller包,那mapper包里的接口它就看不到,启动时出现的第一个报错就是“找不到UserMapper类型的Bean”。这类问题在最后的坑位章节里会再次详聊。
3.3 Spring Boot与MyBatis-Plus的扫描器关系
Spring Boot本身不认识MyBatis-Plus的Mapper接口,需要两个注解来沟通:启动类上的@MapperScan用于告诉Spring Boot“这些包下的接口是Mapper,扫描后生成代理对象”;或者每个Mapper接口上加@Mapper注解,逐一注册。两种方式效果相同,经验是使用@MapperScan更省事,因为新加Mapper接口时不需要再补注解。
启动类最终的代码是这样的:
@SpringBootApplication @MapperScan("com.example.demo.mapper") public class DemoApplication { public static void main(String[] args) { SpringApplication.run(DemoApplication.class, args); } }这一步做完,项目骨架已经立住了。但先别急着启动,因为还没配数据库和代码,启动不会报错,可也没有任何内容能验证。
4. 数据源关联与配置:yml配置看不懂,后面全是玄学错误
数据库连接的配置直接决定你的程序能不能连上MySQL。很多入门用户在这一步把url里的参数抄错,比如漏了时区参数serverTimezone,程序就报“The server time zone value is unrecognized”,然后开始怀疑MySQL安装出了问题。实际上这只是连接字符串里少了一个时区声明。
4.1 application.yml配置项逐行解释
Spring Boot默认读取src/main/resources/application.yml文件。一个最小配置如下:
server: port: 8080 spring: datasource: url: jdbc:mysql://localhost:3306/mybatis_plus_demo?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai&useSSL=false username: root password: 你自己的密码 driver-class-name: com.mysql.cj.jdbc.Driver mybatis-plus: configuration: log-impl: org.apache.ibatis.logging.stdout.StdOutImpl global-config: db-config: id-type: assign_id table-underline: true logic-delete-field: deleted logic-delete-value: 1 logic-not-delete-value: 0url参数拆开看:
| 参数 | 作用 |
|---|---|
| useUnicode=true&characterEncoding=utf8 | 确保中文数据在传输时不被乱码 |
| serverTimezone=Asia/Shanghai | 告诉驱动和MySQL使用东八区时间,避免时区偏移 |
| useSSL=false | 本地开发不必走SSL加密 |
driver-class-name写成com.mysql.cj.jdbc.Driver,注意不是老版本的com.mysql.jdbc.Driver。MySQL 8的驱动类路径以cj结尾,如果写错,启动时会明确报找不到Driver类。
mybatis-plus.configuration.log-impl这一项是我个人强烈建议开启的。它会在控制台打印每条SQL的执行语句和参数,排查问题时能直接看到框架替你生成的SQL长什么样,对初学者理解MyBatis-Plus的工作方式非常有帮助。
4.2 数据库连接验证:别等运行时才排查
配置完成后,最稳妥的做法是先用一个测试类验证数据库能连通。可以在src/test/java下的测试类里注入DataSource,然后打印连接对象:
@SpringBootTest class ConnectionTest { @Resource private DataSource dataSource; @Test void testConnection() throws SQLException { Connection connection = dataSource.getConnection(); System.out.println(connection); connection.close(); } }如果测试方法正常输出一个带连接地址的对象,说明数据库连接没有问题,可以放心继续往下写代码。如果报错,九成是url里的库名写错了,或密码不对,或MySQL服务没有启动。这一步能帮你把“环境问题”和“代码问题”彻底隔离,省下来的排查时间非常可观。
5. 从建表SQL到实体类映射:底层字段对上号,CRUD才能跑通
MyBatis-Plus省去了写SQL的功夫,但前提是实体类的字段和数据库表的字段必须能正确对上。这里的映射规则是整个框架的命脉,值得花时间理解透。
5.1 设计一张用户表:字段尽量贴近真实场景
本文的示例表设定为最典型的用户信息表。字段选择上刻意覆盖了三种情况:主键自增、普通字段、逻辑删除。SQL如下:
CREATE TABLE user ( id BIGINT NOT NULL AUTO_INCREMENT COMMENT '主键ID', username VARCHAR(50) NOT NULL COMMENT '用户名', password VARCHAR(100) NOT NULL COMMENT '密码', nickname VARCHAR(50) DEFAULT NULL COMMENT '昵称', age INT DEFAULT NULL COMMENT '年龄', email VARCHAR(100) DEFAULT NULL COMMENT '邮箱', deleted TINYINT NOT NULL DEFAULT 0 COMMENT '是否删除 0未删除 1已删除', create_time DATETIME DEFAULT NULL COMMENT '创建时间', update_time DATETIME DEFAULT NULL COMMENT '更新时间', PRIMARY KEY (id) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='用户表';表名直接用user看起来简单,但部分数据库会把user当成保留字,建表时需要注意。真正生产环境里我会给表加前缀,比如t_user,这能避免保留字冲突,也让实体类的@TableName注解更有存在感。为了教程直观,这里继续用user表示。
5.2 实体类注解解析:每个注解背后都有含义
实体类对应一个Java类,使用Lombok简化后比较清爽:
@Data @TableName("user") public class User { @TableId(type = IdType.AUTO) private Long id; private String username; private String password; private String nickname; private Integer age; private String email; @TableLogic @TableField("deleted") private Integer deleted; @TableField(value = "create_time", fill = FieldFill.INSERT) private LocalDateTime createTime; @TableField(value = "update_time", fill = FieldFill.INSERT_UPDATE) private LocalDateTime updateTime; }逐一看每个注解:
@TableName("user"):指定实体类对应的数据库表名。如果省略,MyBatis-Plus会默认把类名User转成user,这里写上是明确的声明,也能成为后续学习的语法样例。@TableId(type = IdType.AUTO):声明主键字段。AUTO代表数据库自增,插入数据时不传id,数据库自动生成。如果使用雪花算法,则用ASSIGN_ID。@TableField:用来说明实体类属性和表字段的映射关系。比如deleted属性对应表里的deleted列,其实这种同名字段不写注解也能识别,但写上的好处是后续增加逻辑删除、自动填充等功能时,框架能通过这个注解找到正确字段。@TableLogic:逻辑删除注解,后面专门讲。
表格中额外加了create_time和update_time字段,配合fill属性可以做自动填充,也就是说插入和更新时不需要手动给这两个时间赋值,由框架统一处理。这属于进阶功能,但建议从整合第一天就用起来,避免日后每张表都要写一遍“当前时间赋值”的冗余代码。自动填充的处理器类本文暂不展开,后续单独整理一篇。
5.3 驼峰映射:一个配置项,两个世界的桥梁
Java实体类习惯用驼峰命名,比如createTime,数据库表字段习惯用下划线命名create_time。MyBatis-Plus默认开启了驼峰映射,意味着createTime能自动对应到create_time,不需要每个字段都写@TableField。
这个默认行为对绝大多数项目是好事,但如果你手里的表字段本来就是驼峰风格,比如就叫createTime,反而要注意是否会出现大小写匹配问题。MySQL在Linux环境下对大小写敏感,Windows下不敏感,跨平台时会产生“本地正常、服务器报字段错误”的现象。经验是:数据库字段统一使用小写下划线,实体类统一使用驼峰,让框架的默认机制发挥作用,这是最省心的方案。
6. Mapper到Controller全链路:一个最小可用的CRUD接口库
骨架搭好了、表建好了,剩下的就是真正写代码。这一段顺着一条完整链路走:Mapper接口 → Service层 → Controller层。
6.1 BaseMapper与IService:什么时候用哪个,不该是玄学
MyBatis-Plus提供两种风格供选择。第一种是BaseMapper:你自定义一个接口继承它,然后这个接口直接拥有约20个内置CRUD方法,在任何地方注入这个Mapper就能调用,SQL由框架自动生成。
第二种是IService:先定义实体对应的Service接口继承IService<User>,再定义一个ServiceImpl继承ServiceImpl<UserMapper, User>并实现该接口。这种方式在BaseMapper基础上增加了业务层封装,比如批量插入、链式查询调用等。
个人建议:如果只是写一个简单测试Demo,直接用BaseMapper就够。如果是在真实项目里,建议必须走IService这条路,因为业务逻辑迟早会需要一层Service来组织,没有这层抽象,Controller里会堆积大量逻辑,后面想重构就非常痛苦。
6.2 写一个用户查询分页接口:完整代码逐段说明
先创建Mapper接口:
public interface UserMapper extends BaseMapper<User> { }这个接口内部可以不写任何方法,框架会通过动态代理生成一个类,这个类实现了BaseMapper中定义的全部方法。从代码层面看,你只是声明了一个接口,但运行时所有操作都可用。
再写Service层:
public interface UserService extends IService<User> { } @Service public class UserServiceImpl extends ServiceImpl<UserMapper, User> implements UserService { }ServiceImpl已经提供了大部分通用方法的实现,接口是为了让Controller面向接口编程。如果你的Service不需要额外方法,这两行代码就是全部。
Controller则可以完成增删改查和分页。代码用MyBatis-Plus的条件构造器LambdaQueryWrapper实现条件查找:
@RestController @RequestMapping("/user") public class UserController { @Resource private UserService userService; @PostMapping public Boolean addUser(@RequestBody User user) { return userService.save(user); } @DeleteMapping("/{id}") public Boolean deleteUser(@PathVariable Long id) { return userService.removeById(id); } @PutMapping public Boolean updateUser(@RequestBody User user) { return userService.updateById(user); } @GetMapping("/{id}") public User getUser(@PathVariable Long id) { return userService.getById(id); } @GetMapping("/page") public IPage<User> pageUsers(@RequestParam Integer pageNum, @RequestParam Integer pageSize, @RequestParam(required = false) String keyword) { LambdaQueryWrapper<User> wrapper = new LambdaQueryWrapper<>(); if (StrUtil.isNotBlank(keyword)) { wrapper.like(User::getUsername, keyword); } wrapper.orderByDesc(User::getCreateTime); return userService.page(new Page<>(pageNum, pageSize), wrapper); } }这段代码已经把常见的接口场景覆盖全了。save方法会自动根据id是否为空来决定是新增还是更新?不会,它会做对应的插入操作,字段非空判断逻辑散去。removeById在配置了逻辑删除后,不再执行DELETE语句,而是执行UPDATE语句把deleted字段变成1。getById是查主键。
分页接口里IPage和Page是两个关键类型。new Page<>(pageNum, pageSize)构建分页参数,LambdaQueryWrapper里的like方法对应SQL里的LIKE '%keyword%'。注意这里的keyword不是直接拼进SQL,而是使用预编译占位符,避免了SQL注入问题。
6.3 分页插件为什么不配置就无法生效
MyBatis-Plus的分页功能不是天然开启的,需要手动注册一个分页插件到MyBatis的拦截器链中。没有这一步,Page对象虽然传进了方法,但实际执行时并不会在SQL后面拼LIMIT语句,可能返回的还是全表数据。很多新手以为代码写错了,其实只是漏了配置。
分页插件的配置方法:
@Configuration public class MybatisPlusConfig { @Bean public MybatisPlusInterceptor mybatisPlusInterceptor() { MybatisPlusInterceptor interceptor = new MybatisPlusInterceptor(); interceptor.addInnerInterceptor(new PaginationInnerInterceptor(DbType.MYSQL)); return interceptor; } }DbType.MYSQL明确告诉插件当前数据库是MySQL,插件生成的方言分页语句才不会跑偏。如果你换用了PostgreSQL或SQL Server,这个参数也要跟着改。拦截器执行顺序按照添加顺序排列,分页插件建议放在第一位或单独使用,避免和其他拦截器冲突。
配置好之后,调用分页接口应该能在控制台看到SQL末尾带有LIMIT ?字样的日志输出,这就说明分页生效了。
7. 最容易翻车的四个故障:我把排查思路和真实原因写在一起
整合教程看再多,真正让自己成长的都是调试BUG时的过程。下面四个问题是我在指导新手过程中出现频率最高的,每个都有明确的症状和定位路径。
7.1 启动报错找不到Mapper的Bean:三级排查法
错误特征:启动时出现Field userMapper in ... required a bean of type '...UserMapper' that could not be found。
排查顺序从简单到复杂:
第一,检查DemoApplication上是否加了@MapperScan,并且包路径是否真的对应到Mapper接口所在地。com.example.demo.mapper和com.example.demo.Mapper在大小写敏感的文件系统上是两个完全不同的路径。
第二,检查Mapper接口上有没有加@Mapper。如果加了@MapperScan,@Mapper可以不加,两者存在一个会重复扫描,但不会因此报错。真正会报错的是两者都没加。
第三,检查pom.xml有没有引入mybatis-plus-boot-starter。有时只引入了MyBatis核心包或Spring Boot Starter JDBC,导致Mapper的注解处理器根本没注册。
排查时不要同时改多个位置,避免定位不到真正的修复点。
7.2 时间字段查出来是字符串或时间差八小时
表现特征:接口返回的createTime变成了比数据库时间少8小时的字符串,或者前台拿到的时间格式不是预期的yyyy-MM-dd HH:mm:ss。
根因通常是两个:一个是JDBC连接串里的serverTimezone设置和系统当前时区不一致,另一个是JSON序列化时没有指定时间格式。前者改url参数即可,后者在application.yml里配置Jackson:
spring: jackson: date-format: yyyy-MM-dd HH:mm:ss time-zone: Asia/Shanghai测试环境数据库用datetime类型保存的是不带时区的时间值,Java拿到以后按本地时区理解,再经过Jackson序列化输出,每一步都必须对齐。我遇到过的状况是“本地电脑正常但服务器输出差8小时”,最后发现服务器系统时区被设置成了UTC,加上JVM默认TimeZone跟随系统,于是数字偏移出现。可以用TimeZone.setDefault(TimeZone.getTimeZone("Asia/Shanghai"))作为临时修正,但更推荐在部署脚本里给JVM加-Duser.timezone=Asia/Shanghai参数。
7.3 配置了逻辑删除,查询却还能看到已删除的数据
原因通常是@TableLogic和application.yml里的全局逻辑删除配置有所重复,但实际执行时其中一方没有生效。最典型的是:实体类字段上加了@TableLogic,但yml里没配置逻辑删除的值属性,或者配置了全局字段但实体类的字段名不匹配。
框架的逻辑删除原理是:执行删除时把deleted字段从0更新为1;执行查询时自动追加WHERE deleted = 0条件。这个自动行为必须等到框架把你定义的字段认定为逻辑删除字段后才生效。检查点如下:
- 实体类字段名与表字段名是否一致(或写了正确的
@TableField映射); logic-delete-field全局配置的值是否等于实体类属性名deleted,注意这里填的是Java属性名,不是数据库列名;- 新增时deleted的默认值是否设置,如果数据库字段没有DEFAULT 0,插入时框架也不会自动帮你补0,需要在实体类初始化时手动赋值。
还有一种低级情况是查询语句自己在XML里写了WHERE deleted = 0,同时又配置了全局逻辑删除,结果拼接SQL时出现重复条件。虽然不会报错,但语义上很冗余。
7.4 分页查询返回的总条数不对或没有LIMIT
分页插件没有生效时,最明显的特点是SQL日志里没有LIMIT关键字,系统返回的数据是全部数据,但total字段却算出一个奇怪的值。
检查分页插件是否被注册,以及对DbType的指定是否与当前数据库匹配。如果项目引入了多个MybatisPlusInterceptorBean,也会出现插件互相覆盖的情况,最好保持全项目只有一个这类Bean。
另外一个容易被忽略的细节:Page对象必须作为第一个参数传入Mapper方法。如果你把Page对象放到Wrapper后面,有些版本的分页拦截器可能解析不到,SQL就不会拼接分页。标准调用姿势是userService.page(new Page<>(pageNum, pageSize), wrapper)。传参位置不对导致的故障现象在排查时非常迷惑,所以优先检查这个方法调用顺序。
8. 整合完成后,下一步建议这样扩展
项目能跑起来只是第一步。既然已经建立了Spring Boot + MyBatis-Plus的完整链路,后续可以按下面的顺序继续补充实战能力:
- 实现自动填充:统一处理
create_time和update_time,避免每个业务方法都手动赋值; - 掌握条件构造器:
QueryWrapper和LambdaQueryWrapper的常用方法能覆盖大部分动态SQL场景,减少XML文件数量; - 配置乐观锁:通过
@Version注解处理并发更新问题,MyBatis-Plus内置乐观锁插件,配置成本极低; - 了解多数据源:在项目里配置两个数据库连接,MyBatis-Plus官方有动态数据源扩展包,适合读写分离场景;
- 熟悉代码生成器:根据表结构自动生成实体、Mapper、Service、Controller模板代码,配合现有整合配置能极大提速新模块开发。
还有一个建议必须单独提醒:不要把MyBatis-Plus当成万能工具。复杂报表汇总、多表联查优化、特殊SQL语法仍然建议手写XML,框架的好处是让80%简单操作成为“现成的”,剩下20%复杂场景应该回归MyBatis本身的能力去处理。
我在实际整合过程中的体会是,配置本身只要花些时间就能搞定,真正拉开差距的是理解框架背后的映射规则和拦截器机制。遇到问题先看SQL日志,再对照框架文档,比盲目改配置更有效率。希望这篇保姆级内容帮你省下那些我当年完全靠踩坑才换来的时间。