1、需求分析
我们在这里均以 sie-snest-log
操作日志这个app为例来描述iidp-plugin
的需求、设计和实现方案。
-
我们定义一个model的时候,采取的注解格式是这样的:
比如说在上述模型定义的注解中有个@Model(name = "operator_log", parent = "operator_log", displayName = "操作日志", isAutoLog = Bool.True, type = Model.ModelType.Buss) public class OperatorLog extends BaseModel<OperatorLog> { @Property(displayName = "id") private String id; @Property(displayName = "访问时间", dataType = DataType.DATE_TIME) private Date accessStartTime; @Property(displayName = "app名称") private String appName; @Property(displayName = "模型名称") private String logModelName; @Property(displayName = "服务名称") private String serviceName; @Property(displayName = "入参", dataType = DataType.TEXT, widget = "codeeditor") private String inParameterData; @Property(displayName = "出参", dataType = DataType.TEXT, widget = "codeeditor") private String outParameterData; // 用户id @ManyToOne(targetModel = "rbac_user", displayName = "用户") @JoinColumn(name = "caller_user_id") private Map<String, Object> callerUserId; // ip @Property(displayName = "ip") private String callerIp; // 耗时 @Property(displayName = "耗时(ms)") private String takeTime; // 变更 @Property(displayName = "数据变更") private Boolean modify; }
parent
字段,它是一个string,意味着它可以写任何的字符串,在编译阶段是不知道parent是否存在,是否填写正确的, 这给开发阶段带来一些容易出错的地方,这些错误只能到运行阶段才能发现,而且还不容易发现。 -
我们定义一个views的时候,采取的是json的描述方式:
结合model的定义,能够发现views中的json定义也是基于string,比如"operator_log_grid": { "body": { "buttons": [ { "action": "preview", "auth": "read", "name": "详情" }, { "action": "previewEr", "auth": "read", "name": "参数" } ], "columns": [ { "displayName": "访问时间", "name": "accessStartTime" }, { "displayName": "app名称", "name": "appName" }, { "displayName": "模型名称", "name": "logModelName" }, { "displayName": "服务名称", "name": "serviceName" }, { "displayName": "调用者用户id", "name": "callerUserId" }, { "displayName": "ip", "name": "callerIp" }, { "displayName": "耗时", "name": "takeTime" }, { "displayName": "数据变更", "name": "modify" } ], "tbar": [ { "action": "delete", "auth": "delete", "name": "删除" } ], "type": "grid" }, "mode": "primary", "model": "operator_log", "name": "操作日志-表格", "type": "grid" }
columns.name
是跟model中定义的private String appName;
定义的成员变量名称保持一致,如果在开发阶段编写错了, 也是无法发现的,只能在运行阶段前端获取view的时候出错。 -
访问模型名称和模型字段等
继续举例通过meta访问模型数据的方式:meta = new Meta(Meta.SUPERUSER, new HashMap<>()); meta.get("operator_log").call("create", operatorLogList); RecordSet rs2 = meta.get("operator_details"); for (OperatorLog ol : operatorLogList) { rs2.call("create", ol.getOperatorDetailsList()); }
OperatorLog operatorLog = DbUtils.select(filter, "operator_log", OperatorLog.class); Filter f = Filter.equal("traceID", filter.getFilterOp("id").getValue());
同样地发现存在 operator_log、create、traceID 等都是string字符串的形式存在,在编译阶段也不会做任何的校验, 只能在运行阶段才发现问题,而且还不容易发现,比如 traceID 写错了,只是这个filter失效,不会报错,但查出的结果却是不对的。
-
元模型get set方法
有时候我们需要从模型中get set对应成员变量的值,这些方法写起来也相对繁琐,而且很容易写错,因为这里的get其实会去查数据库, 而有时候我们需要的是从map直接获取值而已,但如果直接使用get方法并不是从map中获取值,所以这里会有一些容易混淆的地方。// getter setter public String getID() { return this.getStr("id"); } public void setID(String id) { this.set("id", id); } public Date getAccessStartTime() { return this.getDate("accessStartTime"); } public void setAccessStartTime(Date accessStartTime) { this.set("accessStartTime", accessStartTime); } public Date getAccessEndTime() { return this.getDate("accessEndTime"); } public void setAccessEndTime(Date accessEndTime) { this.set("accessEndTime", accessEndTime); } public String getAppName() { return this.getStr("appName"); } public void setAppName(String appName) { this.set("appName", appName); } public String getLogModelName() { return this.getStr("logModelName"); } public void setLogModelName(String logModelName) { this.set("logModelName", logModelName); } public String getServiceName() { return this.getStr("serviceName"); } public void setServiceName(String serviceName) { this.set("serviceName", serviceName); } public String getInParameterData() { return this.getStr("inParameterData"); } public void setInParameterData(String inParameterData) { this.set("inParameterData", inParameterData); } public String getOutParameterData() { return this.getStr("outParameterData"); } public void setOutParameterData(String outParameterData) { this.set("outParameterData", outParameterData); } public int getResultDisplay() { return this.getInt("resultDisplay"); } public void setResultDisplay(int resultDisplay) { this.set("resultDisplay", resultDisplay); } public String getAbnormalDisplay() { return this.getStr("abnormalDisplay"); } public void setAbnormalDisplay(String abnormalDisplay) { this.set("abnormalDisplay", abnormalDisplay); } public String getCallerUserName() { return this.getStr("callerUserName"); } public void setCallerUserName(String callerUserName) { this.set("callerUserName", callerUserName); } public String getCallerUserId() { return this.getStr("callerUserId"); } public void setCallerUserId(String callerUserId) { this.set("callerUserId", callerUserId); } public String getCallerIp() { return this.getStr("callerIp"); } public void setCallerIp(String callerIp) { this.set("callerIp", callerIp); } public String getTakeTime() { return this.getStr("takeTime"); } public void setTakeTime(String takeTime) { this.set("takeTime", takeTime); } public Boolean getModify() { return this.getBoolean("modify"); } public void setModify(Boolean modify) { this.set("modify", modify); } public List<OperatorDetails> getOperatorDetailsList() { return (List<OperatorDetails>) this.get("operatorDetailsList"); } public void setOperatorDetailsList(List<OperatorDetails> operatorDetailsList) { this.set("operatorDetailsList", operatorDetailsList); }
总结:从上述的几种基于iidp编写app的过程中,我们发现有很多地方都是基于string字符串的形式来表述,很容易写错, 而且没办法在编译器进行校验,这给app开发者带来了很多麻烦,降低了开发的效率。如果我们能够提供一个插件,生成获取模型filed的字符串方法,生成获取模型名称字符串方法,更通用地说生成任何字符串的方法,那么在开发阶段,只需要调用className.getFiled(),就能够获取所需的字符串,这个获取是确定性的,IDE会进行静态检查,不存在不小心输入错误的可能,提高开发者开发效率,同时也能保持代码的精简。
2、方案设计
- 原理
从Javac的代码来看,编译过程大致可以分为3个过程:- 解析与填充符号表过程
- 插入式注解处理器的注解处理过程
- 分析与字节码生成过程
Javac编译动作的入口是com.sun.tools.javac.main.JavaCompiler类,上述3个过程的代码逻辑集中在这个类的compile()和compile2()方法中,下面给出整个编译过程中最关键的几个步骤:
(1):准备过程,初始化插入式注解处理器
(2):词法分析,语法分析
(3):输入到符号表
(4):注解处理
(5):分析及字节码生成
(6):标注
(7):数据流分析
(8):解语法糖
(9):字节码生成
- 1.1 解析
解析步骤由上述代码清单中的parseFiles()方法(过程(2))完成,解析步骤包括了经典程序编译原理中的词法分析和语法分析两个过程
词法分析是将源代码的字符流转变为标记(Token)集合,单个字符是程序编写过程的最小元素,而标记则是编译过程的最小元素,关键字、变量名、字面量、运算符都可以成为标记,如int a= b + 2这句代码包含了6个标记,分别是int、a、=、b、+、2,虽然关键字int由3个字符构成,但是它只是一个Token,不可再拆分。在Javac的源码中,词法分析过程由com.sun.tools.javac.parser.Scanner类来实现
语法分析是根据Token序列构造抽象语法树的过程,抽象语法树(Abstract Syntax Tree,AST)是一种用来描述程序代码语法结构的树形表示方式,语法树的每一个节点都代表着程序代码中的一个语法结构(Construct),例如包、类型、修饰符、运算符、接口、返回值甚至代码注释等都可以是一个语法结构。在Javac的源码中,语法分析过程由com.sun.tools.javac.parser.Parser类实现,这个阶段产出的抽象语法树由com.sun.tools.javac.tree.JCTree类表示,经过这个步骤之后,编译器就基本不会再对源码文件进行操作了,后续的操作都建立在抽象语法树之上.
- 1.2 填充符号表
完成了语法分析和词法分析之后,下一步就是填充符号表的过程,也就是enterTrees()方法(过程(3))所做的事情。符号表(Symbol Table)是由一组符号地址和符号信息构成的表格,可以把它想象成哈希表中K-V值对的形式(实际上符号表不一定是哈希表实现,可以是有序符号表、树状符号表、栈结构符号表等)。符号表中所登记的信息在编译的不同阶段都要用到。在语义分析中,符号表所登记的内容将用于语义检查(如检查一个名字的使用和原先的说明是否一致)和产生中间代码。在目标代码生成阶段,当对符号名进行地址分配时,符号表是地址分配的依据
在Javac源代码中,填充符号表的过程由com.sun.tools.javac.comp.Enter类实现,此过程的出口是一个待处理列表(To Do List),包含了每一个编译单元的抽象语法树的顶级节点,以及package-info.java(如果存在的话)的顶级节点
- 2 JSR-269
在Javac源码中,插入式注解处理器的初始化过程是在initPorcessAnnotations()方法中完成的,而它的执行过程则是在processAnnotations()方法中完成的,这个方法判断是否还有新的注解处理器需要执行,如果有的话,通过com.sun.tools.javac.processing.JavacProcessingEnvironment类的doProcessing()方法生成一个新的JavaCompiler对象对编译的后续步骤进行处理
在JDK 1.6中实现了JSR-269规范JSR-269:Pluggable Annotations Processing API(插入式注解处理API)。提供了一组插入式注解处理器的标准API在编译期间对注解进行处理。我们可以把它看做是一组编译器的插件,在这些插件里面,可以读取、修改、添加抽象语法树中的任意元素。如果这些插件在处理注解期间对语法树进行了修改,编译器将回到解析及填充符号表的过程重新处理,直到所有插入式注解处理器都没有再对语法树进行修改为止,每一次循环称为一个Round,也就是第一张图中的回环过程。 有了编译器注解处理的标准API后,我们的代码才有可能干涉编译器的行为,由于语法树中的任意元素,甚至包括代码注释都可以在插件之中访问到,所以通过插入式注解处理器实现的插件在功能上有很大的发挥空间。只要有足够的创意,程序员可以使用插入式注解处理器来实现许多原本只能在编码中完成的事情
-
3 编译相关的数据结构与API
-
3.1 JCTree
-
3.2 TreeMaker
-
3.2.1 TreeMaker.Modifiers
-
以iidp-plugin源码为例:
iidp-plugin IDEA 端进行解析
- 参考lombok进行代码生成
- 基于注解的process处理
- 生成指定的代码
- 生成符号表,对特定字符串进行校验和提示
- 对引用的字符串进行校验
- 对字符串进行智能跳转