「9+」 Java 编译器插件教程 101
写了上万行 Java 代码,相比你已经对 Java “木纳呆板”的语法恨之入骨了。
那么,有没有一种可能,我是说可能,我们可以给 Java 编译器写插件?
如果你要在面包店里买面包,你最好跟店员说你要买哪块面包。本文所指的 Java 编译器
均为 OpenJDK 自带的那个 javac
,而不是其他的前端编译器实现。
前言
自从 Java 8 起,Oracle 就将插件系统引入了 javac
中,因此是可以写 Javac 插件的。
虽然写一个 Javac 插件并且修改代码是可能的,但是这方面的资料很少(国内就更少了),并且大多内容重复(教你写个 HelloWorld 然后就结束),其次 javac 的东西也不是很好摸,毕竟不是公开 API( JDK 9+ 开始已经不暴露在外了)。
恰逢今年还没写技术类的博客,就拿来磨磨刀吧。
先从插件说起
上文刚刚提到了一个词,插件
。它其实正是 com.sun.source.util.Plugin。通过这个类,我们可以让 Javac 在编译时加载我们的代码。
加载了代码,那么做什么呢?所以我们立一个目标:给所有 @Jsonized
标注过的类都生成一个可以输出 json 的 toString()
方法。
什么意思呢?就比如说这样一个类:
1 | public class MyResponse { |
然后你想把它序列化成 Json。
1 | var response = new MyResponse("\"Success!\" Took me 114514 yr 1919810 m",false).toString(); |
又快又便捷,还不需要第三方类库,这就是我们的目标。
那么先把这个注解写出来吧!
1 | package org.inlambda.kiwi.magic; |
值得提一点就是这些编译器注解的 Retention
用 RetentionPolicy.SOURCE
也是可以的,但是以后可能运行期间我们需要识别到这些被修改过的类,所以设置为 RUNTIME
。
接着是,实现 Javac 的 Plugin
接口!
1 | package org.inlambda.kiwi.magic.plugin; |
但是它不让你过编译。
打通模块的穴位
这怎么能忍!在默认包目录下创建一个 module-info.java
。
然后我们写点东西进去。
1 | module kiwi.magic.main { // module 后跟模块名,下文要用 |
加入这些之后,还要配置一下构建工具的编译参数。
(本教程使用 Gradle, Maven/SBT 用户请自行摸索….)
1 | compileJava { |
这样就能过编译了!虽然 IDEA 仍然会划出红线,但是只要接受他的解决方案就好(形如 add XX to compiler option
)
由于 IDEA 可能不会自动补全没有确定模块关系的类,教程可能会大量使用完整的类名或是指向 Java SE 8 Documentation 的引用链接。
配置 Service
Javac 通过 Java SPI 发现插件,因此你需要写一个 Service 文件让他能够发现你。
在 src/main/resource/META-INF/services/com.sun.source.util.Plugin
中写出:
1 |
|
但是还差一步。
加载插件
Javac 也是一个 Java 程序,他是在他的运行时 classpath 里面寻找插件的。
而在 Gradle 中,annotationProcessor
就会被加入到编译器的 classpath 中。但我们不能用 annotationProcessor this
,因为 gradle 不允许,所以我们需要另外新建一个模块专门测试插件。
为了加载插件,应当确保 build.gradle
中有如下内容:
1 | dependencies { |
万事具备,开 assemble
!
现在,我们已经让 Javac 加载了我们的代码,但这仅仅是个开始。为了实现 @Jsonized
的目标,我们还需要注入代码。
得到编译单元
对 AST 下手,首先要拿到 CompilationUnit
而 Javac 通过 TaskEvent 将它传递给我们,因此我们要注册一个 com.sun.source.util.TaskListener
来收 TaskEvent
。
1 |
|
KiwiTaskListener
:
1 | package org.inlambda.kiwi.magic.plugin.jc; |
编译流程:
Java 编译器的几个阶段:
- COMPILATION
- PARSE – 构造抽象语法树 (AST)
- ENTER – 源码里的引用均已被解析
- ANALYZE – 生成 AST 并用于分析错误
- GENERATE – 为源码生成输出 (.class)
- ANNOTATION_PROCESSING - 注解处理器被唤起
- ANNOTATION_PROCESSING_ROUND
- COMPILATION
因为我们的目标是修改代码(对编译器来说,也就是对抽象语法树下手),所以只需要关心 PARSE 阶段就好了。
如果你对 “AST” 和 “PARSE” 的概念还不太了解,那么你可以先看看我的另一篇博客
获取 AST
得到 CompilationUnit
后,我们便可以“访问”到对应的 class
了。
1 | package org.inlambda.kiwi.magic.plugin.jc; |
接下来逐个讲解这些代码。
TreeMaker 是一个非常重要的组件,通过
TreeMaker
我们可以创建语法树的组件然后把它们插入到现有的语法树里,也就是修改代码。Names 也是一个重要组件,因为它几乎就是符号表,虽然其本身是
Identifier
….。JCTree.JCClassDecl
就是类在AST中的定义,我们接下来会讲解到它以及更多JCTree
子类的应用。
同时,我们在 magic-test
模块中创建一个类用于测试 @Jsonized
,但为了节约篇幅,类的代码不列出。
让我们的访客访问 CompilationUnit
:
1 |
|
尝试编译 magic-test
,你应当能在 compileJava
阶段看到 Jsonized class found: XXX
。
对 AST 动手动脚
哎呀,你怎么动手动脚的!
拿到了类定义,我们就可以访问类里的所有元素了!另外,JCTree
的子类通常是可以直接 toString() 出来的,你可以利用这一点查看编译的输出。
但在修改之前,得先了解一下 Javac 内部的 List 实现。
List in Javac
Javac 不知道出于什么缘故,他自己有一个链表(com.sun.tools.javac.util.List<A>
)的实现,而且他是不可变的。
这个链表不对外公开(因为 Oracle 官方网站也没有 Javadoc),所以不提供引用链接了。
此处介绍几个常用的方法。
List.nil()
静态方法。顾名思义,空集。List.of(A x1, A x2, A x3, A… rest)
一个静态工厂,用于创建一个定长的 List.一些类似
prepend
和append
这样对元素操作的方法…
他们都返回新的实例,因为List<A>
是不可变的,小心别被坑了。
等等,那不定长的呢?于是我们还有一个类,他就是 com.sun.tools.javac.util.ListBuffer<A>
。
ListBuffer<A>
是 List<A>
某种类似 Builder 的工具,他的 append
等方法始终返回他自己,用完之后可以 toList()
转换成 List<A>
。
做好这些基础知识的准备工作,我们终于,终于,终于可以开始动工了。
访问类里的元素
JCClassDecl
并没有严格区分开来方法和字段,他们都是 member
。然而 getMembers()
是只读的(因为 List<A>
不可变),所以我们要绕开 getMembers()
直接访问到后面的字段。
绕也很简单…
没想到吧,Javac 里面就是这么乱。
接下来往 defs
里面插入方法即可。
构造方法然后插进去!
这里我们就要请出刚刚提到过的大名鼎鼎的 TreeMaker
了! 方法定义在 Javac 中就是 com.sun.tools.javac.tree.JCTree.MethodDecl
,可以通过 TreeMaker#MethodDef
构造。
1 | // 建议把生成方法单独放起来 |
是不是有些迷糊?我们”娓娓道来”…
at(claz.pos).MethodDef(
这句的意思是把TreeMaker
当前的位置调整到目标类上然后再创建一个方法定义,不然可能会把方法生成到别的地方。(不过我没试过,其实方法和类关系是比较确定的,所以这个pos
可能是给语句用的,因为语句有顺序。)symbolTable.fromString("toString")
Names
提供了fromString
方法用来创建对应的标识符/名字(Identifier)。maker.Ident
Ident
是一个很常用的方法,他可以接受一个Names
然后输出一个JCIdent
。
而JCIdent
恰好是JCExpression
,也就是JCTree
的子类。
注意,从符号表拿类型并不需要把命名写完整(就好像上文写的不是 java.lang.String),如果要引入外部的类型只需要 maker.Import
即可
例如:
maker.Import(maker.Ident(names.fromString("java.util.Objects")).getTree(), false);
这还只是方法的基本信息,接下来是方法体,也就是最关键的那部分。
但在写输出 Json 之前,我们不妨先写个 HelloWorld
试试。
1 | private static List<JCTree.JCStatement> makeReturnJsonExpress(TreeMaker maker, Names name, JCTree.JCClassDecl claz) { |
然后我们回到上文,把新的方法体插入到类里。
1 | var claz = (JCTree.JCClassDecl) node; |
尝试编译 magic-test
模块并且查看编译输出,如果你没有干坏事的话应该能看到 @Jsonized
标注过的类里多出来一个 public final String toString()
,并且代码体正是 return "Hello Jsonized!";
。
(由于我的 Jsonized 用的是 RetentionPolicy.SOURCE
,所以注解编译后就被抹除掉了)
AST 的常用姿势
到上一节,你已经成功的:
- 让 Javac 加载你的代码
- 对特定注解标志过的类添加代码
那么这一节,我们着重讲解一些常用的操作以及更多基础知识。
从获取字段开始
为了序列化出所有字段,我们首先需要知道我们的类里有哪些字段。
幸好,使用 Stream
就可以很轻松地做到这件事:
1 | var nameToVar = claz.getMembers().stream() |
产出一个 Map<Names, JCTree>
。JCTree
就是类定义,此处为 JCVariableDecl
有了这样一个 nameToVar 之后,我们就可以构造 Json 了。
拼接字符串与二元表达式
哪个男孩不想体验一下二元运算符呢?
“+” 是一个二元运算符,它接受两个参数: a
和 b
并且产出一个结果。
在 Java 里,我们是这样写的:a + b
那么如果更多参数呢?
以此类推,可以构造出一个很长很长的二元树,而这正好是我们今天要做的事情。
先从拼接字符串开始,举个例子:a + "literal"
如果我们要让一个命名 a
和一个字面量("literal"
)相加,要怎么做呢?其实很简单:
1 | JCTree.JCBinary binary = maker.Binary( |
回过头来,我们拼接 Json 的代码应该是这样的:
1 | "{\"success\":"+ success +",\"response\":+" response "+}" |
也就是:
不难看出,到最后这些拼接代码都会被聚合成一个 JCBinary
。通过这个性质,我们可以使用 Stream#reduce
来把众多元素聚合成一个 JCBinary
。
此处贴出完整的 makeReturnJsonExpress
以供参考。
1 | private static List<JCTree.JCStatement> makeReturnJsonExpress(TreeMaker maker, Names name, JCTree.JCClassDecl claz) { |
至此,我们的 @Jsonized
生成的 toString
已经可以处理简单情况了。
调用方法
但是游戏还没结束,你很快会发现一个问题…. 如果数据里存有特殊字符,例如 "
那就出事了!因此,要给字符串加一些特殊处理。
1 | "{\"success\":"+ success +",\"response\":+" response.replaceAll("\\\"","\\\\\"") "+}" // 拼接的时候把 " 替换为 \" |
也就是说,我们要对 response
进行方法调用。那么,先引入一个新方法吧!
1 | private static JCTree.JCExpression processValue(JCTree value, Name name, TreeMaker maker, Names names) { |
写这篇博文的时候我还没有去深究具体要怎么获取到
JCVariable
的类型关系,所以只有这么蠢的方法。
还是老样子,逐步解释代码:
Exec
执行的意思,这里可以传入一个JCExpression
Apply
返回一个JCMethodInvocation
,正是我们要的东西Select
返回一个JCFieldAccess
,而参数是发起操作的对象和他对应的方法/字段(瞎猜的)
当然这里你也可以用maker.Ident(name)
来代替Select
,也就是直接对 name 对应的对象发起动作。
之后再修改一下之前的代码:
1 | .map(e -> |
大功告成。现在你已经得到了一个可以处理 String/CharSequence/StringBuilder/StringBuffer
里的转义问题(当然,只限于双引号)的编译期序列化 Json 的编译器插件了!
一种更好的做法: 委托
与其这样大费周章的在编译器完成这些工作,倒不如再带几个类进去然后委托到类上的静态方法进行转换。
这样做的好处有很多,例如无需重新编译这些class
, 例如可以通过代码编写更灵活的转换机制… 等等。实际上,Java 14+ 中 Record 的equals
正是通过INVOKEDYNAMIC
委托到别处比较实现的,并且高版本 Java 中 String 类型的拼接也使用了委托。
附:使用注解处理器
除了直接编写 Javac 插件,我们还有另外一种做法,就是 Annotation Processor
,注解处理器。
比起 Javac 插件,它的局限较高,但是用起来会方便一点因为不用加那个”-Xplugin”的编译器 参数
所以这里简单介绍一下如何使用注解处理器访问 AST。类似 Plugin
,你需要先继承一个 AbstractProcessor
:
1 | package org.inlambda.kiwi.magic.plugin; |
然后一样在 META-INF/services/javax.annotation.processing.Processor
里写上你的类名,注意是 Processor
而不是 AbstractProcessor
…
拿到 TreeMaker
, Context
,Trees
和 Names
之后就好办很多了,接下来的问题是怎么获取到 AST。
好在 Annotation Processor API 允许我们处理所有元素而不只是规定的几个注解标注过的元素,我们可以在 boolean process(...)
方法中访问到所有我们要的元素(前提是 @SupportedAnnotationTypes("*")
):
1 |
|
That’s it! 其实这种需求用注解处理器的话似乎比直接写 Javac 插件还要简单一些?
注意: 注解处理器不能和 javac 插件混着用,而且在 gradle 中,他们似乎是在不同的环境里被加载的。(也就是你无法通过静态字段传递加载信息来判断用户想用的是插件还是注解处理器)
End / 结语
本文简要介绍了 javac 闭包 API 的基本使用以及一种使用注解处理器访问 javac AST 的方法。
不过需要注意的是,虽然这些 API 自从 1.8 加入以来就没怎么变过(应该?),他们仍然是不稳定,不安全,无保障的。因此,若要使用,请总是进行单元测试并且尝试检查版本兼容性。
本篇博客的所有代码均为 Kiwi 项目的一部分,Kiwi 以 MIT 协议开源,如果你想 Star 但是没有 Codeberg 帐号的话可以去 GitHub 上的镜像 (疯狂暗示)
文章可能有偏差,可以在评论区指正。
End.
Credits:
StackoverFlow - Accessing com.sun.tools.javac.util from Java 9
Baeldung - Java Annotation Processing and create a builder
Baeldung - Creating a Java Compiler Plugin
… and many random StackoverFlow Answers. Thanks for them.
「9+」 Java 编译器插件教程 101