04-工具开发

  1. 工具实现思路
  2. 元信息定义
  3. 工具开发

1. 制作工具规划

1. 明确需求和业务

  • 快速将一个项目制作为 可以动态定制部分内容 的代码生成器。并且以 SpringBoot 初始化项目模板为例,演示如何根据需要动态生成 Java 后端初始化项目
  • 生成器制作工具、代码生成器和目标代码的关系
3ff94f69d53848aabc3a690d8e8de33b
  • 完整业务流程图
ff38990b9b4b451ca4be55a572d7a692

2. 实现思路

  1. 工具应该提供哪些能力?怎么提高代码生成器的制作效率?
  2. 如何动态生成命令行工具?如何动态打 jar 包?
  3. 如何动态生成模板文件?怎么从原始文件中抽取参数?有哪些类型的参数?

  1. 开发基础的工具
    • 移除第一阶段 ACM 生成器的硬编码,能在已有项目模板的基础上,通过读取 人工配置 跑通生成器的核心制作流程(不用在代码中找路径、改路径)
  2. 配置文件增强
    • 以实现 Spring Boot 生成器为目标,给配置文件增加更多参数,可以更灵活地制作更复杂的生成器
  3. 工具能力增强
    • 给工具增加更多能力,可以帮助开发者自动生成 / 更新配置文件、FTL 动态模板文件等,进一步提高制作效率

2. 核心设计

纯人工开发一个生成器步骤:

  1. 基于一个要生成的项目,手动挖坑,制作 FTL 动态模板文件(最复杂)
  2. 编写数据模型文件
  3. 编写 Picocli 命令类
  4. 编写代码生成文件 Generator(文件路径还是硬编码“写死”的)
  5. 手动执行 Maven 命令打 jar 包
  6. 自己封装快捷执行脚本

1. 需求分析

假如已经有了一套现成的项目模板文件(包含 FTL、Data),剩下的步骤能否让工具来自动实现呢?

  • 项目 acm-templateMainTemplate.java.ftl 得到现成的项目模板文件
  • 已经有了 FTL、Data,将这些信息保存为 配置文件 ,让工具 读取配置文件 来生成数据模型文件、Picocli 命令类、Generator、打 jar、封装脚本等。相当于上述(2 - 6)步骤工具来实现

2. 元信息定义

  • 一般是用来描述项目的数据。eg:项目的名称、作者等
  • 本质上:把在项目中硬编码的内容转为可以灵活替换的配置

1. 元信息的存储结构

  • JSON 格式来存储元信息。理由:常用、通用、结构清晰、便于理解、对前端 JS 非常友好
  • 将元信息文件定义为 meta.json,放在工具项目的 resources 目录下

2. 元信息的字段配置

根据元信息的作用对配置字段进行分类,便于后面按层级组织各种配置:

  1. 记录生成器的基本信息
    • 项目名称、作者、版本号等
  2. 记录生成文件信息
    • 输入文件路径、输出路径、文件类别(目录或文件)、生成类别(静态或动态)等
  3. 记录数据模型信息
    • 参数的名称、描述、类型、默认值等

3. 示例配置信息

acm-template 的生成器的元信息 JSON 文件

  • 能提前确认的字段就提前确认,之后尽量只新增字段、避免修改字段
  • 建议在外层尽量用对象来组织字段,而不是数组。更有利于字段的扩展(数组修改会改其中的所有对象)
{
    "name": "acm-template-generator",
    "description": "ACM 示例模板生成器",
    "basePackage": "com.listao",
    "version": "1.0",
    "author": "listao",
    "createTime": "2023-11-22",
    "fileConfig": {
        "inputRootPath": "/Users/yupi/Code/acm-template",
        "outputRootPath": "generated",
        "type": "dir",
        "files": [
            {
                "inputPath": "src/com/yupi/acm/MainTemplate.java.ftl",
                "outputPath": "src/com/yupi/acm/MainTemplate.java",
                "type": "file",
                "generateType": "dynamic"
            },
            {
                "inputPath": ".gitignore",
                "outputPath": ".gitignore",
                "type": "file",
                "generateType": "static"
            },
            {
                "inputPath": "README.md",
                "outputPath": "README.md",
                "type": "file",
                "generateType": "static"
            }
        ]
    },
    "modelConfig": {
        "models": [
            {
                "fieldName": "loop",
                "type": "boolean",
                "description": "是否生成循环",
                "defaultValue": false,
                "abbr": "l"
            },
            {
                "fieldName": "author",
                "type": "String",
                "description": "作者注释",
                "defaultValue": "yupi",
                "abbr": "a"
            },
            {
                "fieldName": "outputText",
                "type": "String",
                "description": "输出信息",
                "defaultValue": "sum = ",
                "abbr": "o"
            }
        ]
    }
}







 
























 

























3. 工具开发

开发顺序遵循上面需求分析中提到的生成器制作步骤,分为:

  1. 项目初始化
  2. 读取元信息
  3. 生成数据模型文件
  4. 生成 Picocli 命令类
  5. 生成代码生成文件
  6. 程序构建 jar 包
  7. 程序封装脚本
  8. 测试验证

1. maker项目初始化

1. 代码和目录结构优化

  1. DynamicGenerator.java 改为 DynamicFileGenerator
package com.listao.maker.generator.file;

import cn.hutool.core.io.FileUtil;
import freemarker.cache.ClassTemplateLoader;
import freemarker.template.Configuration;
import freemarker.template.Template;
import freemarker.template.TemplateException;

import java.io.File;
import java.io.FileWriter;
import java.io.IOException;
import java.io.Writer;

/**
 * 动态文件生成
 */
public class DynamicFileGenerator {

    /**
     * 使用相对路径生成文件
     *
     * @param relativeInputPath 模板文件相对输入路径
     * @param outputPath        输出路径
     * @param model             数据模型
     */
    public static void doGenerate(String relativeInputPath, String outputPath, Object model) throws IOException, TemplateException {
        // new 出 Configuration 对象,参数为 FreeMarker 版本号
        Configuration cfg = new Configuration(Configuration.VERSION_2_3_32);

        // 获取模板文件所属包和模板名称
        int lastSplitIndex = relativeInputPath.lastIndexOf("/");
        String basePackagePath = relativeInputPath.substring(0, lastSplitIndex);
        String templateName = relativeInputPath.substring(lastSplitIndex + 1);

        // 通过类加载器读取模板(jar 时必需)
        // ClassTemplateLoader templateLoader = new ClassTemplateLoader(DynamicFileGenerator.class, basePackagePath);
        // cfg.setTemplateLoader(templateLoader);
        File templateDir = new File(relativeInputPath).getParentFile();
        cfg.setDirectoryForTemplateLoading(templateDir);

        // 设置模板文件使用的字符集
        cfg.setDefaultEncoding("utf-8");

        // 创建模板对象,加载指定模板
        Template template = cfg.getTemplate(templateName);

        // 文件不存在则创建文件和父目录
        if (!FileUtil.exist(outputPath)) {
            FileUtil.touch(outputPath);
        }

        // 生成
        Writer out = new FileWriter(outputPath);
        template.process(model, out);

        // 生成文件后别忘了关闭哦
        out.close();
    }

    /**
     * 生成文件
     *
     * @param inputPath  模板文件输入路径
     * @param outputPath 输出路径
     * @param model      数据模型
     */
    public static void doGenerateByPath(String inputPath, String outputPath, Object model) throws IOException, TemplateException {
        // new 出 Configuration 对象,参数为 FreeMarker 版本号
        Configuration configuration = new Configuration(Configuration.VERSION_2_3_32);

        // 指定模板文件所在的路径
        File templateDir = new File(inputPath).getParentFile();
        configuration.setDirectoryForTemplateLoading(templateDir);

        // 设置模板文件使用的字符集
        configuration.setDefaultEncoding("utf-8");

        // 创建模板对象,加载指定模板
        String templateName = new File(inputPath).getName();
        Template template = configuration.getTemplate(templateName);

        // 文件不存在则创建文件和父目录
        if (!FileUtil.exist(outputPath)) {
            FileUtil.touch(outputPath);
        }

        // 生成
        Writer out = new FileWriter(outputPath);
        template.process(model, out);

        // 生成文件后别忘了关闭哦
        out.close();
    }

}
  1. StaticGenerator.java 改为 StaticFileGenerator。删除自定义递归方法
package com.listao.maker.generator.file;

import cn.hutool.core.io.FileUtil;

/**
 * 静态文件生成
 */
public class StaticFileGenerator {

    /**
     * 拷贝文件(Hutool 实现,会将输入目录完整拷贝到输出目录下)
     */
    public static void copyFilesByHutool(String inputPath, String outputPath) {
        FileUtil.copy(inputPath, outputPath, false);
    }
}
  1. 优化 MainGenerator.java 改为 FileGenerator.java,使语义更明确
  2. 将生成文件相关的类都从 maker.generator 包移动到 maker.generator.file 包下,防止和后面的其他生成器混在一起
  3. 删除多余的无用代码
    • 删除 .gitignore 文件,统一在项目根目录管理 git 文件
    • 删除现有的 FTL 模板文件
    • 删除单元测试文件
    • 删除所有制作好的脚本文件等
package com.listao.maker.generator.file;

import freemarker.template.TemplateException;
import org.junit.jupiter.api.Test;

import java.io.File;
import java.io.IOException;
import java.util.HashMap;

/**
 * 核心生成器
 */
public class FileGenerator {

    /**
     * 生成
     *
     * @param model 数据模型
     */
    public static void doGenerate(Object model) throws TemplateException, IOException {
        String projectPath = System.getProperty("user.dir");
        // 整个项目的根路径
        // File parentFile = new File(projectPath).getParentFile();
        File projectFile = new File(projectPath);
        // 输入路径
        String inputPath = new File(projectFile, "src/main/resources/acm-template").getAbsolutePath();
        String outputPath = projectPath + File.separator + ".tmp";
        // 生成静态文件
        StaticFileGenerator.copyFilesByHutool(inputPath, outputPath);
        // 生成动态文件
        String inputDynamicFilePath = projectPath + File.separator + "src/main/resources/acm-template/src/com/yupi/acm/MainTemplate.java.ftl";
        String outputDynamicFilePath = outputPath + File.separator + "acm-template/src/com/yupi/acm/MainTemplate.java";
        DynamicFileGenerator.doGenerate(inputDynamicFilePath, outputDynamicFilePath, model);
    }

    @Test
    public void test() throws TemplateException, IOException {
        HashMap<String, Object> map = new HashMap<>();
        map.put("author", "ooxx");
        map.put("loop", false);
        map.put("outputText", "求和结果:");
        doGenerate(map);
    }

}

目录结构如下:

2854b170893349ffbb287768e828c978

2. 生成文件目录结构化

  • 需要生成一个完整的项目(generator-basic),把对应的 FTL 放到对应的包下,和原项目的文件位置一一对应
  • static 目录用于存放可以直接拷贝的静态文件
8bdb3c4fed1e47b2af21f1014d89852f

2. 读取元信息

1. 元信息定义

提供了一个较为完整的元信息配置文件。存放 resources 目录下

736e81295cfd4e859aac650c7c9254d4
{
    "name": "acm-template-generator",
    "description": "ACM 示例模板生成器",
    "basePackage": "com.listao",
    "version": "1.0",
    "author": "listao",
    "createTime": "2023-11-22",
    "fileConfig": {
        // ...
    },
    "modelConfig": {
        // ...
    }
}
  • name:代码生成器名称,项目的唯一标识
  • description:生成器的描述
  • basePackage:控制生成代码的基础包名
  • version:生成器的版本号,会影响 Maven 的 pom.xml 文件,从而影响 Jar 包的名称
  • author:作者名称
  • createTime:创建时间
  • fileConfig:是一个对象,用于控制文件的生成配置
  • modelConfig:是一个对象,用于控制数据模型(动态参数)信息

2. 元信息模型类

maker.meta 包下新建 Meta 类,用于接受 JSON 字段

JSON 文件转 Java 类代码的 IDEA 插件

  • GsonFormatPlus(推荐)
  • RoboPOJOGenerator
  • Json2Pojo
  • 打开 Meta.javacmd + n 选择 GsonFormatPlus
  • 将完整的 meta.json 复制到左侧窗口
0786ae21609a4eeca105e6ccde2e3ca4
  • 点击左下角的 Setting 按钮,弹出高级配置,按照下图的规则配置
e57fcb5a3ba144a8849249e07dda23ae
9fdf1d2ebf3b4bd5953a4b8fb4e4c2e9

一定要人工校验,防止出现细节问题

  1. Files 改名为 FileInfo。复数变单数,更好理解
  2. Models 改名为 ModelInfo
  3. 修改 ModelInfo.defaultValue 的类型为 Object,同时兼容多种不同的类型

3. 读取元信息 - 单例

String metaJson = ResourceUtil.readUtf8Str("meta.json");
Meta newMeta = JSONUtil.toBean(metaJson, Meta.class);

配置文件在运行时基本不会发生变更,项目运行期间只有一个 Meta 对象,避免重复创建对象开销 —— 单例模式

package com.listao.maker.meta;

import cn.hutool.core.io.resource.ResourceUtil;
import cn.hutool.json.JSONUtil;

public class MetaManager {

    private static volatile Meta meta;

    private MetaManager() {
        // 私有构造函数,防止外部实例化
    }

    public static Meta getMetaObject() {
        if (meta == null) {
            synchronized (MetaManager.class) {
                if (meta == null) {
                    meta = initMeta();
                }
            }
        }
        return meta;
    }

    private static Meta initMeta() {
        String metaJson = ResourceUtil.readUtf8Str("meta.json");
        Meta newMeta = JSONUtil.toBean(metaJson, Meta.class);
        // todo 校验和处理默认值
        return newMeta;
    }

}







 






 
 
 















饿汉式单例模式, 类加载时即初始化对象实例 ,从而保证在任何时候都只有一个实例

public class MetaManager {

    private static final Meta meta = initMeta();

    private MetaManager() {
        // 私有构造函数,防止外部实例化
    }

    public static Meta getMetaObject() {
        return meta;
    }

    private static Meta initMeta() {
        String metaJson = ResourceUtil.readUtf8Str("meta.json");
        Meta newMeta = JSONUtil.toBean(metaJson, Meta.class);
        // todo 校验和处理默认值
        return newMeta;
    }
}


 
















4. 调用测试

public class MetaTest {
    public static void main(String[] args) {
        Meta meta = MetaManager.getMetaObject();
        System.out.println(meta);
    }
}

3. 生成 DataModel

1. 元信息定义

  • fieldName:参数名称,模型字段的唯一标识
  • type:参数类别,比如字符串、布尔等
  • description:参数的描述信息
  • defaultValue:参数的默认值
  • abbr:参数的缩写,用于生成命令行选项的缩写语法
{
    // ...
    "modelConfig": {
        "models": [
            {
                "fieldName": "loop",
                "type": "boolean",
                "description": "是否生成循环",
                "defaultValue": false,
                "abbr": "l"
            },
            {
                "fieldName": "author",
                "type": "String",
                "description": "作者注释",
                "defaultValue": "yupi",
                "abbr": "a"
            },
            {
                "fieldName": "outputText",
                "type": "String",
                "description": "输出信息",
                "defaultValue": "sum = ",
                "abbr": "o"
            }
        ]
    }
}

2. 开发实现

工具作用:制作代码生成器,生成的代码是要对开发者可见的,而不是在 Java 程序运行时动态生成、结束后不留痕迹,所以采用 FreeMarker 实现

  • ${modelInfo.defaultValue?c} 作用:将任何类型的变量(boolean 类型和 String 类型)都转换为字符串
package ${basePackage}.model;

import lombok.Data;

/**
 * 数据模型
 */
@Data
public class DataModel {
<#list modelConfig.models as modelInfo>

    <#if modelInfo.description??>
    /**
     * ${modelInfo.description}
     */
    </#if>
    private ${modelInfo.type} ${modelInfo.fieldName}<#if modelInfo.defaultValue??> = ${modelInfo.defaultValue?c}</#if>;
</#list>
}









 

 







3. 调用测试

  1. 定义生成器的根路径。当前项目下的 generated/生成器名称 目录
    • 注意:.gitignore 文件中忽略 generated 目录
  2. 定义要生成的 Java 代码根路径。需要将元信息中的 basePackage 转换为实际的文件路径
  3. 调用 DynamicFileGenerator 生成 DataModel 文件
public class T_Main {

    /**
     * `@Test` 运行需要 test/resources/templates
     */
    public static void main(String[] args) throws IOException, InterruptedException, TemplateException {
        Meta meta = MetaManager.getMetaObject();

        // 1. 输出根路径
        String projectPath = System.getProperty("user.dir");
        String outputPath = projectPath + "/.tmp/" + meta.getName();
        if (!FileUtil.exist(outputPath)) {
            FileUtil.mkdir(outputPath);
        }

        // 2. 读取 resources 目录
        ClassPathResource classPathResource = new ClassPathResource("");
        String inputResourcePath = classPathResource.getAbsolutePath();

        // 3. Java 包基础路径
        String outputBasePackage = meta.getBasePackage();
        String outputBasePackagePath = StrUtil.join("/", StrUtil.split(outputBasePackage, "."));
        String outputBaseJavaPackagePath = outputPath + "/src/main/java/" + outputBasePackagePath;

        String inputFilePath;
        String outputFilePath;

        // 4. model/DataModel
        inputFilePath = inputResourcePath + "templates/java/model/DataModel.java.ftl";
        outputFilePath = outputBaseJavaPackagePath + "/model/DataModel.java";
        DynamicFileGenerator.doGenerate(inputFilePath, outputFilePath, meta);
    }
}



























 





4. 生成 Picocli

  1. 具体命令 GenerateCommand.javaListCommand.javaConfigCommand.java
  2. 命令执行器:CommandExecutor.java
  3. 调用命令执行器的主类:Main.java

1. GenerateCommand.java.ftl

package ${basePackage}.cli.command;

import cn.hutool.core.bean.BeanUtil;
import ${basePackage}.generator.MainGenerator;
import ${basePackage}.model.DataModel;
import lombok.Data;
import picocli.CommandLine.Command;
import picocli.CommandLine.Option;

import java.util.concurrent.Callable;

@Command(name = "generate", description = "生成代码", mixinStandardHelpOptions = true)
@Data
public class GenerateCommand implements Callable<Integer> {
<#list modelConfig.models as modelInfo>

    @Option(names = {<#if modelInfo.abbr??>"-${modelInfo.abbr}", </#if>"--${modelInfo.fieldName}"}, arity = "0..1", <#if modelInfo.description??>description = "${modelInfo.description}", </#if>interactive = true, echo = true)
    private ${modelInfo.type} ${modelInfo.fieldName}<#if modelInfo.defaultValue??> = ${modelInfo.defaultValue?c}</#if>;
</#list>

    public Integer call() throws Exception {
        DataModel dataModel = new DataModel();
        BeanUtil.copyProperties(this, dataModel);
        MainGenerator.doGenerate(dataModel);
        return 0;
    }
}














 

 










2. ListCommand.java.ftl

${fileConfig.inputRootPath} 动态生成。作用:指定模板文件的根路径

package ${basePackage}.cli.command;

import cn.hutool.core.io.FileUtil;
import picocli.CommandLine.Command;

import java.io.File;
import java.util.List;

@Command(name = "list", description = "查看文件列表", mixinStandardHelpOptions = true)
public class ListCommand implements Runnable {

    public void run() {
        // 输入路径
        String inputPath = "${fileConfig.inputRootPath}";
        List<File> files = FileUtil.loopFiles(inputPath);
        for (File file : files) {
            System.out.println(file);
        }
    }

}
 












 







3. ConfigCommand.java.ftl

package ${basePackage}.cli.command;

import cn.hutool.core.util.ReflectUtil;
import ${basePackage}.model.DataModel;
import picocli.CommandLine.Command;

import java.lang.reflect.Field;

@Command(name = "config", description = "查看参数信息", mixinStandardHelpOptions = true)
public class ConfigCommand implements Runnable {

    public void run() {
        // 实现 config 命令的逻辑
        System.out.println("查看参数信息");

        Field[] fields = ReflectUtil.getFields(DataModel.class);

        // 遍历并打印每个字段的信息
        for (Field field : fields) {
            System.out.println("字段名称:" + field.getName());
            System.out.println("字段类型:" + field.getType());
            System.out.println("---");
        }
    }
}
 
























4. CommandExecutor.java.ftl

package ${basePackage}.cli;

import ${basePackage}.cli.command.GenerateCommand;
import ${basePackage}.cli.command.ListCommand;
import ${basePackage}.cli.command.ConfigCommand;
import picocli.CommandLine;
import picocli.CommandLine.Command;

/**
 * 命令执行器
 */
@Command(name = "${name}", mixinStandardHelpOptions = true)
public class CommandExecutor implements Runnable {

    private final CommandLine commandLine;

    {
        commandLine = new CommandLine(this)
                .addSubcommand(new GenerateCommand())
                .addSubcommand(new ConfigCommand())
                .addSubcommand(new ListCommand());
    }

    @Override
    public void run() {
        // 不输入子命令时,给出友好提示
        System.out.println("请输入具体命令,或者输入 --help 查看命令提示");
    }

    /**
     * 执行命令
     *
     * @param args
     * @return
     */
    public Integer doExecute(String[] args) {
        return commandLine.execute(args);
    }
}
 

 
 







 



























5. Main

package ${basePackage};

import ${basePackage}.cli.CommandExecutor;

public class Main {

    public static void main(String[] args) {
        CommandExecutor commandExecutor = new CommandExecutor();
        commandExecutor.doExecute(args);
    }
}
 

 








6. 调用测试

 // cli.command.ConfigCommand
 inputFilePath = inputResourcePath + "templates/java/cli/command/ConfigCommand.java.ftl";
 outputFilePath = outputBaseJavaPackagePath + "/cli/command/ConfigCommand.java";
 DynamicFileGenerator.doGenerate(inputFilePath, outputFilePath, meta);

 // cli.command.GenerateCommand
 inputFilePath = inputResourcePath + "templates/java/cli/command/GenerateCommand.java.ftl";
 outputFilePath = outputBaseJavaPackagePath + "/cli/command/GenerateCommand.java";
 DynamicFileGenerator.doGenerate(inputFilePath, outputFilePath, meta);

 // cli.command.ListCommand
 inputFilePath = inputResourcePath + "templates/java/cli/command/ListCommand.java.ftl";
 outputFilePath = outputBaseJavaPackagePath + "/cli/command/ListCommand.java";
 DynamicFileGenerator.doGenerate(inputFilePath, outputFilePath, meta);

 // cli.CommandExecutor
 inputFilePath = inputResourcePath + "templates/java/cli/CommandExecutor.java.ftl";
 outputFilePath = outputBaseJavaPackagePath + "/cli/CommandExecutor.java";
 DynamicFileGenerator.doGenerate(inputFilePath, outputFilePath, meta);

 // Main
 inputFilePath = inputResourcePath + "templates/java/Main.java.ftl";
 outputFilePath = outputBaseJavaPackagePath + "/Main.java";
 DynamicFileGenerator.doGenerate(inputFilePath, outputFilePath, meta);
 




 




 




 




 



  • 生成项目的目录结构如下:
7ee3a3fcfc5e49eebec011a5e63f4919

5. 生成 pom.xml

使用 Maven 打包项目,项目的根目录下必须要有 pom.xml 项目管理文件。根据元信息动态生成的

<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
         xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
         xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
    <modelVersion>4.0.0</modelVersion>

    <groupId>${basePackage}</groupId>
    <artifactId>${name}</artifactId>
    <version>${version}</version>

    <properties>
        <maven.compiler.source>8</maven.compiler.source>
        <maven.compiler.target>8</maven.compiler.target>
        <project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
    </properties>

    <dependencies>
        <!-- https://freemarker.apache.org/index.html -->
        <dependency>
            <groupId>org.freemarker</groupId>
            <artifactId>freemarker</artifactId>
            <version>2.3.32</version>
        </dependency>
        <!-- https://picocli.info -->
        <dependency>
            <groupId>info.picocli</groupId>
            <artifactId>picocli</artifactId>
            <version>4.7.5</version>
        </dependency>
        <!-- https://doc.hutool.cn/ -->
        <dependency>
            <groupId>cn.hutool</groupId>
            <artifactId>hutool-all</artifactId>
            <version>5.8.16</version>
        </dependency>
        <!-- https://mvnrepository.com/artifact/org.apache.commons/commons-collections4 -->
        <dependency>
            <groupId>org.apache.commons</groupId>
            <artifactId>commons-collections4</artifactId>
            <version>4.4</version>
        </dependency>
        <!-- https://projectlombok.org/ -->
        <dependency>
            <groupId>org.projectlombok</groupId>
            <artifactId>lombok</artifactId>
            <version>1.18.30</version>
            <scope>provided</scope>
        </dependency>
        <dependency>
            <groupId>junit</groupId>
            <artifactId>junit</artifactId>
            <version>4.13.2</version>
            <scope>test</scope>
        </dependency>
    </dependencies>

    <build>
        <plugins>
            <plugin>
                <groupId>org.apache.maven.plugins</groupId>
                <artifactId>maven-assembly-plugin</artifactId>
                <version>3.3.0</version>
                <configuration>
                    <descriptorRefs>
                        <descriptorRef>jar-with-dependencies</descriptorRef>
                    </descriptorRefs>
                    <archive>
                        <manifest>
                            <mainClass>${basePackage}.Main</mainClass> <!-- 替换为你的主类的完整类名 -->
                        </manifest>
                    </archive>
                </configuration>
                <executions>
                    <execution>
                        <phase>package</phase>
                        <goals>
                            <goal>single</goal>
                        </goals>
                    </execution>
                </executions>
            </plugin>
        </plugins>
    </build>
</project>






 
 
 



























































 















// pom.xml
inputFilePath = inputResourcePath + File.separator + "templates/pom.xml.ftl";
outputFilePath = outputPath + File.separator + "pom.xml";
DynamicFileGenerator.doGenerate(inputFilePath , outputFilePath, meta);
 



6. 生成生成器文件

通过元信息自动生成模板文件的路径

1. 元信息定义

  • inputRootPath:输入模板文件的根路径,即到哪里去找 FTL 模板文件(可以是相对路径)
  • outputRootPath:输出最终代码的根路径(可以是相对路径)
  • type:文件类别,目录或文件
  • files:子文件列表,支持递归
    • inputPath:输入文件的具体路径(可以是相对路径)
    • outputPath:输出文件的具体路径(可以是相对路径)
    • generateType:文件的生成类型,静态或动态
{
    // ...
    "fileConfig": {
        "inputRootPath": "/Users/yupi/Code/acm-template",
        "outputRootPath": "generated",
        "type": "dir",
        "files": [
            {
                "inputPath": "src/com/yupi/acm/MainTemplate.java.ftl",
                "outputPath": "src/com/yupi/acm/MainTemplate.java",
                "type": "file",
                "generateType": "dynamic"
            },
            {
                "inputPath": ".gitignore",
                "outputPath": ".gitignore",
                "type": "file",
                "generateType": "static"
            },
            {
                "inputPath": "README.md",
                "outputPath": "README.md",
                "type": "file",
                "generateType": "static"
            }
        ]
    }
}
1. 文件路径规则选取

两种结构其实是可以通过编写程序相互转换的,所以目前先采用成本最低的方式

  • 层层递归结构
"files": [
    {
        "path": "src",
        "type": "dir",
        "files": [
            {
                "path": "com",
                "type": "dir",
                "files": [
                    {

                    }
                ]
            }
        ]
    }
]
  • 折叠路径结构
"files": [
   {
     "path": "src/com",
     "type": "dir"
   }
]

尽量折叠路径。原因:

  1. 考虑到 URL 地址的场景,路径的结构不一定需要展开
  2. 扁平化的路径会让 JSON 结构更清晰精简,前期更利于开发和维护

2. MainGenerator.java.ftl

package ${basePackage}.generator;

import com.yupi.model.DataModel;
import freemarker.template.TemplateException;

import java.io.File;
import java.io.IOException;

/**
 * 核心生成器
 */
public class MainGenerator {

    /**
     * 生成
     *
     * @param model 数据模型
     * @throws TemplateException
     * @throws IOException
     */
    public static void doGenerate(Object model) throws TemplateException, IOException {
        String inputRootPath = "${fileConfig.inputRootPath}";
        String outputRootPath = "${fileConfig.outputRootPath}";

        String inputPath;
        String outputPath;
	<#list fileConfig.files as fileInfo>

    	inputPath = new File(inputRootPath, "${fileInfo.inputPath}").getAbsolutePath();
    	outputPath = new File(outputRootPath, "${fileInfo.outputPath}").getAbsolutePath();
    	<#if fileInfo.generateType == "static">
        StaticGenerator.copyFilesByHutool(inputPath, outputPath);
    	<#else>
        DynamicGenerator.doGenerate(inputPath, outputPath, model);
    	</#if>
	</#list>
    }
}


























 



 







3. DynamicGenerator.java.ftl

通用的动态代码生成工具,所以不需要定制,修改下包名即可

package ${basePackage}.generator;

import cn.hutool.core.io.FileUtil;
import freemarker.template.Configuration;
import freemarker.template.Template;
import freemarker.template.TemplateException;

import java.io.File;
import java.io.FileWriter;
import java.io.IOException;
import java.io.Writer;

/**
 * 动态文件生成
 */
public class DynamicGenerator {

    /**
     * 生成文件
     *
     * @param inputPath 模板文件输入路径
     * @param outputPath 输出路径
     * @param model 数据模型
     * @throws IOException
     * @throws TemplateException
     */
    public static void doGenerate(String inputPath, String outputPath, Object model) throws IOException, TemplateException {
        // new 出 Configuration 对象,参数为 FreeMarker 版本号
        Configuration configuration = new Configuration(Configuration.VERSION_2_3_32);

        // 指定模板文件所在的路径
        File templateDir = new File(inputPath).getParentFile();
        configuration.setDirectoryForTemplateLoading(templateDir);

        // 设置模板文件使用的字符集
        configuration.setDefaultEncoding("utf-8");

        // 创建模板对象,加载指定模板
        String templateName = new File(inputPath).getName();
        Template template = configuration.getTemplate(templateName);

        // 文件不存在则创建文件和父目录
        if (!FileUtil.exist(outputPath)) {
            FileUtil.touch(outputPath);
        }

        // 生成
        Writer out = new FileWriter(outputPath);
        template.process(model, out);

        // 生成文件后别忘了关闭哦
        out.close();
    }

}
 






















































4. StaticGenerator.java.ftl

通用的静态文件生成工具,所以不需要定制,修改下包名即可

package ${basePackage}.generator;

import cn.hutool.core.io.FileUtil;

/**
 * 静态文件生成
 */
public class StaticGenerator {

    /**
     * 拷贝文件(Hutool 实现,会将输入目录完整拷贝到输出目录下)
     *
     * @param inputPath
     * @param outputPath
     */
    public static void copyFilesByHutool(String inputPath, String outputPath) {
        FileUtil.copy(inputPath, outputPath, false);
    }
}
 


















c5c1c7aa1ba24afe85b9da87bc66e0fb

5. 调用测试

// generator.DynamicGenerator
inputFilePath = inputResourcePath + File.separator + "templates/java/generator/DynamicGenerator.java.ftl";
outputFilePath = outputBaseJavaPackagePath + "/generator/DynamicGenerator.java";
DynamicFileGenerator.doGenerate(inputFilePath , outputFilePath, meta);

// generator.MainGenerator
inputFilePath = inputResourcePath + File.separator + "templates/java/generator/MainGenerator.java.ftl";
outputFilePath = outputBaseJavaPackagePath + "/generator/MainGenerator.java";
DynamicFileGenerator.doGenerate(inputFilePath , outputFilePath, meta);

// generator.StaticGenerator
inputFilePath = inputResourcePath + File.separator + "templates/java/generator/StaticGenerator.java.ftl";
outputFilePath = outputBaseJavaPackagePath + "/generator/StaticGenerator.java";
DynamicFileGenerator.doGenerate(inputFilePath , outputFilePath, meta);
 




 




 



011dc9266a1b4f53b3252903c781eb97

7. 工具构建 jar 包

之前是通过手动执行 Maven 命令来构建 jar 包的,如果要实现程序自动构建 jar 包,只需要让程序来执行 Maven 打包命令即可

1. 开发实现

  1. 在本地(或服务器)安装 Maven 并配置环境变量,参考教程open in new window
    • 执行 mvn -v 命令检测是否安装成功
vim ~/.zshrc

export MAVEN_HOME=/Applications/IntelliJ\ IDEA.app/Contents/plugins/maven/lib/maven3
export PATH=$PATH:$MAVEN_HOME/bin

source ~/.zshrc
  1. generator 目录下新建 JarGenerator.java 类,编写 jar 包构建逻辑
  2. 程序实现的关键是:使用 Java 内置的 Process 类执行 Maven 打包命令,并获取到命令的输出信息。注意:
    • 不同的操作系统,执行的命令代码不同
    • 修改了环境变量,idea 要重启
package com.listao.maker.generator;

import org.junit.jupiter.api.Test;

import java.io.*;
import java.util.Map;

public class JarGenerator {

    public static void doGenerate(String projectDir) throws IOException, InterruptedException {
        // 清理之前的构建并打包
        // 1. 注意:不同操作系统,执行的命令不同
        String winMavenCommand = "mvn.cmd clean package -DskipTests=true";
        String otherMavenCommand = "mvn clean package -DskipTests=true";
        String mavenCommand = otherMavenCommand;

        // 2. 这里一定要拆分!
        ProcessBuilder processBuilder = new ProcessBuilder(mavenCommand.split(" "));
        processBuilder.directory(new File(projectDir));
        Map<String, String> environment = processBuilder.environment();
        System.out.println(environment);
        Process process = processBuilder.start();

        // 3. 读取命令的输出
        InputStream inputStream = process.getInputStream();
        BufferedReader reader = new BufferedReader(new InputStreamReader(inputStream));
        String line;
        while ((line = reader.readLine()) != null) {
            System.out.println(line);
        }

        // 4. 等待命令执行完成
        int exitCode = process.waitFor();
        System.out.println("命令执行结束,退出码:" + exitCode);
    }

    @Test
    public void test() throws IOException, InterruptedException {
        doGenerate(System.getProperty("user.dir") + "/.tmp/01-local-generator");
    }
}

















 



 


 







 








2. 调用测试

[INFO] Scanning for projects...
[INFO] 
[INFO] -------------------< com.listao:01-local-generator >--------------------
[INFO] Building 01-local-generator 1.0
[INFO]   from pom.xml
[INFO] --------------------------------[ jar ]---------------------------------
[INFO] 
[INFO] --- clean:3.2.0:clean (default-clean) @ 01-local-generator ---
[INFO] Deleting /Users/listao/mca/listao_generator/02-tool-generator/.tmp/01-local-generator/target
[INFO] 
[INFO] --- resources:3.3.0:resources (default-resources) @ 01-local-generator ---
[INFO] skip non existing resourceDirectory /Users/listao/mca/listao_generator/02-tool-generator/.tmp/01-local-generator/src/main/resources
[INFO] 
[INFO] --- compiler:3.10.1:compile (default-compile) @ 01-local-generator ---
[INFO] Changes detected - recompiling the module!
[INFO] Compiling 10 source files to /Users/listao/mca/listao_generator/02-tool-generator/.tmp/01-local-generator/target/classes
[INFO] 
[INFO] --- resources:3.3.0:testResources (default-testResources) @ 01-local-generator ---
[INFO] skip non existing resourceDirectory /Users/listao/mca/listao_generator/02-tool-generator/.tmp/01-local-generator/src/test/resources
[INFO] 
[INFO] --- compiler:3.10.1:testCompile (default-testCompile) @ 01-local-generator ---
[INFO] No sources to compile
[INFO] 
[INFO] --- surefire:3.0.0:test (default-test) @ 01-local-generator ---
[INFO] Tests are skipped.
[INFO] 
[INFO] --- jar:3.3.0:jar (default-jar) @ 01-local-generator ---
[INFO] Building jar: /Users/listao/mca/listao_generator/02-tool-generator/.tmp/01-local-generator/target/01-local-generator-1.0.jar
[INFO] 
[INFO] --- assembly:3.3.0:single (default) @ 01-local-generator ---
[INFO] Building jar: /Users/listao/mca/listao_generator/02-tool-generator/.tmp/01-local-generator/target/01-local-generator-1.0-jar-with-dependencies.jar
[INFO] ------------------------------------------------------------------------
[INFO] BUILD SUCCESS
[INFO] ------------------------------------------------------------------------
[INFO] Total time:  1.750 s
[INFO] Finished at: 2025-01-25T15:28:18+08:00
[INFO] ------------------------------------------------------------------------
[WARNING] 
[WARNING] Plugin validation issues were detected in 3 plugin(s)
[WARNING] 
[WARNING]  * org.apache.maven.plugins:maven-assembly-plugin:3.3.0
[WARNING]  * org.apache.maven.plugins:maven-compiler-plugin:3.10.1
[WARNING]  * org.apache.maven.plugins:maven-resources-plugin:3.3.0
[WARNING] 
[WARNING] For more or less details, use 'maven.plugin.validation' property with one of the values (case insensitive): [BRIEF, DEFAULT, VERBOSE]
[WARNING] 
命令执行结束,退出码:0
a18508766d114d309b243b4707e05d6f

8. 工具封装脚本

有了 jar 包后,就可以根据 jar 包的路径,让程序来自动生成脚本文件了

1. 开发实现

  • 脚本文件的内容非常简单,只有几行代码,所以不用编写 FTL 模板了,直接使用 StringBuilder 拼接字符串,然后写入文件
  • 注意,如果是 Linux 系统,还需要在生成文件后,使用 PosixFilePermissions 类给文件默认添加可执行权限
public class ScriptGenerator {

    public static void doGenerate(String outputPath, String jarPath) throws IOException {

        // 直接写入脚本文件
        // linux
        StringBuilder sb = new StringBuilder();
        sb.append("#!/bin/bash").append("\n");
        sb.append(String.format("java -jar %s \"$@\"", jarPath)).append("\n");
        FileUtil.writeBytes(sb.toString().getBytes(StandardCharsets.UTF_8), outputPath + ".sh");
        // 添加可执行权限
        try {
            Set<PosixFilePermission> permissions = PosixFilePermissions.fromString("rwxrwxrwx");
            Files.setPosixFilePermissions(Paths.get(outputPath + ".sh"), permissions);
        } catch (Exception e) {

        }

        // windows
        sb = new StringBuilder();
        sb.append("@echo off").append("\n");
        sb.append(String.format("java -jar %s %%*", jarPath)).append("\n");
        FileUtil.writeBytes(sb.toString().getBytes(StandardCharsets.UTF_8), outputPath + ".bat");
    }

    public static void main(String[] args) throws IOException {
        Meta meta = MetaManager.getMetaObject();
        String outputPath = System.getProperty("user.dir") + "/.tmp/" + meta.getName() + "/generator";
        String jarName = String.format("%s-%s-jar-with-dependencies.jar", meta.getName(), meta.getVersion());
        String jarPath = "target/" + jarName;
        System.out.println("outputPath = " + outputPath);
        doGenerate(outputPath, jarPath);
    }
}









 


 









 








 


2. 调用测试

生成脚本文件

443ec3aebc4c4d44a05020f6af8a3bef

在终端中进入生成的项目目录,运行脚本文件

➜  01-local-generator-dist git:(main)# ./generator.sh generate -h
Usage: 01-local-generator generate [-hV] [-l[=<loop>]] [--needGit[=<needGit>]]
生成代码
  -h, --help            Show this help message and exit.
  -l, --loop[=<loop>]   是否生成循环
      --needGit[=<needGit>]
                        是否生成 .gitignore 文件
  -V, --version         Print version information and exit.