本节教程属于项目的第一阶段——开发本地代码生成器。
重点内容:
由于我们的项目包含多个阶段,本质上是多个项目,所以为了统一管理整个项目,我们创建一个干净的yuzi-generator空文件夹,作为整个项目的根目录,后续各阶段的项目和目录都放到它之下。
这样做还有一个好处,就是让不同项目模块可以用相对路径寻找文件,便于整个项目的开源共享。
建议大家养成习惯,使用Git来管理项目。如果使用IDEA开发工具来创建新项目,可以直接勾选CreateGitrepository,工具会自动帮你初始化项目为Git仓库。
如下图:
当然,也可以进入项目根目录,执行gitinit命令创建Git仓库。
创建好新项目后,使用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跟踪:
gitrm-rf--cached.执行效果如图:
可以看到文件变成了红色(未被Git托管)或黄色(被忽略)状态:
然后,让我们将.gitignore文件添加到Git暂存区,让它能够被Git管理。
项目根目录就初始化完成了,建议大家像鱼皮一样在项目根目录中新建一个README.md文件,用于介绍项目、记录自己的学习和开发过程等~
为了制作代码生成器,我们需要一些示例模板代码,后续会基于这些模板代码来定制生成。
比如第一阶段,我们会用到一套鱼皮提前编写好的ACM示例模板代码,从而制作定制化ACM模板代码生成器;在第二阶段,我们会用到一套SpringBoot初始化项目模板。
让我们新建一个yuzi-generator-demo-projects目录,统一存放所有的示例代码,然后将鱼皮准备的ACM模板项目(acm-template)复制到该目录下。
整个项目的目录结构如下图:
鱼皮准备的ACM示例代码模板非常简单,只是一个干净的Java项目,没有使用Maven和任何第三方依赖。
结构如下,核心组成是静态文件README.md和代码文件MainTemplate:
README.md内容如图,仅包含了简单的描述文本:
MainTemplate.java是一段ACM示例输入代码,作用是计算并输出多数之和。
完整代码如下:
packagecom.shiguang.acm;importjava.util.Scanner;/***ACM输入模板(多数之和)*/publicclassMainTemplate{publicstaticvoidmain(String[]args){Scannerscanner=newScanner(System.in);while(scanner.hasNext()){//读取输入元素个数intn=scanner.nextInt();//读取数组int[]arr=newint[n];for(inti=0;i 使用IDEA开发工具,在项目根目录中新建工程,创建yuzi-generator-basic模块。需要注意以下几点: 完整配置如图: 注意,鱼皮视频中实际创建的是Model,而文档中创建的是Project 并将文件添加(add)到Git暂存区,如下图: 需要在项目的pom.xml文件中引入一些依赖,主要是一些工具类和单元测试,便于后续提高开发效率。 依赖代码如下: 较低版本的IDEA可能没有示例代码,比如我使用的2022.3.2版本就没有,默认main方法是输出helloworld 第一阶段我们的目标是制作本地代码生成器(基于命令行的脚手架),要求能够根据用户的输入生成不同的ACM示例代码模板。 对于完全没开发过类似项目的同学来说,可能会觉得比较困难。 遇到这种情况,我们首先要根据业务实际情况对需求进行拆解,把一个复杂的大目标拆解为一步一步的小工作。 如何拆解呢? 那就先把需求分为2段,本地代码生成器+基于命令行的脚手架。 首先思考如何制作本地代码生成器。先看看我们要生成的项目文件结构吧!前面也提到过,ACM示例代码模板的核心文件是README.md和MainTemplate.java。 其中,README.md的作用仅仅是描述项目,并不影响开发者的使用。所以我们要生成代码时,完全不用修改README.md的任何内容,直接复制即可。我们将这类文件定义为“静态文件”。 而MainTemplate.java是开发者实际要使用的ACM输入模板文件,默认是包含了循环接受输入的逻辑的。示例代码如下: Scannerscanner=newScanner(System.in);while(scanner.hasNext()){intn=scanner.nextInt();...System.out.println("Sum:"+sum);}但如果用户不需要循环输入,只要保留其他代码呢?像下面这样删除while代码片段: Scannerscanner=newScanner(System.in);intn=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种方法: 我们在初始化yuzi-generator-basic项目时,就已经引入了Hutool库的依赖。 现在我们想复制目录下的所有文件,可以直接使用Hutool的copy方法,方法信息如下图,一定要格外注意输入参数的含义: 让我们在com.shiguang.generator包下创建一个StaticGenerator类,作为静态文件生成的代码。 先编写一个公开的静态方法copyFilesByHutool,方法中的核心代码就一行,直接调用Hutool提供的FileUtil.copy方法,就能实现指定目录下所有文件的复制! /***拷贝文件(Hutool实现,会将输入目录完整拷贝到输出目录下)*@paraminputPath输入路径*@paramoutputPath输出路径*/publicstaticvoidcopyFilesByHutool(StringinputPath,StringoutputPath){FileUtil.copy(inputPath,outputPath,false);}然后编写一个Main方法来调用这个方法即可,完整复制我们之前准备好的ACM示例代码模板(建议使用相对路径)。 示例代码如下: publicstaticvoidmain(String[]args){StringprojectPath=System.getProperty("user.dir");FileparentFile=newFile(projectPath).getParentFile();StringinputPath=newFile(parentFile,"yuzi-generator-demo-projects/acm-template").getAbsolutePath();StringoutputPath=projectPath;copyFilesByHutool(inputPath,outputPath);}注意,上述代码中,我们通过System.getProperty("user.dir")获取到的路径是yuzi-generator/yuzi-generator-basic,而不是yuzi-generator,所以才用getParentFile()的方式去获取父目录yuzi-generator的路径。 如果在你实际运行代码的过程中发现System.getProperty("user.dir")获取到的路径已经是yuzi-generator了,那可以不用获取父目录的路径,代码如下: publicstaticvoidmain(String[]args){StringprojectPath=System.getProperty("user.dir");FileprojectFile=newFile(projectPath);StringinputPath=newFile(projectFile,"yuzi-generator-demo-projects/acm-template").getAbsolutePath();StringoutputPath=projectPath;copyFilesByHutool(inputPath,outputPath);}完整代码如下 这种方式的优点显而易见,非常简单;但缺点就是不够灵活,只能整个目录生成,如果想忽略目录中的某个文件,就得生成后再删除,浪费性能。 第二种复制目录的方式就是手动编写递归算法依次遍历所有目录和文件。 对于学过算法和数据结构的同学来说,递归并不难,但如果没学过递归算法,实现起来就比较费脑筋了。 遇到这种情况,我们也有比较巧妙的方法,比如参考前人的代码实现。 以Hutool为例,点进FileUtil.copy方法的源码: 注意,如果看不到源码里的中文注释,则需要在IDEA里下载完整的sources源码: 按两下shift,然后输入sources就能找到下载源码指令了 源码并不是很复杂,能够发现整体的思路为递归复制: 看了别人的源码后,哪怕不能完全理解递归算法,我们也能够学习到一些关键的文件操作API。比如下面这些: 1)拷贝文件: Files.copy(src.toPath(),dest.toPath(),optionList.toArray(newCopyOption[0]));2)创建多级文件夹(哪怕中间有目录不存在): Filedest;dest.mkdirs();3)判断是否为目录: Filedest;dest.isDirectory();4)文件是否存在: Filedest;dest.exists()掌握这些API,就能完成检测目录、创建目录、复制文件的一条龙操作了。 递归算法的实现还是有一定复杂度的。核心思路就是先在目标位置创建和源项目相同的目录,然后依次遍历源目录下的所有子文件并复制;如果子文件又是一个目录,则再遍历子文件下的所有“孙”文件,如此循环往复。 鱼皮这里直接给出示例代码,将它放到StaticGenerator文件中,建议大家自己Debug一下来帮助理解: 整个静态文件生成器StaticGenerator.java的完整代码如下: 实现了静态文件生成(复制目录)后,接下来让我们思考下如何对某个基础文件进行定制,根据用户的输入参数动态生成文件。 对于ACM示例模板项目,我们可以怎么定制生成呢? 让我们先明确几个动态生成的需求: 举个例子,想要得到的示例代码如下: packagecom.shiguang.acm;importjava.util.Scanner;/***ACM输入模板(多数之和)*@authoryupi(1.增加作者注释)*/publicclassMainTemplate{publicstaticvoidmain(String[]args){Scannerscanner=newScanner(System.in);//2.可选是否循环//while(scanner.hasNext()){//读取输入元素个数intn=scanner.nextInt();//读取数组int[]arr=newint[n];for(inti=0;i 最经典的实现方法就是:提前基于基础文件“挖坑”,编写模板文件,然后将用户输入的参数“填坑”,替换到模板文件中,从而生成完整代码。 举个例子,用户输入参数: author=yupi模板文件代码: 将参数注入到模板文件中,得到生成的完整代码: 如果想要使用这套模板文件来生成其他的代码,只需要改变输入参数的值即可,而不需要改变模板文件。 听起来好像很简单,那么问题来了,如何编写模板文件呢?程序怎么知道应该把哪些变量替换为用户实际输入的参数呢?又该如何执行替换操作呢? 难道需要自己定义一套模板语法和规则,比如指定两个尖括号{{参数}}中的内容为需要替换的参数,然后通过正则表达式或者字符串匹配扫描文件来进行替换么? 显然这太麻烦了!而且如果我需要根据用户的输入来生成不同次数的重复代码(也就是循环),又该如何实现呢? 所以建议大家直接使用已有的模板引擎技术,轻松实现模板编写和动态内容生成。 模板引擎是一种用于生成动态内容的类库(或框架),通过将预定义的模板与特定数据合并,来生成最终的输出。 使用模板引擎有很多的优点,首先就是提供现成的模板文件语法和解析能力。开发者只要按照特定要求去编写模板文件,比如使用${参数}语法,模板引擎就能自动将参数注入到模板中,得到完整文件,不用再自己编写解析逻辑了。 其次,模板引擎可以将数据和模板分离,让不同的开发人员独立工作。比如后端专心开发业务逻辑提供数据,前端专心写模板等,让系统更易于维护。 此外,模板引擎可能还具有一些安全特性,比如防止跨站脚本攻击等。所以强烈大家掌握至少一种模板引擎的用法。 有很多现成的模板引擎技术,比如Java的Thymeleaf、FreeMarker、Velocity,前端的Mustache等。 本项目中,我会以知名的、稳定的经典模板引擎FreeMarker为例,带大家掌握模板引擎的使用方法。 FreeMarker是Apache的开源模板引擎,优点是入门简单、灵活易扩展。它不用和Spring开发框架、Servlet环境、第三方依赖绑定,任何Java项目都可以使用。 我个人推荐的FreeMarker学习方式是直接阅读官方文档,虽然是英文的,但每一节基本都有代码示例,还是比较好理解的。 看不懂英文也没关系,鱼皮下面就带大家学习FreeMarker,只讲常用的特性,主打一个快速入门! 上面已经讲过了模板引擎的作用,这里就再用FreeMarker官网的一张图,强化下大家的理解。 如下图,FreeMarker模板引擎的作用就是接受模板和Java对象,对它们进行处理,输出完整的内容。 下面我们先依次来学习FreeMarker的核心概念(模板和数据模型),然后通过一个Demo快速入门。 FreeMarker拥有自己的模板编写规则,一般用FTL表示FreeMarker模板语言。比如myweb.html.ftl就是一个FreeMarker的模板文件。 模板文件由4个核心部分组成: 1)文本:固定的内容,会按原样输出。 2)插值:用${...}语法来占位,尖括号中的内容在经过计算和替换后,才会输出。 3)FTL指令:有点像HTML的标签语法,通过<#xxx...>来实现各种特殊功能。比如<#listelementsaselement>实现循环输出。 4)注释:和HTML注释类似,使用<#--...-->语法,注释中的内容不会输出。 让我们以《鱼皮官网》为例,举一个FreeMarker模板文件的例子: 学过前端开发框架的同学应该会觉得很眼熟~ 在FreeMarker中,数据模型一般是树形结构,可以是复杂的Java对象、也可以是HashMap等更通用的结构。 比如为上述《鱼皮官网》模板准备的数据模型,结构可能是这样的: 首先创建一个Maven项目(这里就用我们的yuzi-generator-basic项目),在pom.xml中引入FreeMarker: //new出Configuration对象,参数为FreeMarker版本号Configurationconfiguration=newConfiguration(Configuration.VERSION_2_3_32);//指定模板文件所在的路径configuration.setDirectoryForTemplateLoading(newFile("src/main/resources/templates"));//设置模板文件使用的字符集configuration.setDefaultEncoding("utf-8");3、准备模版并加载我们将上述《鱼皮官网》的模板代码保存为myweb.html.ftl文件,存放在上面指定的目录下。 准备好模板文件后,通过创建Template对象来加载该模板。示例代码如下: //创建模板对象,加载指定模板Templatetemplate=configuration.getTemplate("myweb.html.ftl");4、创建数据模型如果想保证数据的质量和规范性,可以使用对象来保存“喂”给模板的数据;反之,如果想更灵活地构造数据模型,推荐使用HashMap结构。 比如我们想构造《鱼皮官网》的数据模型,需要制定当前年份和导航菜单项,示例代码如下: Writerout=newFileWriter("myweb.html");6、生成文件一切准备就绪,最后只需要调用template对象的process方法,就可以处理并生成文件了。 template.process(dataModel,out);out.close();生成后的文件如下: 组合上面的所有代码并执行,发现在项目的根路径下生成了网页文件,至此Demo结束,很简单吧~ FreeMarkerTest.java文件的完整代码: 注意,FreeMarker的语法和特性非常多,本文仅带大家学习常用的、易用的语法。无需记忆,日后需要用到FreeMarker时,再去对照官方文档查漏补缺即可。 在上面的Demo中,已经给大家演示了差值的基本语法(${xxx})。但插值还有很多花样可以玩,比如支持传递表达式: 表达式:${100+money}不过个人不建议在模板文件中写表达式,为什么不在创建数据模型时就计算好要展示的值呢? 和程序开发一样,FreeMarker模板也支持分支表达式(if...else),示例代码如下: <#ifuser=="鱼皮">我是鱼皮<#else>我是猪皮#if>分支语句的一个常用场景就是判空,比如要判断user参数是否存在,可以用下面的语法: <#ifuser>存在用户<#else>用户不存在#if>3、默认值FreeMarker对变量的空值校验是很严格的,如果模板中某个对象为空,FreeMarker将会报错而导致模板生成中断。 为了防止这个问题,建议给可能为空的参数都设置默认值。使用表达式!默认值的语法,示例代码如下: ${user!"用户为空"}上述代码中,如果user对象为空,则会输出“用户为空”字符串。 在上述Demo实战部分,已经给大家演示了循环的用法。即<#listitemsasitem>表达式,可以遍历某个序列类型的参数并重复输出多条内容。 <#listusersasuser>${user}#list>其中,users是整个列表,而user是遍历列表每个元素时临时存储的变量,跟for循环一样,会依次输出每个user的值。 学过C语言和C++的同学应该对“宏”这个词并不陌生。可以把“宏”理解为一个预定义的模板片段。支持给宏传入变量,来复用模板片段。 其实类似于前端开发中组件复用的思想。 在FreeMarker中,使用macro指令来定义宏。 让我们来定义一个宏,用于输出特定格式的用户昵称,比如: <#macrocarduserName>---------${userName}---------#macro>其中,card是宏的名称,userName是宏接受的参数。 可以用@语法来使用宏,示例代码如下: <@carduserName="鱼皮"/><@carduserName="二黑"/>实际生成的输出结果为: ---------鱼皮------------------二黑---------宏标签中支持嵌套内容,不过还是有些复杂的(再讲下去就成前端课了),大家需要用到时查看官方文档就好。 内建函数是FreeMarker为了提高开发者处理参数效率而提供的的语法糖,可以通过来调用内建函数。 比如将字符串转为大写: ${userNameupper_case}比如输出序列的长度: ${myListsize}把内建函数想象成调用Java对象的方法,就很好理解了。 内建函数是FreeMarker非常强大的一个能力,比如想在循环语法中依次输出元素的下标,就可以使用循环表达式自带的index内建函数: <#listusersasuser>${userindex}#list>内建函数种类丰富、数量极多,因此不建议大家记忆,需要用到的时候去查阅官方文档即可。 还有更多特性,比如命名空间,其实就相当于Java中的包,用于隔离代码、宏、变量等。 不过没必要细讲,因为掌握上述常用语法后,基本就能够开发大多数模板文件了。更多内容自主查阅官方文档学习即可。 这是因为FreeMarker使用Java平台的本地化敏感的数字格式信息,如果想把分割符取消掉,怎么办呢? 我们可以通过查阅官方文档看到以下信息: 按照文档的提示,修改configuration配置类的number_format设置,即可调整默认生成的数字格式啦。 学完了FreeMarker模板引擎后,让我们立刻实战一番,实现ACM示例模板项目的动态生成吧! 核心步骤为: 针对上述需求,我们在com.shiguang.model包下新建一个模板配置对象,用来接收要传递给模板的参数。 注意要根据替换需求选择参数的类型,比如可选生成用boolean、字符串替换用String,示例代码如下: packagecom.shiguang.model;importlombok.Data;/***静态模板配置*CreatedByShiguangOn2024/6/1721:39*/@DatapublicclassMainTemplateConfig{//明确需求//1.在代码开头增加作者@Author注释(增加代码)//2.修改程序输出的信息提示(替换代码)//3.将循环读取输入改为单次读取(可选代码)/***是否生成循环*/privatebooleanloop;/***作者注释*/privateStringauthor;/***输出信息*/privateStringoutputText;}其实也可以使用HashMap,但是不如定义对象更清晰、更规范。 在resources/templates目录下新建FTL模板文件MainTemplate.java.ftl(模板和上面定义的数据模型名称保持一致)。 制作模板的方法很简单:先复制原始代码,再挖坑。 完整动态模板代码如下: packagecom.shiguang.acm;importjava.util.Scanner;/***ACM输入模板(多数之和)*@author${author}*/publicclassMainTemplate{publicstaticvoidmain(String[]args){Scannerscanner=newScanner(System.in);<#ifloop>while(scanner.hasNext()){#if>//读取输入元素个数intn=scanner.nextInt();//读取数组int[]arr=newint[n];for(inti=0;i 同静态文件生成器一样,我们在com.shiguang.generator目录下新建动态文件生成器类DynamicGenerator。 和上述FreeMarkerDemo实战一样,在Main方法中编写生成逻辑,依次完成:创建Configuration对象、模板对象、创建数据模型、指定输出路径、执行生成。 虽然已经实现了动态文件生成,但我们还要进一步优化代码的健壮性、灵活性。 经过测试发现,如果数据模型的字符串变量不设置任何值,那么会报如下错误: 所以建议给所有字符串指定一个默认值,这里有两种方法: 1)方法1,直接给POJO设置默认值: privateStringoutputText="sum=";2)方法2,使用FreeMarker的默认值操作符: System.out.println("${outputText!'sum='}"+sum);个人更推荐第一种方式,不用多学一套语法,也不用让其他开发者理解模板文件。 让我们修改MainTemplateConfig文件,给数据模型增加默认值: @DatapublicclassMainTemplateConfig{privatebooleanloop;privateStringauthor="yupi";privateStringoutputText="sum=";}抽取方法上述代码中,我们是把模板文件路径、数据模型、输出路径全部硬编码在了Main方法中。而为了提高代码的可复用性,我们可以将生成逻辑封装为一个方法,将硬编码的参数作为方法输入参数,可以用调用方指定。 代码如下: /***生成文件**@paraminputPath模板文件输入路径*@paramoutputPath输出路径*@parammodel数据模型*@throwsIOException*@throwsTemplateException*/publicstaticvoiddoGenerate(StringinputPath,StringoutputPath,Objectmodel)throwsIOException,TemplateException{//new出Configuration对象,参数为FreeMarker版本号Configurationconfiguration=newConfiguration(Configuration.VERSION_2_3_32);//指定模板文件所在的路径FiletemplateDir=newFile(inputPath).getParentFile();configuration.setDirectoryForTemplateLoading(templateDir);//设置模板文件使用的字符集configuration.setDefaultEncoding("utf-8");//设置数字格式不显示分隔符configuration.setNumberFormat("0.######");//创建模板对象,加载指定模板StringtemplateName=newFile(inputPath).getName();Templatetemplate=configuration.getTemplate(templateName);//生成Writerout=newFileWriter(outputPath);template.process(model,out);//生成文件后别忘了关闭哦out.close();}然后Main方法(调用方)的代码就可以大大简化了,如下: 如果在你实际运行代码的过程中发现System.getProperty("user.dir")获取到的路径是yuzi-generator/yuzi-generator-basic,那么可以直接使用相对路径获取到模板地址。 整个动态文件生成器DynamicGenerator.java的完整代码如下: 在com.shiguang.generator包下新建MainGenerator.java类,编写一个doGenerator生成方法,接受外层传来的Model数据模型。 需要注意的是,上述代码中,无论是要复制的静态文件、还是要生成的动态模板文件,我们都是在代码中写死了文件的路径。对于制作一个本地的代码生成器而言,这么做就足够了,但如果要生成一个动态文件非常多的项目,难道要一个个去指定动态文件所在的路径么? 这个问题,留给大家去思考。 以上就是本期教程,我们已经实现了本地的代码生成器。 但是现在这种方式实现的代码生成器虽然可以让懂Java编程的同学来使用(只需要修改Main方法中的数据模型),但如果是没学过Java的同学呢?有没有更方便快捷的使用方式呢? 下期教程,带大家解决这个问题! 1)学习拆解需求和实现步骤的思路 2)掌握静态文件和FreeMarker模板引擎动态生成文件的方法 3)提前思考最后一个问题,如何提高现有程序的易用性? 4)自己编写代码实现本节项目,并且在自己的代码仓库完成一次提交