做后端开发的朋友,十有八九都在项目里用过 MyBatis Generator。这东西确实省事,数据库表建好,一条命令跑完,实体类、Mapper 接口和 XML 映射文件全都有了,比手写强太多。但它默认也就生成到 Mapper 这一层,Service、Controller 这些更上层的代码,它一概不管。
所以很多团队的做法是:MBG 生成基础代码,然后人肉补 Service 和 Controller。表少的时候还能忍,表一多,这套流程就变成纯体力活了。尤其是那种几十张表的老项目,每张表都要写一遍 Service 接口、ServiceImpl、Controller,写到最后人都是麻的。
我这篇要讲的就是怎么让 generatorConfig.xml 不只是生成 Mapper,而是把 Service、ServiceImpl、Controller 一并给你生成出来。核心思路是自定义一个 MBG 插件,通过它拿到每张表的元数据,然后用代码生成器内部那套 Java DOM 构建器,把上层代码也拼出来。整个过程我已经在自己的项目里完整跑通过,属于可以直接抄作业的方案。
内容覆盖插件 API 的选择、generatorConfig.xml 每一项关键配置的含义、Service 和 Controller 的生成代码模板,以及我实际踩过的坑。适合已经用过 MBG、想进一步榨干它的同学,也适合刚接触自动化代码生成、想一步到位的新手。
1. 为什么默认生成器生成不了 Service 和 Controller
1.1 默认配置到底能生成什么
先弄清楚 MBG 默认的能力边界。它在设计时的定位是“数据访问层代码生成器”,所以它的输出范围很明确:model 包下的实体类、dao 包下的 Mapper 接口、以及 mapper 包下的 XML 映射文件。这三样东西对应的是 MyBatis 里最基础的三层结构,再往上就超纲了。
你去看 MBG 源码里的生成流程,它拿到数据库表结构之后,会做这么几件事:
- 根据表结构生成实体类,属性对应表的列
- 根据主键、索引和表关系,生成 Mapper 接口里的方法声明
- 根据配置生成 XML 映射文件里的 CRUD 语句
- 如果有配置注释生成器,还会顺手加上一些注释
整个流程结束之后,生成器就觉得自己干完活了,后面的业务层它完全不关心。
这里有个关键点:MBG 生成的所有内容都是围绕“表”这一层。表里有主键,它就考虑怎么按主键查;表里有唯一索引,它就考虑怎么按唯一索引查。至于“用户点了一个按钮之后调哪个 Service 方法”,这种业务逻辑它根本没法替你想,所以设计上就没把 Service 和 Controller 纳入默认输出范围。
1.2 让 MBG 生成扩展代码的三条路
既然默认不行,那就得想办法扩展它。我查过网上各路方案,大概有这么几条路可以走。
第一条路是修改 MBG 源码,直接在它的生成流程里加入 Service 和 Controller 的生成逻辑。这个思路很直接,但问题也明显:MBG 是独立的开源项目,改了自己的流程之后,后续升级版本会比较麻烦,而且你改的是人家内部的主逻辑,稍微一个版本迭代,代码结构变了,你的改动就得重新适配。除非你有非常特殊的定制需求,否则真心不建议。
第二条路是写一个 Maven 插件或者脚本,在 MBG 生成完基础代码之后,用模板引擎(比如 FreeMarker、Velocity)扫描表结构,再生成 Service 和 Controller。这个思路看起来挺干净,实际上把问题复杂化了。你得自己维护一套数据库元数据的采集逻辑,还要处理模板和项目结构的耦合。数据库表、字段类型、主键这些信息,MBG 已经全部解析好了,你非要绕开它自己再干一遍,属于重复造轮子。
第三条路就是 MBG 官方预留的插件扩展机制 Plugin。生成的每一步,只要你愿意,都可以通过插件接口介入。它不仅能改生成的过程,还能在生成完指定的模型或者客户端类型之后,往上下文里追加额外的输出文件。我的方案就是基于这个机制。
1.3 我选 Plugin 方案的理由
Plugin 方案最大的好处是它不动 MBG 的主流程,你只是在一个约定的钩子里做自己的事。相当于你站在生成器的旁边,等它处理完一张表,你拿到这张表所有的结构信息,然后自由发挥。
| 表方向 | 修改源码 | 自研模板引擎 | 自定义 Plugin |
|---|---|---|---|
| 维护成本 | 高,升级即重写 | 高,重复采集元数据 | 低,跟着MBG升级 |
| 灵活度 | 非常高 | 高 | 中高 |
| 上手难度 | 高,需读懂源码 | 中,要自己写大量胶水 | 中,掌握插件API即可 |
| 推荐程度 | 不推荐 | 一般 | 强烈推荐 |
我自己的项目里当时有三十多张表,用 Plugin 方案写了一个生成器类,之后每次加表,跑一下命令,所有层级的代码全部出来了。最舒服的是,你生成出来的代码可以自定义模板,想输出什么风格完全由你控制,不受默认生成结果的限制。
2. 环境准备与基础配置
2.1 把插件加到项目里
第一步先搞定构建工具。我这里用 Maven 作为示例,因为它的生态最成熟,MBG 官方也提供了 Maven 插件支持。
在 pom.xml 里需要引入几个关键依赖。第一是 MyBatis 本身的依赖,这个一般项目里已经有了,没有就顺手加上。第二是数据库驱动,我用的是 MySQL,所以引入 mysql-connector-j。第三才是重点,maven 的 mybatis-generator-maven-plugin,版本建议用 1.4.2 或者更新的版本,老版本对 Java 8 之后的一些语法支持不太友好。
<plugin> <groupId>org.mybatis.generator</groupId> <artifactId>mybatis-generator-maven-plugin</artifactId> <version>1.4.2</version> <configuration> <configurationFile>src/main/resources/generatorConfig.xml</configurationFile> <overwrite>true</overwrite> <verbose>true</verbose> </configuration> <dependencies> <dependency> <groupId>mysql</groupId> <artifactId>mysql-connector-java</artifactId> <version>8.0.33</version> </dependency> </dependencies> </plugin>这里有几个细节要说一下。configurationFile 指定了 generatorConfig.xml 的路径,我用的是 src/main/resources 下,这样配置文件跟项目代码放一起,不会丢。overwrite 控制是否覆盖已存在的文件,如果你希望生成器跑完后不覆盖你手改过的文件,这里可以设成 false,但我个人建议生成器只在初始阶段跑,跑完之后这些文件就当普通代码维护,不用每次重新生成的逻辑,所以 overwrite 设 true 反而更利于反复调试。verbose 打开之后,控制台会输出更多生成过程的信息,排查问题的时候很管用。
2.2 第一版 generatorConfig.xml
接下来是 generatorConfig.xml 的基础配置。先放一个最简可用的版本,后面我会逐项拆解。
<?xml version="1.0" encoding="UTF-8"?> <!DOCTYPE generatorConfiguration PUBLIC "-//mybatis.org//DTD MyBatis Generator Configuration 1.0//EN" "http://mybatis.org/dtd/mybatis-generator-config_1_0.dtd"> <generatorConfiguration> <context id="mysqlContext" targetRuntime="MyBatis3" defaultModelType="flat"> <commentGenerator> <property name="suppressAllComments" value="true"/> <property name="suppressDate" value="true"/> </commentGenerator> <jdbcConnection driverClass="com.mysql.cj.jdbc.Driver" connectionURL="jdbc:mysql://localhost:3306/demo?useUnicode=true&characterEncoding=UTF-8&serverTimezone=Asia/Shanghai" userId="root" password="123456"> </jdbcConnection> <javaTypeResolver> <property name="forceBigDecimals" value="false"/> </javaTypeResolver> <javaModelGenerator targetPackage="com.example.demo.model" targetProject="src/main/java"> <property name="enableSubPackages" value="true"/> <property name="trimStrings" value="true"/> </javaModelGenerator> <sqlMapGenerator targetPackage="mapper" targetProject="src/main/resources"> <property name="enableSubPackages" value="true"/> </sqlMapGenerator> <javaClientGenerator type="XMLMAPPER" targetPackage="com.example.demo.mapper" targetProject="src/main/java"> <property name="enableSubPackages" value="true"/> </javaClientGenerator> <table tableName="t_user" domainObjectName="User"/> </context> </generatorConfiguration>这段配置的核心意思很好理解:
- context 是整个生成任务的上下文,targetRuntime 选择 MyBatis3,表示采用标准的 MyBatis 3 风格生成代码
- commentGenerator 控制生成的注释,我把所有注释都关掉了,因为默认注释太啰嗦,没意义
- jdbcConnection 配置数据库连接
- javaModelGenerator 生成实体类到指定包
- sqlMapGenerator 生成 XML 到 resources 目录
- javaClientGenerator 生成 Mapper 接口
- table 指定要生成哪张表,并指定生成的实体类名
跑一下mvn mybatis-generator:generate,如果能正常生成基础代码,说明环境没问题。接下来再往这个配置里加自定义插件。
2.3 先跑通默认的 Java Client 生成
这里我建议停下来先跑一次,确认默认流程通顺了再继续。因为后面自定义插件的排查往往建立在“默认生成能成功”这个前提之下。跑完你会看到 target 目录下生成了 User.java、UserMapper.java、UserMapper.xml,目录结构和配置里的 targetPackage 一一对应。
如果这个阶段就报错,大多是数据库连接的问题。常见的有几种:一是 MySQL 8 的驱动类名写错,要用 com.mysql.cj.jdbc.Driver;二是连接串里需要加上 serverTimezone 参数,否则会报时区错误;三是密码不对或者权限不足。这些都是老生常谈,确认一下就能解决。
2.4 命名策略细节:表名到类名的映射
还有一个小细节值得提,就是表名到类名的映射。默认情况下,MBG 会把表名直接转成类名,比如 t_user 可能会被转成 TUser,而不是 User。很多项目里的表都带统一前缀,比如 t_、sys_、tb_,如果不处理,生成出来的类名会非常难看。
有两种办法处理。第一种是在 table 标签里显式指定 domainObjectName,像我上面那样写domainObjectName="User",简单直接,但表多了以后每个表都要手动指定,比较啰嗦。第二种是在 context 里加上命名规则,让 MBG 自动去掉前缀:
<domainObjectRenamingRule searchString="^t_" replaceString=""/>把这个配置放在 context 下,表名 t_user 就会自动变成 User,t_order 变成 Order。我比较推荐这种方式,一劳永逸。注意这个配置只影响实体类名,不影响 Mapper 接口名和数据库表本身的引用。
3. 自定义 Plugin 前需要掌握的关键 API
3.1 IntrospectedTable 能查到什么
MBG 在设计插件机制时,核心的数据载体就是 IntrospectedTable。这个类翻译过来就是“被内省的表”,含义很直白:把数据库表的结构信息全部解析出来,塞到内存里,交给后续的生成过程使用。
你在写插件的时候,几乎所有想知道的表信息,都可以从这个对象上拿到:
- 表名、实体类名、Mapper 接口名
- 主键列、普通列的完整类型和注释
- 有哪些生成规则被打开了,例如是否生成 insert 方法,是否生成 selectByExample
- 配置的实体类包名、Mapper 包名
举个例子,判断这张表是否有主键,可以这样:
List<IntrospectedColumn> primaryKeyColumns = introspectedTable.getPrimaryKeyColumns(); if (primaryKeyColumns != null && primaryKeyColumns.size() > 0) { // 有主键,可以生成按主键查询的方法 }判断是否生成过 insert 方法,可以这样:
boolean generateInsert = introspectedTable.getRules().isGenerateInsert();这里有个点要特别注意:IntrospectedTable 里的表名和类名是两套东西。表名是数据库里的,类名是经过 MBG 命名规则处理之后的。你在生成 Service 和 Controller 的时候,一定要用类名,不要用表名,否则生成出来是乱的。
3.2 钩子方法 contextGenerateAdditionalJavaFiles 的调用时机
Plugin 是个接口,里面方法非常多。直接实现接口很累,所以官方给了一个空的适配类 PluginAdapter,你只需要继承它,重写需要的方法就行了。
用来生成额外 Java 文件的钩子方法有两个,一个是generatedJavaFile,一个是contextGenerateAdditionalJavaFiles。我研究过这两个方法的行为差异,最后选了 contextGenerateAdditionalJavaFiles。
generatedJavaFile 的触发时机是“每次生成一个 Java 文件时”都会调用,传进来的参数是当前生成完的 Java 文件。你在这里面拦截,需要自己判断当前文件是实体类还是 Mapper,然后决定是否追加生成逻辑。这个判断逻辑很麻烦,容易出问题。
contextGenerateAdditionalJavaFiles 则是以表为粒度调用的,每次处理完一张表之后,把这张表的 IntrospectedTable 传给你,你直接基于它构建新的文件列表返回。语义非常清晰,我不需要关心当前阶段生成到了哪里,只需要关心“这张表需要哪些附加文件”,非常适合生成 Service 和 Controller。
这个方法的签名是:
public List<GeneratedJavaFile> contextGenerateAdditionalJavaFiles(IntrospectedTable introspectedTable)返回的列表会被 MBG 自动写入磁盘。需要注意,文件写到哪里是由你构造 GeneratedJavaFile 时指定的路径决定的,而不是跟当前表生成的文件走。
3.3 生成 Java 文件的核心构建方式
MBG 自己带了一套 Java DOM 构建工具,在 org.mybatis.generator.api.dom.java 包下面。这套工具可以让你用代码的方式拼出一个完整的 Java 类,而不是用字符串拼接。
初看会觉得这套类可能很重,但实际用起来非常顺手。核心就几个类:
- FullyQualifiedJavaType 代表一个类型,比如 com.example.demo.model.User
- Interface 用来构建接口
- TopLevelClass 用来构建普通类
- Method 用来构建方法
- Parameter 用来构建方法参数
- Field 用来构建字段
例如构建一个“按主键查询”的 Service 接口方法,大致就是:
Method method = new Method("getById"); method.setVisibility(JavaVisibility.PUBLIC); method.setReturnType(userType); method.addParameter(new Parameter(idType, "id"));这套构建方式比写文本模板强在哪?它自带缩进、修饰符、泛型处理,生成出来的 Java 文件格式是标准的,不会出现字符串拼接常见的括号不匹配问题。
3.4 让配置项可读取
最后还有一个比较重要的点,插件最好做成可配置的。比如 Service 包名、Controller 包名,这些不应该是写死在插件代码里的,而是应该由 generatorConfig.xml 这个配置文件来决定。
做法是在 plugin 标签里加 property 子标签:
<plugin type="com.example.generator.ServiceAndControllerPlugin"> <property name="servicePackage" value="com.example.demo.service"/> <property name="controllerPackage" value="com.example.demo.controller"/> </plugin>然后在插件的 validate 方法里把这些配置读出来:
@Override public boolean validate(List<String> warnings) { servicePackage = properties.getProperty("servicePackage", "com.example.service"); controllerPackage = properties.getProperty("controllerPackage", "com.example.controller"); return true; }这样一来,同一套插件代码,放到不同项目里,只要改 generatorConfig.xml 的 property,就能生成不同包名,完全不需要动 Java 代码。
4. 生成 Service 接口和实现类
4.1 Service 接口的生成模板
有了前面的基础,现在进入正题。第一步,生成 Service 接口。
接口的类名规则是实体类名加上 Service 后缀,比如 User 对应 UserService,Order 对应 OrderService。包名则从 plugin 配置里读取,默认用 com.example.service。
我这里的接口生成代码大致长这样:
String servicePackage = ...; // 从配置读取 String domainObjectName = introspectedTable.getFullyQualifiedTable().getDomainObjectName(); FullyQualifiedJavaType serviceType = new FullyQualifiedJavaType(servicePackage + "." + domainObjectName + "Service"); FullyQualifiedJavaType entityType = introspectedTable.getBaseColumns() .get(0).getFullyQualifiedJavaType(); // 这里只是为了示意,实际上要用 model 的完整类型注意这里有个容易忽略的地方:实体类型不能简单从字段列里取得,因为如果列类型被 javaTypeResolver 转换过,字段类型和实体类型可能会对不上。更稳妥的做法是从配置里拼出来,或者直接使用 introspectedTable 里的规则计算出的实体类全限定名。我习惯直接拼:
String modelPackage = context.getJavaModelGeneratorConfiguration().getTargetPackage(); FullyQualifiedJavaType entityType = new FullyQualifiedJavaType(modelPackage + "." + domainObjectName);这个方式简单可靠,因为 MBG 生成实体类的包名我们本身就在 generatorConfig.xml 里配置了,插件里直接读同一个配置即可,不会出现类型不匹配的情况。
接口的生成逻辑,就是把需要暴露的方法都声明出来,主键类型、实体类型、泛型类型全部通过 IntrospectedTable 获取。我把完整代码写在这里,可以直接复制改包名用:
@Override public List<GeneratedJavaFile> contextGenerateAdditionalJavaFiles(IntrospectedTable introspectedTable) { List<GeneratedJavaFile> files = new ArrayList<>(); String domainObjectName = introspectedTable.getFullyQualifiedTable().getDomainObjectName(); String modelPackage = context.getJavaModelGeneratorConfiguration().getTargetPackage(); String servicePackage = properties.getProperty("servicePackage", "com.example.service"); String controllerPackage = properties.getProperty("controllerPackage", "com.example.controller"); FullyQualifiedJavaType entityType = new FullyQualifiedJavaType(modelPackage + "." + domainObjectName); FullyQualifiedJavaType serviceType = new FullyQualifiedJavaType(servicePackage + "." + domainObjectName + "Service"); FullyQualifiedJavaType implType = new FullyQualifiedJavaType(servicePackage + ".impl." + domainObjectName + "ServiceImpl"); // 生成 Service 接口 Interface serviceInterface = new Interface(serviceType); serviceInterface.setVisibility(JavaVisibility.PUBLIC); serviceInterface.addImportedType(entityType); boolean hasInsert = introspectedTable.getRules().isGenerateInsert(); boolean hasSelectByPrimaryKey = introspectedTable.getRules().isGenerateSelectByPrimaryKey(); boolean hasUpdate = introspectedTable.getRules().isGenerateUpdateByPrimaryKeySelective(); boolean hasDelete = introspectedTable.getRules().isGenerateDeleteByPrimaryKey(); boolean hasSelectByExample = introspectedTable.getRules().isGenerateSelectByExample(); if (hasInsert) { Method method = new Method("insert"); method.setVisibility(JavaVisibility.PUBLIC); method.setReturnType(new FullyQualifiedJavaType("int")); method.addParameter(new Parameter(entityType, "record")); serviceInterface.addMethod(method); } if (hasSelectByPrimaryKey) { // 取主键列类型 IntrospectedColumn primaryKeyColumn = introspectedTable.getPrimaryKeyColumns().get(0); FullyQualifiedJavaType primaryKeyType = primaryKeyColumn.getFullyQualifiedJavaType(); Method method = new Method("getById"); method.setVisibility(JavaVisibility.PUBLIC); method.setReturnType(entityType); method.addParameter(new Parameter(primaryKeyType, "id")); serviceInterface.addMethod(method); } if (hasUpdate) { Method method = new Method("updateById"); method.setVisibility(JavaVisibility.PUBLIC); method.setReturnType(new FullyQualifiedJavaType("int")); method.addParameter(new Parameter(entityType, "record")); serviceInterface.addMethod(method); } if (hasDelete) { IntrospectedColumn primaryKeyColumn = introspectedTable.getPrimaryKeyColumns().get(0); FullyQualifiedJavaType primaryKeyType = primaryKeyColumn.getFullyQualifiedJavaType(); Method method = new Method("deleteById"); method.setVisibility(JavaVisibility.PUBLIC); method.setReturnType(new FullyQualifiedJavaType("int")); method.addParameter(new Parameter(primaryKeyType, "id")); serviceInterface.addMethod(method); } if (hasSelectByExample) { List<IntrospectedColumn> allColumns = introspectedTable.getAllColumns(); Method listMethod = new Method("listAll"); listMethod.setVisibility(JavaVisibility.PUBLIC); listMethod.setReturnType(new FullyQualifiedJavaType("java.util.List", entityType)); listMethod.addImportedType(new FullyQualifiedJavaType("java.util.List")); serviceInterface.addMethod(listMethod); } JavaFile serviceFile = new JavaFile(serviceInterface); files.add(new GeneratedJavaFile(serviceFile, context.getJavaModelGeneratorConfiguration().getTargetProject())); // ... 后面还有实现类和 Controller 的生成 return files; }如果你觉得方法名 getById、updateById 不符合团队规范,完全可以直接改。这套模板的核心是结构,不是命名。
4.2 ServiceImpl 实现类的生成细节
接口定义完了,自然就是实现类。实现类的类名规则是实体类名加 ServiceImpl,放在 service 包的 impl 子包里。
实现类的结构比较固定:声明自己实现了对应的 Service 接口,注入对应的 Mapper,然后逐个实现接口里的方法。与接口生成的区别是,这里需要拼方法体。
生成实现类时,有一个特别容易被忽略的点:它需要导入 Mapper 类型。Mapper 的完整类型名可以通过 introspectedTable 相关的配置拿,也可以直接拼:
String mapperPackage = context.getJavaClientGeneratorConfiguration().getTargetPackage(); FullyQualifiedJavaType mapperType = new FullyQualifiedJavaType(mapperPackage + "." + domainObjectName + "Mapper");然后给实现类加一个 Mapper 类型的字段。字段注入的方式我用了 Spring 的 @Resource 注解,好处是不依赖 Spring 版本,而且如果项目里没有显式引入 Spring 注解也能用(前提是项目依赖了 spring-context)。如果你习惯用 @Autowired,替换即可。
TopLevelClass implClass = new TopLevelClass(implType); implClass.setVisibility(JavaVisibility.PUBLIC); implClass.addSuperInterface(serviceType); implClass.addImportedType(serviceType); implClass.addImportedType(mapperType); implClass.addImportedType(entityType); Field mapperField = new Field(domainObjectName.toLowerCaseFirstChar() + "Mapper", mapperType); mapperField.setVisibility(JavaVisibility.PRIVATE); mapperField.addAnnotation("@Resource"); implClass.addField(mapperField); implClass.addImportedType(new FullyQualifiedJavaType("javax.annotation.Resource"));方法体就是直接调用 Mapper 对应的方法。比如 getById 的实现:
Method method = new Method("getById"); method.setVisibility(JavaVisibility.PUBLIC); method.addAnnotation("@Override"); method.setReturnType(entityType); method.addParameter(new Parameter(primaryKeyType, "id")); method.addBodyLine("return " + mapperFieldName + ".selectByPrimaryKey(id);"); implClass.addMethod(method);insert、updateById、deleteById 同理,只是方法名从 Mapper 接口里对应的方法名取。这样一张表的 Service 实现就完整了。
4.3 根据表配置动态裁剪方法
上面的代码里已经体现了动态裁剪:hasInsert、hasSelectByPrimaryKey 等参数来自表的生成规则。为什么必须这么做?因为有的时候,某张表的业务场景比较特殊,表结构没有主键,这时候就没有 getById 和 deleteById 这两个方法;又或者某些表是只读的,不需要 insert 和 update。
如果插件不加判断,无脑把所有方法都生成,那么编译时就会报错,因为 Mapper 接口里根本没有对应的方法。被生成的代码自己都编译不过,比手写还麻烦。
所以我强烈建议把这个逻辑做成动态的。哪个方法被关闭了,Service 和 Controller 里就不生成对应的方法。这样 generatorConfig.xml 里怎么配,代码就怎么生成,行为完全由配置驱动,非常干净。
4.4 无主键或复合主键的特殊处理
还有一种情况必须单独处理:表没有主键,或者主键是复合的。
没有主键的表,MBG 生成 Mapper 接口的时候,会跳过所有基于主键的方法。这个时候如果插件继续用introspectedTable.getPrimaryKeyColumns().get(0),就会抛 ArrayIndexOutOfBoundsException。我在第一次写插件的时候就在这栽过,当时排查了半天才意识到是一张日志表没有主键。
解决方式很简单,在取主键之前先判断列表是否为空:
List<IntrospectedColumn> primaryKeyColumns = introspectedTable.getPrimaryKeyColumns(); if (primaryKeyColumns != null && !primaryKeyColumns.isEmpty()) { IntrospectedColumn primaryKeyColumn = primaryKeyColumns.get(0); // 正常处理 }如果是复合主键,取第一个虽然不会报错,但在生成 getById 的时候,入参只有一个,而 Mapper 的 selectByPrimaryKey 需要多个参数。这种情况我建议直接把基于主键的方法全部跳过,因为业务上复合主键的按主键查询通常都要单独定制,不适合自动生成。这也是一个值得写进你自己的生成器规则里的判断逻辑。
5. 生成 Controller 层
5.1 设计 Controller 的接口形态
Service 和实现类都生成完了,再往上是 Controller。Controller 这层和 Service 有一个本质区别:Controller 是跟 HTTP 协议打交道的,所以不仅要考虑方法,还要考虑请求方式、路径、参数绑定方式。
我这里的设计方案是走 RESTful 风格:
- POST 负责新增
- DELETE 负责删除
- PUT 负责更新
- GET 负责查询
路径前缀是 /api,后面跟上实体类名的小驼峰格式。比如 User 对应 /api/user,OrderItem 对应 /api/orderItem。
这个设计不是唯一的,你可以根据自己的项目规范调整,但核心思路是一样的:Controller 只做 HTTP 层的事,把请求参数转成 Service 方法的入参,再把 Service 的返回结果转成 HTTP 响应。
5.2 生成 Controller 的代码模板
Controller 的类名规则是实体类名加 Controller,包名用前面配置的 controllerPackage。生成的时候有几个关键点:
第一,要用 @RestController 注解。这是 Spring Boot 场景下的标准写法,如果你用的是传统 Spring MVC,可能需要改成 @Controller,并在每个方法上额外加 @ResponseBody。实际上现在大多数项目都是 Spring Boot,所以直接生成 RESTful 风格的 Controller。
第二,每个方法上要有对应的请求路径注解。新增用 @PostMapping,删除用 @DeleteMapping("/{id}"),更新用 @PutMapping,查询单条用 @GetMapping("/{id}"),列表查询用 @GetMapping。
第三,参数绑定。新增和更新接收请求体,用 @RequestBody 注解,后面跟上实体类型;删除和查询单条接收路径变量,用 @PathVariable("id"),参数类型是主键类型。
生成 Controller 的主要代码:
TopLevelClass controllerClass = new TopLevelClass(controllerType); controllerClass.setVisibility(JavaVisibility.PUBLIC); controllerClass.addAnnotation("@RestController"); controllerClass.addAnnotation("@RequestMapping(\"/api/" + lowerCamelName(domainObjectName) + "\")"); controllerClass.addImportedType(new FullyQualifiedJavaType("org.springframework.web.bind.annotation.RestController")); controllerClass.addImportedType(new FullyQualifiedJavaType("org.springframework.web.bind.annotation.RequestMapping")); // 注入 Service Field serviceField = new Field(lowerCamelName(domainObjectName) + "Service", serviceType); serviceField.setVisibility(JavaVisibility.PRIVATE); serviceField.addAnnotation("@Resource"); controllerClass.addField(serviceField); controllerClass.addImportedType(new FullyQualifiedJavaType("javax.annotation.Resource")); if (hasInsert) { Method createMethod = new Method("create"); createMethod.addAnnotation("@PostMapping"); createMethod.setVisibility(JavaVisibility.PUBLIC); createMethod.setReturnType(new FullyQualifiedJavaType("java.lang.Object")); createMethod.addImportedType(new FullyQualifiedJavaType("java.lang.Object")); createMethod.addParameter(new Parameter(entityType, "record")); createMethod.addBodyLine("return " + lowerCamelName(domainObjectName) + "Service.insert(record);"); controllerClass.addMethod(createMethod); controllerClass.addImportedType(new FullyQualifiedJavaType("org.springframework.web.bind.annotation.PostMapping")); } if (hasDelete) { Method deleteMethod = new Method("delete"); deleteMethod.addAnnotation("@DeleteMapping(\"/{id}\")"); deleteMethod.setVisibility(JavaVisibility.PUBLIC); deleteMethod.setReturnType(new FullyQualifiedJavaType("java.lang.Object")); deleteMethod.addParameter(new Parameter(primaryKeyType, "id")); deleteMethod.addBodyLine("return " + lowerCamelName(domainObjectName) + "Service.deleteById(id);"); controllerClass.addMethod(deleteMethod); controllerClass.addImportedType(new FullyQualifiedJavaType("org.springframework.web.bind.annotation.DeleteMapping")); controllerClass.addImportedType(new FullyQualifiedJavaType("org.springframework.web.bind.annotation.PathVariable")); }需要注意一个细节:Controller 方法返回值我这里暂时用了 java.lang.Object。原因很简单,很多项目里都有自己的统一返回包装类,比如 Result 、ApiResponse 。如果用 Object,生成的代码至少能编译通过,你可以在生成后统一替换成团队自己的包装类,把返回值类型改掉,不影响其他代码。
如果你想一步到位,也可以配置一个公共的返回类型,比如在 generatorConfig.xml 的 plugin 属性里加一个resultType,插件里读取之后用到返回类型上。
5.3 生成后立即调整的3个地方
Controller 生成完之后,有 3 个地方我建议立刻确认一下。
第一个是包扫描范围。如果你的启动类在 com.example.demo 包下,而 Controller 生成到了 com.example.demo.controller,那没问题。但如果你的 Service 和 Controller 的包名跟启动类不在同一个根包下,Spring 是扫描不到这些类的,运行时会报 404 或者无法注入。解决办法是在启动类上补上 @ComponentScan 的扫描范围。
第二个是依赖是否完整。生成出来的 Controller 使用了 Spring MVC 的注解,ServiceImpl 使用了 Spring 的依赖注入注解。如果你的项目是一个纯 MyBatis 项目,没有引入 spring-boot-starter-web 和 spring-context,那么这些代码确实是编译不过的。生成器只负责生成代码,不负责替你把依赖加上,这个要自己在 pom.xml 里管好。最典型的错误就是 javassist 报错或者符号找不到的提示。
第三个是路径冲突。如果你表多,并且有两张表的主键类型相同,但又恰好生成了同名的路径,那可能没问题;但如果有两张表的实体类名相同(比如数据库里不同 schema 下各有一张 user 表),生成的 Controller 路径会冲突。这种情况需要在 table 配置里显式指定 domainObjectName,让它们不同。
6. 常见问题与排查
6.1 容易撞上的几个问题
写生成器这种事,最大的问题不是代码写不出来,而是跑的时候各种报错让人摸不着头脑。我把实际过程中碰到的问题整理成了一个表,方便直接对号排查。
| 问题现象 | 根本原因 | 解决办法 |
|---|---|---|
| 自定义插件类报 ClassNotFoundException | maven 插件运行时没把插件类所在 jar 加载进来 | 插件类所在模块先用 mvn install 装到本地仓库,或者在 maven 插件的 dependencies 里声明这个模块 |
| 生成的 Service/Controller 里缺少 spring 相关 import | 代码中构建 import 时没有把需要的类型加进 ImportedType | 对照生成的代码检查缺少的类型,在生成器里补上 addImportedType |
| 生成的方法调用不存在 | Function | 检查 generatorConfig.xml 中 enableXXX 配置和实际生成规则的对应关系,确认该方法在 Mapper 中已生成 |
| 表无主键,但插件生成了 getById | 没有判断 primaryKeyColumns 为空 | 取主键前先判断非空 |
| 生成的返回类型不是想要的 Result | 插件模板中 returnType 写死为 Object | 改为从配置读取统一返回类型 |
| 多次生成后文件错乱 | 生成器没有按预期查看现有文件,覆盖了手写改动 | 确认 overwrite 配置,结合 Git 管理,必要时给生成文件加标记注释 |
| Java 8 之后MBG生成缩进与注释异常 | 老版本 MBG 的 Java DOM 构建器在某些场景下缩进处理有缺陷 | 升级 mybatis-generator-maven-plugin 到 1.4.x |
6.2 某些方法没有按预期生成
这类问题在动态裁剪逻辑里最容易出现。举个例子,你明明在 table 标签里写了enableInsert="false",但生成的 Service 里还是有 insert 方法。这种情况十有八九不是插件的问题,而是你改了 generatorConfig.xml 之后忘了重新执行 mvn mybatis-generator:generate,旧文件还留在项目里。
还有一种情况更隐蔽:table 标签里的 enableXXX 属性,MBG 只认它自己的规则名。比如 enableUpdateByPrimaryKey 和 enableUpdateByPrimaryKeySelective 是两个不同的开关。如果只是关了 enableUpdateByPrimaryKey、但没关 enableUpdateByPrimaryKeySelective,那么 update 相关的方法仍然会生成。所以在排查的时候,建议先把 verbose 打开,看看 MBG 在日志里输出了哪些生成的方法,再和 generatorConfig.xml 里的开关对应起来。
6.3 我实际折腾时的一个小技巧
最后说一个我自己的经验。生成器这个东西最怕“生成的代码和你手动改的代码混在一起”,时间久了就分不清哪些是自动生成的、哪些是人改的。我的做法是在生成器模板里,给每个文件都加上一段特定注释标记,比如注释里写“Generated by MBG, do not modify manually”。然后写一个简单的脚本,搜索所有带这个标记的文件,比对改动历史。这样既不用完全放弃生成器,也不用担心手写的代码被覆盖,挺实用的。
如果你后续也想让列表查询变成分页查询,那就在 Service 的 listAll 方法上增加分页参数,加一个 pageNum 和 pageSize,Controller 里也加上对应的请求参数。这已经超出了基础配置的范畴,但思路是相通的。生成器这东西,本质上就是帮你把重复劳动标准化,你只需要把它控制好,就能一直享受它带来的便利。