MyBatis-Plus 3.0代码生成器实战:CRUD、分页与避坑指南 简介这套 MyBatis-Plus 3.0 案例面向正在使用 SpringBoot 开发后端业务、希望减少重复增删改查代码的开发者核心是演示基于数据库表进行逆向工程自动生成 Mapper 接口、XML 映射文件、Service 接口与实现类以及 Controller 的分层骨架。压缩包共 28 个文件约 66KB内含 Java 源码、XML 与 properties 配置、class 和 jar 依赖以及 Maven、Eclipse 工程相关配置导入后便于查看生成结果与目录组织。内容结合 3.0 版本改动涉及 JDK 1.8 以上新特性、Lambda 表达式、Lombok、主键生成策略和日期字段映射策略可帮助读者对比旧版差异理解逆向工程的参数设置与 SpringBoot 整合方式。可直接对照生成的 Controller、Service 与 Mapper 分层关系观察实体字段、主键与日期类型如何映射到数据库并据此调整模板和配置快速复用到自己的表结构上。目前已有 572 人学习下载适合需要快速搭建项目基础结构、梳理分层代码或改造生成模板的初中级后端开发者参考。1. 从一堆手写 XML 到一键生成mybatis-plus 3.0 到底解决什么问题接手一套老后台的时候库里六十多张表原班人马用的是原生 MyBatis每张表配一份 XML改一个字段要同步动 Entity、Mapper、XML、Service 四五个文件。后来团队决定整体切到 baomidou 维护的 mybatis-plus 3.0落地第一件事就是把代码反向生成器跑通——连上库、配好包名几十张表的 Controller、Service、Mapper、Entity 一口气全出来。这套东西的价值不在「少写代码」四个字上而在于把表结构当成唯一事实来源谁改了库谁重跑一遍生成器前后端口径自然对齐。它适合两类人手上一堆存量表想快速铺开 CRUD 的后端以及刚接触 MyBatis-Plus 想看清生成代码到底长什么样的新手。复杂 SQL 它不解决索引和事务还得自己扛但重复劳动能压到近乎为零。2. 技术栈与工程结构3.0 版本的依赖组合与目录分层2.1 依赖坐标与版本对齐mybatis-plus 3.0 这条线跟后来 3.5.x 最大的差别在 API 命名上比如分页还是PaginationInterceptor代码生成器还是AutoGenerator那一套六件套配置。要是你从网上抄了一份 3.5 的生成器代码丢进 3.0 工程十有八九卡在类找不到或者方法签名对不上。血泪经验是starter 和 generator 两个坐标必须同版本别一个 3.0.7 一个 3.0.3否则生成器内部引用的元数据类容易在运行时错位。!-- pom.xml 片段properties 里统一管版本 -- properties mybatis-plus.version3.0.7.1/mybatis-plus.version /properties dependencies !-- 运行时核心自动装配 SqlSessionFactory、Mapper 扫描 -- dependency groupIdcom.baomidou/groupId artifactIdmybatis-plus-boot-starter/artifactId version${mybatis-plus.version}/version /dependency !-- 代码生成器只在开发期用上线不打进包里 -- dependency groupIdcom.baomidou/groupId artifactIdmybatis-plus-generator/artifactId version${mybatis-plus.version}/version scopeprovided/scope /dependency !-- 3.0 生成器默认模板引擎是 Velocity不引会报模板找不到 -- dependency groupIdorg.apache.velocity/groupId artifactIdvelocity-engine-core/artifactId version2.1/version /dependency !-- MySQL 8 驱动注意驱动类名带 cj -- dependency groupIdmysql/groupId artifactIdmysql-connector-java/artifactId version8.0.16/version /dependency !-- 生成出来的实体默认带 Data不引 Lombok 编译不过 -- dependency groupIdorg.projectlombok/groupId artifactIdlombok/artifactId optionaltrue/optional /dependency /dependencies几个点值得说清楚。mybatis-plus-generator的 scope 我一般写provided因为它只在本地跑一次生成生产包带上纯属浪费体积有人图省事用默认 compile结果打出来的 jar 里多出一堆模板文件翻包的时候看着别扭。velocity-engine-core是 3.0 的硬依赖3.1 之后才支持切 FreeMarker 和 Beetl用 3.0 就别折腾换引擎。MySQL 8 的驱动类必须写成com.mysql.cj.jdbc.Driver写成老版的com.mysql.jdbc.Driver启动会刷一屏废弃警告虽然能跑但日志里混着黄字不利于排查真问题。2.2 目录分层与 application.yml 配置生成器产出的代码往哪儿放直接决定了后面 Mapper 扫描能不能扫到。我习惯把生成器单独放在src/test/java下理由是它不该出现在主源码里测试目录跑完即弃也不会被 Spring 扫描到。生成的代码统一落到src/main/java下按包分层。# 工程目录结构生成后的样子 src/main/java/com/example/demo ├── DemoApplication.java # 启动类MapperScan 挂在这里 ├── config │ └── MybatisPlusConfig.java # 分页插件、逻辑删除等全局配置 ├── controller │ └── UserController.java ├── service │ ├── UserService.java │ └── impl │ └── UserServiceImpl.java ├── mapper │ └── UserMapper.java └── entity └── User.java src/main/resources ├── application.yml └── mapper # 只有复杂 SQL 才往这里放 XMLMapperScan别图省事写com.example.demo.**3.0 对通配包的解析不如新版稳老老实实写com.example.demo.mapper更安全多个模块就多挂几个注解。下面是配套的application.yml每一行都有它在解决什么问题。spring: datasource: # serverTimezone 必带MySQL 8 驱动不加会报时区异常 url: jdbc:mysql://127.0.0.1:3306/demo?useUnicodetruecharacterEncodingutf8serverTimezoneGMT%2B8useSSLfalse username: root password: root driver-class-name: com.mysql.cj.jdbc.Driver mybatis-plus: # XML 文件位置用了纯注解 CRUD 这条也可以留着不动 mapper-locations: classpath*:/mapper/**/*.xml # 实体别名包XML 里 resultType 可以直接写类名 type-aliases-package: com.example.demo.entity global-config: db-config: # 主键策略auto 表示依赖数据库自增 id-type: auto # 表名是否自动转下划线true 表示 User - user table-underline: true # 逻辑删除字段名与取值约定 logic-delete-field: deleted logic-delete-value: 1 logic-not-delete-value: 0 configuration: # 结果集下划线转驼峰实体 userName 对应 user_name map-underscore-to-camel-case: true # 开发期把 SQL 打到控制台上线记得删掉 log-impl: org.apache.ibatis.logging.stdout.StdOutImplid-type: auto和logic-delete-field这两项在 3.0 里存在版本差异3.0.1 到 3.0.4 之间logic-delete-field还没进 yml只能靠实体上贴TableLogic到 3.0.7 才支持全局配置。所以升级小版本时配置项要跟着对一遍不然会出现「配置写了却不生效」的玄学现象。log-impl那一行是我调试期的固定伴侣它把实际执行的 SQL、参数、结果行数全打出来比任何断点都直接缺点是日志量大压测时必须关掉。3. 代码反向生成器拆解从数据库表到四层代码的完整链路3.1 AutoGenerator 的六大配置块3.0 的生成器入口是AutoGenerator它把「生成什么」拆成了六块互相独立的配置理解这六块的分工比背 API 有用得多。我按改动频率从高到低列一下配置块类名决定什么最常改的参数全局配置GlobalConfig输出目录、作者、覆盖策略、主键类型outputDir、fileOverride、idType数据源配置DataSourceConfig连哪个库、哪个 schemaurl、username、schemaName包配置PackageConfig代码落到哪个包parent、moduleName、各层子包名策略配置StrategyConfig生成哪些表、命名规则、是否链式include、tablePrefix、naming模板配置TemplateConfig用哪套模板、关掉哪几层setXml(null)、setController(...)注入配置InjectionConfig额外塞变量给模板用setMap、setFileCreate其中StrategyConfig.include是控制范围的关键开关不填默认全库生成六十张表的项目第一次跑会刷出一堆不相关的类。我的做法是先include两三张表试水确认识别出来的字段类型、注释对不对再放开全表。tablePrefix则用来剥掉统一前缀比如sys_user配了sys_之后类名是User而不是SysUser省得后面手改。3.2 一份可直接跑的生成器脚本下面这份代码我在多个项目里用过改改库连接和包名就能跑。放在src/test/java/.../CodeGenerator.java下右键 main 方法执行即可。import com.baomidou.mybatisplus.annotation.IdType; import com.baomidou.mybatisplus.generator.AutoGenerator; import com.baomidou.mybatisplus.generator.config.*; import com.baomidou.mybatisplus.generator.config.rules.NamingStrategy; public class CodeGenerator { public static void main(String[] args) { // 1. 全局配置输出目录用 user.dir 拼绝对路径避免相对路径解析到意外的位置 String projectPath System.getProperty(user.dir); GlobalConfig gc new GlobalConfig(); gc.setOutputDir(projectPath /src/main/java); gc.setAuthor(dev); // 会写进类注释改成你的标识 gc.setOpen(false); // 生成完不自动打开文件夹避免桌面被弹窗打断 gc.setFileOverride(true); // 同名文件直接覆盖重跑不会出现 User.java.bak 之类的残留 gc.setIdType(IdType.AUTO); // 主键策略与建表语句保持一致 // 2. 数据源生成期独立连库不依赖 Spring 容器 DataSourceConfig dsc new DataSourceConfig(); dsc.setUrl(jdbc:mysql://127.0.0.1:3306/demo?useUnicodetruecharacterEncodingutf8serverTimezoneGMT%2B8); dsc.setDriverName(com.mysql.cj.jdbc.Driver); dsc.setUsername(root); dsc.setPassword(root); // 3. 包配置parent 是根包其余各层自动拼在下面 PackageConfig pc new PackageConfig(); pc.setParent(com.example.demo); pc.setEntity(entity); pc.setMapper(mapper); pc.setService(service); pc.setServiceImpl(service.impl); pc.setController(controller); // 4. 策略配置控制范围与命名 StrategyConfig sc new StrategyConfig(); sc.setNaming(NamingStrategy.underline_to_camel); // 表名 sys_user - 类名 SysUser sc.setColumnNaming(NamingStrategy.underline_to_camel); // 列名 user_name - 字段 userName sc.setEntityLombokModel(true); // 实体加 Data sc.setRestControllerStyle(true); // Controller 用 RestController sc.setInclude(sys_user, sys_role); // 只生成这两张放开就把这行去掉 sc.setTablePrefix(sys_); // 剥掉表前缀类名变成 User sc.setControllerMappingHyphenStyle(true); // 路由变成 sys-user 而不是 sysUser // 5. 模板配置不生成 Mapper XML日常 CRUD 用 BaseMapper 就够 TemplateConfig tc new TemplateConfig(); tc.setXml(null); // 6. 组装并执行 AutoGenerator mpg new AutoGenerator(); mpg.setGlobalConfig(gc); mpg.setDataSource(dsc); mpg.setPackageInfo(pc); mpg.setStrategy(sc); mpg.setTemplate(tc); mpg.execute(); } }几个参数值得单独拎出来讲。setFileOverride(true)是双刃剑它让重跑变干净但你二次开发后手动改过的 Service 实现类会被覆盖回去所以我通常只在首次生成或表结构大改时打开平时改成false手动挑文件。setTablePrefix(sys_)和setInclude一起用时要注意顺序先 include 实际表名带前缀再由 prefix 剥掉写反了会生成一个都不匹配的空结果。setIdType(IdType.AUTO)必须和建表语句里的AUTO_INCREMENT对上这个坑后面会单说。生成完之后控制台会逐张表打印「文件已生成」的路径如果只看到 Velocity 初始化日志却没有文件输出八成是模板引擎没引或者输出目录权限问题往下看避坑那章。3.3 模板定制改前缀、加统一返回体默认模板生成出来的 Controller 长这样RequestMapping(/user)方法直接返回实体。真实项目里通常有统一前缀和统一响应体与其生成后一个个手改不如直接改模板。模板文件在mybatis-plus-generator的 jar 包里路径是templates/controller.java.vm把它复制到工程的src/main/resources/templates/generator/下然后在TemplateConfig里指过去。## controller.java.vm 的头部改造片段 package ${package.Controller}; import org.springframework.web.bind.annotation.RequestMapping; #if(${restControllerStyle}) import org.springframework.web.bind.annotation.RestController; #else import org.springframework.stereotype.Controller; #end import com.example.common.Result; RestController RequestMapping(/api/${table.entityPath}) public class ${table.controllerName} { // 生成一个统一的响应方法骨架业务逻辑生成后自己补 GetMapping(/{id}) public Result${entity} detail(PathVariable Long id) { return Result.ok(); } }${table.entityPath}是生成器内置变量由表名转驼峰后得到sys_user配了前缀剥离会变成user所以路由是/api/user。${package.Controller}来自PackageConfig里配的根包加controller子包改包名只需动一处。${restControllerStyle}是个布尔变量跟StrategyConfig.setRestControllerStyle(true)联动写模板时用它做条件判断能一套模板同时适配前后端分离和传统 MVC 两种场景。改模板最容易翻车的地方是 Velocity 语法里#if和#end必须成对少一个的话生成会静默失败日志里只有一行「模板解析异常」很难定位所以改完先拿一张表跑通。4. 通用 CRUD 与条件构造器BaseMapper 与 QueryWrapper 的实战边界4.1 BaseMapper 内置方法与 IService 分层生成出来的 Mapper 接口是空的但它继承的BaseMapperT已经带了 17 个方法覆盖单表增删改查的所有基础动作。真正需要你手写的只有多表关联和复杂聚合。// UserMapper.java日常 CRUD 全靠继承一行不用写 public interface UserMapper extends BaseMapperUser { // 只有这种跨表或带子查询的才需要自己写 Select(SELECT u.* FROM sys_user u JOIN sys_user_role ur ON u.id ur.user_id WHERE ur.role_id #{roleId} AND u.deleted 0) ListUser selectByRoleId(Param(roleId) Long roleId); }Service 层同理ServiceImplM, T把BaseMapper的方法包了一层还额外提供了saveBatch、updateBatchById这类批处理。有个细节新手常忽略IService接口的命名不要写成IUserService因为ServiceImpl内部引用的是IService两个 I 开头混在一起看代码容易串。我在GlobalConfig里一般配setServiceName(%sService)让生成器直接产出UserService绕开这个命名坑。public interface UserService extends IServiceUser { // 自定义业务方法放这里 } Service public class UserServiceImpl extends ServiceImplUserMapper, User implements UserService { // 没有额外逻辑时这个类可以是空的 }批处理的实际收益比想象中大。saveBatch默认每批 1000 条提交十万条数据用循环insert要跑好几分钟换成saveBatch通常压到十几秒差异主要来自 JDBC 批处理和事务提交次数。批量更新用updateBatchById但它要求实体带上主键并且只更新非 null 字段这一点跟updateById行为一致别指望它把字段置空。4.2 QueryWrapper 与 LambdaQueryWrapper 的写法差异条件构造器是 MyBatis-Plus 用得最多也最容易写错的地方。字符串版QueryWrapper直接写数据库列名Lambda 版走方法引用两者生成的 SQL 一样差别在编译期检查。// 写法一字符串列名拼错只能等运行时抛异常 QueryWrapperUser qw new QueryWrapper(); qw.eq(status, 1) .like(user_name, 张) .ge(create_time, LocalDate.now().minusDays(7)) .orderByDesc(create_time); ListUser list userMapper.selectList(qw); // 写法二Lambda 方法引用字段改名 IDE 能帮你重构 LambdaQueryWrapperUser lqw Wrappers.UserlambdaQuery() .eq(User::getStatus, 1) .like(User::getName, 张) .ge(User::getCreateTime, LocalDate.now().minusDays(7)) .orderByDesc(User::getCreateTime); ListUser list2 userMapper.selectList(lqw);eq的第二个参数如果传 null默认行为是跳过这个条件不是生成 null。这个特性很多时候是救命稻草前端筛选条件可传可不传时直接eq(StringUtils.isNotBlank(name), user_name, name)带个布尔开关就行不用在 Java 里写一堆 if。但反过来如果你真想查IS NULL得用isNull(column)写eq(column, null)会得到一个不带该条件的 SQL这是新手最常见的误用。like默认两边加%也就是%张%前缀模糊走不了索引。数据量大的表要改成likeRight生成张%能用上 B 树。表里字段一多条件构造器链式写下来能到十几行建议超过五行就拆成独立方法并起个业务名比如buildActiveUserQuery比一坨链式调用好维护。4.3 分页插件与逻辑删除的配置3.0 版本的分页不是开箱即用必须手动注册PaginationInterceptor不注册的话Page对象传进去只会在内存里截断total永远是 0。Configuration public class MybatisPlusConfig { // 3.0 用 PaginationInterceptor3.4 之后才改成 MybatisPlusInterceptor Bean public PaginationInterceptor paginationInterceptor() { PaginationInterceptor interceptor new PaginationInterceptor(); // 单页最大条数防止前端传个 100000 把库拖死 interceptor.setLimit(500); return interceptor; } }setLimit(500)这行是防护性配置线上被扫接口的时候没有上限的分页参数能直接把数据库连接池打满。使用方法就是标准的两行PageUser page new Page(1, 10); // 第 1 页每页 10 条 IPageUser result userMapper.selectPage(page, lqw); long total result.getTotal(); // 注册拦截器后这里才有值逻辑删除在 yml 里配了logic-delete-field之后removeById实际执行的是UPDATE ... SET deleted 1所有select自动带WHERE deleted 0。要注意它只对通过 MyBatis-Plus 发起的方法生效你自己在 XML 里手写的 SQL 不会自动加这个条件得自己补。另外逻辑删除后的数据唯一索引会冲突比如user_name加了唯一约束删掉一个叫「张三」的再建一个插入会失败标准解法是把唯一索引改成(user_name, deleted)复合索引。5. 避坑与常见问题排查版本、字段映射、分页那些血泪记录5.1 生成器跑完控制台没报错目录里却找不到文件现象main 方法执行完毕控制台刷了一堆 Velocity 日志src/main/java下空空如也。原因setOutputDir用了相对路径src/main/java实际会以 IDE 运行配置里的 working directory 为基准解析。生成器放在 test 目录时working directory 有时指向target/test-classes文件就生成到那儿去了。解决一律用System.getProperty(user.dir)拼绝对路径执行前先System.out.println把最终路径打出来肉眼确认一遍。这个习惯帮我省了至少三次「文件去哪了」的排查。5.2 实体字段是驼峰查询结果却全是 null现象表字段user_name生成出来的实体是userName但selectById返回的对象里userName为 null其他同名字段正常。原因生成阶段的NamingStrategy.underline_to_camel只决定实体类怎么写运行时的结果集映射由configuration.map-underscore-to-camel-case决定两个开关分属不同环节只配了一个就出现这种半死不活的状态。解决application.yml里补上map-underscore-to-camel-case: true。如果只有个别字段对不上在实体字段上贴TableField(user_name)显式声明比全局配置更精准。5.3 分页查询 records 有数据total 恒为 0现象Page对象传进selectPage返回的records是当前页数据total和pages都是 0。原因PaginationInterceptor没注册成 Bean或者项目用了多数据源拦截器只加到了主SqlSessionFactory从库那条链路没加。解决确认配置类被Configuration扫到多数据源场景下每个SqlSessionFactory都要单独setPlugins。排查时打开log-impl看实际 SQL 里有没有LIMIT没有就是拦截器没生效不用怀疑数据。5.4 主键策略 AUTO插入报 Field id doesnt have a default value现象调用save或insertSQL 里没有id字段数据库报主键无默认值。原因IdType.AUTO的含义是「主键交给数据库自增」但建表时主键没写AUTO_INCREMENT两边约定不一致。3.0 时代还没有ASSIGN_ID分布式场景要用ID_WORKER生成雪花 ID。解决要么改表结构加AUTO_INCREMENT要么把GlobalConfig.setIdType和建表语句一起切到ID_WORKER。改完记得重跑生成器让实体上的TableId注解跟着变。5.5 逻辑删除配了removeById 还是物理删现象yml 里写了logic-delete-field: deleted调removeById后数据真的从表里消失了。原因3.0 早期小版本还没支持 yml 全局配置逻辑删除只认实体字段上的TableLogic另外logic-delete-field配的是实体属性名不是列名写成deleted_flag而实体叫deleted也对不上。解决实体字段上补TableLogic全局配置可留可删确认字段名与实体属性一致。升级 3.0 小版本时把这一项的配置方式对着 release note 核一遍能省掉一次线上数据误删。6. 二次开发改造思路自定义模板、SQL 注入器与验证方法生成器用顺了之后大部分团队都会往两个方向改一是让生成出来的代码直接符合团队规范二是扩展它没覆盖的 SQL 能力。前者靠改.vm模板后者靠实现ISqlInjector。模板这块我一般先在resources/templates/generator下复制一份默认模板当基线只改三处类注释加团队标识、实体继承公共基类、Controller 套统一返回体。改完拿一张表跑git diff看一眼生成结果确认没有多出空行或者漏掉 import。这里有个反直觉的点——模板里的变量名不是随便起的${table.entityPath}、${entity}、${package.Mapper}都是生成器在AbstractTemplateEngine里预先塞进去的想加自定义变量得走InjectionConfig.setMap直接在模板里写${myVar}会得到一个字面量原样输出不报错但也不替换。SQL 注入器是 3.0 里比较被低估的扩展点。它允许你在所有 Mapper 上挂同一个方法比如批量插入。做法是实现ISqlInjector在inject方法里往MapperBuilderAssistant注册自定义MappedStatement再在配置类里替换默认注入器。这块代码量不小但一次投入能覆盖全库所有实体比重写saveBatch划算。验证方法很直接注入完启动应用随便挑一个 Mapper 调新方法看log-impl打出来的 SQL 是不是你要的批量语句如果启动就报Mapped Statements collection already contains value说明方法名跟BaseMapper内置的撞了换个名字即可。最后说验证闭环。生成器改完不要只看文件生成成功我一般强制走三步mvn compile确认没语法问题启动应用确认MapperScan扫得到用一个简单查询接口验证 SQL 能执行。三步里任何一步挂掉问题范围立刻就缩小了。有一次我图快跳过编译直接启动结果卡在一个模板漏了 import 上日志里只报ClassNotFoundException翻了半小时才定位到是生成代码的问题。从那以后我每次改完生成器模板或者升级 mybatis-plus 小版本都强制先跑一遍mvn clean compile再启动绝不省这一步。希望帮到你。本文还有配套的精品资源点击获取