第六章 配置能力增强
第六章 配置能力增强
创建时间: 2025年4月16日 14:44
状态: Done
前言
本笔记主要用途是学习up 主程序员鱼皮的项目:代码生成器时的一些学习心得**。
代码地址:https://github.com/Liucc-123/yuzi-generator.git
项目教程:https://www.codefather.cn/course/1790980795074654209
上一章节内容:https://editor.csdn.net/md/?articleId=147324749
前情回顾
在前面的章节中,我们开发并优化了基本的代码生成器制作工具。但目前的制作工具能力有限,只能生成基本的 ACM示例代码生成器,对于复杂的、真实的项目生成器,还无法满足制作需求。
所以本章节的目标是增强制作工具的配置能力,能够让元信息支持更加灵活的配置,从而制作更加复杂的代码生成器。
本节重点
本章节属于项目的第二阶段 — 开发代码生成器制作工具
重点内容有 :
- 生成目标 — SpringBoot 模板项目介绍
- 工具通用能力分析
- 配置能力增强开发
一、需求分析
我们第二阶段的目标是通过制作工具得到一个 Spring Boot 项目模板代码生成器,能够让开发者快速定制生成自己的 Spring Boot 项目。
SpringBoot项目模板介绍
将提前准备好的SpringBoot项目模板解压,放到 yuzi-generator-demo-projects
目录下。
SpringBoot项目模板:
在GitHub项目根目录下进行下载
模板能力
在该项目的 README.md 文件中,可以看到关于该项目的介绍,比如运用的技术、业务特性、业务功能等。
模板拥有的能力如下:
1)实现了用户登录、注册、注销、更新、检索、权限管理bDwqeD5mloOWGl/U9KYpZxoMy96VHRFAqbGgO+8bBwk=
2)帖子创建、删除、编辑、更新、数据库检索、ES 灵活检索
3)使用了 MySQL、Redis、Elasticsearch 数据存储
4)使用 Swagger + Knife4j 实现接口文档生成Pga3o8dlAlk5jom/XG4u38VZgjWTs6SzXy1vtLtsRaA=
5)支持全局跨域处理
项目结构
可以使用tree
命令打印出模板项目的树形结构:
.
├── Dockerfile
├── README.md
├── mvnw
├── mvnw.cmd
├── pom.xml
└── src├── main│ ├── java│ │ └── com│ │ └── yupi│ │ └── springbootinit│ │ ├── MainApplication.java│ │ ├── common│ │ │ ├── BaseResponse.java│ │ │ ├── DeleteRequest.java│ │ │ ├── ErrorCode.java│ │ │ ├── PageRequest.java│ │ │ └── ResultUtils.java│ │ ├── config│ │ │ ├── CorsConfig.java│ │ │ ├── JsonConfig.java│ │ │ ├── Knife4jConfig.java│ │ │ └── MyBatisPlusConfig.java│ │ ├── constant│ │ │ └── UserConstant.java│ │ ├── controller│ │ │ ├── PostController.java│ │ │ └── UserController.java│ │ ├── exception│ │ │ ├── BusinessException.java│ │ │ ├── GlobalExceptionHandler.java│ │ │ └── ThrowUtils.java│ │ ├── mapper│ │ │ ├── PostMapper.java│ │ │ └── UserMapper.java│ │ ├── model│ │ │ ├── dto│ │ │ │ ├── post│ │ │ │ │ ├── PostAddRequest.java│ │ │ │ │ ├── PostEsDTO.java│ │ │ │ │ ├── PostQueryRequest.java│ │ │ │ │ └── PostUpdateRequest.java│ │ │ │ └── user│ │ │ │ ├── UserAddRequest.java│ │ │ │ ├── UserLoginRequest.java│ │ │ │ ├── UserQueryRequest.java│ │ │ │ ├── UserRegisterRequest.java│ │ │ │ └── UserUpdateRequest.java│ │ │ ├── entity│ │ │ │ ├── Post.java│ │ │ │ └── User.java│ │ │ ├── enums│ │ │ │ └── UserRoleEnum.java│ │ │ └── vo│ │ │ └── LoginUserVO.java│ │ └── service│ │ ├── PostService.java│ │ ├── UserService.java│ │ └── impl│ │ ├── PostServiceImpl.java│ │ └── UserServiceImpl.java│ └── resources│ ├── application.yml│ └── mapper│ ├── PostMapper.xml│ └── UserMapper.xml└── test└── java
生成器应具备的功能
基于模板具有的基本能力,我们可以分析用户可能会有哪些定制化的代码生成需求,并明确代码生成器应该具备的功能。
比如:
1)替换生成的代码包名
2)控制是否生成帖子相关功能
3)控制是否需要开启跨域
4)自定义 Knife4jConfig 接口文档配置
5)自定义 MySQL 配置信息
6)控制是否开启 Redis
7)控制是否开启 Elasticsearch
二、实现思路
通过分析上述功能,我们可以从特定的需求中挖掘出代码生成器的通用能力。
逐个分析
1)需求:替换生成的代码包名
实现思路:还是挖坑,在所有出现包名的地方进行**挖坑,**然后指定类似于basePackage 的模型参数,让用户自己输入。
通用能力:这里用到包名的地方特别多,如果是我们手动“挖坑”来制作 FTL 模板文件,一个问题是成本高,再一个可能会出现遗漏。
所以,我们要利用制作工具在自动“挖坑”并生成模板文件。
2)需求:控制是否生成帖子相关功能
实现思路:允许用户通过一个参数来控制帖子相关代码是否生成,帖子相关代码包括有:PostController、PostService、Mapper、Model、Mapper.xml等等。
通用能力:用一个参数同时控制多个文件是否生成,而不仅仅是某段代码是否生成。
3)需求:控制是否需要开启跨域
实现思路:允许用户通过一个参数控制跨域相关文件是否生成,比如CorsConfig.java
文件是否生成
通用能力:用一个参数控制某个文件是否生成,而不仅仅是某段代码是否生成。
4)需求:自定义 Knife4jConfig 接口文档配置
实现思路:修改knife4jConfig文件的配置,比如 title、description、version、apis扫描包路径等。
通用能力:由于knif4j 需要配置的参数特别多,如果需要让用户全部输入,用户的使用成本会比较大,可能就不会再用你的产品。因此,可以用一个参数控制是否开启接口文档配置。如果开启,再让用户输入一组配置参数。
5)需求:自定义 MySQL 配置信息
实现思路:修改 application.yml文件中MySQL 的url、username、password 相关参数。
通用能力:由于要配置的参数比较多,可以定义一组隔离的配置参数。
6)需求:控制是否开启 Redis
实现思路:修改和开启Redis相关的代码,比如application.yml、pom.xml、MainApplication.java等多个文件中的部分代码。
通用能力:用一个参数控制多个文件的代码修改(之前已经实现过)
7)需求:控制是否开启 Elasticsearch
实现思路:
- 修改ElasticSearch相关的代码,比如 PostController、PostService、application.yml等多个文件中的部分代码。
- 用参数控制整个PostEsDTO整个文件是否生成
通用能力:用一个参数控制同时控制多个文件的代码以及某个文件是否要生成。
实现流程
通过对生成器的功能逐个分析,我们发现,有些需求的实现思路是囊括前面的需求的,也就是说,这些需求的实现上是有前后顺序的,我们可以根据这种“顺序”一步步进行实现。
现在我们的制作工具已经有的能力是:根据某个模型参数同时控制多处代码的修改。
而根据排序,制作工具需要增强的能力有:
- 一个模型参数控制某个文件是否生成
- 一个模型参数控制多个文件是否生成
- 一个模型参数同时控制多处代码修改以及多个文件是否生成
- 定义一组相关的模型参数,控制代码修改或文件生成
- 定义一组相关的模型参数,并通过其他模型参数控制是否需要输入这次参数(knif4jConfig接口配置文档)
可以发现,这些能力都和制作工具的元信息配置文件
相关,即我们需要增强他的能力,允许开发者通过修改元信息,得到能让用户更灵活生成代码的代码生成器。
如果实现了上述增强能力,我们就能依次实现下列需求:
- 控制是否开启跨域
- 控制是否生成帖子相关功能
- 控制 Redis 是否开启
- 控制 ElasticSearch 是否开启
- 自定义 MySQL 配置信息
- 自定义 knife4jConfig接口文档配置
到时候,想制作一个 SpringBoot 项目模板代码生成器就很简单了。
至于“替换生成的代码包名”需求,在实现方式上和上述功能有较大的差别,回放到后续章节中进行实现。
三、开发实现
下面就依次实现上面提到的通用能力。
- 一个模型参数控制某个文件是否生成
- 一个模型参数控制多个文件是否生成
- 一个模型参数同时控制多处代码修改以及多个文件是否生成
- 定义一组相关的模型参数,控制代码修改或文件生成
- 定义一组相关的模型参数,并通过其他模型参数控制是否需要输入这次参数(knif4jConfig接口配置文档)
1、参数控制文件生成
还是以 ACM 模板项目为例,我们的需求是:用一个模型参数 needGit
来控制是否要生成.gitignore
文件。
元信息修改
修改元信息配置文件 meta.json
,在 modelConfug.models
下新增 needGit
模型参数:
{..."modelConfig": {"models": [{"fieldName": "needGit","type": "boolean","description": "是否生成 .gitignore 文件","defaultValue": true},...]}
}
代码生成器实现
最大的问题是:怎么通过 needGit
参数控制文件的生成呢?
首先,.gitignore文件是在哪儿生成的?
是代码生成器acm-template-pro-generator
项目下的 MainGenerator
生成的,如下所示:
因此可以想到的是,我们可以通过添加 if
块来条件控制 .gitignore
文件的生成呀!
可以手动调整下代码生成器来验证此方法是否可行?
1)修改 MainGenerator 中生成.gitignore相关代码
public class MainGenerator {public static void doGenerate(DataModel model) throws TemplateException, IOException {...boolean needGit = model.isNeedGit();if(needGit) {inputPath = new File(inputRootPath, ".gitignore").getAbsolutePath();outputPath = new File(outputRootPath, ".gitignore").getAbsolutePath();StaticGenerator.copyFilesByHutool(inputPath, outputPath);}...}
}
这里,我们有两处改动:
1、方法参数 model
从 Object
类型改为 DataModel
类型,因为在后续程序中,需要获取 model的needGit
属性。
2、添加 if 条件代码块,来判断是否生成.gitignore
文件
修改主类 Main
方法,命令行参数指定—needGit=false
并运行,发现 generated
目录下没有生成 git 文件,符合预期,证明此方法可行!
制作工具实现
接下来我们的目标就是,增强制作工具的能力,让它能够生成上述代码(参数控制某个文件的生成)。
1)修改 MainGenerator.java.ftl
模板文件,将方法参数类型改为 DataModel
,修改代码如下:
public static void doGenerate(DataModel model) throws TemplateException, IOException {}
2)如何将模型参数和文件生成进行关联呢?
在上面 MainGenerator
的代码中,我们先获取模型的 needGit 字段的值,然后将其作为 if 的判断条件,是否要生成git 忽略文件。
一种实现方式是,在 meta 的 fileConfig.files
中添加一个条件字段 condition,值为 model 的字段,这样就可以生成同样的代码:
boolean needGit = model.isNeedGit();
if(needGit) {inputPath = new File(inputRootPath, ".gitignore").getAbsolutePath();outputPath = new File(outputRootPath, ".gitignore").getAbsolutePath();StaticGenerator.copyFilesByHutool(inputPath, outputPath);
}
修改元信息配置文件,添加 condition 字段:
{..."fileConfig": {"files": [{"inputPath": ".gitignore","outputPath": ".gitignore","type": "file","generateType": "static","condition": "needGit"},]},...
}
同步修改 Meta
类,给 FileInfo
添加 condition
字段:
@NoArgsConstructor
@Data
public static class FileInfo {private String inputPath;private String outputPath;private String type;private String generateType;private String condition;
}
3)目前还存在一个问题:if 块想要直接使用模型参数名作为变量,就需要先获取参数值。但获取对象属性的 方法有的是 getxxx、有的是 isxxx,结构不一致,导致MainGenerator.java.ftl模板文件在获取模型参数值时比较复杂。
boolean needGit = model.isNeedGit();
boolean loop = model.isLoop();
String author = model.getAuthor();
String outputText = model.getOutputText();
解决方案:一种解决方案是最简单的,就是修改DataModel
字段的访问修饰符为 public
,这样就不需要调用方法获取字段值了。
补充:
还有一种方式是:使用 Lombok 插件的@Accessors 注解自定义 get/set方法代码的生成规则。将 fluent 设置为 true 表示所有的 get/set方法名称都等同于属性名称,从而保证统一。
参考官方文档:https://projectlombok.org/features/experimental/Accessors
这种方式的缺点是会影响 FreeMarker 模板获取对象的值(FreeMarker 默认是从 getXxx 获取属性值的),因此不推荐。
然后我们就可以直接通过属性名获取属性值:
boolean needGit = model.needGit;
if (needGit) {...
}
4)修改 DataModel.java.ftl 模板文件,调整字段访问修饰符为 public
@Data
public class DataModel {<#list modelConfig.models as modelInfo><#if modelInfo.description??>/*** ${modelInfo.description}*/</#if>public ${modelInfo.type} ${modelInfo.fieldName} <#if modelInfo.defaultValue??>= ${modelInfo.defaultValue?c}</#if>;</#list>
}
5)最后,修改 MainGenerator.ftl模板文件,补充 DataModel 参数的获取,以及条件判断生成.gitignore文件的逻辑。
完整代码如下:
package ${basePackage}.generator;import freemarker.template.TemplateException;import java.io.File;
import java.io.IOException;
import ${basePackage}.model.DataModel;/*** 核心模板生成器(静态+动态)*/
public class MainGenerator {public static void doGenerate(DataModel model) throws TemplateException, IOException {String inputRootPath = "${fileConfig.inputRootPath}";String outputRootPath = "${fileConfig.outputRootPath}";String inputPath;String outputPath;
<#list modelConfig.models as modelInfo>${modelInfo.type} ${modelInfo.fieldName} = model.${modelInfo.fieldName};
</#list>
<#list fileConfig.files as fileInfo><#if fileInfo.condition??>if (${fileInfo.condition}) {inputPath = new File(inputRootPath, "${fileInfo.inputPath}").getAbsolutePath();outputPath = new File(outputRootPath, "${fileInfo.outputPath}").getAbsolutePath();<#if fileInfo.generateType == "dynamic">DynamicGenerator.doGenerate(inputPath, outputPath, model);<#else>StaticGenerator.copyFilesByHutool(inputPath, outputPath);</#if>}<#else>inputPath = new File(inputRootPath, "${fileInfo.inputPath}").getAbsolutePath();outputPath = new File(outputRootPath, "${fileInfo.outputPath}").getAbsolutePath();<#if fileInfo.generateType == "dynamic">DynamicGenerator.doGenerate(inputPath, outputPath, model);<#else>StaticGenerator.copyFilesByHutool(inputPath, outputPath);</#if></#if>
</#list>}
}
6)测试执行,代码生成器是否支持参数控制文件生成。
运行 Main 主类生成代码生成器,打开生成的代码生成器,运行其 Main 主类,检查.gitignore是否可以控制生成。
2、同参数控制多个文件生成
想要用同一个参数控制多个文件是否生成,最简单的方式就是直接给多个文件配置相同参数的 condition 就可以了,如下:
"files": [{"inputPath": "src/com/yupi/acm/MainTemplate.java.ftl","outputPath": "src/com/yupi/acm/MainTemplate.java","type": "file","generateType": "dynamic","condition": "needGit"},{"inputPath": ".gitignore","outputPath": ".gitignore","type": "file","generateType": "static","condition": "needGit"}
]
这种方式的优点是简单,缺点是不便于维护,如果后面文件数量多了,都是用这个 condition 字段,再要调整字段名称,需要全部做更改。
为了解决这个问题,我们可以根据生成条件,对文件进行分组。
这里有两种方案:
方案一
给元信息的 fileInfo 增加 group 字段,指定每个文件分属于哪个组。
"files": [{"inputPath": "src/com/yupi/acm/MainTemplate.java.ftl","outputPath": "src/com/yupi/acm/MainTemplate.java","type": "file","generateType": "dynamic","group": "post"}
]
然后在 FileConfig增加 groupConfig 组配置,包括组名、生成条件等。示例代码:
"fileConfig": {..."groupConfig": {"groups": [{"name": "post","condition": "needCors"}]}
}
这种设计类似于库表设计,将组和文件分开定义,再通过文件指定所属组进行关联。
优点是结构清晰,可以通过读取 groupConfig
直接获取所有组的信息;
缺点是不能通过配置文件直接获取同组下的所有文件,还可能会导致生成的代码不够优雅,出现很多重复的 if 块,比如:
boolean needGit = model.needGit;if (needGit) {inputPath = new File(inputRootPath, ".gitignore").getAbsolutePath();outputPath = new File(outputRootPath, ".gitignore").getAbsolutePath();StaticGenerator.copyFilesByHutool(inputPath, outputPath);
}if (needGit) {inputPath = new File(inputRootPath, "README.md").getAbsolutePath();outputPath = new File(outputRootPath, "README.md").getAbsolutePath();StaticGenerator.copyFilesByHutool(inputPath, outputPath);
}
方式二(推荐)
将文件组当做是一个特殊的文件夹,然后将同组文件都放到该组配置下。
元信息部分代码如下:
"files": [{"inputPath": "src/com/liucc/acm/MainTemplate.java.ftl","outputPath": "src/com/liucc/acm/MainTemplate.java","type": "file","generateType": "dynamic"},{"groupKey": "git","groupName": "开源","type": "group","condition": "needGit","files": [{"inputPath": ".gitignore","outputPath": ".gitignore","type": "file","generateType": "static"},{"inputPath": "README.md","outputPath": "README.md","type": "file","generateType": "static"}]}]
在 fileInfo 中新增字段:
- groupKey:组的唯一标识
- groupName:组的名称
- type:值为 group 表示这段配置是文件组
- condition:开关,控制该组下所有文件的生成
这种方式的优点是可以直接从 fileConfig 配置中看出文件的层级关系,且可以方便获取同组文件信息、控制同组文件的生成。
缺点是:生成代码的时候,如果是文件组类别,需要多遍历一层 files。但这点儿代码开发成本是在可接受范围内。因此最终推荐方式二实现同参数控制多文件生成。
最终的实现类似于:
boolean needGit = model.needGit;// groupKey = git
if (needGit) {inputPath = new File(inputRootPath, ".gitignore").getAbsolutePath();outputPath = new File(outputRootPath, ".gitignore").getAbsolutePath();StaticGenerator.copyFilesByHutool(inputPath, outputPath);inputPath = new File(inputRootPath, "README.md").getAbsolutePath();outputPath = new File(outputRootPath, "README.md").getAbsolutePath();StaticGenerator.copyFilesByHutool(inputPath, outputPath);
}
扩展:采用这种方案后,可以将同一个文件放在不同组下,这样当任一组的生成条件满足时,都可以生成改文件。
开发实现
1)修改 Meta
类的属性,给 FileInfo
添加文件组相关属性:
@NoArgsConstructor
@Data
public static class FileInfo {private String inputPath;private String outputPath;private String type;private String generateType;private String condition;private String groupKey;private String groupName;private List<FileInfo> files;
}
2)修改FileTypeEnum
枚举类,增加文件组枚举值
GROUP("文件组", "group")
3)修改MetaValidator校验器
原先对于 fileInfo 的 inputPath 校验规则是必填项,但现在fileInfo 的type 如果是 group,是可以没有 inputPath 的,因此需要调整校验器的逻辑。为了简单起见,如果type 是 group,则跳过本次校验。
部分代码如下:
for (Meta.FileConfigDTO.FileInfo fileInfo : fileInfoList) {// 如果类型是 group,就跳过校验if (FileTypeEnum.GROUP.getType().equals(fileInfo.getType())) {continue;}...
}
4)修改MainGenerator.java.ftl
模板文件
多了文件组的概念后,生成文件的逻辑需要做如下调整:
- 判断当前 fileInfo 的condition是否为空?为空,则说明该文件没有被参数控制生成。不为空,说明是被参数控制生成的,需要进一步判断。
- 进一步判断groupKey 是否有值?有值,则表示是一个文件组,遍历 files 生成多个文件;没有值,表示是一个文件,只需生成一个文件。
完整代码如下:
package ${basePackage}.generator;import freemarker.template.TemplateException;import java.io.File;
import java.io.IOException;
import ${basePackage}.model.DataModel;/*** 核心模板生成器(静态+动态)*/
public class MainGenerator {public static void doGenerate(DataModel model) throws TemplateException, IOException {String inputRootPath = "${fileConfig.inputRootPath}";String outputRootPath = "${fileConfig.outputRootPath}";String inputPath;String outputPath;
<#list modelConfig.models as modelInfo>${modelInfo.type} ${modelInfo.fieldName} = model.${modelInfo.fieldName};
</#list>
<#list fileConfig.files as fileInfo><#if fileInfo.condition??>if (${fileInfo.condition}) {<#if fileInfo.groupKey??>// 说明是一组文件 groupKey = ${fileInfo.groupKey}<#list fileInfo.files as fileInfo>inputPath = new File(inputRootPath, "${fileInfo.inputPath}").getAbsolutePath();outputPath = new File(outputRootPath, "${fileInfo.outputPath}").getAbsolutePath();<#if fileInfo.generateType == "dynamic">DynamicGenerator.doGenerate(inputPath, outputPath, model);<#else>StaticGenerator.copyFilesByHutool(inputPath, outputPath);</#if></#list><#else >// 说明是一个文件inputPath = new File(inputRootPath, "${fileInfo.inputPath}").getAbsolutePath();outputPath = new File(outputRootPath, "${fileInfo.outputPath}").getAbsolutePath();<#if fileInfo.generateType == "dynamic">DynamicGenerator.doGenerate(inputPath, outputPath, model);<#else>StaticGenerator.copyFilesByHutool(inputPath, outputPath);</#if></#if>}<#else>// 没有被参数控制生成inputPath = new File(inputRootPath, "${fileInfo.inputPath}").getAbsolutePath();outputPath = new File(outputRootPath, "${fileInfo.outputPath}").getAbsolutePath();<#if fileInfo.generateType == "dynamic">DynamicGenerator.doGenerate(inputPath, outputPath, model);<#else>StaticGenerator.copyFilesByHutool(inputPath, outputPath);</#if></#if>
</#list>}
}
目前仍存在的问题:可以看到模板文件中存在多很重复的代码,比如:
inputPath = new File(inputRootPath, "${fileInfo.inputPath}").getAbsolutePath();
outputPath = new File(outputRootPath, "${fileInfo.outputPath}").getAbsolutePath();
<#if fileInfo.generateType == "dynamic">
DynamicGenerator.doGenerate(inputPath, outputPath, model);
<#else>
StaticGenerator.copyFilesByHutool(inputPath, outputPath);
</#if>
FreeMarker 支持宏定义,将重复的代码定义为一个“组件”,支持传递不停的参数,然后可以被其他地方引用。
FreeMarker 宏定义官方文档:http://www.kerneler.com/freemarker2.3.23/ref_directive_macro.html
将文件生成逻辑定义为宏,宏代码段的名称(id)为generateFile
,参数有缩进(indent
)、fileInfo
;
最终版
package ${basePackage}.generator;import freemarker.template.TemplateException;import java.io.File;
import java.io.IOException;
import ${basePackage}.model.DataModel;<#macro generateFile indent fileInfo>
${indent}inputPath = new File(inputRootPath, "${fileInfo.inputPath}").getAbsolutePath();
${indent}outputPath = new File(outputRootPath, "${fileInfo.outputPath}").getAbsolutePath();
<#if fileInfo.generateType == "dynamic">
${indent}DynamicGenerator.doGenerate(inputPath, outputPath, model);
<#else>
${indent}StaticGenerator.copyFilesByHutool(inputPath, outputPath);
</#if>
</#macro>/*** 核心模板生成器(静态+动态)*/
public class MainGenerator {public static void doGenerate(DataModel model) throws TemplateException, IOException {String inputRootPath = "${fileConfig.inputRootPath}";String outputRootPath = "${fileConfig.outputRootPath}";String inputPath;String outputPath;
<#list modelConfig.models as modelInfo>${modelInfo.type} ${modelInfo.fieldName} = model.${modelInfo.fieldName};
</#list>
<#list fileConfig.files as fileInfo><#if fileInfo.condition??>if (${fileInfo.condition}) {<#if fileInfo.groupKey??>// 说明是一组文件 groupKey = ${fileInfo.groupKey}<#list fileInfo.files as fileInfo><@generateFile indent=" " fileInfo=fileInfo/></#list><#else >// 说明是一个文件<@generateFile indent=" " fileInfo=fileInfo/></#if>}<#else><@generateFile indent=" " fileInfo=fileInfo/></#if>
</#list>}
}
调用 Main 方法测试执行,检查生成的 MainGenerator 是否实现同参数控制多个文件的生成:
3、同参数控制代码生成和文件生成
这个能力其实我们已经实现了!只要让同一个参数即出现在控制文件生成的 condition 中,又出现在FreeMarker 动态模板中作为生成代码的参数即可。
4、定义一组相关的参数
对于一个复杂的代码生成器,可能会有很多允许用户自定义的参数,比如 MySQL 的配置可能就有十几条。如果把这些参数全部按照顺序写到元信息里,那么用户就会被要求一次性填写大量的参数,增加了用户的理解和使用成本,而且配置参数之间也可能存在命名冲突,比如 MySQL 和 Redis 都会有 url 参数。
对于这种情况,最常用的就是对参数进行**分组,**和上述文件分组类似,我们也可以对数据模型进行分组,各分组下的参数相互隔离、互不影响。
TypeScript 和 FreeMarker 等很多框架都会采用类似命名空间(分组)的概念隔离变量。
元信息修改
在元信息meta.json
新增字段groupKey、groupName、models
模型参数。
修改后的元信息代码如下:
"modelConfig": {"models": [{"fieldName": "needGit","type": "boolean","description": "是否生成 .gitignore 文件","defaultValue": false,"abbr": "g"},{"fieldName": "loop","type": "boolean","description": "是否生成循环","defaultValue": false,"abbr": "l"},{"groupKey": "mainTemplate","groupName": "核心模板","type": "MainTemplate","description": "用于生成核心模板文件","models": [{"fieldName": "author","type": "String","description": "作者注释","defaultValue": "liucc","abbr": "a"},{"fieldName": "outputText","type": "String","description": "输出信息","defaultValue": "sum = ","abbr": "o"}]}]}
字段解释如下:
- groupKey:模型分组的唯一表示,由 groupKey 开启分组,必须是英文
- groupName:分组名称
- type:分组类型,一般就是组对应的 Java Class 类型,必须大写开头
- models:组里面具体的模型参数信息
同步修改 Meta 实体类的属性,为ModelInfo 添加新字段:
@NoArgsConstructor
@Data
public static class ModelInfo {private String fieldName;private String type;private String description;private Object defaultValue;private String abbr;private String groupKey;private String groupName;private List<ModelInfo> models;
}
同步修改 Meta 校验器MetaValidator
,如果模型的 groupKey
不为空,表示这是模型组配置,简单起见,直接跳过本次校验。修改后的部分代码如下:
for (Meta.ModelConfigDTO.ModelInfo modelInfo : modelInfoList) {// 如果类型是 group,则跳过本次校验String groupKey = modelInfo.getGroupKey();if (StrUtil.isNotEmpty(groupKey)) {continue;}
}
代码生成器实现
根据上述分组配置,先试着编写一个能够实现分组功能的代码生成器,从而进一步实现具备分组功能的制作工具。
在制作工具生成的代码生成器acm-template-pro-generator
项目中进行修改。
1)如何实现分组参数呢?
将一组参数抽取为对象参数,封装到一个类中去。比如,我们将原先 DataModel
中的author
、outputText
字段抽取到 MainTemplate
类中,简单起见,MainTemplate 就定义到 DataModel 的静态内部类。
完整代码如下:
package com.liucc.model;import lombok.Data;/*** 动态模板配置*/
@Data
public class DataModel {/*** 是否生成 .gitignore 文件*/public boolean needGit = false;/*** 是否生成循环*/public boolean loop = false;/*** 核心模板*/public MainTemplate mainTemplate = new MainTemplate();/*** 用于生成核心模板文件*/@Datapublic static class MainTemplate {/*** 作者注释*/public String author = "liucc";/*** 输出信息*/public String outputText = "sum = ";// 重写 toString方法@Overridepublic String toString() {return "MainTemplate{" +"author='" + author + '\'' +", outputText='" + outputText + '\'' +'}';}}
}
2)那现在如何让用户输入的参数能自动填充到对象里去呢?
FreeMarker 框架提供了复杂参数传递的解决方案,提供了参数组特性。
Picocli 参数组官方介绍:https://picocli.info/#_argument_groups
复制 GenerateCommand
为 TestArgGroupCommand
类,用于测试参数组特性。
在TestArgGroupCommand命令类中,定义参数组属性,添加@ArgGroup
注解,用于标识分组:
@CommandLine.ArgGroup(exclusive = false, heading = "核心模板 %n")
static MainTemplateCommand mainTemplateCommand;
在类中创建静态内部类MainTemplateCommand
,定义 author
和 outputText
属性,并给选项注解的 name
属性添加参数组为 groupKey
前缀,比如 mainTemplate.a
,从而实现同名参数组相互隔离。
代码如下:
@Data
static class MainTemplateCommand {@CommandLine.Option(names = {"-mainTemplateCommand.a", "--mainTemplateCommand.author"}, arity = "0..1", description = "作者注释", interactive = true, echo = true)private String author = "liucc";@CommandLine.Option(names = {"-mainTemplateCommand.o", "--mainTemplateCommand.outputText"}, arity = "0..1", description = "输出信息", interactive = true, echo = true)private String outputText = "sum=";
}
在命令类的run
方法中打印所有属性值,并在 main
方法中编写测试程序。
最终TestArgGroupCommand
的完整代码如下:
package com.liucc.cli.command;import lombok.Data;
import picocli.CommandLine;@CommandLine.Command(name = "generate", description = "生成代码", mixinStandardHelpOptions = true)
@Data
public class TestArgGroupCommand implements Runnable {@CommandLine.Option(names = {"-g", "--needGit"}, arity = "0..1", description = "是否生成 .gitignore 文件", interactive = true, echo = true)private boolean needGit = true;@CommandLine.Option(names = {"-l", "--loop"}, arity = "0..1", description = "是否生成循环", interactive = true, echo = true)private boolean loop = false;@CommandLine.ArgGroup(exclusive = false, heading = "核心模板 %n")static MainTemplateCommand mainTemplateCommand;@Overridepublic void run() {System.out.println(needGit);System.out.println(loop);System.out.println(mainTemplateCommand);}@Datastatic class MainTemplateCommand {@CommandLine.Option(names = {"-mainTemplateCommand.a", "--mainTemplateCommand.author"}, arity = "0..1", description = "作者注释", interactive = true, echo = true)private String author = "liucc";@CommandLine.Option(names = {"-mainTemplateCommand.o", "--mainTemplateCommand.outputText"}, arity = "0..1", description = "输出信息", interactive = true, echo = true)private String outputText = "sum=";}public static void main(String[] args) {CommandLine commandLine = new CommandLine(TestArgGroupCommand.class);commandLine.execute("--help");// commandLine.execute("-l", "-mainTemplateCommand.a", "-mainTemplateCommand.o");}}
执行main 方法,输入—help
参数,发现 FreeMarker 的帮助手册已经支持自动分组:
测试输入各选项参数值,从输出结果可以看到用户输入的值成功传递到对象参数里了。
目前存在的问题:虽然已经实现了参数分组,但是还是需要用户一次性输入全部参数,仍存在使用成本。
期望:用户选择开启某个配置,程序再引导用户进一步输入相关配置信息。如果没有选择开启,那么这一组的参数用户就无需填写了。
5、定义可选开启的参数组
需求:可以根据用户输入的某个开关参数,来控制是否需要进一步输入对应的参数组。
也就是说,模型参数之间是存在前后依赖关系的,必须按照某个顺序依次引导用户进行输入。
可以先引导用户输入最外层未分组的参数,再根据用户的输入情况,判断是否要引导用户输入参数组的信息。
实现思路
如何实现呢?套娃
用一个 CommandLine 类引导用户输入外层参数信息,根据用户输入信息,触发另一个 CommandLine 引导用户完成参数组的填写。
步骤如下:
- 给参数组定义一个单独的Picocli Command 类(这里使用静态内部类),通过命令行接收参数组的值;
- 用根Command 类(TestGroupCommand)接收用户外层输入,在run 方法中打印用户填写参数的值,并根据开关参数的值判断是否触发参数组 Command,进而引导用户填写参数组配置信息。
代码生成器具体实现
有了实现思路后,先调整代码生成器,再考虑制作工具的优化。
1)ModelData 创建静态内部类,在内部类创建参数组对应的属性。
代码如下:
package com.liucc.model;import lombok.Data;/*** 动态模板配置*/
@Data
public class DataModel {/*** 是否生成 .gitignore 文件*/public boolean needGit = false;/*** 是否生成循环*/public boolean loop = false;/*** 核心模板*/public MainTemplate mainTemplate = new MainTemplate();/*** 用于生成核心模板文件*/@Datapublic static class MainTemplate {/*** 作者注释*/public String author = "liucc";/*** 输出信息*/public String outputText = "sum = ";// 重写 toString方法@Overridepublic String toString() {return "MainTemplate{" +"author='" + author + '\'' +", outputText='" + outputText + '\'' +'}';}}
}
2)编写 Picocli 命令类,在类中定义属性。
复制TestArgGroupCommand
为TestGroupCommand
,后续代码实现都在此类中完成。
定义对象参数,定义为静态的,便于后续静态内部类的方法能够获取到该属性。
static DataModel.MainTemplate mainTemplate = new DataModel.MainTemplate();
编写单独的命令类(静态内部类)MainTemplateCommand
用于接收参数组,在run 方法中将从命令行中接收到的值传递到外层mainTemplate对象。
@CommandLine.Command(name = "mainTemplate", description = "用于生成核心模板文件", mixinStandardHelpOptions = true)
@Data
static class MainTemplateCommand implements Runnable{@CommandLine.Option(names = {"-a", "--author"}, arity = "0..1", description = "作者注释", interactive = true, echo = true)private String author = "liucc";@CommandLine.Option(names = {"-o", "--outputText"}, arity = "0..1", description = "输出信息", interactive = true, echo = true)private String outputText = "sum=";@Overridepublic void run() {mainTemplate.author = author;mainTemplate.outputText = outputText;}
}
在外层 Command 的run 方法中,打印参数的值,并根据开关参数值判断是否触发参数组 Command。示例代码:
@CommandLine.Command(name = "generate", description = "生成代码", mixinStandardHelpOptions = true)
@Data
public class TestGroupCommand implements Runnable {@CommandLine.Option(names = {"-g", "--needGit"}, arity = "0..1", description = "是否生成 .gitignore 文件", interactive = true, echo = true)private boolean needGit = true;@CommandLine.Option(names = {"-l", "--loop"}, arity = "0..1", description = "是否生成循环", interactive = true, echo = true)private boolean loop = false;static DataModel.MainTemplate mainTemplate = new DataModel.MainTemplate();@Overridepublic void run() {System.out.println(needGit);System.out.println(loop);// 如果用户开启 loop,再让用户进一步填写核心配置信息if (loop){System.out.println("请输入核心配置信息:");CommandLine commandLine = new CommandLine(MainTemplateCommand.class);commandLine.execute("-a", "-o");}System.out.println(mainTemplate);}...
}
最终的完整代码如下:
package com.liucc.cli.command;import com.liucc.model.DataModel;
import lombok.Data;
import picocli.CommandLine;@CommandLine.Command(name = "generate", description = "生成代码", mixinStandardHelpOptions = true)
@Data
public class TestGroupCommand implements Runnable {@CommandLine.Option(names = {"-g", "--needGit"}, arity = "0..1", description = "是否生成 .gitignore 文件", interactive = true, echo = true)private boolean needGit = true;@CommandLine.Option(names = {"-l", "--loop"}, arity = "0..1", description = "是否生成循环", interactive = true, echo = true)private boolean loop = false;static DataModel.MainTemplate mainTemplate = new DataModel.MainTemplate();@Overridepublic void run() {System.out.println(needGit);System.out.println(loop);// 如果用户开启 loop,再让用户进一步填写核心配置信息if (loop){System.out.println("请输入核心配置信息:");CommandLine commandLine = new CommandLine(MainTemplateCommand.class);commandLine.execute("-a", "-o");}System.out.println(mainTemplate);}@CommandLine.Command(name = "mainTemplate", description = "用于生成核心模板文件", mixinStandardHelpOptions = true)@Datastatic class MainTemplateCommand implements Runnable{@CommandLine.Option(names = {"-a", "--author"}, arity = "0..1", description = "作者注释", interactive = true, echo = true)private String author = "liucc";@CommandLine.Option(names = {"-o", "--outputText"}, arity = "0..1", description = "输出信息", interactive = true, echo = true)private String outputText = "sum=";@Overridepublic void run() {mainTemplate.author = author;mainTemplate.outputText = outputText;}}public static void main(String[] args) {CommandLine commandLine = new CommandLine(TestGroupCommand.class);
// commandLine.execute("--help");commandLine.execute("-g", "-l");}}
制作工具实现
确定了要制作的代码生成器后,就可以增强我们的制作工具,通过配置自动生成代码生成器。
比较关键的一点是:如果根据一个模型参数,控制另一个模型组参数是否要输入?
给modelInfo
添加 condition
字段,可以将其他模型参数的 filedName
作为 condition 表达式的值。
1)修改元信息 meta.json文件models 组配置,补充 condition 字段,用 loop 字段作为控制开关,示例代码如下:
{"groupKey": "mainTemplate","groupName": "核心模板","type": "MainTemplate","description": "用于生成核心模板文件","condition": "loop","models": [{"fieldName": "author","type": "String","description": "作者注释","defaultValue": "yupi","abbr": "a"},{"fieldName": "outputText","type": "String","description": "输出信息","defaultValue": "sum = ","abbr": "o"}]
}
同步修改 Meta
实体类,给 ModelInfo
补充 condition
字段:
@NoArgsConstructor
@Data
public static class ModelInfo {private String fieldName;private String type;private String description;private Object defaultValue;private String abbr;private String groupKey;private String groupName;private String condition;private List<ModelInfo> models;
}
2)修改 DataModel.java.ftl模板文件,目标是生成前面已经跑通流程的代码。
注意,重复的代码片段使用 FreeMarker 宏定义进行抽取,完整代码如下:
package ${basePackage}.model;import lombok.Data;<#macro generateModel indent modelInfo>
<#if modelInfo.description??>
${indent}/**
${indent} * ${modelInfo.description}
${indent} */
</#if>
${indent}public ${modelInfo.type} ${modelInfo.fieldName} <#if modelInfo.defaultValue??>= ${modelInfo.defaultValue?c}</#if>;
</#macro>/*** 动态模板配置*/
@Data
public class DataModel {<#list modelConfig.models as modelInfo><#--是模型组--><#if modelInfo.groupKey??>/*** ${modelInfo.groupName}*/public ${modelInfo.type} ${modelInfo.groupKey} = new ${modelInfo.type}();/*** 用于生成核心模板文件*/@Datapublic static class MainTemplate {<#list modelInfo.models as subModelInfo><@generateModel indent=" " modelInfo=subModelInfo/></#list>}<#--非模型组--><#else><@generateModel indent=" " modelInfo=modelInfo/></#if></#list>
}
3)修改MainGenerator.java.ftl模板文件
因为 DataModel 的属性层级已经调整,所以MainGenerator 中获取 DataModel 属性值的程序也需要做相应调整,需要多取一个层级:
String outputText = model.mainTemplate.outputText;
String author = model.mainTemplate.author;
模板文件 MainGenerator.java.ftl修改部分代码:
<#list modelConfig.models as modelInfo><#--有分组--><#if modelInfo.groupKey??><#list modelInfo.models as subModelInfo>${subModelInfo.type} ${subModelInfo.fieldName} = model.${modelInfo.groupKey}.${subModelInfo.fieldName};</#list><#--无分组--><#else>${modelInfo.type} ${modelInfo.fieldName} = model.${modelInfo.fieldName};</#if>
</#list>
4)修改GeneratorCommand.java.ftl模板文件,目标是生成前面已跑通流程的代码。
这是最复杂的地方在于,如何根据模型组生成包含所有参数的 args 参数列表:
commandLine.execute("--author", "--outputText");
实现过程涉及到遍历(models)、取特定字段(fieldname)、拼接字符串(用逗号进行拼接)等逻辑,如果这些操作全部通过 FreeMarker 来实现就太复杂了。我们我们的思想是:简单逻辑使用 FreeMarker 实现,复杂逻辑通过 Java 程序实现。通过程序将变量处理好后,直接传递给 FreeMarker,FreeMarker 直接使用即可!
实现
给Meta 实体类的 ModelInfo新增中间字段allArgsStr用于处理模型组下所有命令参数的拼接,示例代码如下:
@NoArgsConstructor
@Data
public static class ModelInfo {private String fieldName;private String type;private String description;private Object defaultValue;private String abbr;private String groupKey;private String groupName;private List<ModelInfo> models;private String condition;// 中间参数// 该分组下所有参数拼接字符串private String allArgsStr;
}
在 MetaValidator中添加维护allArgsStr变量的逻辑,部分代码如下:
for (Meta.ModelConfigDTO.ModelInfo modelInfo : modelInfoList) {// 如果类型是 group,则跳过本次校验String groupKey = modelInfo.getGroupKey();if (StrUtil.isNotEmpty(groupKey)) {// 维护 allArgsStr 参数 "--author", "--outputText"String allArgsStr = modelInfo.getModels().stream().map(subModelInfo -> {return String.format("\"--%s\"", subModelInfo.getFieldName());}).collect(Collectors.joining(",")).toString();modelInfo.setAllArgsStr(allArgsStr);continue;}...}
然后我们在模板文件中就可以直接使用这个中间参数变量了。
参照上面编写的代码生成器命令类 TestGroupCommand
,我们可以编写出 GenerateCommand.java.ftl
模板文件了,完整代码如下:
package ${basePackage}.cli.command;import cn.hutool.core.bean.BeanUtil;
import cn.hutool.core.util.StrUtil;
import ${basePackage}.model.DataModel;
import ${basePackage}.generator.MainGenerator;
import lombok.Data;
import picocli.CommandLine;import java.util.concurrent.Callable;<#--宏定义-->
<#--生成选项-->
<#macro generateOption indent modelInfo>
${indent}@CommandLine.Option(names = {<#if modelInfo.abbr??>"-${modelInfo.abbr}"</#if>, <#if modelInfo.fieldName??>"--${modelInfo.fieldName}"</#if>}, arity = "0..1", <#if modelInfo.description??>description = "${modelInfo.description}"</#if>, interactive = true, echo = true)
${indent}private ${modelInfo.type} ${modelInfo.fieldName} <#if modelInfo.defaultValue??>= ${modelInfo.defaultValue?c}</#if>;
</#macro>@CommandLine.Command(name = "generate", description = "生成代码", mixinStandardHelpOptions = true)
@Data
public class GenerateCommand implements Callable<Integer> {
<#list modelConfig.models as modelInfo><#--有分组--><#if modelInfo.groupKey??>static DataModel.${modelInfo.type} ${modelInfo.groupKey} = new DataModel.${modelInfo.type}();@CommandLine.Command(name = "${modelInfo.groupKey}", description = "${modelInfo.description}", mixinStandardHelpOptions = true)@Datastatic class ${modelInfo.type}Command implements Runnable{<#list modelInfo.models as subModelInfo><@generateOption indent=" " modelInfo=subModelInfo/></#list>@Overridepublic void run() {<#list modelInfo.models as subModelInfo>${modelInfo.groupKey}.${subModelInfo.fieldName} = ${subModelInfo.fieldName};</#list>}}<#--无分组--><#else><@generateOption indent=" " modelInfo=modelInfo/></#if>
</#list>public Integer call() throws Exception {<#list modelConfig.models as modelInfo><#if modelInfo.condition??>// 如果用户开启 核心配置,再让用户进一步填写核心配置信息if (${modelInfo.condition}){System.out.println("请输入${modelInfo.groupName}信息:");CommandLine commandLine = new CommandLine(${modelInfo.type}Command.class);commandLine.execute(${modelInfo.allArgsStr});}</#if></#list>// 填充数据模型DataModel dataModel = new DataModel();BeanUtil.copyProperties(this, dataModel);<#list modelConfig.models as modelInfo><#if modelInfo.groupKey??>dataModel.${modelInfo.groupKey} = ${modelInfo.groupKey};</#if></#list>MainGenerator.doGenerate(dataModel);return 0;}
}
5)执行测试
最后测试效果,如果 loop
我们填写 true
,工具会引导我们输入核心模板配置:
loop
填写 false
,程序会直接结束,不会引导用户填写其他配置信息:
但目前也存在一个问题,查看代码生成器生成的目标代码时,发现对应的参数是没有值得,这是什么问题?
通过排查发现,这是因为DataModel的属性层级结构变了,但是MainTemplate.java.ftl取模型 model 字段的层级没有对应变化,导致无法正确获取模型参数值:
调整原始模板文件 ACM 项目中acm-template-pro
中的模板文件,从 a u t h o r 改为 {author}改为 author改为{mainTemplate.author}。另一个参数类似。
最后重新测试执行。
通过这个例子我们也可以发现现在还存在的一个问题是:模板文件和数据模型之间是强绑定的,如果数据模型的属性层级结构变化了,模板文件也需要我们手动去调整的,否则会出现不一致问题,就很麻烦,我们将在下一章节对该问题进行优化处理。
最后
以上就是本章节的内容,通过生成 SpringBoot 项目代码生成器的目标,梳理出了制作工具应具备的增强配置能力,并且以此实现。
实现过程中涉及到多个 FreeMarker 模板文件的编写,极其需要细心和耐心。
在下一章节中,会继续增加制作工具的功能,使制作工具能够自己生成配置文件和 FTL 动态模板文件,自动和数据模型进行绑定,就不需要我们自己挖坑了,进一步提高我们制作代码生成器的效率。
掌握点
- 掌握从个例需求梳理通用需求的方法
- 掌握 FreeMarker 宏定义,掌握组件复用的思想