比MyBatis Generator更强大的代码生成器

2,655

本文为《从零打造项目》系列第一篇文章,首发于个人网站

《从零打造项目》系列文章

工具

ORM框架选型

数据库变更管理

定时任务框架

缓存

安全框架

开发规范

前言

工欲善其事,必先利其器。作为《从零打造项目》系列的第一篇文章,本文主要带大家认识一下生成基础模版代码的脚手架。

文章更新记录

2021年4月构建本项目,集成了 Mybatis 和 Mybatis Plus 两种的生成逻辑。

2021年5月添加resultMap模板生成逻辑。

2022年9月补充了 SpringData JPA 的代码生成逻辑,同时重构了一下代码。

背景

在 SpringBoot 项目开发前,关于初始代码的生成,是值得考虑的一件事。当我们根据业务需求完成表设计后,接下来就需要根据表生成相关代码,在 SpringBoot 项目中需要以下几部分内容:

  • entity, 实体层,用于存放我们的实体类,与数据库中的属性值基本保持一致,实现set和get的方法 ;
  • mapper,对数据库进行数据持久化操作,它的方法语句是直接针对数据库操作的,主要实现一些增删改查操作,在 mybatis 中方法主要与与 xxx.xml 内相互一一映射;
  • service,业务 service 层,给 controller 层的类提供接口进行调用。一般就是自己写的方法封装起来,就是声明一下,具体实现在 serviceImpl 中;
  • controller,控制层,负责具体模块的业务流程控制,需要调用 service 逻辑设计层的接口来控制业务流程。因为 service 中的方法是我们使用到的,controller 通过接收前端 H5 或者 App 传过来的参数进行业务操作,再将处理结果返回到前端。

除了上述项目架构中最基本的文件,为了更好的管理项目,我们还增加以下几个层级:

  • dto文件,用来分担实体类的功效,可以将查询条件单独封装一个类,以及前后端交互的实体类(有时候我们可能会传入 entity 实体类中不存在的字段);
  • vo文件,后台返回给前台的数据结构,同样可以自定义字段;
  • struct文件,用来处理 dto 、entity、vo 文件之间的转换。

项目中使用的 ORM 框架多为 Mybatis、 Mybatis Plus 和 Spring Data JPA,虽然各自的官方文档都有代码生成器配置,但是过于简单,无法满足实际需求,因此整理出一套通用的代码生成器,势在必行。

在开始本文之前,首先介绍一下要用到的知识点。

知识点

FreeMarker

FreeMarker 是一款模板引擎: 即一种基于模板和要改变的数据, 并用来生成输出文本(HTML网页,电子邮件,配置文件,源代码等)的通用工具。 它不是面向最终用户的,而是一个Java类库,是一款程序员可以嵌入他们所开发产品的组件。

模板编写为FreeMarker Template Language (FTL)。它是简单的,专用的语言, 不是 像 PHP 那样成熟的编程语言。 那就意味着要准备数据在真实编程语言中来显示,比如数据库查询和业务运算, 之后模板显示已经准备好的数据。在模板中,你可以专注于如何展现数据, 而在模板之外可以专注于要展示什么数据。

Mybatis

MyBatis 是一款优秀的持久层框架,它支持自定义 SQL、存储过程以及高级映射。MyBatis 免除了几乎所有的 JDBC 代码以及设置参数和获取结果集的工作。MyBatis 可以通过简单的 XML 或注解来配置和映射原始类型、接口和 Java POJO(Plain Old Java Objects,普通老式 Java 对象)为数据库中的记录。

Mybatis Plus

MyBatis-Plus (opens new window)(简称 MP)是一个 MyBatis (opens new window)的增强工具,在 MyBatis 的基础上只做增强不做改变,为简化开发、提高效率而生。

Spring Data JPA

Spring Data JPA 是 Spring Data 项目的一部分,它可以更轻松地实现基于 JPA 的存储库。

Spring Data JPA 可以与 Hibernate、Eclipse Link 或任何其他 JPA 提供程序一起使用。使用 Spring 或 Java EE 的一个非常有趣的好处是您可以使用@Transactional注解以声明方式控制事务边界。

本文主要讲述选择使用 Mybatis、 Mybatis Plus 和 Spring Data JPA 时,相关代码文件的生成过程。

JCommander

JCommander 是一个用于解析命令行参数的Java框架,支持解析所有基本的数据类型,也支持将命令行解析成用户自定义的类型,只需要写一个转变函数。

接下来就进行代码实战环节。

实战

首先新建一个 maven 项目,命名为 mybatis-generator。

基本环境配置

导入依赖

<?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>

    <parent>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-parent</artifactId>
        <version>2.6.3</version>
<!--        <version>2.5.12</version>-->
        <relativePath/> <!-- lookup parent from repository -->
    </parent>

    <groupId>com.msdn.generator</groupId>
    <artifactId>orm-generator</artifactId>
    <version>1.0-SNAPSHOT</version>

    <properties>
        <java.version>1.8</java.version>
        <logback.version>1.2.3</logback.version>
        <fastjson.version>1.2.73</fastjson.version>
        <hutool.version>5.5.1</hutool.version>
        <mysql.version>8.0.19</mysql.version>
        <mybatis.version>2.1.4</mybatis.version>
        <mapper.version>4.1.5</mapper.version>
    </properties>

    <dependencies>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-web</artifactId>
        </dependency>
<!--        <dependency>-->
<!--            <groupId>org.springframework.boot</groupId>-->
<!--            <artifactId>spring-boot-starter-validation</artifactId>-->
<!--        </dependency>-->
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-data-jpa</artifactId>
        </dependency>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-security</artifactId>
        </dependency>
        <dependency>
            <groupId>com.alibaba</groupId>
            <artifactId>fastjson</artifactId>
            <version>${fastjson.version}</version>
        </dependency>
        <dependency>
            <groupId>ch.qos.logback</groupId>
            <artifactId>logback-classic</artifactId>
            <version>${logback.version}</version>
        </dependency>
        <dependency>
            <groupId>cn.hutool</groupId>
            <artifactId>hutool-all</artifactId>
            <version>${hutool.version}</version>
        </dependency>
        <dependency>
            <groupId>org.projectlombok</groupId>
            <artifactId>lombok</artifactId>
            <optional>true</optional>
        </dependency>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-test</artifactId>
            <scope>test</scope>
        </dependency>
        <dependency>
            <groupId>mysql</groupId>
            <artifactId>mysql-connector-java</artifactId>
            <version>${mysql.version}</version>
            <scope>runtime</scope>
        </dependency>
        <dependency>
            <groupId>org.mybatis.spring.boot</groupId>
            <artifactId>mybatis-spring-boot-starter</artifactId>
            <version>${mybatis.version}</version>
        </dependency>
        <dependency>
            <groupId>tk.mybatis</groupId>
            <artifactId>mapper</artifactId>
            <version>${mapper.version}</version>
        </dependency>
        <!--JCommander解析命令行参数-->
        <dependency>
            <groupId>com.beust</groupId>
            <artifactId>jcommander</artifactId>
            <version>1.78</version>
        </dependency>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-freemarker</artifactId>
        </dependency>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-configuration-processor</artifactId>
            <optional>true</optional>
        </dependency>
        <dependency>
            <groupId>com.github.pagehelper</groupId>
            <artifactId>pagehelper-spring-boot-starter</artifactId>
            <version>1.4.3</version>
        </dependency>
        <dependency>
            <groupId>org.springframework.data</groupId>
            <artifactId>spring-data-commons</artifactId>
            <version>2.4.6</version>
        </dependency>
        <dependency>
            <groupId>com.baomidou</groupId>
            <artifactId>mybatis-plus-boot-starter</artifactId>
            <version>3.5.1</version>
        </dependency>
        <dependency>
            <groupId>org.springdoc</groupId>
            <artifactId>springdoc-openapi-ui</artifactId>
            <version>1.6.9</version>
        </dependency>

        <dependency>
            <groupId>org.mapstruct</groupId>
            <artifactId>mapstruct-jdk8</artifactId>
            <version>1.5.2.Final</version>
        </dependency>
        <dependency>
            <groupId>org.mapstruct</groupId>
            <artifactId>mapstruct-processor</artifactId>
            <version>1.5.2.Final</version>
        </dependency>
    </dependencies>

    <build>
        <plugins>
            <plugin>
                <groupId>org.springframework.boot</groupId>
                <artifactId>spring-boot-maven-plugin</artifactId>
            </plugin>
        </plugins>
    </build>
</project>

配置文件

application.yml 文件内容如下:

server:
  port: 8525
spring:
  application:
    name: orm-generator
springdoc:
  swagger-ui:
    # 修改Swagger UI路径
    path: /swagger-ui.html
    # 开启Swagger UI界面
    enabled: true
  api-docs:
    # 修改api-docs路径
    path: /v3/api-docs
    # 开启api-docs
    enabled: true
    # 配置需要生成接口文档的扫描包
    packages-to-scan: com.msdn.generator.controller

代码生成

实体类

为了接收相关配置参数,我们通过 JCommander 解析命令行参数,此处创建对应的实体类 GenerateParameter来接收这些参数。

@Getter
@Setter
@Schema(name = "使用帮助")
@Parameters(commandDescription = "使用帮助")
public class GenerateParameter {

  @Schema(name = "mysql主机名")
  @Parameter(names = {"--host", "-h"}, description = "mysql主机名")
  private String host;

  @Schema(name = "mysql端口")
  @Parameter(names = {"--port", "-P"}, description = "mysql端口")
  private Integer port;

  @Schema(name = "mysql用户名")
  @Parameter(names = {"--username", "-u"}, description = "mysql用户名")
  private String username;

  @Schema(name = "mysql密码")
  @Parameter(names = {"--password", "-p"}, description = "mysql密码")
  private String password;

  @Schema(name = "mysql数据库名")
  @Parameter(names = {"--database", "-d"}, description = "mysql数据库名")
  private String database;

  @Schema(name = "mysql数据库表")
  @Parameter(names = {"--table", "-t"}, description = "mysql数据库表")
  private List<String> table;

  @Schema(name = "业务模块名")
  @Parameter(names = {"--module", "-m"}, description = "业务模块名")
  private String module;

  @Schema(name = "业务分组,目前是base和business")
  @Parameter(names = {"--group", "-g"}, description = "业务分组,目前是base和business")
  private String group;

  @Schema(name = "是否按表名分隔目录")
  @Parameter(names = {"--flat"}, description = "是否按表名分隔目录")
  private boolean flat;

  @Schema(name = "orm框架选择")
  @Parameter(names = {"--type"}, description = "orm框架选择")
  private String type;

  @Schema(name = "查看帮助")
  @Parameter(names = "--help", help = true, description = "查看帮助")
  private boolean help;

  @Schema(name = "表名截取起始索引,比如表名叫做t_sale_contract_detail,生成的实体类为ContractDetail,则该字段为7")
  @Parameter(names = {"--tableStartIndex", "-tsi"}, description = "表名截取起始索引")
  private String tableStartIndex;
}

当连接上数据库后,我们需要解析读取的表结构,包括获取表字段,字段备注,字段类型等内容,对应此处创建的 Column 类。

@Data
public class Column {
    /**
     * 是否是主键
     */
    private Boolean isPrimaryKey;

    /**
     * Mybatis plus生成类主键类型,默认为ASSIGN_ID(3)
     */
    private String primaryKeyType = "ASSIGN_ID";
    /**
     * 数据库表名称
     */
    private String tableName;
    /**
     * 表描述
     */
    private String tableDesc;
    /**
     * 数据库字段名称
     **/
    private String fieldName;
    /**
     * 数据库字段类型
     **/
    private String fieldType;
    /**
     * Java类型
     */
    private String javaType;
    /**
     * 是否是数字类型
     */
    private Boolean isNumber;
    /**
     * 数据库字段驼峰命名,saleBooke
     **/
    private String camelName;
    /**
     * 数据库字段Pascal命名,SaleBook
     **/
    private String pascalName;
    /**
     * 数据库字段注释
     **/
    private String comment;

    private String field;

    private String key;

    /**
     * 是否是公共字段
     */
    private Boolean isCommonField;
}

最后创建一个常量类 Config,来存储常量信息。

public class Config {

  public static final String OUTPUT_PATH = "." + File.separator + "output";

  public static final String AUTHOR = "hresh";

  // 公共实体类字段
  public static final String[] JPA_COMMON_COLUMNS = new String[]{
      "create_user_code", "create_user_name", "created_date", "last_modified_code",
      "last_modified_name"
      , "last_modified_date", "version", "id", "del_flag"
  };

  public static final String[] MYBATIS_COMMON_COLUMNS = new String[]{
      "create_user_code", "create_user_name", "created_date", "last_modified_code",
      "last_modified_name"
      , "last_modified_date", "version", "id", "del_flag"
  };

  public static final String[] MYBATIS_PLUS_COMMON_COLUMNS = new String[]{
      "create_user_code", "create_user_name", "created_date", "last_modified_code",
      "last_modified_name"
      , "last_modified_date", "version", "id", "del_flag"
  };
}

服务类

首先定义 FreeMarker 的使用代码:

@Service
public class FreemarkerService {

  @Autowired
  private Configuration configuration;

  /**
   * 输出文件模板
   *
   * @param templateName      resources 文件夹下的模板名,比如说model.ftl,是生成实体类的模块
   * @param dataModel         表名,字段名等内容集合
   * @param filePath          输出文件名,包括路径
   * @param generateParameter
   * @throws Exception
   */
  public void write(String templateName, Map<String, Object> dataModel, String filePath,
      GenerateParameter generateParameter) throws Exception {
    // FTL(freemarker templete language)模板的文件名称
    Template template = configuration
        .getTemplate(dataModel.get("type") + File.separator + templateName + ".ftl");
    File file;
    // 判断是不是多表,如果是,则按照表名生成各自的文件夹目录
    if (generateParameter.isFlat()) {
      file = new File(
          Config.OUTPUT_PATH + File.separator + dataModel.get("tempId") + File.separator + filePath);
    } else {
      file = new File(
          Config.OUTPUT_PATH + File.separator + dataModel.get("tempId") + File.separator + dataModel
              .get("tableName") + File.separator + filePath);
    }
    if (!file.exists()) {
      file.getParentFile().mkdirs();
      file.createNewFile();
    }
    FileOutputStream fileOutputStream = new FileOutputStream(file);
    OutputStreamWriter outputStreamWriter = new OutputStreamWriter(fileOutputStream,
        StandardCharsets.UTF_8);
    template.process(dataModel, outputStreamWriter);
    fileOutputStream.flush();
    fileOutputStream.close();
  }
}

接下来是本项目最核心的代码,通过读取数据表,获取表的定义信息,然后利用 FreeMarker 读取 Ftl 模板文件来生成关于该表的基础代码。

基础服务类 BaseService

public class BaseService {

  private static Connection connection;

  public static void setConnection(GenerateParameter generateParameter) throws Exception {
    connection = getConnection(generateParameter);
  }

  public static void closeConnection() throws SQLException {
    connection.close();
  }

  public static String getUrl(GenerateParameter generateParameter) {
    return "jdbc:mysql://" + generateParameter.getHost() + ":" + generateParameter.getPort() + "/"
        + generateParameter.getDatabase()
        + "?useSSL=false&serverTimezone=Asia/Shanghai&characterEncoding=UTF-8";
  }


  /**
   * 数据库连接,类似于:DriverManager.getConnection("jdbc:mysql://localhost:3306/test_demo?useSSL=false&allowPublicKeyRetrieval=true&serverTimezone=UTC","root","password");
   *
   * @param generateParameter 请求参数
   * @return 数据库连接
   * @throws Exception
   */
  public static Connection getConnection(GenerateParameter generateParameter) throws Exception {
    return DriverManager.getConnection(getUrl(generateParameter), generateParameter.getUsername(),
        generateParameter.getPassword());
  }

  /**
   * 根据表具体位置,获取表中字段的具体信息,包括字段名,字段类型,备注等
   *
   * @param tableName
   * @return
   * @throws Exception
   */
  public List<Column> getColumns(String tableName, String[] commonColumns) throws Exception {
    // 获取表定义的字段信息
    ResultSet resultSet = connection.createStatement()
        .executeQuery("SHOW FULL COLUMNS FROM " + tableName);
    List<Column> columnList = new ArrayList<>();
    while (resultSet.next()) {
      String fieldName = resultSet.getString("Field");
      Column column = new Column();
      // 判断是否是主键
      column.setIsPrimaryKey("PRI".equals(resultSet.getString("Key")));
      // 获取字段名称
      column.setFieldName(fieldName);

      // 实体类特定字段从核心类里获取
      if (Objects.nonNull(commonColumns) && Arrays.asList(commonColumns).contains(fieldName)) {
        column.setIsCommonField(true);
      } else {
        column.setIsCommonField(false);
      }
      // 获取字段类型
      column.setFieldType(resultSet.getString("Type").replaceAll("\\(.*\\)", ""));
      switch (column.getFieldType()) {
        case "json":
        case "longtext":
        case "char":
        case "varchar":
        case "text":
          column.setJavaType("String");
          column.setIsNumber(false);
          break;
        case "date":
        case "datetime":
          column.setJavaType("Date");
          column.setIsNumber(false);
          break;
        case "timestamp":
          column.setJavaType("LocalDateTime");
          column.setIsNumber(false);
          break;
        case "bit":
          column.setJavaType("Boolean");
          column.setIsNumber(false);
          break;
        case "int":
        case "tinyint":
          column.setJavaType("Integer");
          column.setIsNumber(true);
          break;
        case "bigint":
          column.setJavaType("Long");
          column.setIsNumber(true);
          break;
        case "decimal":
          column.setJavaType("BigDecimal");
          column.setIsNumber(true);
          break;
        case "varbinary":
          column.setJavaType("byte[]");
          column.setIsNumber(false);
          break;
        default:
          throw new Exception(
              tableName + " " + column.getFieldName() + " " + column.getFieldType() + "类型没有解析");
      }
      // 转换字段名称,receipt_sign_name字段改为 receiptSignName
      column.setCamelName(StringUtils.underscoreToCamel(column.getFieldName()));
      // 首字母大写
      column.setPascalName(StringUtils.firstLetterUpperCase(column.getCamelName()));
      // 字段在数据库的注释
      column.setComment(resultSet.getString("Comment"));
      columnList.add(column);
    }
    return columnList;
  }

  /**
   * 获取表的描述
   *
   * @param tableName
   * @param parameter
   * @return
   * @throws Exception
   */
  public String getTableComment(String tableName, GenerateParameter parameter) throws Exception {
    Connection connection = getConnection(parameter);
    ResultSet resultSet = connection.createStatement().executeQuery(
        "SELECT table_comment FROM INFORMATION_SCHEMA.TABLES WHERE table_schema = '" + parameter
            .getDatabase()
            + "' AND table_name = '" + tableName + "'");
    String tableComment = "";
    while (resultSet.next()) {
      tableComment = resultSet.getString("table_comment");
    }
    return tableComment;
  }

}

GenerateService 获取表信息生成相关代码

@Service
@Slf4j
public class GenerateService extends BaseService {

  @Autowired
  private FreemarkerService freemarkerService;

  /**
   * @param tableName 数据库表名
   * @param parameter 模块名
   * @param uuid      生成uuid
   * @throws Exception
   */
  public void generate(String tableName, GenerateParameter parameter, String uuid)
      throws Exception {

    // 各模块包名,比如 com.msdn.sale 或 com.msdn.finance
    String packagePrefix = "com.msdn." + parameter.getModule();
    // 分组
    if (!StringUtils.isEmpty(parameter.getGroup())) {
      packagePrefix = packagePrefix + "." + parameter.getGroup();
    }

    // 根据项目设计的表名获取到表名,比如表名叫做:t_sale_contract_detail
    // 现在表名截取起始索引该由参数配置
    Integer index = new Integer(parameter.getTableStartIndex());
    // 驼峰命名,首字母小写,比如:contractDetail
    String camelName = StringUtils.underscoreToCamel(tableName.substring(index));

    Map<String, Object> dataModel = new HashMap<>();
    //获取表中字段的具体信息,包括字段名,字段类型,备注等,排除指定字段
    String[] commonColumns = Config.MYBATIS_COMMON_COLUMNS;
    if ("jpa".equals(parameter.getType().toLowerCase())) {
      commonColumns = Config.JPA_COMMON_COLUMNS;
    } else if ("mybatis".equals(parameter.getType().toLowerCase())) {
      commonColumns = Config.MYBATIS_COMMON_COLUMNS;
    } else if ("mybatisplus".equals(parameter.getType().toLowerCase())) {
      commonColumns = Config.MYBATIS_PLUS_COMMON_COLUMNS;
    }
    List<Column> columns = getColumns(tableName, commonColumns);
    Column primaryColumn = columns.stream().filter(Column::getIsPrimaryKey).findFirst()
        .orElse(null);
    dataModel.put("package", packagePrefix);
    dataModel.put("camelName", camelName);
    // 首字母转大写,作为实体类名称等
    dataModel.put("pascalName", StringUtils.firstLetterUpperCase(camelName));
    dataModel.put("moduleName", parameter.getModule());
    dataModel.put("tableName", tableName);
    // 表描述
    dataModel.put("tableComment", getTableComment(tableName, parameter));
    dataModel.put("columns", columns);
    dataModel.put("primaryColumn", primaryColumn);
    dataModel.put("tempId", uuid);
    dataModel.put("author", Config.AUTHOR);
    dataModel.put("date", DateUtil.now());
    dataModel.put("type", parameter.getType());

    log.info("准备生成模板代码的表名为:" + tableName + ",表描述为:" + dataModel.get("tableComment"));

    // 生成模板代码
    log.info("**********开始生成Model模板文件**********");
    generateModel(dataModel, parameter);
    log.info("**********开始生成VO视图模板文件**********");
    generateVO(dataModel, parameter);
    log.info("**********开始生成DTO模板文件**********");
    generateDTO(dataModel, parameter);
    log.info("**********开始生成Struct模板文件**********");
    generateStruct(dataModel, parameter);
    log.info("**********开始生成Mapper模板文件**********");
    generateMapper(dataModel, parameter);
    log.info("**********开始生成Service模板文件**********");
    generateService(dataModel, parameter);
    log.info("**********开始生成Controller模板文件**********");
    generateController(dataModel, parameter);
  }

  /**
   * 生成 controller 模板代码
   *
   * @param dataModel
   * @param generateParameter
   * @throws Exception
   */
  private void generateController(Map<String, Object> dataModel,
      GenerateParameter generateParameter) throws Exception {
    String path =
        "java" + File.separator + "controller" + File.separator + dataModel.get("pascalName")
            + "Controller.java";
    freemarkerService.write("controller", dataModel, path, generateParameter);
  }

  private void generateDTO(Map<String, Object> dataModel, GenerateParameter generateParameter)
      throws Exception {
    String path = "java" + File.separator + "dto" + File.separator + dataModel.get("pascalName");
    freemarkerService.write("dto", dataModel, path + "DTO.java", generateParameter);
    freemarkerService.write("dto-page", dataModel, path + "QueryPageDTO.java", generateParameter);
  }

  //
  private void generateModel(Map<String, Object> dataModel, GenerateParameter generateParameter)
      throws Exception {
    String path =
        "java" + File.separator + "model" + File.separator + dataModel.get("pascalName") + ".java";
    freemarkerService.write("model", dataModel, path, generateParameter);
  }

  private void generateStruct(Map<String, Object> dataModel, GenerateParameter generateParameter)
      throws Exception {
    String path = "java" + File.separator + "struct" + File.separator + dataModel.get("pascalName")
        + "Struct.java";
    freemarkerService.write("struct", dataModel, path, generateParameter);
  }

  private void generateMapper(Map<String, Object> dataModel, GenerateParameter generateParameter)
      throws Exception {
    if (!"jpa".equals(generateParameter.getType().toLowerCase())) {
      String path = "java" + File.separator + "mapper" + File.separator + dataModel.get("pascalName")
          + "Mapper.java";
      freemarkerService.write("mapper", dataModel, path, generateParameter);

      path = "resources" + File.separator + dataModel.get("pascalName") + "Mapper.xml";
      freemarkerService.write("mapper-xml", dataModel, path, generateParameter);
    }else {
      String path = "java" + File.separator + "repository" + File.separator + dataModel.get("pascalName")
          + "Repository.java";
      freemarkerService.write("repository", dataModel, path, generateParameter);
    }
  }

  private void generateService(Map<String, Object> dataModel, GenerateParameter generateParameter)
      throws Exception {
    String path = "java" + File.separator + "service" + File.separator + dataModel.get("pascalName")
        + "Service.java";
    freemarkerService.write("service", dataModel, path, generateParameter);

    path =
        "java" + File.separator + "service" + File.separator + "impl" + File.separator + dataModel
            .get("pascalName") + "ServiceImpl.java";
    freemarkerService.write("service-impl", dataModel, path, generateParameter);
  }

  private void generateVO(Map<String, Object> dataModel, GenerateParameter generateParameter)
      throws Exception {
    String path =
        "java" + File.separator + "vo" + File.separator + dataModel.get("pascalName") + "VO.java";
    freemarkerService.write("vo", dataModel, path, generateParameter);
  }
}

控制器

为了更加方便地使用代码生成器,我们通过 swagger 来调用 Rest 服务接口。

@RestController("/generator")
@Slf4j
@RequiredArgsConstructor
public class GeneratorController {

  private final GenerateService generateService;
  private final XmlGenerateService xmlGenerateService;

  /*
      // 请求参数
      {
          "database": "db_tl_sale",
          "flat": true,
          "type": "mybatis",
          "group": "base",
          "host": "127.0.0.1",
          "module": "sale",
          "password": "123456",
          "port": 3306,
          "table": [
              "t_xs_sale_contract"
          ],
          "username": "root"
      }
   */
  @PostMapping("/build")
  @Operation(description = "选择orm框架后生成基础模版代码")
  public void build(@RequestBody GenerateParameter parameter, HttpServletResponse response)
      throws Exception {
    log.info("**********欢迎使用基于FreeMarker的模板文件生成器**********");
    log.info("************************************************************");
    String uuid = UUID.randomUUID().toString();
    BaseService.setConnection(parameter);
    for (String table : parameter.getTable()) {
      generateService.generate(table, parameter, uuid);
    }
    log.info("**********模板文件生成完毕,准备下载**********");
    String path = Config.OUTPUT_PATH + File.separator + uuid;
    //设置响应头控制浏览器的行为,这里我们下载zip
    response.setHeader("Content-disposition", "attachment; filename=code.zip");
    response.setHeader("Access-Control-Expose-Headers", "Content-disposition");
    // 将response中的输出流中的文件压缩成zip形式
    ZipDirectory(path, response.getOutputStream());
    // 递归删除目录
    FileSystemUtils.deleteRecursively(new File(path));
    BaseService.closeConnection();
    log.info("************************************************************");
    log.info("**********模板文件下载完毕,谢谢使用**********");
  }

  @PostMapping("/buildXml")
  @Operation(description = "选择orm框架后生成基础模版代码,针对Mybatis会补充生成xml文件中的resultMap")
  public void buildXml(@RequestBody GenerateParameter parameter, HttpServletResponse response)
      throws Exception {
    log.info("**********欢迎使用基于FreeMarker的模板文件生成器**********");
    log.info("************************************************************");
    String uuid = UUID.randomUUID().toString();
    BaseService.setConnection(parameter);
    for (String table : parameter.getTable()) {
      xmlGenerateService.generate(table, parameter, uuid);
    }
    log.info("**********模板文件生成完毕,准备下载**********");
    String path = Config.OUTPUT_PATH + File.separator + uuid;
    //设置响应头控制浏览器的行为,这里我们下载zip
    response.setHeader("Content-disposition", "attachment; filename=code.zip");
    response.setHeader("Access-Control-Expose-Headers", "Content-disposition");
    // 将response中的输出流中的文件压缩成zip形式
    ZipDirectory(path, response.getOutputStream());
    // 递归删除目录
    FileSystemUtils.deleteRecursively(new File(path));
    BaseService.closeConnection();
    log.info("************************************************************");
    log.info("**********模板文件下载完毕,谢谢使用**********");
  }

  /**
   * 一次性压缩多个文件,文件存放至一个文件夹中
   */
  public static void ZipDirectory(String directoryPath, ServletOutputStream outputStream) {
    try {
      ZipOutputStream output = new ZipOutputStream(outputStream);
      List<File> files = getFiles(new File(directoryPath));
      for (File file : files) {
        try (InputStream input = new FileInputStream(file)) {
          output.putNextEntry(new ZipEntry(file.getPath().substring(directoryPath.length() + 1)));
          int temp;
          while ((temp = input.read()) != -1) {
            output.write(temp);
          }
        }
      }
      output.close();
    } catch (Exception e) {
      e.printStackTrace();
    }
  }

  public static List<File> getFiles(File file) {
    List<File> files = new ArrayList<>();
    for (File subFile : Objects.requireNonNull(file.listFiles())) {
      if (subFile.isDirectory()) {
        List<File> subFiles = getFiles(subFile);
        files.addAll(subFiles);
      } else {
        files.add(subFile);
      }
    }
    return files;
  }
}

启动类

@SpringBootApplication(exclude = {DataSourceAutoConfiguration.class})
public class GeneratorApplication {

  /**
   * 测试的时候添加参数 -h 127.0.0.1 -P 3306 -d db_tl_sale -u root -p 123456 -m sale -g base -t
   * t_xs_sale_contract,t_xs_sale_contract_detail
   *
   * @param args
   */
  public static void main(String[] args) {
    SpringApplication.run(GeneratorApplication.class, args);
  }
}

模板文件

定义的模板文件如下图所示:

模板文件

其它代码

除了上述代码,还有一些工具类,以及公共组件,这里就不一一介绍了,我会在下篇文章中详细介绍这些基础代码,大致内容如下图所示: 通用代码

包括请求日志记录、返回对象封装、全局异常捕获等等。

效果

项目整体目录结构如下所示: 项目整体目录结构

启动项目后,直接访问 http://localhost:8525/swagger-ui.html#/。

执行器页面

传入参数根据个人需要按照如下格式整理信息:

{
    "database": "db_tl_sale",
    "flat": true,
    "type": "mybatis",
    "group": "base",
    "host": "127.0.0.1",
    "module": "sale",
    "password": "123456",
    "port": 3306,
    "table": [
        "t_xs_sale_contract"
    ],
    "username": "root",
    "tableStartIndex":"5"
}

type 属性可以设置为 common、mybatis、mybatisplus、jpa,后三个属性值对应不同的 orm 框架。

然后点击执行,执行成功后点击下载,将生成好的代码下载到本地。文件结构如下图所示:

生成文件结构

这里截取一部分代码图片,首先是实体类:

实体类

然后是查询实体类:

查询实体类

接着是 Service 接口:

服务接口

以及对应的实现类:

服务实现类

最后是 controller:

控制器

扩展

一对多关联查询

resultMap 元素是 MyBatis 中最重要最强大的元素。它可以让你从 90% 的 JDBC ResultSets 数据提取代码中解放出来,并在一些情形下允许你进行一些 JDBC 不支持的操作。实际上,在为一些比如连接的复杂语句编写映射代码的时候,一份 resultMap 能够代替实现同等功能的数千行代码。ResultMap 的设计思想是,对简单的语句做到零配置,对于复杂一点的语句,只需要描述语句之间的关系就行了。

需求:

目前订单类详情查询返回的结果中,除了包含订单类的全部信息,还需要返回多个订单子项的数据,也就是我们常说的一对多关系,那么在实际开发中如何操作呢?

首先我们看一下代码案例:

1、订单类

public class OmsOrder implements Serializable {

    private static final long serialVersionUID = 1L;
    
    @ApiModelProperty(value = "订单id")
    private Long id;

    private Long memberId;

    private Long couponId;

    @ApiModelProperty(value = "订单编号")
    private String orderSn;

   ........
}

2、订单子项类

public class OmsOrderItem implements Serializable {
	private static final long serialVersionUID = 1L;
    private Long id;

    @ApiModelProperty(value = "订单id")
    private Long orderId;

    @ApiModelProperty(value = "订单编号")
    private String orderSn;

    private Long productId;

  	........

 }

3、前端返回类

public class OmsOrderDetail extends OmsOrder {
    @Getter
    @Setter
    @ApiModelProperty("订单商品列表")
    private List<OmsOrderItem> orderItemList;
}

4、OmsOrderMapper.xml 文件中自定义 SQL 语句

<resultMap id="orderDetailResultMap" type="com.macro.mall.dto.OmsOrderDetail" extends="com.macro.mall.mapper.OmsOrderMapper.BaseResultMap">
    <collection property="orderItemList" resultMap="com.macro.mall.mapper.OmsOrderItemMapper.BaseResultMap" columnPrefix="item_"/>
</resultMap>

<select id="getDetail" resultMap="orderDetailResultMap">
    SELECT o.*,
    oi.id item_id,
    oi.product_id item_product_id,
    oi.product_sn item_product_sn,
    oi.product_pic item_product_pic,
    oi.product_name item_product_name,
    oi.product_brand item_product_brand,
    oi.product_price item_product_price,
    oi.product_quantity item_product_quantity,
    oi.product_attr item_product_attr
    FROM
    oms_order o
    LEFT JOIN oms_order_item oi ON o.id = oi.order_id
    WHERE
    o.id = #{id}
    ORDER BY oi.id ASC DESC
</select>

其中 com.macro.mall.mapper.OmsOrderItemMapper.BaseResultMap 是引用自 OmsOrderItemMapper.xml 文件中的定义,

<resultMap id="BaseResultMap" type="com.macro.mall.model.OmsOrderItem">
    <id column="id" property="id" />
    <result column="order_id"  property="orderId" />
    <result column="order_sn" property="orderSn" />
    <result column="product_id" jdbcType="BIGINT" property="productId" />
    <result column="product_pic" jdbcType="VARCHAR" property="productPic" />
    <result column="product_name" jdbcType="VARCHAR" property="productName" />
    <result column="product_brand" jdbcType="VARCHAR" property="productBrand" />
    <result column="product_sn" jdbcType="VARCHAR" property="productSn" />
    <result column="product_price" jdbcType="DECIMAL" property="productPrice" />
    <result column="product_quantity" jdbcType="INTEGER" property="productQuantity" />
    <result column="product_sku_id" jdbcType="BIGINT" property="productSkuId" />
    <result column="product_sku_code" jdbcType="VARCHAR" property="productSkuCode" />
    <result column="product_category_id" jdbcType="BIGINT" property="productCategoryId" />
    <result column="promotion_name" jdbcType="VARCHAR" property="promotionName" />
    <result column="promotion_amount" jdbcType="DECIMAL" property="promotionAmount" />
    <result column="coupon_amount" jdbcType="DECIMAL" property="couponAmount" />
    <result column="integration_amount" jdbcType="DECIMAL" property="integrationAmount" />
    <result column="real_amount" jdbcType="DECIMAL" property="realAmount" />
    <result column="gift_integration" jdbcType="INTEGER" property="giftIntegration" />
    <result column="gift_growth" jdbcType="INTEGER" property="giftGrowth" />
    <result column="product_attr" jdbcType="VARCHAR" property="productAttr" />
</resultMap>

5、执行效果

微信截图_20210514090340.png

这种查询方式相较于先查主表,再根据主表字段关联查询子表信息,减少了 IO 连接查询次数,效率更高一些。

resultMap模板生成

通过上述代码我们可知,实现一对多关联查询的关键在于定义子项数据(多)的 resultMap 定义,既然我们通过代码生成器生成了基本的项目代码,那么是否可以生成 resultMap 呢?说干就干,代码如下:

1、定义模板 ftl 文件

<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" "http://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace="${package}.mapper.${pascalName}Mapper">

    <resultMap id="BaseResultMap" type="${package}.model.${pascalName}">
    <#list columns as column>
    <#if column.isPrimaryKey>
        <id column="${column.fieldName}" property="${column.camelName}" />
    <#else>
        <result column="${column.fieldName}" property="${column.camelName}" />
    </#if>
    </#list>
    </resultMap>

</mapper>

2、编写服务类 XmlGenerateService

@Service
public class XmlGenerateService extends BaseService {

    private static final Logger logger = LoggerFactory.getLogger(XmlGenerateService.class);

    @Autowired
    private FreemarkerService freemarkerService;

    /**
     * @param tableName 数据库表名
     * @param parameter 模块名
     * @param uuid
     * @throws Exception
     */
    public void generate(String tableName, GenerateParameter parameter, String uuid) throws Exception {

        // 各模块包名,比如 com.msdn.sale 或 com.msdn.finance
        String packagePrefix = "com.msdn." + parameter.getModule();
        // 分组
        if (!StringUtils.isEmpty(parameter.getGroup())) {
            packagePrefix = packagePrefix + "." + parameter.getGroup();
        }

        // 根据项目设计的表名获取到表名,比如表名叫做:t_sale_contract_detail
        // 现在表名截取起始索引该由参数配置
//        int index = tableName.indexOf("_", 2);
        Integer index = new Integer(parameter.getTableStartIndex());
        // 驼峰命名,首字母小写,比如:contractDetail
        String camelName = StringUtils.underscoreToCamel(tableName.substring(index));

        Map<String, Object> dataModel = new HashMap<>();
        //获取表中字段的具体信息,包括字段名,字段类型,备注等,排除指定字段
        List<Column> columns = getColumns(tableName, parameter, null);
        Column primaryColumn = columns.stream().filter(Column::getIsPrimaryKey).findFirst().orElse(null);
        dataModel.put("package", packagePrefix);
        dataModel.put("camelName", camelName);
        // 首字母转大写,作为实体类名称等
        dataModel.put("pascalName", StringUtils.capitalize(camelName));
        dataModel.put("moduleName", parameter.getModule());
        dataModel.put("tableName", tableName);
        // 表描述
        dataModel.put("tableComment", getTableComment(tableName, parameter));
        dataModel.put("columns", columns);
        dataModel.put("primaryColumn", primaryColumn);
        dataModel.put("tempId", uuid);
        dataModel.put("author", Config.Author);
        dataModel.put("date", DateUtil.now());
        dataModel.put("type", parameter.getType());

        logger.info("准备生成模板代码的表名为:" + tableName + ",表描述为:" + dataModel.get("tableComment"));

        // 生成模板代码
        logger.info("**********开始生成Model模板文件**********");
        generateXML(dataModel, parameter);
    }

    /**
     * 生成 controller 模板代码
     *
     * @param dataModel
     * @param generateParameter
     * @throws Exception
     */
    private void generateXML(Map<String, Object> dataModel, GenerateParameter generateParameter) throws Exception {
        String path = "resources" + File.separator + "xml" + File.separator + dataModel.get("pascalName") + "Mapper.xml";
        freemarkerService.write("mybatis-xml", dataModel, path, generateParameter);
    }
}

3、服务接口

@PostMapping("/generator/buildXml")
public void buildXml(@RequestBody GenerateParameter parameter, HttpServletResponse response) throws Exception {
    logger.info("**********欢迎使用基于FreeMarker的模板文件生成器**********");
    logger.info("************************************************************");
    String uuid = UUID.randomUUID().toString();
    for (String table : parameter.getTable()) {
        xmlGenerateService.generate(table, parameter, uuid);
    }
    logger.info("**********模板文件生成完毕,准备下载**********");
    String path = Config.OutputPath + File.separator + uuid;
    //设置响应头控制浏览器的行为,这里我们下载zip
    response.setHeader("Content-disposition", "attachment; filename=code.zip");
    response.setHeader("Access-Control-Expose-Headers", "Content-disposition");
    // 将response中的输出流中的文件压缩成zip形式
    ZipDirectory(path, response.getOutputStream());
    // 递归删除目录
    FileSystemUtils.deleteRecursively(new File(path));
    logger.info("************************************************************");
    logger.info("**********模板文件下载完毕,谢谢使用**********");
}

4、通过 swagger 调用 api

微信截图_20210514091320.png

5、执行结果

<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" "http://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace="com.msdn.mall.mapper.OmsOrderItemMapper">

    <resultMap id="BaseResultMap" type="com.msdn.mall.model.OmsOrderItem">
        <id column="order_item_id" property="orderItemId" />
        <result column="order_id" property="orderId" />
        <result column="order_sn" property="orderSn" />
        <result column="product_id" property="productId" />
        <result column="product_pic" property="productPic" />
        <result column="product_name" property="productName" />
        <result column="product_brand" property="productBrand" />
        <result column="product_sn" property="productSn" />
        <result column="product_price" property="productPrice" />
        <result column="purchase_amount" property="purchaseAmount" />
        <result column="product_sku_id" property="productSkuId" />
        <result column="product_sku_code" property="productSkuCode" />
        <result column="product_category_id" property="productCategoryId" />
        <result column="sp1" property="sp1" />
        <result column="sp2" property="sp2" />
        <result column="sp3" property="sp3" />
        <result column="promotion_name" property="promotionName" />
        <result column="promotion_money" property="promotionMoney" />
        <result column="coupon_money" property="couponMoney" />
        <result column="integration_money" property="integrationMoney" />
        <result column="real_money" property="realMoney" />
        <result column="gift_integration" property="giftIntegration" />
        <result column="gift_growth" property="giftGrowth" />
        <result column="product_attr" property="productAttr" />
        <result column="is_deleted" property="isDeleted" />
        <result column="create_user_code" property="createUserCode" />
        <result column="create_user_name" property="createUserName" />
        <result column="create_date" property="createDate" />
        <result column="update_user_code" property="updateUserCode" />
        <result column="update_user_name" property="updateUserName" />
        <result column="update_date" property="updateDate" />
        <result column="version" property="version" />
    </resultMap>

</mapper>

后续

在生产开发中如果还遇到好玩的东西,会不定期追加更新,希望工具越来越强大。如果有更好的建议,也可以在评论区@我。

总结

虽然 Mybatis 和 Mybatis Plus 都有相关的代码生成器配置,但是构建器代码不容易整合,外部调用也不方便,最主要的是无法满足实际需求。为了能够一次性生成所有代码,最终选择 SpringBoot 和 FreeMarker 来构建我们专属的代码生成器。

除了可以生成 Java 相关代码,FreeMarker 还可以根据模板文件来生成前端代码,又或者是 Word 文档等,后续更多功能会根据情况逐步补充的。

感兴趣的朋友可以去我的 Github 下载相关代码,如果对你有所帮助,不妨 Star 一下,谢谢大家支持!

参考文献

ZipOutputStream相关知识

使用JCommander开发命令行交互(CLI)式JAVA程序