【定制化代码生成项目】3、代码生成
作者: 時光
原文: https://blog.shiguang666.eu.org/2024/06/17/e7566f48d4f5
来源: 時光的博客园子
本节重点
本节教程属于项目的第一阶段 —— 开发本地代码生成器。
重点内容:
- 完成项目的初始化
- 静态文件生成
- 动态文件代码生成
- FreeMarker 模板引擎入门及实战
- 动静结合 - ACM 示例项目模板代码生成
一、项目初始化
1、初始化根目录
由于我们的项目包含多个阶段,本质上是多个项目,所以为了统一管理整个项目,我们创建一个干净的 yuzi-generator
空文件夹,作为整个项目的根目录,后续各阶段的项目和目录都放到它之下。
这样做还有一个好处,就是让不同项目模块可以用 相对路径 寻找文件,便于整个项目的开源共享。
建议大家养成习惯,使用 Git 来管理项目。如果使用 IDEA 开发工具来创建新项目,可以直接勾选 Create Git repository
,工具会自动帮你初始化项目为 Git 仓库。
如下图:
当然,也可以进入项目根目录,执行
git init
命令创建 Git 仓库。
2、忽略无用提交
创建好新项目后,使用 IDEA 开发工具打开项目,进入底部的 Git
标签,会发现很多和项目无关的 IDEA 自动生成的工程文件被添加到了 Git 托管。
但我们是不希望提交这些文件的,没有意义,所以需要使用 .gitignore
文件来忽略这些文件,不让它们被 Git 托管。
如何编写 .gitignore
文件呢?
其实很简单,不用自己编写!我们在 IDEA 的 Settings => Plugins
中搜索 .ignore
插件并安装:
然后在项目根目录处选中右键,使用 .ignore
插件创建 .gitignore
文件:
.ignore 插件提供了很多默认的 .gitignore 模板,根据自己的项目类型和使用的开发工具进行选择,此处我们选择 Java 和 JetBrains 模板:
然后可以在项目根目录看到生成的 .gitignore 文件,模板已经包含了常用的 Java 项目忽略清单,比如编译后的文件、日志文件、压缩包等:
让我们再手动添加几个要忽略的目录和文件,比如打包生成的 target 目录:
但是,我们会发现,即使有些文件已经添加到了 .gitignore 文件中,在 IDEA 中显示的还是绿色(已被 Git 托管)状态。如下图:
这是因为这些文件已经被 Git 跟踪。而 .gitignore 文件仅影响未跟踪的文件,如果文件已经被 Git 跟踪,那么 .gitignore 文件对它们没有影响。
所以我们需要打开终端,在项目根目录下执行如下命令,取消 Git 跟踪:
git rm -rf --cached .
执行效果如图:
可以看到文件变成了红色(未被 Git 托管)或黄色(被忽略)状态:
然后,让我们将 .gitignore 文件添加到 Git 暂存区,让它能够被 Git 管理。
项目根目录就初始化完成了,建议大家像鱼皮一样在项目根目录中新建一个 README.md
文件,用于介绍项目、记录自己的学习和开发过程等~
3、创建 Demo 示例代码工程
为了制作代码生成器,我们需要一些示例模板代码,后续会基于这些模板代码来定制生成。
比如第一阶段,我们会用到一套鱼皮提前编写好的 ACM 示例模板代码,从而制作定制化 ACM 模板代码生成器;在第二阶段,我们会用到一套 Spring Boot 初始化项目模板。
让我们新建一个 yuzi-generator-demo-projects
目录,统一存放所有的示例代码,然后将鱼皮准备的 ACM 模板项目(acm-template)复制到该目录下。
ACM 模板项目,可通过云盘下载: https://www.alipan.com/s/vzzRbJEjTxA 提取码: h5z0
整个项目的目录结构如下图:
ACM 示例代码模板介绍
鱼皮准备的 ACM 示例代码模板非常简单,只是一个干净的 Java 项目,没有使用 Maven 和任何第三方依赖。
结构如下,核心组成是静态文件 README.md
和代码文件 MainTemplate
:
README.md
内容如图,仅包含了简单的描述文本:
MainTemplate.java
是一段 ACM 示例输入代码,作用是计算并输出多数之和。
完整代码如下:
package com.shiguang.acm;
import java.util.Scanner;
/**
* ACM 输入模板(多数之和)
*/
public class MainTemplate {
public static void main(String[] args) {
Scanner scanner = new Scanner(System.in);
while (scanner.hasNext()) {
// 读取输入元素个数
int n = scanner.nextInt();
// 读取数组
int[] arr = new int[n];
for (int i = 0; i < n; i++) {
arr[i] = scanner.nextInt();
}
// 处理问题逻辑,根据需要进行输出
// 示例:计算数组元素的和
int sum = 0;
for (int num : arr) {
sum += num;
}
System.out.println("Sum: " + sum);
}
scanner.close();
}
}
在第一阶段的教程中,我们将改造这个 Java 代码文件,让它能同时支持多种不同的输入方式。
4、创建本地代码生成器项目
使用 IDEA 开发工具,在项目根目录中新建工程,创建 yuzi-generator-basic
模块。需要注意以下几点:
- 项目存放位置在
yuzi-generator
目录下 - 取消 Git 仓库勾选(因为已经在外层进行 Git 托管)
- 使用 Maven 管理项目
- JDK 选择 1.8!不要追求新版本
- 高级设置中,指定 GroupId 和 ArtifactId
完整配置如图:
注意,鱼皮视频中实际创建的是Model,而文档中创建的是Project
并将文件添加(add)到 Git 暂存区,如下图:
需要在项目的 pom.xml
文件中引入一些依赖,主要是一些工具类和单元测试,便于后续提高开发效率。
依赖代码如下:
<dependencies>
<!-- 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>
引入依赖后,刷新 Maven,然后执行 IDEA 生成的项目中自带的 Main 文件,看到下图的输出,则项目初始化完成:
较低版本的IDEA可能没有示例代码,比如我使用的2022.3.2版本就没有,默认main方法是输出helloworld
二、实现流程
第一阶段我们的目标是制作本地代码生成器(基于命令行的脚手架),要求能够根据用户的输入生成不同的 ACM 示例代码模板。
对于完全没开发过类似项目的同学来说,可能会觉得比较困难。
需求拆解
遇到这种情况,我们首先要根据业务实际情况对需求进行 拆解 ,把一个复杂的大目标拆解为一步一步的小工作。
如何拆解呢?
那就先把需求分为 2 段,本地代码生成器 + 基于命令行的脚手架。
首先思考如何制作本地代码生成器。先看看我们要生成的项目文件结构吧!前面也提到过,ACM 示例代码模板的核心文件是 README.md 和 MainTemplate.java。
其中,README.md 的作用仅仅是描述项目,并不影响开发者的使用。所以我们要生成代码时,完全不用修改 README.md 的任何内容,直接复制即可。我们将这类文件定义为 “静态文件”。
而 MainTemplate.java 是开发者实际要使用的 ACM 输入模板文件,默认是包含了循环接受输入的逻辑的。示例代码如下:
Scanner scanner = new Scanner(System.in);
while (scanner.hasNext()) {
int n = scanner.nextInt();
...
System.out.println("Sum: " + sum);
}
但如果用户不需要循环输入,只要保留其他代码呢?像下面这样删除 while 代码片段:
Scanner scanner = new Scanner(System.in);
int n = scanner.nextInt();
...
System.out.println("Sum: " + sum);
也就是说,这个文件是需要作为一个 基础模板 ,能够接受用户的输入从而支持定制化生成的。我们将这类文件定义为 “动态文件”。
将文件划分为静态和动态后,我们就可以将需求拆解为 “生成静态文件” 和 “生成动态文件” 两个步骤了。
同理,我们再思考如何制作基于命令行的脚手架。在制作命令行工具前,我们是不是可以先通过直接运行 Main 方法、在 Main 方法中写死输入参数的方式实现完整的代码生成逻辑呢?然后只需要把在 Main 方法中写死的输入参数改为读取命令行来接收,剩下的逻辑不都可以复用了么?最后,可以再改变执行方式,把 Main 方法运行改为调用 jar 包(脚本)。
实现步骤
通过上面的需求拆解后,第一阶段的实现方案和流程就非常清晰了:
1)生成静态文件,通过 Main 方法运行
2)生成动态文件,通过 Main 方法运行
3)同时生成静态和动态文件,通过 Main 方法运行,得到完整代码生成
4)开发命令行工具,接受用户的输入并生成完整代码
5)将工具封装为 jar 包和脚本,供用户调用
明确了实现步骤后,你会发现每一步都只需要解决一个小问题,不再像最初定的目标一样让我们毫无头绪了。
接下来我们就一步一步实现即可,本节教程会完成第 1 - 3 步,即编写一个通过 Main 方法生成完整代码的程序。
三、静态文件生成
此处的静态文件,是我们根据需求下的一个定义,指生成时可以直接复制、不做任何改动的文件。
那我们就先定个小目标:输入一个项目的目录,在另一个位置生成一模一样的项目文件。
你会如何实现呢?
其实本质上就是复制文件嘛!
这里提供 2 种方法:
- 使用现成的工具库直接复制完整目录
- 手动递归复制目录和文件
1、现成的工具库复制目录
我们在初始化 yuzi-generator-basic
项目时,就已经引入了 Hutool 库的依赖。
Hutool 是一个功能非常齐全的工具集,包含了 HTTP 请求、日期时间处理、集合类处理、文件处理、JSON 处理等能够大幅提高开发效率的工具类。
Hutool 官方文档:https://www.hutool.cn/
现在我们想复制目录下的所有文件,可以直接使用 Hutool 的 copy 方法,方法信息如下图,一定要格外注意输入参数的含义:
让我们在 com.shiguang.generator
包下创建一个 StaticGenerator
类,作为静态文件生成的代码。
先编写一个公开的静态方法 copyFilesByHutool
,方法中的核心代码就一行,直接调用 Hutool 提供的 FileUtil.copy
方法,就能实现指定目录下所有文件的复制!
/**
* 拷贝文件(Hutool 实现,会将输入目录完整拷贝到输出目录下)
* @param inputPath 输入路径
* @param outputPath 输出路径
*/
public static void copyFilesByHutool(String inputPath, String outputPath) {
FileUtil.copy(inputPath, outputPath, false);
}
然后编写一个 Main 方法来调用这个方法即可,完整复制我们之前准备好的 ACM 示例代码模板(建议使用相对路径)。
示例代码如下:
public static void main(String[] args) {
String projectPath = System.getProperty("user.dir");
File parentFile = new File(projectPath).getParentFile();
String inputPath = new File(parentFile, "yuzi-generator-demo-projects/acm-template").getAbsolutePath();
String outputPath = projectPath;
copyFilesByHutool(inputPath, outputPath);
}
注意,上述代码中,我们通过 System.getProperty("user.dir")
获取到的路径是 yuzi-generator/yuzi-generator-basic
,而不是 yuzi-generator
,所以才用 getParentFile()
的方式去获取父目录 yuzi-generator
的路径。
如果在你实际运行代码的过程中发现 System.getProperty("user.dir")
获取到的路径已经是 yuzi-generator
了,那可以不用获取父目录的路径,代码如下:
public static void main(String[] args) {
String projectPath = System.getProperty("user.dir");
File projectFile = new File(projectPath);
String inputPath = new File(projectFile, "yuzi-generator-demo-projects/acm-template").getAbsolutePath();
String outputPath = projectPath;
copyFilesByHutool(inputPath, outputPath);
}
完整代码如下
package com.shiguang.generator;
import cn.hutool.core.io.FileUtil;
import java.io.File;
/**
* 静态文件生成器
* Created By Shiguang On 2024/6/17 15:39
*/
public class StaticGenerator {
public static void main(String[] args) {
String projectPath = System.getProperty("user.dir");
// 输入路径,获取相对路径,不同操作系统可能有所不同
// D:\Workspace\shiguang-coding\yuzi-generator\yuzi-generator-demo-projects\acm-template
// File parentFile = new File(projectPath).getParentFile();
// String inputPath = new File(parentFile, "yuzi-generator-demo-projects" + File.separator + "acm-template").getAbsolutePath();
String inputPath = projectPath + File.separator + "yuzi-generator-demo-projects" + File.separator + "acm-template";
// 输出路径
String outputPath = projectPath;
System.out.println("输入路径: " + inputPath);
System.out.println("输出路径: " + outputPath);
copyFilesByHutool(inputPath, outputPath);
}
/**
* 拷贝文件(Hutool 实现,会将输入目录完整拷贝到输出目录下)
*
* @param inputPath 输入路径
* @param outputPath 输出路径
*/
public static void copyFilesByHutool(String inputPath, String outputPath) {
FileUtil.copy(inputPath, outputPath, false);
}
}
执行后就能看到项目目录下成功复制了完整的目录:
这种方式的优点显而易见,非常简单;但缺点就是不够灵活,只能整个目录生成,如果想忽略目录中的某个文件,就得生成后再删除,浪费性能。
2、递归遍历
第二种复制目录的方式就是手动编写递归算法依次遍历所有目录和文件。
对于学过算法和数据结构的同学来说,递归并不难,但如果没学过递归算法,实现起来就比较费脑筋了。
遇到这种情况,我们也有比较巧妙的方法,比如参考前人的代码实现。
以 Hutool 为例,点进 FileUtil.copy 方法的源码:
注意,如果看不到源码里的中文注释,则需要在 IDEA 里下载完整的 sources 源码:
按两下 shift,然后输入 sources 就能找到下载源码指令了
源码并不是很复杂,能够发现整体的思路为 递归复制 :
看了别人的源码后,哪怕不能完全理解递归算法,我们也能够学习到一些关键的文件操作 API。比如下面这些:
文件操作 API
1)拷贝文件:
Files.copy(src.toPath(), dest.toPath(), optionList.toArray(new CopyOption[0]));
2)创建多级文件夹(哪怕中间有目录不存在):
File dest;
dest.mkdirs();
3)判断是否为目录:
File dest;
dest.isDirectory();
4)文件是否存在:
File dest;
dest.exists()
掌握这些 API,就能完成检测目录、创建目录、复制文件的一条龙操作了。
示例代码
递归算法的实现还是有一定复杂度的。核心思路就是先在目标位置创建和源项目相同的目录,然后依次遍历源目录下的所有子文件并复制;如果子文件又是一个目录,则再遍历子文件下的所有 “孙” 文件,如此循环往复。
鱼皮这里直接给出示例代码,将它放到 StaticGenerator
文件中,建议大家自己 Debug 一下来帮助理解:
/**
* 递归拷贝文件(递归实现,会将输入目录完整拷贝到输出目录下)
*
* @param inputPath 输入路径
* @param outputPath 输出路径
*/
public static void copyFilesByRecursive(String inputPath, String outputPath) {
File inputFile = new File(inputPath);
File outputFile = new File(outputPath);
try {
copyFileByRecursive(inputFile, outputFile);
} catch (Exception e) {
System.err.println("文件复制失败");
e.printStackTrace();
}
}
/**
* 文件 A => 目录 B,则文件 A 放在目录 B 下
* 文件 A => 文件 B,则文件 A 覆盖文件 B
* 目录 A => 目录 B,则目录 A 放在目录 B 下
* <p>
* 核心思路:先创建目录,然后遍历目录内的文件,依次复制
*
* @param inputFile 输入路径
* @param outputFile 输出路径
* @throws IOException
*/
private static void copyFileByRecursive(File inputFile, File outputFile) throws IOException {
// 区分是文件还是目录
if (inputFile.isDirectory()) {
System.out.println(inputFile.getName());
File destOutputFile = new File(outputFile, inputFile.getName());
// 如果是目录,首先创建目标目录
if (!destOutputFile.exists()) {
destOutputFile.mkdirs();
}
// 获取目录下的所有文件和子目录
File[] files = inputFile.listFiles();
// 无子文件,直接结束
if (ArrayUtil.isEmpty(files)) {
return;
}
for (File file : files) {
// 递归拷贝下一层文件
copyFileByRecursive(file, destOutputFile);
}
} else {
// 是文件,直接复制到目标目录下
Path destPath = outputFile.toPath().resolve(inputFile.getName());
Files.copy(inputFile.toPath(), destPath, StandardCopyOption.REPLACE_EXISTING);
}
}
这种方式的优点是更灵活,可以依次对每一个文件进行处理(还能顺便复习算法);缺点是需要自己实现,不仅麻烦,还有可能出现小 Bug。
完整代码
整个静态文件生成器 StaticGenerator.java
的完整代码如下:
package com.shiguang.generator;
import cn.hutool.core.io.FileUtil;
import cn.hutool.core.util.ArrayUtil;
import java.io.File;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.StandardCopyOption;
/**
* 静态文件生成器
* Created By Shiguang On 2024/6/17 15:39
*/
public class StaticGenerator {
public static void main(String[] args) {
String projectPath = System.getProperty("user.dir");
// 输入路径,获取相对路径,不同操作系统可能有所不同
// D:\Workspace\shiguang-coding\yuzi-generator\yuzi-generator-demo-projects\acm-template
// File parentFile = new File(projectPath).getParentFile();
// String inputPath = new File(parentFile, "yuzi-generator-demo-projects" + File.separator + "acm-template").getAbsolutePath();
String inputPath = projectPath + File.separator + "yuzi-generator-demo-projects" + File.separator + "acm-template";
// 输出路径
String outputPath = projectPath;
System.out.println("输入路径: " + inputPath);
System.out.println("输出路径: " + outputPath);
// copyFilesByHutool(inputPath, outputPath);
copyFilesByRecursive(inputPath, outputPath);
}
/**
* 拷贝文件(Hutool 实现,会将输入目录完整拷贝到输出目录下)
*
* @param inputPath 输入路径
* @param outputPath 输出路径
*/
public static void copyFilesByHutool(String inputPath, String outputPath) {
FileUtil.copy(inputPath, outputPath, false);
}
/**
* 递归拷贝文件(递归实现,会将输入目录完整拷贝到输出目录下)
*
* @param inputPath 输入路径
* @param outputPath 输出路径
*/
public static void copyFilesByRecursive(String inputPath, String outputPath) {
File inputFile = new File(inputPath);
File outputFile = new File(outputPath);
try {
copyFileByRecursive(inputFile, outputFile);
} catch (Exception e) {
System.err.println("文件复制失败");
e.printStackTrace();
}
}
/**
* 文件 A => 目录 B,则文件 A 放在目录 B 下
* 文件 A => 文件 B,则文件 A 覆盖文件 B
* 目录 A => 目录 B,则目录 A 放在目录 B 下
* <p>
* 核心思路:先创建目录,然后遍历目录内的文件,依次复制
*
* @param inputFile 输入路径
* @param outputFile 输出路径
* @throws IOException
*/
private static void copyFileByRecursive(File inputFile, File outputFile) throws IOException {
// 区分是文件还是目录
if (inputFile.isDirectory()) {
System.out.println(inputFile.getName());
File destOutputFile = new File(outputFile, inputFile.getName());
// 如果是目录,首先创建目标目录
if (!destOutputFile.exists()) {
destOutputFile.mkdirs();
}
// 获取目录下的所有文件和子目录
File[] files = inputFile.listFiles();
// 无子文件,直接结束
if (ArrayUtil.isEmpty(files)) {
return;
}
for (File file : files) {
// 递归拷贝下一层文件
copyFileByRecursive(file, destOutputFile);
}
} else {
// 是文件,直接复制到目标目录下
Path destPath = outputFile.toPath().resolve(inputFile.getName());
Files.copy(inputFile.toPath(), destPath, StandardCopyOption.REPLACE_EXISTING);
}
}
}
扩展思路
如果自己实现递归遍历,就可以很轻松地得到目录的完整结构树信息,可以由此制作出文件对比工具、目录分析工具、目录总结工具等等。
四、动态文件生成思路
实现了静态文件生成(复制目录)后,接下来让我们思考下如何对某个基础文件进行定制,根据用户的输入参数动态生成文件。
明确动态生成需求
对于 ACM 示例模板项目,我们可以怎么定制生成呢?
让我们先明确几个动态生成的需求:
- 在代码开头增加作者 @Author 注释(增加代码)
- 修改程序输出的信息提示(替换代码)
- 将循环读取输入改为单次读取(可选代码)
举个例子,想要得到的示例代码如下:
package com.shiguang.acm;
import java.util.Scanner;
/**
* ACM 输入模板(多数之和)
* @author yupi(1. 增加作者注释)
*/
public class MainTemplate {
public static void main(String[] args) {
Scanner scanner = new Scanner(System.in);
// 2. 可选是否循环
// while (scanner.hasNext()) {
// 读取输入元素个数
int n = scanner.nextInt();
// 读取数组
int[] arr = new int[n];
for (int i = 0; i < n; i++) {
arr[i] = scanner.nextInt();
}
// 处理问题逻辑,根据需要进行输出
// 示例:计算数组元素的和
int sum = 0;
for (int num : arr) {
sum += num;
}
// 3. 输出信息可以修改
System.out.println("求和结果: " + sum);
// }
scanner.close();
}
}
动态生成的核心原理
那么如何实现这些功能呢?在开篇教程中就已经讲过了,我们再复习一遍。
最经典的实现方法就是:提前基于基础文件 “挖坑”,编写 模板文件 ,然后将用户输入的 参数 “填坑”,替换到模板文件中,从而生成完整代码。
举个例子,用户输入参数:
author = yupi
模板文件代码:
将参数注入到模板文件中,得到生成的完整代码:
如果想要使用这套模板文件来生成其他的代码,只需要改变输入参数的值即可,而不需要改变模板文件。
听起来好像很简单,那么问题来了,如何编写模板文件呢?程序怎么知道应该把哪些变量替换为用户实际输入的参数呢?又该如何执行替换操作呢?
难道需要自己定义一套模板语法和规则,比如指定两个尖括号 {{ 参数 }}
中的内容为需要替换的参数,然后通过正则表达式或者字符串匹配扫描文件来进行替换么?
显然这太麻烦了!而且如果我需要根据用户的输入来生成不同次数的重复代码(也就是循环),又该如何实现呢?
所以建议大家直接使用已有的 模板引擎 技术,轻松实现模板编写和动态内容生成。
五、FreeMarker 模板引擎入门
什么是模板引擎?为什么需要它?
模板引擎是一种用于生成动态内容的类库(或框架),通过将预定义的模板与特定数据合并,来生成最终的输出。
使用模板引擎有很多的优点,首先就是提供现成的模板文件语法和解析能力。开发者只要按照特定要求去编写模板文件,比如使用 ${参数}
语法,模板引擎就能自动将参数注入到模板中,得到完整文件,不用再自己编写解析逻辑了。
其次,模板引擎可以将数据和模板分离,让不同的开发人员独立工作。比如后端专心开发业务逻辑提供数据,前端专心写模板等,让系统更易于维护。
此外,模板引擎可能还具有一些安全特性,比如防止跨站脚本攻击等。所以强烈大家掌握至少一种模板引擎的用法。
有很多现成的模板引擎技术,比如 Java 的 Thymeleaf、FreeMarker、Velocity,前端的 Mustache 等。
本项目中,我会以知名的、稳定的经典模板引擎 FreeMarker 为例,带大家掌握模板引擎的使用方法。
什么是 FreeMarker?
FreeMarker 是 Apache 的开源模板引擎,优点是入门简单、灵活易扩展。它不用和 Spring 开发框架、Servlet 环境、第三方依赖绑定,任何 Java 项目都可以使用。
我个人推荐的 FreeMarker 学习方式是直接阅读官方文档,虽然是英文的,但每一节基本都有代码示例,还是比较好理解的。
FreeMarker 官方文档:https://freemarker.apache.org/docs/index.html
看不懂英文也没关系,鱼皮下面就带大家学习 FreeMarker,只讲常用的特性,主打一个快速入门!
模板引擎的作用
上面已经讲过了模板引擎的作用,这里就再用 FreeMarker 官网的一张图,强化下大家的理解。
如下图,FreeMarker 模板引擎的作用就是接受模板和 Java 对象,对它们进行处理,输出完整的内容。
下面我们先依次来学习 FreeMarker 的核心概念(模板和数据模型),然后通过一个 Demo 快速入门。
模板
FreeMarker 拥有自己的模板编写规则,一般用 FTL 表示 FreeMarker 模板语言。比如 myweb.html.ftl
就是一个 FreeMarker 的模板文件。
模板文件由 4 个核心部分组成:
1)文本:固定的内容,会按原样输出。
2)插值:用 ${...}
语法来占位,尖括号中的内容在经过计算和替换后,才会输出。
3)FTL 指令:有点像 HTML 的标签语法,通过 <#xxx ... >
来实现各种特殊功能。比如 <#list elements as element>
实现循环输出。
4)注释:和 HTML 注释类似,使用 <#-- ... -->
语法,注释中的内容不会输出。
让我们以《鱼皮官网》为例,举一个 FreeMarker 模板文件的例子:
学过前端开发框架的同学应该会觉得很眼熟~
<!DOCTYPE html>
<html>
<head>
<title>時光官网</title>
<meta http-equiv="Content-Type" content="text/html; charset=utf-8">
</head>
<body>
<h1>欢迎来到時光官网</h1>
<ul>
<#-- 循环渲染导航条 -->
<#list menuItems as item>
<li><a href="${item.url}">${item.label}</a></li>
</#list>
</ul>
<#-- 底部版权信息(注释部分,不会被输出)-->
<footer>
${currentYear} 時光官网. All rights reserved.
</footer>
</body>
</html>
数据模型
我们把为模板准备的所有数据整体统称为 数据模型 。
在 FreeMarker 中,数据模型一般是树形结构,可以是复杂的 Java 对象、也可以是 HashMap 等更通用的结构。
比如为上述《鱼皮官网》模板准备的数据模型,结构可能是这样的:
{
"currentYear": 2024,
"menuItems": [
{
"url": "https://codefather.cn",
"label": "编程导航",
},
{
"url": "https://laoyujianli.com",
"label": "老鱼简历",
}
]
}
Demo 实战
在了解模板和数据模型后,让我们通过 FreeMarker 对二者进行组合处理。
1、引入依赖
首先创建一个 Maven 项目(这里就用我们的 yuzi-generator-basic
项目),在 pom.xml
中引入 FreeMarker:
<!-- https://freemarker.apache.org/index.html -->
<dependency>
<groupId>org.freemarker</groupId>
<artifactId>freemarker</artifactId>
<version>2.3.32</version>
</dependency>
如果是 Spring Boot 项目的话,可以直接引入 starter 依赖:
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-freemarker</artifactId>
</dependency>
2、创建配置对象
在 test/java
目录下新建一个单元测试类 FreeMarkerTest
,在 Test 方法中创建一个 FreeMarker 的全局配置对象,可以统一指定模板文件所在的路径、模板文件的字符集等。
示例代码如下:
// new 出 Configuration 对象,参数为 FreeMarker 版本号
Configuration configuration = new Configuration(Configuration.VERSION_2_3_32);
// 指定模板文件所在的路径
configuration.setDirectoryForTemplateLoading(new File("src/main/resources/templates"));
// 设置模板文件使用的字符集
configuration.setDefaultEncoding("utf-8");
3、准备模版并加载
我们将上述《鱼皮官网》的模板代码保存为 myweb.html.ftl
文件,存放在上面指定的目录下。
准备好模板文件后,通过创建 Template 对象来加载该模板。示例代码如下:
// 创建模板对象,加载指定模板
Template template = configuration.getTemplate("myweb.html.ftl");
4、创建数据模型
如果想保证数据的质量和规范性,可以使用对象来保存 “喂” 给模板的数据;反之,如果想更灵活地构造数据模型,推荐使用 HashMap 结构。
比如我们想构造《鱼皮官网》的数据模型,需要制定当前年份和导航菜单项,示例代码如下:
// 数据模型
Map<String, Object> dataModel = new HashMap<>();
dataModel.put("currentYear", 2024);
List<Map<String, Object>> menuItems = new ArrayList<>();
Map<String, Object> menuItem1 = new HashMap<>();
menuItem1.put("url", "https://shiguang666.eu.org");
menuItem1.put("label", "時光主页");
Map<String, Object> menuItem2 = new HashMap<>();
menuItem2.put("url", "https://blog.shiguang666.eu.org");
menuItem2.put("label", "時光博客园子");
Map<String, Object> menuItem3 = new HashMap<>();
menuItem3.put("url", "https://nav.shiguang666.eu.org");
menuItem3.put("label", "時光导航站");
menuItems.add(menuItem1);
menuItems.add(menuItem2);
menuItems.add(menuItem3);
dataModel.put("menuItems", menuItems);
5、指定生成的文件
可以直接使用 FileWriter 对象,指定生成的文件路径和名称:
Writer out = new FileWriter("myweb.html");
6、生成文件
一切准备就绪,最后只需要调用 template 对象的 process 方法,就可以处理并生成文件了。
示例代码如下:
template.process(dataModel, out);
out.close();
生成后的文件如下:
<!DOCTYPE html>
<html>
<head>
<title>時光官网</title>
<meta http-equiv="Content-Type" content="text/html; charset=utf-8">
</head>
<body>
<h1>欢迎来到時光官网</h1>
<ul>
<li><a href="https://shiguang666.eu.org">時光主页</a></li>
<li><a href="https://blog.shiguang666.eu.org">時光博客园子</a></li>
<li><a href="https://nav.shiguang666.eu.org">時光导航站</a></li>
</ul>
<footer>
2,024 時光官网. All rights reserved.
</footer>
</body>
</html>
运行如下所示:
这里添加 <meta http-equiv="Content-Type" content="text/html; charset=utf-8">
是为了防止在IDEA预览时乱码,如果出现乱码问题,可参考文章
7、完整代码
组合上面的所有代码并执行,发现在项目的根路径下生成了网页文件,至此 Demo 结束,很简单吧~
FreeMarkerTest.java
文件的完整代码:
import freemarker.template.Configuration;
import freemarker.template.Template;
import freemarker.template.TemplateException;
import org.junit.Test;
import java.io.File;
import java.io.FileWriter;
import java.io.IOException;
import java.io.Writer;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
/**
* Created By Shiguang On 2024/6/17 20:42
*/
public class FreeMarkerTest {
@Test
public void test() throws IOException, TemplateException {
// new 出 Configuration 对象,参数为 FreeMarker 版本号
Configuration configuration = new Configuration(Configuration.VERSION_2_3_32);
// 指定模板文件所在的路径
configuration.setDirectoryForTemplateLoading(new File("src/main/resources/templates"));
// 设置模板文件使用的字符集
configuration.setDefaultEncoding("utf-8");
// 创建模板对象,加载指定模板
Template template = configuration.getTemplate("myweb.html.ftl");
// 数据模型
Map<String, Object> dataModel = new HashMap<>();
dataModel.put("currentYear", 2024);
List<Map<String, Object>> menuItems = new ArrayList<>();
Map<String, Object> menuItem1 = new HashMap<>();
menuItem1.put("url", "https://shiguang666.eu.org");
menuItem1.put("label", "時光主页");
Map<String, Object> menuItem2 = new HashMap<>();
menuItem2.put("url", "https://blog.shiguang666.eu.org");
menuItem2.put("label", "時光博客园子");
Map<String, Object> menuItem3 = new HashMap<>();
menuItem3.put("url", "https://nav.shiguang666.eu.org");
menuItem3.put("label", "時光导航站");
menuItems.add(menuItem1);
menuItems.add(menuItem2);
menuItems.add(menuItem3);
dataModel.put("menuItems", menuItems);
Writer out = new FileWriter("myweb.html");
template.process(dataModel, out);
// 关闭输出流
out.close();
}
}
常用语法
学会了 FreeMarker 的基本开发流程后,我们来学习一些 FreeMarker 中的实用特性。
注意,FreeMarker 的语法和特性非常多,本文仅带大家学习常用的、易用的语法。无需记忆,日后需要用到 FreeMarker 时,再去对照官方文档查漏补缺即可。
1、插值
在上面的 Demo 中,已经给大家演示了差值的基本语法( ${xxx}
)。但插值还有很多花样可以玩,比如支持传递表达式:
表达式:${100 + money}
不过个人不建议在模板文件中写表达式,为什么不在创建数据模型时就计算好要展示的值呢?
2、分支和判空
和程序开发一样,FreeMarker 模板也支持分支表达式(if ... else),示例代码如下:
<#if user == "鱼皮">
我是鱼皮
<#else>
我是猪皮
</#if>
分支语句的一个常用场景就是判空,比如要判断 user 参数是否存在,可以用下面的语法:
<#if user??>
存在用户
<#else>
用户不存在
</#if>
3、默认值
FreeMarker 对变量的空值校验是很严格的,如果模板中某个对象为空,FreeMarker 将会报错而导致模板生成中断。
为了防止这个问题,建议给可能为空的参数都设置默认值。使用 表达式!默认值
的语法,示例代码如下:
${user!"用户为空"}
上述代码中,如果 user 对象为空,则会输出 “用户为空” 字符串。
4、循环
在上述 Demo 实战部分,已经给大家演示了循环的用法。即 <#list items as item>
表达式,可以遍历某个序列类型的参数并重复输出多条内容。
示例代码如下:
<#list users as user>
${user}
</#list>
其中,users 是整个列表,而 user 是遍历列表每个元素时临时存储的变量,跟 for 循环一样,会依次输出每个 user 的值。
5、宏定义
学过 C 语言和 C++ 的同学应该对 “宏” 这个词并不陌生。可以把 “宏” 理解为一个预定义的模板片段。支持给宏传入变量,来复用模板片段。
其实类似于前端开发中组件复用的思想。
在 FreeMarker 中,使用 macro
指令来定义宏。
让我们来定义一个宏,用于输出特定格式的用户昵称,比如:
<#macro card userName>
---------
${userName}
---------
</#macro>
其中,card 是宏的名称,userName 是宏接受的参数。
可以用 @
语法来使用宏,示例代码如下:
<@card userName="鱼皮"/>
<@card userName="二黑"/>
实际生成的输出结果为:
---------
鱼皮
---------
---------
二黑
---------
宏标签中支持嵌套内容,不过还是有些复杂的(再讲下去就成前端课了),大家需要用到时查看官方文档就好。
6、内建函数
内建函数是 FreeMarker 为了提高开发者处理参数效率而提供的的语法糖,可以通过 ?
来调用内建函数。
比如将字符串转为大写:
${userName?upper_case}
比如输出序列的长度:
${myList?size}
把内建函数想象成调用 Java 对象的方法,就很好理解了。
内建函数是 FreeMarker 非常强大的一个能力,比如想在循环语法中依次输出元素的下标,就可以使用循环表达式自带的 index
内建函数:
<#list users as user>
${user?index}
</#list>
内建函数种类丰富、数量极多,因此不建议大家记忆,需要用到的时候去查阅官方文档即可。
7、其他
还有更多特性,比如命名空间,其实就相当于 Java 中的包,用于隔离代码、宏、变量等。
不过没必要细讲,因为掌握上述常用语法后,基本就能够开发大多数模板文件了。更多内容自主查阅官方文档学习即可。
问题解决示例
给大家分享一个通过查阅官方文档解决具体问题的例子,比如之前生成的网站文件中,我们发现数字中间加了一个逗号分割符,如下图:
这是因为 FreeMarker 使用 Java 平台的本地化敏感的数字格式信息,如果想把分割符取消掉,怎么办呢?
我们可以通过查阅官方文档看到以下信息:
地址:http://freemarker.foofun.cn/app_faq.html#faq_number_grouping
按照文档的提示,修改 configuration 配置类的 number_format 设置,即可调整默认生成的数字格式啦。
configuration.setNumberFormat("0.######");
更多学习资源
官方文档:https://freemarker.apache.org/docs/index.html
中文教程:http://freemarker.foofun.cn/toc.html
FreeMarker 教程网:http://www.freemarker.net/
六、动态文件生成实现
学完了 FreeMarker 模板引擎后,让我们立刻实战一番,实现 ACM 示例模板项目的动态生成吧!
核心步骤为:
- 定义数据模型
- 编写动态模板
- 组合生成
- 完善优化
1、定义数据模型
针对上述需求,我们在 com.shiguang.model
包下新建一个模板配置对象,用来接收要传递给模板的参数。
注意要根据替换需求选择参数的类型,比如可选生成用 boolean、字符串替换用 String,示例代码如下:
package com.shiguang.model;
import lombok.Data;
/**
* 静态模板配置
* Created By Shiguang On 2024/6/17 21:39
*/
@Data
public class MainTemplateConfig {
// 明确需求
//1.在代码开头增加作者 @Author 注释(增加代码)
//2.修改程序输出的信息提示(替换代码)
//3.将循环读取输入改为单次读取(可选代码)
/**
* 是否生成循环
*/
private boolean loop;
/**
* 作者注释
*/
private String author;
/**
* 输出信息
*/
private String outputText;
}
其实也可以使用 HashMap,但是不如定义对象更清晰、更规范。
2、编写动态模板
在 resources/templates
目录下新建 FTL 模板文件 MainTemplate.java.ftl
(模板和上面定义的数据模型名称保持一致)。
制作模板的方法很简单:先复制原始代码,再挖坑。
完整动态模板代码如下:
package com.shiguang.acm;
import java.util.Scanner;
/**
* ACM 输入模板(多数之和)
* @author ${author}
*/
public class MainTemplate {
public static void main(String[] args) {
Scanner scanner = new Scanner(System.in);
<#if loop>
while (scanner.hasNext()) {
</#if>
// 读取输入元素个数
int n = scanner.nextInt();
// 读取数组
int[] arr = new int[n];
for (int i = 0; i < n; i++) {
arr[i] = scanner.nextInt();
}
// 处理问题逻辑,根据需要进行输出
// 示例:计算数组元素的和
int sum = 0;
for (int num : arr) {
sum += num;
}
System.out.println("${outputText}: " + sum);
<#if loop>
}
</#if>
scanner.close();
}
}
其中,我们使用插值表达式 ${author}
接受作者名称,使用 <#if loop> ... </#if>
分支来控制是否生成循环代码,使用 ${outputText}
控制输出信息。
3、组合生成
同静态文件生成器一样,我们在 com.shiguang.generator
目录下新建动态文件生成器类 DynamicGenerator
。
和上述 FreeMarker Demo 实战一样,在 Main 方法中编写生成逻辑,依次完成:创建 Configuration 对象、模板对象、创建数据模型、指定输出路径、执行生成。
完整代码如下:
public static void main(String[] args) throws IOException, TemplateException {
// new 出 Configuration 对象,参数为 FreeMarker 版本号
Configuration configuration = new Configuration(Configuration.VERSION_2_3_32);
String projectPath = System.getProperty("user.dir") + File.separator + "yuzi-generator-basic";
File parentFile = new File(projectPath);
File file = new File(parentFile, "src/main/resources/templates");
System.out.println("projectPath: " + projectPath);
System.out.println("filePath: " + file.getPath());
// 指定模板文件所在的路径
configuration.setDirectoryForTemplateLoading(file);
// 设置模板文件使用的字符集
configuration.setDefaultEncoding("utf-8");
// FreeMarker 使用Java平台的本地化敏感的数字格式信息。
// 默认的本地化数字格式可能是分组或其他不想要的格式。
// 为了避免这种情况,你不得不使用 FreeMarker 设置 中的 number_format 来重写Java平台建议的数字格式
// 设置数字格式不显示逗号分隔符
configuration.setNumberFormat("0.######");
// 创建模板对象,加载指定模板
Template template = configuration.getTemplate("MainTemplateConfig.java.ftl");
// 数据模型
MainTemplateConfig mainTemplateConfig = new MainTemplateConfig();
mainTemplateConfig.setAuthor("時光");
mainTemplateConfig.setOutputText("输出结果");
mainTemplateConfig.setLoop(true);
String outPath = projectPath + File.separator + "MainTemplateConfig.java";
System.out.println("outPath: " + outPath);
Writer out = new FileWriter(outPath);
template.process(mainTemplateConfig, out);
// 关闭输出流
out.close();
}
执行后可以发现,在项目根目录下,生成了 MainTemplate.java
文件,内容符合我们的预期:
4、完善优化
虽然已经实现了动态文件生成,但我们还要进一步优化代码的健壮性、灵活性。
完善模板
经过测试发现,如果数据模型的字符串变量不设置任何值,那么会报如下错误:
所以建议给所有字符串指定一个默认值,这里有两种方法:
1)方法 1,直接给 POJO 设置默认值:
private String outputText = "sum = ";
2)方法 2,使用 FreeMarker 的默认值操作符:
System.out.println("${outputText!'sum = '}" + sum);
个人更推荐第一种方式,不用多学一套语法,也不用让其他开发者理解模板文件。
让我们修改 MainTemplateConfig
文件,给数据模型增加默认值:
@Data
public class MainTemplateConfig {
private boolean loop;
private String author = "yupi";
private String outputText = "sum = ";
}
抽取方法
上述代码中,我们是把模板文件路径、数据模型、输出路径全部硬编码在了 Main 方法中。而为了提高代码的可复用性,我们可以将生成逻辑封装为一个方法,将硬编码的参数作为方法输入参数,可以用调用方指定。
代码如下:
/**
* 生成文件
*
* @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");
// 设置数字格式不显示分隔符
configuration.setNumberFormat("0.######");
// 创建模板对象,加载指定模板
String templateName = new File(inputPath).getName();
Template template = configuration.getTemplate(templateName);
// 生成
Writer out = new FileWriter(outputPath);
template.process(model, out);
// 生成文件后别忘了关闭哦
out.close();
}
然后 Main 方法(调用方)的代码就可以大大简化了,如下:
public static void main(String[] args) throws IOException, TemplateException {
String projectPath = System.getProperty("user.dir") + File.separator + "yuzi-generator-basic";
// String inputPath = projectPath + File.separator + "src" + File.separator + "main" + File.separator + "resources" + File.separator + "templates" + File.separator + "MainTemplateConfig.java.ftl";
String inputPath = projectPath + File.separator + "src/main/resources/templates/MainTemplateConfig.java.ftl";
String outputPath = projectPath + File.separator + "MainTemplateConfig.java";
System.out.println("projectPath: " + projectPath);
System.out.println("inputPath: " + inputPath);
System.out.println("outputPath: " + outputPath);
// 数据模型
MainTemplateConfig mainTemplateConfig = new MainTemplateConfig();
mainTemplateConfig.setAuthor("時光");
mainTemplateConfig.setOutputText("输出结果");
mainTemplateConfig.setLoop(true);
doGenerate(inputPath, outputPath, mainTemplateConfig);
}
注意,上述代码中,我们通过 System.getProperty("user.dir")
获取到的路径是 yuzi-generator
,那么需要给 projectPath
再拼接 yuzi-generator-basic
路径。
如果在你实际运行代码的过程中发现 System.getProperty("user.dir")
获取到的路径是 yuzi-generator/yuzi-generator-basic
,那么可以直接使用相对路径获取到模板地址。
完整代码
整个动态文件生成器 DynamicGenerator.java
的完整代码如下:
package com.shiguang.generator;
import com.shiguang.model.MainTemplateConfig;
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;
/**
* 动态文件生成器
* Created By Shiguang On 2024/6/17 21:56
*/
public class DynamicGenerator {
public static void main(String[] args) throws IOException, TemplateException {
String projectPath = System.getProperty("user.dir") + File.separator + "yuzi-generator-basic";
// String inputPath = projectPath + File.separator + "src" + File.separator + "main" + File.separator + "resources" + File.separator + "templates" + File.separator + "MainTemplateConfig.java.ftl";
String inputPath = projectPath + File.separator + "src/main/resources/templates/MainTemplateConfig.java.ftl";
String outputPath = projectPath + File.separator + "MainTemplateConfig.java";
System.out.println("projectPath: " + projectPath);
System.out.println("inputPath: " + inputPath);
System.out.println("outputPath: " + outputPath);
// 数据模型
MainTemplateConfig mainTemplateConfig = new MainTemplateConfig();
mainTemplateConfig.setAuthor("時光");
mainTemplateConfig.setOutputText("输出结果");
mainTemplateConfig.setLoop(true);
doGenerate(inputPath, outputPath, mainTemplateConfig);
}
/**
* 生成文件
*
* @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");
// 设置数字格式不显示分隔符
configuration.setNumberFormat("0.######");
// 创建模板对象,加载指定模板
String templateName = new File(inputPath).getName();
Template template = configuration.getTemplate(templateName);
// 生成
Writer out = new FileWriter(outputPath);
template.process(model, out);
// 生成文件后别忘了关闭哦
out.close();
}
}
七、动静结合 - 生成完整代码
完成了静态和动态文件生成后,我们想要生成一套完整的 ACM 模板代码,就非常简单了。只需要编写一个类,组合调用这两个生成器,先复制静态文件、再动态生成文件来覆盖即可。
在 com.shiguang.generator
包下新建 MainGenerator.java
类,编写一个 doGenerator
生成方法,接受外层传来的 Model 数据模型。
完整代码如下:
package com.shiguang.generator;
import com.shiguang.model.MainTemplateConfig;
import freemarker.template.TemplateException;
import java.io.File;
import java.io.IOException;
public class MainGenerator {
public static void doGenerate(Object model) throws TemplateException, IOException {
String projectPath = System.getProperty("user.dir");
File parentFile = new File(projectPath).getParentFile();
String inputPath = new File(parentFile, "yuzi-generator-demo-projects/acm-template").getAbsolutePath();
String outputPath = projectPath;
StaticGenerator.copyFilesByRecursive(inputPath, outputPath);
String inputDynamicFilePath = projectPath + File.separator + "src/main/resources/templates/MainTemplate.java.ftl";
String outputDynamicFilePath = outputPath + File.separator + "acm-template/src/com/yupi/acm/MainTemplate.java";
DynamicGenerator.doGenerate(inputDynamicFilePath, outputDynamicFilePath, model);
}
public static void main(String[] args) throws TemplateException, IOException {
MainTemplateConfig mainTemplateConfig = new MainTemplateConfig();
mainTemplateConfig.setAuthor("yupi");
mainTemplateConfig.setLoop(false);
mainTemplateConfig.setOutputText("求和结果:");
doGenerate(mainTemplateConfig);
}
}
执行上述代码,我们就能够完整地生成 ACM 示例代码模板了,改变 Main 方法中的数据模型参数,就能修改生成的模板代码。
需要注意的是,上述代码中,无论是要复制的静态文件、还是要生成的动态模板文件,我们都是在代码中写死了文件的路径。对于制作一个本地的代码生成器而言,这么做就足够了,但如果要生成一个动态文件非常多的项目,难道要一个个去指定动态文件所在的路径么?
这个问题,留给大家去思考。
最后
以上就是本期教程,我们已经实现了本地的代码生成器。
但是现在这种方式实现的代码生成器虽然可以让懂 Java 编程的同学来使用(只需要修改 Main 方法中的数据模型),但如果是没学过 Java 的同学呢?有没有更方便快捷的使用方式呢?
下期教程,带大家解决这个问题!
本期作业
1)学习拆解需求和实现步骤的思路
2)掌握静态文件和 FreeMarker 模板引擎动态生成文件的方法
3)提前思考最后一个问题,如何提高现有程序的易用性?
4)自己编写代码实现本节项目,并且在自己的代码仓库完成一次提交
本期代码
https://github.com/liyupi/yuzi-generator/commit/d7ea8b8755902901edd97f27bbdca2556334c11c