0、学习本文你或许可以收获
1、一个需求从产生、分析到解决的全过程思考
2、简单的面向对象分析实践
3、UML类图实践
4、设计模式的实践应用
5、最后收获一款还算不错的代码生成工具实现思路和源代码
本文将从上面第一点提到的全过程 需求->目标->思路->设计->实现->总结 逐一展开。
本文为了尽量还原真实场景下如何从无到有实现一个需求,所以全文会假设大家都不动代码生成这个东西。是需要从零开始研究的。
下面开始正文。
1、先看需求
某日,风小南(年轻时的自己)代码写的正酣,技术经理突然走了过来,拍拍了风小南肩膀。
“小南啊,你看咱这个系统里面这么多增删改查的功能,虽然拷贝复制可以,但是效率还是不够高”。
风小南心想
"都CV模式了效率还不高,你咋不上天呢 ?"。
紧接着,技术经理说道
你看,现在这个用户管理模块做完了,我做其他角色、机构管理时,代码的规范、基本的增删改查结构是一样的对吧,确实可以快速复制实现其他两个功能,但是这中间有几个问题仍然影响效率
第一:文件重命名问题,一个基本的模块从前台到后台大概7-10个左右的代码文件,在复制的同时均需要重新命名,文件数量这取决于每个公司的开发体系和标准。文件数量也可以更少,或者更多
第二:实体属性肯定有存在不一样的吧,需要修改或新增,这有一部分工作量
第三:类上,方法上,字段上的备注都需要调整吧,比如用户管理类的备注是用户管理,那你拷贝成角色管理得改成角色管理吧。方法字段都一样得调整下吧
第四:类中的属性、方法名、局部变量等也会需要跟着变化,比如,UserController 注入的UserService,方法内部声明的userList变量,那拷贝成角色管理对应的名称均需要调整为 roleXXX
第五:多余不用的方法需要一个个删除
第六:还有个严重的问题,就是拷贝完后经常会有人忘了改上面说的几点内容,导致代码可读性降低。
风小南结合自己实际写代码时的情况还真是存在这样的问题,不禁感到CV大法在复制整个模块时,还真是不太方便快捷,那怎么解决呢? 风小南心生疑惑。
“你这两天给咱研究一下,看行业内还有没有更有效的解决方案。你先尝试着弄,有什么问题咱们随时沟通(话外音,有什么进展问题要及时主动汇报)”
说完,技术经理转身就走了。
哎,故事写的不好,凑合着看看,核心需求表达到就OK了。
2、初步探索
研究新技术那可是风小南最喜欢的事情了,只不过现在还没有明确的方向,自己之前也没有这方面的经验,这时候能给自己指明道路的就是前辈或搜索引擎了,作为爱折腾技术的风小南,那必然是先自己搜索研究一番,短期内没结果的话在请教下前辈。总之原则就是,不耽搁公司的事情,任务按时完成就OK。
打开搜索引擎,咋搜呢? 风小南发现不知道该输入什么关键字才能找到想要的东西,得先确定下关键字。既然是代码拷贝相关问题,那假设有相关工具或解决方案之类的东西,应该也会叫 “代码XX” 吧。
既然跟效率有关,先来个 “代码效率” 搜一波吧。截图部分图,结果如下
大概看了下内容,发现都是跟代码调优加快执行效率相关,并非提升代码开发效率,那就关键字不对,还得调整。
风小南又想了想,代码拷贝工具? 代码复制工具 ?仔细想想应该都不对,但还是尝试了一波,果然没有找到想要的结果。
效率不就是要速度快么,“代码快速开发” 这个咋样呢,再试试。搜索结果如下
列表出现了一系列快速开发平台,而且频繁出现了「代码生成」这个词,直觉告诉风小南这不就是自己心里想要的那个但不知怎么表达的那个关键词吗。风小南又补充了一点既然公司用的是Java,那搜「Java代码生成」肯定更准确一点。至此,关键字确定了就是「Java代码生成」。
操作激动颤抖的双手,在搜索引擎敲下了「Java代码生成」,点击搜索。
风小南迫不及待点开几篇大致浏览了下内容,随后又调整了几次关键字,「Java代码生成工具」、「Java代码生成器」搜索查看了一波资料后,看了相关的实现思路,不禁大喜 “这不正是我们需要的东西吗,自主生成、模板定制、一步到位”。什么改变量名,重命名、备注问题统统不存在,风小南心里大致有谱了。
实现方式大致两种,一种借助现成的代码生成平台,第二种就是自主实现,需要借助模板引擎技术,这里就存在技术方向的确定,需要跟领导汇报了。
风小南将查阅到的资料和自己的理解整理成文档,发给技术经理(主动及时沟通、汇报很重要),并到技术经理的工位旁进行简要汇报,技术经理听完又大致浏览了风小南整理的资料,说道:
做的不错,咱们公司有自己的研发平台,代码生成工具最好是能直接跟咱们现在的平台融合起来,我看了下现成的代码生成工具跟咱现在的平台不好融合,而且功能太多,使用的技术标准也不太一样,我们自己实现一套简单的吧,解决当下核心问题就行,不用太复杂。还是交给你来做吧。
风小南听到这,那个高兴啊。天下还有比撸代码更开心的事吗?有,那就是撸自己不会的代码。哈哈。
至此,前期方案的初步探索就完成了。
探索阶段的工作往往容易不好把控,原因在于探索那一定是未知的领域、未知的问题,任务本身不具体,不知道最终会做成什么样,时间周期可能还长,这时候需要注意的就是自己工作的方式方法。要点就是及时沟通汇报进度。
3、工作任务书
任务有了,大致目标有了,但是还不够具体,为了防止最后做出的东西跟技术经理想要的不一样,我还是把刚才谈话的内容梳理一下,形成一个明确的工作目标,再找经理确认一下达成一致,再开始。
目标基本上清晰了,用什么技术,领导说了要容易融入公司的开发平台,那是要求跟公司现有的技术框架一致,规范一致,哦对,还提到了要简单易用。于是有了下面的内容。
代码生成工具工作任务书
工作目标
(1) 核心解决现在代码拷贝复制效率低下的问题
(2) 通过自主研发的方式实现一套代码生成工具并能够与公司平台集成
技术选型
(1) springboot
(2) thymeleaf
使用方式
(1) 命令行方式使用,不依赖web服务即可使用
风小南把整理的简要工作目标文档,发给了技术经理,过了一会,技术经理回复了过来,加了几项工作目标。
工作目标
(1) 核心解决现在代码拷贝复制效率低下的问题
(2) 通过自主研发的方式实现一套代码生成工具并能够与公司平台集成
(3) 增加新代码模板时要易于扩展
(4) 可按需生成,如可以只生成Controller文件,而不是非得全模块代码生成
至此,工作目标已确定,下来就是具体干活了。
领导很忙的时候,如果没有帮你梳理出具体的任务项,我们需要学会自己梳理,并找领导确认。这也是帮助自己逐步积累的一个过程,以后自己带人带团队这些套路是可以复用、扩展的,面向对象嘛,另一方面还能帮领导减压。领导不会讨厌这样的员工。
5、技术设计
本来风小南都想好了怎么实现,很快就能做出来,无奈技术经理加了两条要求,让这个事情又变得不是那么简单,还需分析分析,好好设计一下子。
核心原理图
既然如此,那还是先把大致的原理图画出来吧,这个是核心的实现思路,风小南大致画了这么一个图。
核心原理图大致是上面这么一个结构。简单解释一下,就是通过读取数据库表元数据和代码模板,通过thymeleaf引擎将代码模板和数据库元数据进行合并,即将模板表达式替换为具体的数据,最后输出文件。
有了这个核心原理后,就需要基于这个结构进行展开设计,来达到后续两点要求。
(3) 增加新代码模板时要易于扩展
(4) 可按需生成,如可以只生成Controller文件,而不是非得全模块代码生成
风小南解读这两点要求后,得出一个结论:实际上第(3)说的就是要满足开闭原则,第(4)条是程序要能够自由组合生成方式,支持个性化自定义。
类图设计
首先那就需要简单设计下基本的类图和关系,怎么做呢?之前没有这方面的经验,想一步到位显然不太可能,那就得拆解步骤一步步来,分析上面的原理图,先不用考虑类之间的关系把能想到的类先全部列出来,就是穷举法,然后逐步优化调整,这是第一步。
做的多了,有了一定经验可能就用不到穷举,穷举法适用于当下没什么太好的思路或不知道怎么开始的情况,相当于在做事的过程中寻找灵感和方法,逐步将自己的思维带入场景。
以读取表元数据这个过程举例来说,要能读取表元数据,是不是需要一个数据库连接的类,链接是不是需要配置文件,读取配置是否需要个类,读回来后的表元数据往哪放,是不是也需要表信息和字段信息的类,先不管合理不合理,先列出来。依次类推,其他步骤过程也这么分析。
简单普及下元数据
什么是元数据?即描述数据的数据就叫做元数据,怎么理解?还是举个例子简单点
比如说有个用户表User,有两个字段 id(int) name(String)
那么描述User表信息和字段信息的数据,就叫做元数据,看下图
左边的这条数据表达了右边用户表的相关信息,如这个表叫什么,什么类型的,用的什么存储引擎,版本等等,这些表述User表信息的数据就叫做User表的元数据。
同理,字段也一样,描述字段名、字段类型、字段长度等等的数据就是字段的元数据。
以Mysql为例,Mysql中创建完成后存在个Schema叫 「information_schema」,这个库就为mysql的元数据库,存储了一些列mysql数据库实例、表、字段、索引等等的元数据信息,里面就有Tables、Columns表存放表和字段的元数据。
最终基于代码生成工具的核心原理过程,风小南列出了下面自己能想到的类清单
类名 | 用途 | 步骤过程 |
---|---|---|
Table | 表元数据信息 | 读取元数据 |
Column | 字段元数据信息 | 读取元数据 |
DbConnection | 获取数据库链接 | 读取元数据 |
YamlRead | 读取YAML配置 | 读取元数据 |
CodeTemplate | 模板文件 | 读取模板信息 |
Command | 生成命令 | 生成代码 |
TypeMapping | 数据库与Java类型映射类 | 生成代码 |
GenCode | 代码生成 | 生成代码 |
CodeOutput | 代码输出 | 输出 |
GenCodeRunner | 开发人员使用的入口类 | 启动运行 |
有了基本清单,下来就借助UML工具进行具体设计,风小南所在公司所有设计均使用的是PowerDesign。
类图初版
风小南认为既然是初版那定然是把大的边界,基本关系先勾勒出来,起到锁住边界和找出关键要素的作用,基本上来说就是把上面列出的类先画出来标上简单的关系,于是有了下图
类图0.1版
基本雏形有了,下面就是对着雏形逐步思考每个过程,填充类的属性、方法,检查是否有遗漏,设计不合理的地方进行调整,调整后如下
几个关键点设计
1 、GenCodeRunner
GenCodeRunner仅一个方法,就是run,run内部调用GenCode对象,为什么需要GenCodeRunner,因为任务目标中为了简化使用,该代码生成工具会使用命令行方式,那命令行控制这部分逻辑应该与核心业务剥离开,毕竟后面可能更换其他使用方式,比如http、gui等都有可能,如果写在核心业务逻辑中,那就不方便更换使用方式了,那么提供一个独立的运行类来接收用户输入实现与用户交互是有必要的。
2、MetaData
这个类在之前初版分析时,并没有,新增这个类是觉得GenCode没必要知道数据库的相关细节,对于GenCode来说它需要的仅仅是元数据,怎么链数据库怎么读配置,它不应该关心,所以这些事情就交给了MetaData。
3、Table、Column
这两个类中分别添加了一个类属性,Table中添加了className,Column中添加了attrName,由于数据库大部分情况下命名标准为下划线分割形式,如user_id,而类中为驼峰式名,如userId,所以为了生成代码的模板表达式足够简单,不处理什么逻辑,虽说模板表达式也可以实现这样的转化,但模板就该干模板该干的事,就是展示数据,因此将user_id -> userId 这一过程消灭在MetaData中,所以分别提供了符合类和属性命名规范的属性。
按说应该分开,单独定义类和属性信息的类,但此处并不考虑留太多的可扩展空间,为了避免引入过多的类,导致设计变得复杂,因此合并到一个类中。
类图0.2版
经过0.1版的设计,风小南感觉整体结构基本清晰了,下来就是需要找到中间存在的变化点,将可能存在变化的地方进行抽象,这样就能避免代码修改或新增时带来的内部调整。对修改关闭,对新增开放,开闭原则。即满足技术经理的第3条要求。
风小南经过分析,发现就目前公司的实际情况来看,可能发生变化的地方主要在模板可能会随时调整新增,那么就会引调用类GenCodeRunner的变化,从而引起Command类进行调整,还有一处就是代码生成完后的输出方式,现在可能输出到文件,未来如果想输出到数据库、命令行也不是不可能。经过上面的思考,风小南对上面提到的几处内容分别进行了抽象设计,形成了下图。
几个关键点设计
1 、Command
Command设计成了接口,对于不同模板的生成,提供不同的生成命令,可提供组合生成命令,即一次生成模块所有代码。
2、CodeOutout
Codeout调整为接口,针对不同输出形式提供不同的实现类。
3、CodeTemplate
CodeTemplate设计为基类,针对不同的模板文件提供不同的扩展子类。
如此设计对于GenCode核心类来说,它操作的相关类都是接口或基类,那么这个层面就可以做到基本稳定,不管实现层如何变化,该层都不会受到影响。
类图0.3版
总体结构已经成型,但是风小南突然发现,Command类的位置貌似放的不太合适,命令应该是有用户触发,而不是GenCode,那么用户如何触发,唯一入口在GenCodeRunner,那么,Command是不是在GenCodeRunner处更合适。于是风小南再次调整了Command类的位置。
Command可以是单个生成命令,可以进行多个生成命令进行组合形成一个命令,如此就满足了技术经理要求的第4条,程序要能够自由组合生成方式,支持个性化自定义。最终得到的结果如下图。
1 、GenCodeRunner
GenCodeRunner 根据用户的输入,会实例化对应的Command对象,调用GenCode的gen方法。
2 、GenCode
GenCode中原理的gen方法是无输入参数的,调整后,gen方法接收一个生成命令,进行具体的代码生成。
类图0.4版
类关系都处理妥当,但是还有一个点遗漏掉了,风小南突然想到,像TypeMapping这种类,如果初始默认值无法满足要求,那得允许用户自己定义呀,嗯,还缺个全局层面的配置类。
目前是只有TypeMapping一个,倒是可以让用户直接通过该类方法进行扩展自定义,但是后面可能会有其他配置项,如果将这些配置接口都抛出给用户,用户使用复杂度不就增加了,那么多配置接口他哪记得住,所以提供一个全局配置的入口类还是很有必要的,不管什么层面的配置自定义,都可以通过该配置类实现。用户只需记住这个全局的配置类就可以了。
就叫它GenCodeConfiguration吧,类图新增成员,最终完整设计如下。
设计完结
至此设计工作结束,下面就可以依据类图设计,进行实现了,或许你感觉这么简单个玩意,折腾这么多类,累不累,我两三个类就可以搞定了,没错,可以搞定,但是要解耦、可扩展、易维护,怎么实现每个过程可被替换?这就需要更多的接口、抽象类、面向对象的设计思想、设计模式的应用才能实现。其实上面的类设计还可以更复杂,只是当下我们的需求就这么多,目前的设计足矣。
如此,是不是有点感觉spring为什么会整的那么复杂,一些开源框架实现的功能可能明明很简单为什么搞那么多接口,继承,目的就是为了解耦、可扩展、易维护,内部实现可自定义替换。
或许你还是感觉没啥必要,那你要这么想,不从这些小的工具、代码中去一点点磨炼自己的编程思维,加深对面向对象设计的理解,那什么时候去练,没有前面小的积累,你能一步去实现个Mybatis吗? 不积跬步无以至千里,所以,经过小场景的锻炼,不断积累,不断突破尝试,才有可能设计出更完善更好的工具或框架。
6、实现过程
核心原理清楚了,设计有了,对风小南来说剩下编码就是最容易的事了。当然对于刚入行的年轻人来说,可能实现也存在一定的障碍,这个障碍主要是见的少,写的少,纯代码的编写能力还不能够驾轻就熟,缺少代码经验。解决办法就是带着脑子多练。
需要注意一点的是,上面的设计并非最终版,因为具体实现的过程中可能还会发现一些设计时考虑不到的内容,所以设计跟实现是一个相互促进完善的过程,毕竟不动手,有些细节情况是考虑不到的,更何况本文并非是作者提前准备好才写的,而是为了尽量还原真实过程,边实现,边输出文章。
代码实现首要做的就是定义项目包结构。实际上就是对设计中的类进行归类建代码包。经过分析后基本形成包结构如下
剩下的事情就是对每个类进行分别实现。从哪开始呢,可以顺着实际代码执行的过程一步步实现。到哪一步需要哪些类配合,再对需要的类实现。风小南画出了整体代码执行过程如下图。
实现部分就以包为单位讲下大致的实现思路,关键位置的代码简要粘贴一点配合说明,其他内容就不一行行代码进行粘贴了,完整代码随后会上传至Github。
GenCodeRunner
命令行交互类,核心作用一个是初始化全局配置和GenCode,另一个作用是接收用户输入,通过交互确定用户需要生成代码的命令和输出方式。关键代码如下。
Scanner scanner = new Scanner(System.in);
scanner.useDelimiter("
");
// 初始化代码生成配置
GenCodeConfiguration genCodeConfiguration = new GenCodeConfiguration();
GenCode genCode = new GenCode(genCodeConfiguration);
// 控制用户交互过程
Command command = this.selectCommand(genCodeConfiguration,scanner);
CodeOutput output = this.selectOutput(genCodeConfiguration,scanner);
String tableName = this.selectTableName(scanner);
// 生成
Console.log("
");
Console.log("开始生成....");
genCode.process(tableName,command,output);
Console.log("生成完成....");
GenCode
核心类,协调各方资源获取命令需要的相关参数,调用Command执行代码生成。核心代码如下。
public void process(String tableName,Command command,CodeOutput codeOutput){
Table tableInfo = MetaData.getTableInfo(tableName,configuration.getTypeMapping());
command.execute(tableInfo,springTemplateEngine,codeOutput);
}
此处实现与设计存在一点差异,设计中模板的获取是在GenCode中,但实际实现时发现放在Command中更合适,此处做了调整,具体到Command包时再看。
command
命令包,用于实现各种代码的生成实现,看下整个包的类图结构
整体分为单命令和组合命令两种,目前单命令实现了Controller、Entity、Vo三种,其他可以任意扩展实现,组合命令实现了一次性生成Controller与Entity,同样可以任意自定义扩展。
看个单命令的核心代码
public class GenControllerCommand extends SingleCommand {
public GenControllerCommand() {
this.commandName = "Controller生成命令";
this.commandCode = "01";
this.template = new ControllerCodeTemplate();
}
@Override
public void process(Table table, Map map) {
// 留给用户自定义处理过程,比如一些自定义参数的处理加一些控制等等,此处暂不需要
}
}
定义命令名称,命名代码(用于与用户交互输入的命令代码对应),绑定对应的代码生成模板。预留process函数的作用时在生成具体代码前提供给用户自行在对数据进行二次加工或处理些其他需要的逻辑。command包结合使用了命令模式和模板方法。
在看下组合命令的核心代码
// 摘自GenControllerAndEntityCommand
public GenControllerAndEntityCommand() {
this.commandName = "Controller & Entity 生成命令";
this.commandCode = "03";
singleCommands.add(new GenControllerCommand());
singleCommands.add(new GenEntityCommand());
}
// 摘自 ComposeCommand
// 执行命令
@Override
public void execute(Table table, SpringTemplateEngine springTemplateEngine, CodeOutput codeOutput){
for(SingleCommand singleCommand : singleCommands){
singleCommand.execute(table,springTemplateEngine,codeOutput);
}
}
组合命令是集多个单命令的集合体,执行过程即遍历挨个调用每个单命令的执行方法,完全复用单命令。
configuration
全局配置类,提供默认参数配置和用户自定义扩展配置。主要完成了命令注册配置、输出类型注册配置、数据库与Java类型映射配置。
所有配置的入口均在GenCodeConfiguration中,统一入口可以方便调用,减少用户的记忆负担。框架内部也能达到集中管控的目的。
db
此包较为简单,封装了数据库连接获取的过程,主要包含一个配置类和一个连接获取类,比较简单,此处就不展开说明了。
exception
为代码生成模块定义了统一的异常类GenCodeException,代码生成模块的异常均会被包装为该异常抛出。
meta
数据表、字段元数据获取包,主要处理逻辑是读取数据库元数据包装为Java对应的Table、TableColumn对象。
// 表元数据
Statement stmt = conn.createStatement();
String sql = "select table_comment from information_schema.tables where table_name = '"+ tableName + "'";
ResultSet tableResultSet = stmt.executeQuery(sql);
while (tableResultSet.next()) {
table.setComment(tableResultSet.getString("table_comment"));
break;
}
// 字段元数据
DatabaseMetaData metaData = conn.getMetaData();
ResultSet resultSet = metaData.getColumns(null, null, table.getCode(), "%");
List<TableColumn> tableColumnList = CollectionUtil.newArrayList();
while (resultSet.next()) {
TableColumn tableColumn = new TableColumn();
tableColumn.setCode(resultSet.getString("column_name").toLowerCase());
tableColumn.setType(typeMapping.getJavaType(resultSet.getString("type_name")));
tableColumn.setComment(resultSet.getString("remarks"));
tableColumnList.add(tableColumn);
}
table.setColumnList(tableColumnList);
output
输出包主要实现最终生成代码的输出形式,目前实现了控制台输出和文件输出,看下类结构。
比较简单,简单的继承结构,大概看下实现代码
// 摘自 ConsoleOutput
@Override
public void out(Table table, String content, CodeTemplate template) {
Console.log("
");
Console.log("-----------------------------");
Console.log(content);
Console.log("-----------------------------");
}
// 摘自 FileOutput
@Override
public void out(Table table, String content, CodeTemplate template) {
String fileName = "dist/" + table.getClassName() + template.getFileTag() + template.getFileSuffix();
File file = new File(fileName);
FileUtil.writeBytes(content.getBytes(),file);
Console.log("文件位置:" + file.getAbsolutePath());
}
template
生成代码的模板定义类,没有多余的逻辑控制,就是模板属性和模板对应最终生成文件相关属性的定义。
看个控制器代码模板的定义
public ControllerCodeTemplate() {
this.tplName = "控制器模板";
this.tplPath = "templates/Controller.tpl";
this.fileTag = "Controller";
this.fileSuffix = ".java";
}
tpl
具体的模板文件,使用的是thymeleaf的text模式,看个简单的模板内容
/**
* [(${comment})]-实体
*
* @author yuboon
* @version v1.0
* @date [(${createDate})]
*/
@Data
public class [(${className})] implements Serializable {
[# th:each="column : ${columnList}"]
/** [(${column.comment})] */
@Column("[(${column.code})]")
private [(${column.type})] [(${column.attrName})];
[/]
}
7、使用体验
命令行交互方式
自定义调用
public class GenCodeTest {
public static void main(String[] args) {
GenCodeConfiguration genCodeConfiguration = new GenCodeConfiguration();
GenCode genCode = new GenCode(genCodeConfiguration);
genCode.process("sys_user",new GenVoCommand(),new ConsoleOutput());
}
}
基于此结构改造为GUI方式获取Web网页方式均很容易。
扩展一个新模板
很简单,定义个新的Command、新的Tpl模板文件、CodeTemplate模板定义文件,通过全局配置注册新Command,整个过程对已有代码没有任何改动。
8、总结
至此,一个简易的代码生成工具在风小南手中实现了。
还有优化空间吗? 当然有,比如说模板引擎是否可以随意替换 ?代码模板文件多版本时怎么办 ? Web端实现,多模块同时生成等等。
本文仅以代码生成工具的核心功能为案例,从需求到最终实现,较为详细的描述了工作过程和一些工作方法包括一些设计程序的思想和理念。
那以后做个小工具需要完全按照上面的步骤来吗,那不是,做的多了有经验了,见的多了,这个过程是可以简化的,一些步骤自己脑子里有个思考过程就行了,甚至可以直接动手写代码,不一定非得像本文这样,一步一步搞这么复杂,文章是为了描述过程全貌,所以写的比较详细,只是表面上看起来复杂而已。
最后,再将全文中的一些关键点提炼一下:
(1) 及时主动沟通、汇报工作,及时主动很重要
(2) 明确工作目标,避免无用功
(3) 搜索关键字很重要
(3) 设计先行,思路比实现重要
(4) 不知道怎么干的时候,特定场景下穷举法试一试
(5) 步子不要跨太大,一步步迭代实现
(6) 总结,搞清为什么做?做什么?怎么做?后续演进空间?
9、完整代码地址
https://github.com/yuboon/java-examples/tree/master/springboot-gencode
10、更多精彩
觉得还行,动动手指留个赞。
以上就是今天的内容,我们下期见。
更多优质内容,首发公众号【风象南】,欢迎关注。