17.MyBatis-Plus

MyBatis-Plus:账户管理中的增删改查、分页与代码生成
做账户管理、商品管理或员工管理时,经常需要按编号查询、按条件筛选、分页显示,再添加、修改和删除记录。这些操作的 SQL 大多相似,重复写一遍并不能增加多少业务价值。
MyBatis-Plus(简称 MP)在 MyBatis 的基础上提供了这些通用操作。我们先告诉它“哪个类对应哪张表”,再通过 Mapper 调用方法;遇到复杂报表或多表查询,仍然可以自己写 SQL。
我们用一个账户管理的小需求来学习:管理员先看到账户列表,再按姓名、年龄筛选,最后对账户进行增删改。先用几个方法把这些操作完成,再看代码生成器怎样省去新建实体和接口的重复工作。
1. Spring Boot:让项目先运行起来
1.1 是什么,为什么要用
Spring Boot 帮我们快速搭建 Spring 应用。过去需要手动配置的很多组件,可以在引入依赖、提供必要配置后,由自动配置完成装配。
拿连接数据库来说,过去往往要分别配置数据源和 MyBatis。使用 Spring Boot 时,先加入 MyBatis-Plus 提供的 starter,再填写数据库地址、用户名和密码,常用组件就可以由框架创建。数据源负责取得数据库连接,SqlSessionFactory 则供 MyBatis 创建执行 SQL 所需的会话。
这就是自动配置的一个例子:框架根据项目中已有的依赖、配置和 Bean 决定提供哪些组件。缺少依赖时不会配置相应组件;我们自己提供了某些组件时,也可能采用自己的配置。因此,自动配置是有条件的,不是随便加入一个库,它就会自动运行。
在日常开发中,它还提供这些便利:
- Web 项目通常可以使用内嵌 Tomcat,直接启动应用。
- 常用设置集中放在
application.yml或application.properties中。 - Spring MVC 可以配合 JSON 转换器处理接口请求和响应。
- 许多第三方库提供 starter,减少重复配置。
这些便利让我们把精力放到业务上,但具体接口仍然要自己编写。比如账户管理常用 GET 查询、POST 新增、PUT 更新、DELETE 删除;Spring Boot 提供运行这些接口的环境,不会自动替我们写好业务。大部分配置可以使用 Java 注解和配置文件完成,需要时也仍然能加载 XML。
1.2 准备本文使用的环境
示例固定使用 Java 17、Spring Boot 3.4.5、MyBatis-Plus 3.5.17、MySQL 8,方便把依赖和代码对应起来。这是演示组合,不表示必须升级到这组版本。
已有 Boot 3 项目时,加入下面的依赖;新建 Maven 项目时,还需要设置 Boot 父项目:
xml
<parent>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>3.4.5</version>
<relativePath/>
</parent>
<properties>
<java.version>17</java.version>
<mybatis-plus.version>3.5.17</mybatis-plus.version>
</properties>
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<!-- Boot 3 使用这个 starter,不要同时引入普通 MyBatis starter。 -->
<dependency>
<groupId>com.baomidou</groupId>
<artifactId>mybatis-plus-spring-boot3-starter</artifactId>
<version>${mybatis-plus.version}</version>
</dependency>
<!-- MP 3.5.9 起,分页解析器需要单独加入。 -->
<dependency>
<groupId>com.baomidou</groupId>
<artifactId>mybatis-plus-jsqlparser</artifactId>
<version>${mybatis-plus.version}</version>
</dependency>
<dependency>
<groupId>com.mysql</groupId>
<artifactId>mysql-connector-j</artifactId>
<scope>runtime</scope>
</dependency>
<!-- 用注解生成 getter、setter,避免实体类被重复方法占满。 -->
<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>
</dependencies>
Lombok 的 @Data 会在编译时生成 getter、setter 等方法。IDE 中使用这些方法时若提示不存在,检查 Lombok 支持和注解处理是否开启。
启动类放在 com.example.mybatisplus 包下,后面的实体、Mapper 和配置分别放在它的子包里:
java
package com.example.mybatisplus;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
@SpringBootApplication
public class MybatisPlusApplication {
public static void main(String[] args) {
SpringApplication.run(MybatisPlusApplication.class, args);
}
}
2. 准备账户表和数据库连接
项目的启动入口有了,接着准备它要操作的数据。先在 MySQL 中创建 account 表,放入三条账户记录。后面每次查询都能拿这三条数据对照,判断条件有没有写对。
sql
CREATE DATABASE IF NOT EXISTS mybatis_plus_demo
CHARACTER SET utf8mb4;
USE mybatis_plus_demo;
CREATE TABLE account (
id INT PRIMARY KEY AUTO_INCREMENT,
username VARCHAR(30) NOT NULL,
password VARCHAR(100) NOT NULL,
age INT
);
INSERT INTO account (username, password, age) VALUES
('张三', 'demo-only', 18),
('李四', 'demo-only', 20),
('王五', 'demo-only', 22);
这里的 demo-only 只是演示数据,后文不实现登录。真正处理用户密码时,应保存 BCrypt 等密码编码器的结果,不能照抄成明文密码存储。
接着在 application.yml 中填写连接信息:
yaml
spring:
datasource:
url: jdbc:mysql://localhost:3306/mybatis_plus_demo?useSSL=false&serverTimezone=Asia/Shanghai&characterEncoding=utf8
username: root
password: root
mybatis-plus:
configuration:
# 学习时观察实际生成的 SQL,理解方法和数据库操作的对应关系。
log-impl: org.apache.ibatis.logging.stdout.StdOutImpl
主机、端口、账号和密码要与自己的数据库一致。SQL 日志会显示参数,适合本地观察;正式环境应按项目日志策略配置,避免输出敏感数据。
3. 实体类:说明对象与表怎样对应
3.1 三个常用注解
现在数据库里已经有账户记录,不过 Java 代码还不知道这些列对应哪个对象。我们先定义 Account 实体类:一行账户记录对应一个对象,记录里的每一列对应对象的一个属性。
为了让框架找对表和列,需要说明这几种映射关系:
@TableName:指定表名。@TableId:指定主键列和主键生成策略。@TableField:指定普通列。
没有指定表名时,框架会按类名推导表名,例如默认设置下 Account 对应 account;普通属性与列名也可以按约定映射,默认支持下划线与驼峰对应。名称符合规则时,可以省略部分注解;名称不同或需要明确策略时,再用注解说明。
java
package com.example.mybatisplus.entity;
import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableField;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import com.fasterxml.jackson.annotation.JsonIgnore;
import lombok.Data;
@Data
@TableName("account")
public class Account {
// 表使用自增主键,新增时交给数据库生成。
@TableId(value = "id", type = IdType.AUTO)
private Integer id;
@TableField("username")
private String username;
// 避免接口序列化时把密码字段返回给客户端。
@JsonIgnore
@TableField("password")
private String password;
private Integer age;
}
这里的 id、username、password 与列名相同,写注解是为了看清对应关系;age 则演示省略普通字段注解。
假如 Java 属性改叫 name,数据库仍叫 username,只需把这一项改为:
java
@TableField("username")
private String name;
这是属性命名的另一种写法,不是再给前面的类增加一个重名含义的字段。本文后续继续使用 username,对应方法是 getUsername()、setUsername()。
主键属性也可以与列名不同。例如把 Java 属性叫作 num,表中的主键仍然叫 id:
java
@TableId(value = "id", type = IdType.AUTO)
private Integer num;
这时 Java 中通过 getNum()、setNum() 访问,数据库仍查询 id 列。它是替换 id 属性的另一种映射;后面的完整示例继续使用 id,避免两套命名混在一起。
不要仅给自增列写 @TableId("id") 就认为已经指定了自增策略。MP 有自己的默认主键策略;像本例这样明确使用 IdType.AUTO,才能和建表方式对应。
3.2 Mapper 为什么只写接口
实体解决了“用什么对象装数据”,接着还需要一个数据库操作入口,这就是 Mapper。
MyBatis-Plus 提供了 BaseMapper<T> 接口,里面定义了按主键查询、添加、修改、删除等通用方法。这里把 T 换成 Account,框架就能结合前面的映射信息,为账户表准备这些方法对应的 SQL。我们只需要声明接口,不必再为普通单表操作逐条编写 XML。
java
package com.example.mybatisplus.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.example.mybatisplus.entity.Account;
import org.apache.ibatis.annotations.Mapper;
@Mapper
public interface AccountMapper extends BaseMapper<Account> {
}
@Mapper 标记这个接口,让整合组件把它注册成可注入的 Mapper 代理。调用代理时,MyBatis 执行对应 SQL;不是 Java 接口自己突然有了普通实现代码。
Mapper 很多时,也可以在启动类上使用 @MapperScan("com.example.mybatisplus.mapper") 统一扫描,二者选一种即可。
4. 先完成基本的增删改查
4.1 准备调用位置
有了实体和 Mapper,现在就可以真正访问账户表了。先从最容易观察的操作开始:查询编号为 1 的账户,打印它的用户名。
为了直接运行这个例子,把调用放到测试类中。测试类与启动类放在同一个根包下,@SpringBootTest 会准备应用环境,@Autowired 则把框架创建的 Mapper 代理交给我们使用。
java
package com.example.mybatisplus;
import com.example.mybatisplus.entity.Account;
import com.example.mybatisplus.mapper.AccountMapper;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
@SpringBootTest
class AccountMapperTest {
@Autowired
private AccountMapper accountMapper;
@Test
void findOne() {
Account account = accountMapper.selectById(1);
// 查不到会返回 null,使用属性前先判断。
if (account != null) {
System.out.println(account.getUsername());
}
}
}
刚执行完初始化 SQL 时,这个测试输出 张三。观察日志还会看到按主键查询的 SQL。
后面的调用片段可以分别放到这个测试类新建的 @Test 方法中执行,继续使用同一个 accountMapper。增删改会改变数据,比较结果时应先恢复初始化数据,或者用测试事务回滚;不要假设反复运行后 id 仍然完全相同。
4.2 添加:保存一个新账户
刚才已经能够读出一条记录了。如果管理员要添加新账户,就反过来:先把信息放进 Account 对象,再交给 insert 保存到数据库。
java
Account account = new Account();
account.setUsername("艾米诺斯");
account.setPassword("demo-only"); // 这里只是数据库操作练习。
account.setAge(18);
// 没有手动设置 id,数据库生成自增值后会回填到对象。
int rows = accountMapper.insert(account);
System.out.println("新增行数:" + rows);
System.out.println("新账户编号:" + account.getId());
插入成功时,rows 通常为 1,account.getId() 能拿到新增主键。主键不保证连续,不能拿“上一次 id 加一”代替实际返回值。
4.3 修改:按编号更新名字
账户添加后,管理员还可能修改它的用户名。下面仍然使用编号 1 的账户:先查出对象,把用户名改为“修改测试”,再调用 updateById 保存变化。
java
Account account = accountMapper.selectById(1);
if (account != null) {
account.setUsername("修改测试");
// 使用对象里的 id 作为 WHERE 条件。
int rows = accountMapper.updateById(account);
System.out.println("修改行数:" + rows);
}
执行后,编号 1 的用户名变为 修改测试。updateById 依据主键定位记录,不能只设置用户名就期待它知道要改谁。
只改一个字段时,也可以新建对象,设置 id 与新值后调用 updateById。默认更新策略通常忽略 null 属性,因此“没有设置”与“把数据库值改为 NULL”不是一回事。
4.4 删除:按指定编号删除
添加和修改都完成了,再看删除。后台页面通常允许管理员勾选几条记录一起删除,这时需要把选中的编号作为条件,而不是删除整张表。
这里先用 QueryWrapper 描述条件。它是 MyBatis-Plus 提供的条件构造器,in("id", ids) 表示“编号在这组编号中”。后面还会用它完成页面筛选。先补上需要的导入:
java
import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper;
import java.util.List;
下面是调用代码:
java
List<Integer> ids = List.of(1, 2, 3);
if (!ids.isEmpty()) {
QueryWrapper<Account> wrapper = new QueryWrapper<>();
wrapper.in("id", ids);
int rows = accountMapper.delete(wrapper);
System.out.println("删除行数:" + rows);
}
这条语句只删除 id 属于 1、2、3 的记录。调用前判断集合非空,既表达了业务意图,也避免空条件组合让删除范围发生变化。
只删一条时可以调用 accountMapper.deleteById(1)。对于更新和删除,先写清条件再执行,不能把无条件操作当作普通批量操作。
5. 条件查询:把页面筛选项变成 SQL 条件
5.1 QueryWrapper 怎样参与查询
前面的删除使用了 QueryWrapper,查询也可以使用它。Wrapper 负责描述“要哪些记录”,selectList 负责执行查询并把每一行转成 Account。
比如账户管理页要查找名字带“三”的用户,我们不用自己拼接 SQL,只要把列名、比较方式和比较值交给 Wrapper:
java
QueryWrapper<Account> wrapper = new QueryWrapper<>();
wrapper.like("username", "三"); // 查询用户名中包含“三”的记录。
List<Account> accounts = accountMapper.selectList(wrapper);
for (Account account : accounts) {
System.out.println(account.getUsername());
}
对初始化的三条记录,这里输出 张三。like 生成的条件相当于 username LIKE '%三%',前后的百分号表示“三”的前后还可以有其他字符。
这一段的过程是:先创建条件对象,再加入条件,最后把它传给 Mapper。条件对象本身不会查询数据库,调用 selectList 时才会执行 SQL。没有匹配记录时,得到的是空列表,循环不会执行。
下面分别看几种常用条件。每个例子都是一次独立查询,都从新的 Wrapper 开始;运行时使用初始化数据,不把前面修改、删除后的结果混进来。
5.2 eq 和 ne:等于、不等于
知道账户编号时,可以用 eq 按明确的值查询:
java
QueryWrapper<Account> wrapper = new QueryWrapper<>();
wrapper.eq("id", 1); // WHERE id = 1
List<Account> accounts = accountMapper.selectList(wrapper);
System.out.println(accounts.get(0).getUsername()); // 初始化数据下是张三。
这里为了观察结果,使用了已经存在的编号 1。实际编号来自用户输入时,可能查不到,读取列表下标前需要先判断是否为空;只按主键取一个账户,直接使用前面的 selectById 会更方便。
如果页面要排除 18 岁的账户,则用 ne,也就是“不等于”:
java
QueryWrapper<Account> wrapper = new QueryWrapper<>();
wrapper.ne("age", 18);
wrapper.orderByAsc("id"); // 按编号显示,方便对照结果。
List<Account> accounts = accountMapper.selectList(wrapper);
for (Account account : accounts) {
System.out.println(account.getUsername());
}
此时输出 李四、王五,因为两人的年龄分别是 20 和 22。ne 不是 Java 中的对象比较,它是在数据库查询条件中表达 age <> 18。
5.3 le 和 ge:比较年龄大小
筛选未超过 18 岁的账户时,需要的是“小于等于”,对应 le:
java
QueryWrapper<Account> wrapper = new QueryWrapper<>();
wrapper.le("age", 18); // 年龄小于或等于 18,包含 18。
List<Account> accounts = accountMapper.selectList(wrapper);
初始化数据下只查到张三。如果把条件改成下面这一句,需求就变成了“至少 18 岁”:
java
wrapper.ge("age", 18); // 年龄大于或等于 18,包含 18。
这句是替换上一段的 le,不是把两个条件叠在一起。替换后,三个人都符合条件。
不想包含边界值时,使用 lt(小于)或 gt(大于)。例如 gt("age", 18) 只查询李四和王五,张三正好 18 岁,不在结果里。
5.4 in:满足一组值中的任意一个
如果页面允许勾选多个年龄,例如只看 18 岁和 22 岁的账户,就用 in:
java
List<Integer> ages = List.of(18, 22);
QueryWrapper<Account> wrapper = new QueryWrapper<>();
wrapper.in("age", ages);
wrapper.orderByAsc("id");
List<Account> accounts = accountMapper.selectList(wrapper);
这相当于 age IN (18, 22),得到张三和王五;20 岁的李四不会被选中。前面的批量删除也是同样的道理,只是条件写在 id 上。
集合来自页面多选项时,要先决定“用户一个也没选”代表什么。对于删除,本例选择不执行;对于查询,可以约定为空时不加筛选条件。不要依赖空集合碰巧生成的 SQL 来决定业务含义。
5.5 between:查询一个包含两端的范围
年龄范围筛选通常有起始年龄和结束年龄,这时可以用 between:
java
QueryWrapper<Account> wrapper = new QueryWrapper<>();
wrapper.between("age", 18, 22); // 包含 18 和 22,不是开区间。
List<Account> accounts = accountMapper.selectList(wrapper);
这里三条记录都符合条件。如果只要范围内且名字带“三”的账户,可以继续接一个 like:
java
QueryWrapper<Account> wrapper = new QueryWrapper<>();
wrapper.between("age", 18, 22)
.like("username", "三");
List<Account> accounts = accountMapper.selectList(wrapper);
连续添加的这两个条件默认用 AND 连接,意思是“年龄在范围内,并且名字包含三”,所以只剩张三。这样就能把页面上的多个筛选项组合起来。
5.6 用户只填写部分筛选项怎么办
现在能表达条件了,不过页面上的条件不一定都填写。例如用户填了姓名,没有填年龄上限,就不应该生成 age <= null。
Wrapper 的很多方法允许把布尔值放在第一个参数:为 true 时加入条件,为 false 时跳过。下面模拟用户只填了姓名:
java
String name = "三";
Integer maxAge = null;
QueryWrapper<Account> wrapper = new QueryWrapper<>();
wrapper.like(name != null && !name.isBlank(), "username", name)
.le(maxAge != null, "age", maxAge);
List<Account> accounts = accountMapper.selectList(wrapper);
这次只按姓名查询。若把 maxAge 改为 18,就会同时要求年龄不超过 18。我们只需描述“什么情况下使用这条条件”,不需要手动拼接 SQL。
注意,列名仍由后端代码决定,输入值则交给参数绑定处理。可以让用户输入姓名,不能把用户输入直接当作任意 SQL 列名或排序片段。
还有一种容易混淆的情况:查找“没有填写年龄”的账户,需要使用 isNull("age")。数据库中判断 NULL 有自己的规则,eq("age", null) 不能代替 IS NULL。
5.7 LambdaQueryWrapper:通过属性选择字段
普通 Wrapper 的第一个参数使用数据库列名,像 "username" 写错一个字母,通常要等运行 SQL 才能发现。如果希望在写代码时就检查属性名,可以换成 LambdaQueryWrapper。
先导入这个类型:
java
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
仍然查询名字带“三”、年龄至少 18 岁的账户:
java
LambdaQueryWrapper<Account> wrapper = new LambdaQueryWrapper<>();
wrapper.like(Account::getUsername, "三")
.ge(Account::getAge, 18)
.orderByAsc(Account::getId);
List<Account> accounts = accountMapper.selectList(wrapper);
Account::getUsername 指向实体的 getter,MP 再按照实体映射找到 username 列。这时我们选择的是 Java 属性,而不是手写数据库列名。查询结果与前面的条件组合相同,仍是张三;变化的是字段选择方式。
6. 分页:让列表每次只取一页
6.1 为什么不能一次查完
前面的条件查询可以找出符合要求的账户,但账户越来越多时,页面通常只显示其中十条。若先把几万条记录全部查出来,再在 Java 中截取十条,数据库传输了大量暂时用不到的数据,应用也要占用更多内存。
所以还需要把“查哪些记录”与“本次只取哪一页”结合起来。
Page 是 MyBatis-Plus 提供的分页对象,既可以装入页码、每页条数,也可以保存查询后的记录、总条数等信息。分页插件会读取这些参数,在执行查询时加入数据库分页语句,让数据库只返回当前页。
6.2 注册分页插件
前面的依赖已经包含 mybatis-plus-jsqlparser。再创建配置类:
java
package com.example.mybatisplus.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 PageConfiguration {
@Bean
public MybatisPlusInterceptor mybatisPlusInterceptor() {
MybatisPlusInterceptor interceptor = new MybatisPlusInterceptor();
PaginationInnerInterceptor pagination =
new PaginationInnerInterceptor(DbType.MYSQL);
// 限制单页最多 1000 条,避免一次请求读取过多数据。
pagination.setMaxLimit(1000L);
// 请求页超过最后一页时,不自动回到第 1 页。
pagination.setOverflow(false);
interceptor.addInnerInterceptor(pagination);
return interceptor;
}
}
@Bean 把插件交给 Spring 管理,MyBatis-Plus 才会把它装入执行过程。以后叠加多个内部插件时,分页插件通常放在最后。
6.3 查询并观察页数据
调用处导入 com.baomidou.mybatisplus.extension.plugins.pagination.Page,然后执行:
java
QueryWrapper<Account> wrapper = new QueryWrapper<>();
wrapper.orderByAsc("id"); // 固定顺序,让前后两页有可比较的结果。
Page<Account> page = new Page<>(1, 2); // 页码从 1 开始,每页 2 条。
Page<Account> result = accountMapper.selectPage(page, wrapper);
System.out.println("总条数:" + result.getTotal());
System.out.println("总页数:" + result.getPages());
for (Account account : result.getRecords()) {
System.out.println(account.getUsername());
}
使用初始化数据时,总条数为 3,总页数为 2,第一页是 张三、李四。改成 new Page<>(2, 2) 后,第二页只有 王五。
getRecords() 是当前页列表,getTotal() 是符合条件的总记录数。筛选年龄再分页时,把年龄条件加入同一个 Wrapper 即可。分页查询应指定稳定排序;数据不断变化时,也不能仅凭页码保证跨多次请求完全不重复。
7. 自定义 SQL:通用方法之外仍然能自己写
7.1 什么时候需要
到这里,单表的增删改查、条件筛选和分页都能通过现成方法完成了。不过,业务不会总是这么规整:比如统计每个账户的订单总额,就可能涉及多表和聚合函数。
这时仍然可以使用 MyBatis 的注解或 XML 编写 SQL。MyBatis-Plus 增加了通用方法,并没有要求所有查询都改成 Wrapper。先用一个简单的自定义条件,看看两种方法怎样放在同一个 Mapper 中。
先给前面的 AccountMapper 增加一个简单查询。下面是扩展后的接口,替换原接口定义即可:
java
package com.example.mybatisplus.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.example.mybatisplus.entity.Account;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
import org.apache.ibatis.annotations.Select;
import java.util.List;
@Mapper
public interface AccountMapper extends BaseMapper<Account> {
// 使用传入编号作为条件,不把阈值写死在 SQL 中。
@Select("""
SELECT id, username, password, age
FROM account
WHERE id > #{id}
ORDER BY id
""")
List<Account> selectByAccountId(@Param("id") Integer id);
}
调用时传入阈值:
java
List<Account> accounts = accountMapper.selectByAccountId(1);
for (Account account : accounts) {
System.out.println(account.getUsername());
}
初始化数据下,结果是 李四、王五。@Param("id") 与 #{id} 对应,MyBatis 使用预编译参数绑定。不要把它改成 ${id} 字符串替换来拼接用户输入。
SQL 较长时可以放到 XML:namespace 对应 Mapper 接口全限定名,语句 id 对应方法名。MyBatis-Plus 增加的是通用能力,并没有取消原来的映射方式。
8. 代码生成器:从表结构生成基础代码
8.1 已经会手写了,为什么还要生成
前面是先建表,再写 Account 和 AccountMapper。一张表这样做很直观,但后台管理通常还有部门表、岗位表、商品表。每增加一张表,都要重复创建实体、Mapper、Service 等基础代码。
代码生成器就是为这类重复工作准备的。它通过 JDBC 读取数据库里的表名、列名、字段类型和主键,再根据模板生成相应代码。例如 account 表里的 username 是字符串列,就能生成实体中的字符串属性。
常见的业务分层是:
text
请求 → Controller → Service 接口 → ServiceImpl → Mapper → 数据库
这些名称分别负责什么,可以先结合账户列表理解:
Controller:接收“查询账户列表”的请求,把处理结果返回给前端。Service:定义账户业务能做什么,例如查询账户列表。ServiceImpl:实现这些业务,需要访问数据时调用 Mapper。Mapper:执行数据库查询,把每一行转成实体对象。Entity:实体类,例如Account,用属性对应账户表的列。
所以 Service 和 Mapper 通常是接口,ServiceImpl 才是业务接口的实现类。生成器能创建这些基础结构,但它不知道“用户名不能重复”“余额不足不能扣款”这样的业务规则,需要我们自己补充。
还要区分“生成 Controller”和“生成完整接口”。默认 Controller 往往只有类和路由标记,并没有把所有增删改查接口都写好。后面我们会在已有 Mapper 上接一条账户查询接口,看看各层怎样配合。
8.2 不同 Spring Boot 版本怎样选择
准备使用生成器之前,先分清两个依赖的用途:
starter 是给应用运行时用的。 它负责把 MyBatis-Plus 接进 Spring Boot,让 Mapper 可以注入、数据库操作可以执行。因此要根据 Boot 大版本选择。
generator 是开发时的工具。 它读取表结构、写出 Java 和 XML 文件,通常是开发者需要生成代码时手动运行一次,不是应用每次启动都运行。
以本文采用的 MP 3.5.17 为例,starter 对应关系是:
| Spring Boot 大版本 | 运行时 starter |
|---|---|
| Boot 2 | mybatis-plus-boot-starter |
| Boot 3 | mybatis-plus-spring-boot3-starter |
| Boot 4 | mybatis-plus-spring-boot4-starter |
而生成器依赖都叫 mybatis-plus-generator,本例都可以采用新版的 FastAutoGenerator 写法。不是 Boot 2 写一套生成器、Boot 3 就必须换一套完全不同的调用代码。
不过,生成的代码最终要放回项目编译,所以模板也要与项目匹配。例如 Boot 3/4 使用 Jakarta 生态:如果自定义模板生成了校验注解,要检查是 jakarta.validation 还是旧项目常见的 javax.validation。普通实体和 Mapper 没有使用这些类型时,不需要为了版本差异额外添加无关注解。
此外,旧教程可能使用 AutoGenerator 的另一套配置方式。FastAutoGenerator 是 3.5.1 及以后提供的新生成器用法,不能把旧配置直接插进新版调用链。本文下面统一使用 3.5.17,不混用两套 API;选择其他版本时,对照相应版本文档。
8.3 准备依赖和模板引擎
前面的项目已经有数据库驱动,再追加两个依赖:
xml
<dependency>
<groupId>com.baomidou</groupId>
<artifactId>mybatis-plus-generator</artifactId>
<version>${mybatis-plus.version}</version>
</dependency>
<dependency>
<groupId>org.freemarker</groupId>
<artifactId>freemarker</artifactId>
</dependency>
这里的 ${mybatis-plus.version} 继续使用前面定义的 3.5.17。生成器负责整理表结构信息,FreeMarker 则负责把这些信息填到代码模板里。可以把模板理解成“已经留好类名、属性名位置的代码样板”。
FreeMarker 在这里用于生成源代码,不是在网站运行时渲染网页,因此不需要为这件事再引入网页模板 starter。如果把生成器做成单独的工具项目,记得同时加入 MySQL 驱动,它仍要自己连接数据库。
8.4 先运行一次,看看生成了什么
下面继续读取已经建好的 account 表。把生成器类放到 com.example.mybatisplus.tool 包中,直接运行 main;它通过 JDBC 访问数据库,不需要先启动 Spring Boot 应用。
为方便观察,代码先写出连接信息和输出位置,再调用生成器。这里的 root/root 对应前面的本地练习配置,实际练习时改成自己的数据库账号。
java
package com.example.mybatisplus.tool;
import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.generator.FastAutoGenerator;
import com.baomidou.mybatisplus.generator.config.OutputFile;
import com.baomidou.mybatisplus.generator.engine.FreemarkerTemplateEngine;
import java.nio.file.Path;
import java.util.Map;
public class AccountCodeGenerator {
public static void main(String[] args) {
String url = "jdbc:mysql://localhost:3306/mybatis_plus_demo"
+ "?useSSL=false&serverTimezone=Asia/Shanghai";
String username = "root";
String password = "root";
// 先生成到独立目录,便于对照已有代码,不覆盖手写的业务。
String output = Path.of(System.getProperty("user.dir"),
"generated-code").toAbsolutePath().toString();
System.out.println("生成位置:" + output);
FastAutoGenerator.create(url, username, password)
.globalConfig(builder -> builder
.author("yigalaxy")
.commentDate("yyyy-MM-dd")
.disableOpenDir()
.outputDir(output + "/java"))
.packageConfig(builder -> builder
.parent("com.example.generated")
.entity("entity")
.mapper("mapper")
.service("service")
.serviceImpl("service.impl")
.controller("controller")
.pathInfo(Map.of(
OutputFile.xml, output + "/resources/mapper")))
.strategyConfig(builder -> {
builder.addInclude("account"); // 这里填数据库表名。
builder.entityBuilder()
.idType(IdType.AUTO)
.enableLombok()
.enableTableFieldAnnotation();
builder.mapperBuilder().enableMapperAnnotation();
// 明确生成 Service 接口以及对应实现类。
builder.serviceBuilder()
.formatServiceFileName("I%sService")
.formatServiceImplFileName("%sServiceImpl");
builder.controllerBuilder().enableRestStyle();
})
.templateEngine(new FreemarkerTemplateEngine())
.execute(); // 配置完成后,才真正读表并生成文件。
}
}
这里暂时只用一张表,也没有自定义模板。运行成功后,控制台会打印输出目录,目录中能看到类似下面的结构:
text
generated-code/
java/com/example/generated/
entity/Account.java
mapper/AccountMapper.java
service/IAccountService.java
service/impl/AccountServiceImpl.java
controller/AccountController.java
resources/mapper/AccountMapper.xml
可以先打开生成的实体,对照数据库里的 id、username、password、age;再看 Mapper 是否继承了 BaseMapper<Account>。这样就能把“读表生成代码”与前面自己手写的内容对应起来。
这些代码现在位于 generated-code,不会因为生成成功就自动加入应用。前面手写的账户代码仍然继续工作,后面再说明如何接入生成结果。
8.5 这几组配置分别控制什么
看到了生成结果,再回头读配置,会比一开始记住整段调用更容易。生成器主要回答四个问题:连哪个库、生成到哪里、使用什么包名、处理哪些表。
第一步是连接数据库。 FastAutoGenerator.create(url, username, password) 使用前面三个变量建立连接。URL 最后的库名是 mybatis_plus_demo,所以 account 必须存在于这个库里。能连接数据库软件,并不代表代码里的端口、库名和密码也一定填对,要逐项对应。
第二步是全局设置。 globalConfig 控制作者、注释日期以及 Java 代码输出位置。比如其中这一项:
java
.outputDir(output + "/java")
表示 Java 文件统一写到 generated-code/java 下面。System.getProperty("user.dir") 是当前运行的工作目录;IDE 的运行目录不同,生成位置也可能不同,所以前面的代码把绝对路径打印出来。
第三步是包设置。 parent("com.example.generated") 给出父包,entity("entity") 则给实体加上子包,两者合起来就是:
java
package com.example.generated.entity;
同样,Mapper 会进入 com.example.generated.mapper,业务接口会进入 com.example.generated.service。包名负责组织 Java 类型,磁盘输出目录则决定文件写到哪,这两件事要分清楚。
XML 另外用 pathInfo 指定位置,因为它最后要作为资源加载,而不是作为 Java 类编译:
java
.pathInfo(Map.of(OutputFile.xml, output + "/resources/mapper"))
有的写法会配置 .xml("mapper.xml"),那主要是在调整 XML 包配置,不能拿它代替实际资源目录安排。本例直接指定资源目录,后面复制到项目时也容易对应。
第四步是生成策略。 strategyConfig 控制哪些表参与生成,以及实体、Mapper、Service、Controller 的细节。这里的 builder 是框架交给我们的配置对象,我们在花括号中调用它的方法即可。
先选表:
java
builder.addInclude("account");
如果以后数据库还有 department 表,需要一起生成,可以把这一句替换为:
java
builder.addInclude("account", "department");
名称要与数据库中的表一致,不能在这里填写 Java 类名 Account。第一次练习只生成 account,等这一套流程跑通后再增加其他表。
再看实体策略:
java
builder.entityBuilder()
.idType(IdType.AUTO)
.enableLombok()
.enableTableFieldAnnotation();
idType 让实体主键采用与自增表一致的策略;enableLombok 使用 Lombok 生成访问方法;enableTableFieldAnnotation 让字段映射更明确。生成结果仍要检查,例如表的主键不是自增时,就不能照用 AUTO。
Mapper 策略中的 enableMapperAnnotation() 会添加 @Mapper。Service 策略中的 %s 是名称占位符,Account 代入后得到 IAccountService 和 AccountServiceImpl,两者分别是接口和实现类。
Controller 策略中的 enableRestStyle() 使用 @RestController。它只是选择控制器风格,具体查询方法还要写。最后的 templateEngine 选择 FreeMarker 模板,execute() 才真正开始生成。
8.6 把生成代码接入业务项目
到这里,生成器已经替我们创建了基础结构。要让应用使用它,还需要把这些结构放到项目能编译、扫描和加载的位置。
先看 Java 类。它们生成在 com.example.generated 下,而启动类位于 com.example.mybatisplus,默认扫描不会包含旁边的 generated 包。初学时最直观的办法,是把生成结果整理到启动类所在包的子包下,并同步调整 package 和 import。
接入时按下面的顺序处理:
- 将需要的 Java 类整理到
src/main/java下,使用应用的包名。 - 将 XML 整理到
src/main/resources/mapper下。 - 确认实体主键、字段类型和表名正确,Mapper 能被扫描。
- 在 ServiceImpl 中加入业务,在 Controller 中加入请求处理方法。
如果想明确指定 XML 位置,可以在已有 mybatis-plus 配置下增加 mapper-locations,不要再建立第二个同名根节点:
yaml
mybatis-plus:
mapper-locations: classpath*:/mapper/**/*.xml
configuration:
log-impl: org.apache.ibatis.logging.stdout.StdOutImpl
XML 里的 namespace 仍然需要对应 Mapper 的全限定名。如果调整了包名,也要一起检查,否则自定义 SQL 可能找不到。
前面已经有手写的 Account 和 AccountMapper,接入生成代码时二选一即可,不要把两套账户类型都放进同一个业务流程。生成器帮我们省去的是基础结构的重复劳动;字段校验、接口输出内容、事务边界等仍需按业务确定。
以后表里增加字段,可以再次生成到独立目录,然后对照新旧实体补上变化。已经手写了业务的 ServiceImpl 和 Controller,通常不应直接用新骨架替换。先比较、再合入需要的内容,能避免丢失业务代码。
需要统一类注释、父类或特殊字段类型时,再考虑自定义模板。先用默认模板完成“生成 → 检查 → 接入 → 调用”的过程,会更容易看出哪些地方值得定制。
9. 把账户查询接到一个接口上
9.1 先确定返回页面,还是返回数据
前面的操作都在测试里调用 Mapper,已经能确认数据库访问没有问题。但管理页面不能直接调用 Java 测试,它需要通过 HTTP 请求取得数据,所以还要补上业务层与控制器。
控制器该使用哪个注解,要看这次请求需要什么结果:
- 页面由服务端渲染时,可以使用
@Controller,方法返回的字符串通常表示视图名。 - 前端页面自己渲染、只向后端要账户数据时,可以使用
@RestController,让返回值写入响应体。
@RestController 相当于 @Controller 加 @ResponseBody。对象和列表通常会通过 JSON 转换器变成 JSON;返回字符串时,则通常直接把字符串写出去。因此,它不是“任何返回值都自动变成 JSON”。普通 @Controller 的方法加上 @ResponseBody,也能返回数据。
这里要提供账户列表给前端,所以选择 @RestController。我们继续使用前面手写的 Account 和 AccountMapper,将这一套流程接起来。
9.2 Service:让控制器通过业务入口取数据
先声明 Service 接口,说明账户业务提供一个“查询账户列表”的方法:
java
package com.example.mybatisplus.service;
import com.example.mybatisplus.entity.Account;
import java.util.List;
public interface AccountService {
List<Account> listAccounts();
}
再写实现类,在业务方法中调用 Mapper。现在这个方法只查询数据,以后要增加校验、组合多个数据库操作或开启事务,就有明确的放置位置。
java
package com.example.mybatisplus.service;
import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper;
import com.example.mybatisplus.entity.Account;
import com.example.mybatisplus.mapper.AccountMapper;
import org.springframework.stereotype.Service;
import java.util.List;
@Service
public class AccountServiceImpl implements AccountService {
private final AccountMapper accountMapper;
public AccountServiceImpl(AccountMapper accountMapper) {
this.accountMapper = accountMapper;
}
@Override
public List<Account> listAccounts() {
QueryWrapper<Account> wrapper = new QueryWrapper<>();
wrapper.orderByAsc("id"); // 让账户列表按编号排列。
return accountMapper.selectList(wrapper);
}
}
单个构造器可以由 Spring 自动注入依赖,因此这里不再写字段上的 @Autowired。控制器使用 AccountService 接口,Spring 会把它对应的实现对象传进来。
如果采用生成器生成的业务层,通常还会看到 IService<Account> 和 ServiceImpl<AccountMapper, Account>。它们是 MP 提供的通用业务接口和实现基类,包含常用业务操作;在生成的接口、实现类中补自己的方法即可。这里手写一个方法,是为了先看清 Controller、Service、Mapper 各自负责哪一步,不需要同时再复制一套生成的类。
9.3 AccountVO:明确前端需要哪些字段
数据库里的账户有密码字段,但列表页面只需要编号、用户名和年龄。与其让表里所有字段都跟着实体返回,不如定义一个专门的展示对象:
java
package com.example.mybatisplus.vo;
import lombok.Data;
@Data
public class AccountVO {
private Integer id;
private String username;
private Integer age;
}
VO 是 View Object 的常见命名约定,表示用于展示的数据对象,不是 Spring 要求的固定后缀。Account 继续对应表结构,AccountVO 只保留这次接口要展示的字段;两者即使当前大部分字段相同,职责也不同。
这里继续使用已经介绍过的 Lombok @Data,由它生成 getter 和 setter。后面控制器设置这三个属性,JSON 转换器就能读到它们。
9.4 Controller:取到账户后,组织列表响应
有了业务入口和展示对象,控制器就可以把数据库中的账户转换成前端需要的列表:
java
package com.example.mybatisplus.controller;
import com.example.mybatisplus.entity.Account;
import com.example.mybatisplus.service.AccountService;
import com.example.mybatisplus.vo.AccountVO;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import java.util.ArrayList;
import java.util.List;
@RestController
@RequestMapping("/accounts")
public class AccountController {
private final AccountService accountService;
public AccountController(AccountService accountService) {
this.accountService = accountService;
}
@GetMapping
public List<AccountVO> list() {
List<Account> accounts = accountService.listAccounts();
List<AccountVO> result = new ArrayList<>();
for (Account account : accounts) {
// 只把列表页面需要的字段放入响应,不加入密码。
AccountVO vo = new AccountVO();
vo.setId(account.getId());
vo.setUsername(account.getUsername());
vo.setAge(account.getAge());
result.add(vo);
}
return result;
}
}
这个例子直接用循环复制字段,便于看清哪些信息进入了响应。数据量大或转换规则复杂时,可以再按项目习惯提取转换方法,不必在第一次学习时增加新的映射工具。
启动应用,访问 GET http://localhost:8080/accounts。使用初始化数据时,可以看到:
json
[
{"id": 1, "username": "张三", "age": 18},
{"id": 2, "username": "李四", "age": 20},
{"id": 3, "username": "王五", "age": 22}
]
一次请求中,Controller 调用 Service,Service 调用 Mapper,Mapper 查询数据库后返回实体列表。Controller 再把实体转换为 VO 列表,最后由 JSON 转换器写入响应体。前端看到的三个字段来自 AccountVO,类名里的 VO 后缀不会变成 JSON 中的额外字段。
前面实体上的 @JsonIgnore 可以控制实体序列化时忽略密码;这里已经返回专门的 VO,所以响应内容直接由 VO 的字段决定。密码依然保留在数据库实体中供内部业务使用,但没有加入这个列表响应。
现在这条接口已经把整个查询过程连通了。账户记录增多后,可以把第 6 节的分页查询放入 Service,让接口接收页码、每页条数,再返回当前页的 VO 列表和分页信息。这样是把已经学过的分页接到业务中,不需要另起一套查询方法。
10. 遇到问题时先检查哪里
10.1 Mapper 无法注入
先看接口有没有 @Mapper,或者启动类是否配置正确的 @MapperScan。再看包路径是否在扫描范围内、starter 是否与 Boot 大版本匹配。
不要为了让注入通过,手写一个空的 Mapper 实现类。真正需要的是让 MyBatis 正确创建代理。
10.2 查询提示表或字段不存在
先检查数据库连接到了哪个库,再对照 @TableName、@TableField 与真实表结构。Java 类名、Java 属性名和数据库列名是三件事,不能只改其中一处。
10.3 分页返回了全部记录
检查分页配置类是否被扫描、MybatisPlusInterceptor 是否注册,以及调用是否使用 selectPage。MP 3.5.9 及以后还要确认存在分页解析器依赖。
10.4 生成器连接失败或生成代码编译失败
连接失败时检查数据库地址、端口、驱动和账号权限。能够在客户端连接,并不代表 Java 运行环境一定使用同样的连接参数。
生成后编译失败时,先看 Lombok 依赖、包名、Service 接口名称和模板导入。使用旧模板时,尤其留意 javax 与 jakarta 的区别。

评论