8. 编译时元编程
编译器在将我们的源码编译成字节码之前,会率先将源代码转换为 AST ( Abstract Syntax Tree,抽象语法树 ),以便于语义分析。而 Groovy 提供的编译时元编程工具使得我们能够在编译器产出真正的字节码之前截获源代码的 AST,并对其修改。从用户的视角而言,和运行时元编程相比,编译时元编程的修改是 “潜移默化” 的。并且由于编译器早在编译期间就做了一些额外的注入工作,这也使得 Groovy 代码在运行期的效率得到一定提升 ( 用户不需要在运行期间做一些动态注入 ) 了。
我们早就体验过编译期元编程为我们带来的便捷:比如笔者在 Groovy 开篇提到的各种懒人注解:@Lazy
,@Immutable
,以及 Groovy 默默为我们生成的各种 GET/SET 方法。对了,或许你还会联想到注入 Lombok 这样的工具,它虽并非 Groovy 实现的,但是也一定使用到了其它的编译时元编程技术。
在本专题中,所有的源代码都是 Groovy
脚本形式。我们将在 Groovy
脚本的编译期间实现代码导航,方法拦截,乃至方法注入。下面有一些可供参考的链接:
Groovy doc 官方教程 | Groovy 元编程练习教程:melix.github.io | youtube 视频教程 (自备梯子)
笔者在一个 Maven 项目中演示例子,且项目的 JDK 为 8+,为了兼容 Groovy 的运行,需添加以下依赖:
<dependencies>
<dependency>
<groupId>javax.xml.bind</groupId>
<artifactId>jaxb-api</artifactId>
<version>2.3.1</version>
</dependency>
<dependency>
<groupId>org.glassfish.jaxb</groupId>
<artifactId>jaxb-runtime</artifactId>
<version>2.3.1</version>
</dependency>
</dependencies>
复制代码
8.1 Groovy AST
在 Groovy 中,某个 Groovy 脚本的 AST 可以包含:ClassNode ( 代表脚本自身的编译类节点),Constructors ( 构造器 ),Methods ( 方法 )。
Methods 包含了众多 Method 节点。节点本身记载了关于此方法所有必要的信息。Method 的语句块由各种类型的 Statement 聚合而成,并最终以 BlockStatement 的形式保存 ( 根据 Groovy 源代码的描述,BlockStatement 被认为是 Statement 的一种子类继承 )。综上所述,语句可以大致分为以下内容:
- 表达一个普通表达式调用语句的 ExpressionStatement。
- 分支语句 IfStatement,
- 选择语句 SwitchStatement。
- 循环语句 ForStatement。
- 表示返回语句的 ReturnStatement。
- 以任意形式聚合以上各种语句的语句块 BlockStatement。
- 特殊的空语句块 Empty Statement。
IfStatement,SwitchStatement,ForStatement,ReturnStatement 可以理解成以特定结构聚合 ExpressionStatement 的语句块,而 BlockStatement 则是广义的语句块。那么显然 ExpressionStatement 是构建其它 Statement 的基础,也是最常见的语句形式。每一条 ExpressionStatement 都可以由各种 Expression ( 表达式 ) 组合而成。 这些表达式又可以细化为:
- PropertyExpression,用于表示一个属性访问的表达式。
- UnaryExpression,表示一个一元表达式,如
!
,~
。 - BinaryExpression,表示一个二元表达式。
- TenaryExpression,表示一个三元表达式,如
?:
。 - MethodCallExpression,表示一个方法调用,比较常见。
- Declaration Expression,表示一个赋值运算。
若再向下细分,Expression 便是各种类型的 Variable ( 变量 ),Constant ( 常量 ) ,Token ( 操作符 ) ,或者其它多个 Expression 的组合。
比如说,每一个方法是一个 MethodCallExpression,它向下拥有一个表示方法调用者的 Variable 节点,表示调用方法名的 Constant 节点,表示参数列表的 ArgumentList 节点。显然,ArgumentList 是一个 X 叉子树,X 是参数的个数。参数本身可以是表达式,可以是变量,或者是常量。
其它 Expression 根据自身的语义不同,各种常量变量的组合方式也各不相同。但是,审视这些内容,我们可以发现它们是语言无关的,因此易得 AST 语法树本身也是语言无关的。任何源代码总是能抽象成 AST 的形式。只要你愿意,甚至可以从一段 Groovy 的逻辑中提取出 AST ,然后利用元编程技术将它翻译成等价的 C++ 代码,以此来提高程序的运行速度 ( 这个过程可以描述为 Groovy CST -> AST -> C++ CST )。
在 Groovy 的 AST 中,上述的所有 AST 节点有一个共同基类,那就是 org.codehaus.groovy.ast.ASTNode。
上文提到了太多的 Statement 或者是 Expression 类型,这一定程度上增加了设计 AST 的难度,但我们不需要对这些概念死记硬背。有一个实用工具 GroovyConsole:它是安装 Groovy 的 “赠品”,存放在 Groovy PATH 的 bin
目录下。
笔者发现 Groovy 3.0.8 版本的 GroovyConsole 在更高版本的 JDK 环境中启动会出现错误:Could not find or load main class org.codehaus.groovy.tools.GroovyStarter
。为了解决这个问题,这里通过修改本机环境变量的方式暂时将本机 JDK 版本回退到 8。
解决了以上小麻烦之后,只需要在控制台输入 groovyConsole
就可以唤起其 GUI 。可以在面板中粘贴一段代码,然后通过 Ctrl
+ T
的方式打开 AST 分析窗口 Groovy AST Browser。当 GroovyConsole 面板的代码更新时,只需要按 F5
刷新即可。利用这个工具,我们后期就不需要自己在纸上写写画画来手动构建一段 AST 。 在设计复杂的 AST 变换时,这尤为有用。
面板代码对应的 AST 结构可以在 ClassNode -> Methods -> MethodNode - run -> BlockStatement
那里找到。
这是一个平平无奇的控制台输出:print shout?msg.toUpperCase():msg
。从语句类型来看,它是一个普通的 ExpressionStatement。这个语句只涉及一个简单的 MethodCallExpression,因为外层是一个对 this.prinln
方法的调用为什么不是System.out?。 print
方法的参数是一个较为复杂的三元表达式。在 Groovy Console 工具中,它是这样显示的:
8.2 从进行 AST 树检查开始
先不要着急想着对 AST 进行一番魔改,不如首先通过一个简单的 AST 检查的例子一窥 Groovy 脚本的 AST 结构 —— 在修改 AST 之前,至少得学会如何定位到想要修改的部分。
从一个例子开始直接说起。我们受够了同事在团队项目中制造的代码异味味道有多大?。与其在事后人工 review 代码,不如让编译器提前进行异味检查,如检测一些不规范的方法命名,参数命名 ( 尤其是使用一个字母 a
或 b
命名方法这种令其它同事深恶痛绝的行为) ,并对这些不规范的行为施加惩戒 —— 比如说抛出异常并拒绝执行。
举个例子,现在随手创建一个 Groovy 脚本,然后随手在里面定义一个方法。
// a for what?
// p for what?
def a(p){
println p
}
// 这段 Groovy 脚本可以正常编译,执行,但是毫无语义可言。
a("bad job")
复制代码
我们的目标是让编译器拦截这个随意的,语义不明的 Groovy 脚本。接下来就是利用 Groovy 的编译时元编程技术实现目标了。这分为两步:
- 找出 Groovy 脚本源代码内的所有 ClassNode 节点 ( 注意,脚本本身也是一个 ClassNode 节点 )。
- 在每个 ClassNode 节点内安插守卫或称观察者 Visitor,检查有关于此 ClassNode 的 Methods,Constructor,Field 等信息。
除此之外,我们希望这个代码异味检查是全局性的,因此要对其进行一个全局的 AST 变换 ( 或称检查 )。
对于第一个目标,需要创建出一个 ASTTransformation
接口的实现。额外的,除非使用 ASTBudiler 工具,该接口可以使用任何一门 JVM 语言来实现。对于第二个目标,可以创建出一个 GroovyClassVisitor
接口的实现。
8.2.1 ASTTransformation
该接口预留了一个用于 AST 变换的 visit
方法,同时还有一个配套的 @GroovyASTTransformation
注解,该注解告知编译器在哪个阶段发起 AST 变换。
实际上,一共有九个阶段可供选择,它们分别是:初始化,解析,转换,语义分析,规范化,指令选择,class 生成,输出,结束。其中:转换 CONVERSION
,语义分析 SEMANTIC_ANALYSIS
和 CANONICALIZATION
规范化是三个不错的时机,因为这三个阶段处于 “既不太晚,也不太早” 的状态。但是,如果想要更丰富的 AST 信息,就必须在越靠后的阶段进行介入。
有关于 visit
的简单介绍,可以参考 Groovy Doc。The Apache Groovy programming language – Groovy Development Kit (groovy-lang.org)
根据 Groovy Doc 的解释,sourceUnit
表示任意一个 .groovy
文件的源码。一份脚本内可以有多个类定义,故该参数称为“源单元”。ASTNode[]
参数一般用于基于注解的局部变换,后文会提到,在当前例子中可以放心地忽略它。
在下面的例子中,程序从源单元中获取了 Groovy 脚本内部定义的所有的类的节点 classNode
,并对准备每一个节点使用一个检查器 MyClassVisitor
来进行代码检查。
import org.codehaus.groovy.ast.ASTNode
import org.codehaus.groovy.control.CompilePhase
import org.codehaus.groovy.control.SourceUnit
import org.codehaus.groovy.transform.ASTTransformation
import org.codehaus.groovy.transform.GroovyASTTransformation
@GroovyASTTransformation(phase = CompilePhase.SEMANTIC_ANALYSIS)
class CodeCheck implements ASTTransformation {
@Override
void visit(ASTNode[] astNodes, SourceUnit sourceUnit) {
sourceUnit.getAST().classes.each {
classNode ->
// 在源单元内定义的每一个类节点定义全部安插一个 "导航助手" 。
// MyClassVisitor 的实现见后文。
classNode.visitContents(new MyClassVisitor())
}
}
}
复制代码
8.2.2 GroovyClassVisitor
MyClassVisitor
内置了五个用于检查 ( 或变换 ) 的方法,它们分别用于类,构造器,字段 Field,属性 Property。如果变换的内容涉及到方方面面,那显然把不同的 AST 变换按照分类存放到这里能让代码的可读性更强一些。在 Groovy 中,Field 和 Property 两者的概念几乎重合了,两者的差异来自 Groovy 的元编程协议 MOP 带来的动态性。细微的差别可以参考 Groovy下的field和property | kazaff’s blog 。
IntelliJ IDE 提供的代码提示足够我们利用已有的线索去实现目标。在此处设定:如果检查到方法名称的长度为 1,那么就抛出一个 SyntaxException
异常,并在控制台中给出相应信息。
import org.codehaus.groovy.ast.ClassNode
import org.codehaus.groovy.ast.ConstructorNode
import org.codehaus.groovy.ast.FieldNode
import org.codehaus.groovy.ast.GroovyClassVisitor
import org.codehaus.groovy.ast.MethodNode
import org.codehaus.groovy.ast.PropertyNode
import org.codehaus.groovy.syntax.SyntaxException
class MyClassVisitor implements GroovyClassVisitor{
// 如果检查出单个字母命名的方法,就报错。
@Override
void visitMethod(MethodNode methodNode) {
if(methodNode.name.size() == 1) throw new SyntaxException(
"single letter is forbidden",
methodNode.lineNumber,
methodNode.columnNumber
)
}
// 在这个例子中,我们没有应用到这些方法,因此只保留空实现。
@Override
void visitClass(ClassNode classNode) {}
@Override
void visitConstructor(ConstructorNode constructorNode) {}
@Override
void visitField(FieldNode fieldNode) {}
@Override
void visitProperty(PropertyNode propertyNode) {}
}
复制代码
8.2.3 注册 SPI (全局变换必须注册)
和前述的拓展 JDK 的做法类似,为了令 groovyc 编译器能够在编译期间发现这个 AST 变换,需要在 Maven resources
目录下新建一个 META-INF/services
目录,并创建一个名为 org.codehaus.groovy.transform.ASTTransformation
的清单。
在该清单中补充 CodeCheck
的全限定名,它必须是实现了 ASTTransformation
的类,否则就不会被识别。清单内可以存在多个转换。
com.i.CodeCheck
复制代码
现在,如果试着重新编译开头的那段脚本,能发现:由于方法命名的不规范,这次编译被拦截了:
Caught: BUG! exception in phase 'semantic analysis' in source unit 'C:\Users\i\IdeaProjects\groovyInJdk11\src\main\java\groovy_script.groovy' single letter is forbidden @ line 2, column 1.
BUG! exception in phase 'semantic analysis' in source unit 'C:\Users\i\IdeaProjects\groovyInJdk11\src\main\java\groovy_script.groovy' single letter is forbidden @ line 2, column 1.
at java.base/jdk.internal.reflect.NativeMethodAccessorImpl.invoke0(Native Method)
at java.base/jdk.internal.reflect.NativeMethodAccessorImpl.invoke(NativeMethodAccessorImpl.java:62)
at java.base/jdk.internal.reflect.DelegatingMethodAccessorImpl.invoke(DelegatingMethodAccessorImpl.java:43)
at java.base/jdk.internal.reflect.NativeMethodAccessorImpl.invoke0(Native Method)
at java.base/jdk.internal.reflect.NativeMethodAccessorImpl.invoke(NativeMethodAccessorImpl.java:62)
at java.base/jdk.internal.reflect.DelegatingMethodAccessorImpl.invoke(DelegatingMethodAccessorImpl.java:43)
at com.intellij.rt.execution.application.AppMainV2.main(AppMainV2.java:131)
Caused by: org.codehaus.groovy.syntax.SyntaxException: single letter is forbidden @ line 2, column 1.
at java.base/jdk.internal.reflect.NativeConstructorAccessorImpl.newInstance0(Native Method)
at java.base/jdk.internal.reflect.NativeConstructorAccessorImpl.newInstance(NativeConstructorAccessorImpl.java:62)
at java.base/jdk.internal.reflect.DelegatingConstructorAccessorImpl.newInstance(DelegatingConstructorAccessorImpl.java:45)
at astTest.MyClassVisitor.visitMethod(MyClassVisitor.groovy:25)
at astTest.CodeCheck$_visit_closure1.doCall(CodeCheck.groovy:15)
at java.base/jdk.internal.reflect.NativeMethodAccessorImpl.invoke0(Native Method)
at java.base/jdk.internal.reflect.NativeMethodAccessorImpl.invoke(NativeMethodAccessorImpl.java:62)
at java.base/jdk.internal.reflect.DelegatingMethodAccessorImpl.invoke(DelegatingMethodAccessorImpl.java:43)
at astTest.CodeCheck.visit(CodeCheck.groovy:13)
...
复制代码
这是一个全局的 AST 变换,它对于项目内的所有 Groovy 代码全部生效。
8.3 基于 AST 全局变换的方法拦截
下面是另一个全局 AST 变换的演示,还是直接从例子开始讲起。有这样一段 Groovy 脚本,脚本内有一处 CheckingAccountUsing
的类定义:
class CheckingAccountUsing {
def audit(int amount){println "audit..."}
// 希望当执行的金额 >10000 时,进行 audit 审计。
def deposit(int amount){println "deposit \$$amount."}
def withdraw(int amount){println "withdraw \$$amount."}
}
def account = new CheckingAccountUsing()
// 取出一万零一块钱。
account.withdraw(10001)
复制代码
我们希望 CheckingAccountUsing
内定义的任何方法 ( 除了 audit
方法本身 ) 当检测到操作的金额大于 10000 时,实例能够自动调用 audit
方法进行审计。基于之前各种 Execute Method Around 模式,运行时方法注入等手段,实现这个目的已经不是什么难事。不过这一次,这个 AOP 的实现将交付给编译器。
首先另创建一个 ASTTransformation
的实现类,然后进入到 visit
方法内实现我们的思路。第一步,从源单元 sourceUnit
中定位到脚本内定义的 CheckingAccountUsing
类,然后从它的 Methods 节点中翻找出所有除了 auduit
以外的方法节点 MethodNode 。注意,如果不小心对 audit
方法节点注入了 audit
方法,这会无意中设置一个无限循环的程序陷阱。
注入的详细步骤被封装成了 injectMethodWithAudit
方法,见下文。
def targetClass = sourceUnit.getAST().classes.find {
// 找出想要变换的类
it.name == "astTest.CheckingAccountUsing"
}
def nonAuditMethods = targetClass?.methods?.findAll {
// 找出想要变换的方法
it.name != "audit"
}
nonAuditMethods?.each { injectMethodWithAudit(it) }
复制代码
现在进入到了待实现的 injectMethodWithAudit
方法内部。后面的思路是,我们需要在每个 MethodNode 的 BlockStatement 节点内部,安插一段这样的逻辑:
// amount 来自于 deposit 或者是 withdraw 方法的参数。
if(amount>10000){
audit(amount)
}else{
println("no need to audit")
}
复制代码
这段语句的 AST 树该如何表示呢?人工分析一定会比较麻烦,尤其是对于初学 AST 的开发者而言。一个投机的办法就是 —— 将这段代码直接复制到 Groovy Console 内,然后 “照葫芦画瓢”。
将这个结构稍微整理一下,给出伪代码结构。
IfStatement(
BooleanStatement(BinaryStatement(Variable(amount),Token(">"),Constant(10000))),
// 表示条件为 true
ExpressionStatement(
MethodCallStatement(
'this','audit',Variable('amount')
)
)
// 表示条件为 false
ExpressionStatement(
MethodCallStatement(
'this','print',Variable('No need to audit')
)
)
)
复制代码
由于 if 和 else 块内只有一行语句,因此在这里可以直接传入 ExpressionStatement。在更一般的情形中,如果块内包含了多条语句,那么就必须要选择 BlockStatement,然后将多条 ExpressionStatement 组织为链表的形式放入其中。或者,条件分支没有 else 时,那么 else 块实际上为空。此时需要选择 EmptyStatement,而不是直接传入一个 null
。
这些节点都是 org.codehaus.groovy.ast.*
包中确切存在的类 ( 在构建的过程中,IDE 可能还会提供其他包的同名类,不要选错了 ) 。当构建好一个蓝图之后,剩下的工作就是将这段逻辑使用源代码去实现。
// condition
def condition = new BooleanExpression(
new BinaryExpression(
new VariableExpression('amount'),
// org.codehaus.groovy.syntax.Types
// org.codehaus.groovy.syntax.Token
// 经过实践证明,startLine 和 startColumn 的设置不会影响构建 AST,在此处全部设置为 -1.
Token.newSymbol(Types.COMPARE_GREATER_THAN, -1, -1),
new ConstantExpression(10000, true)
)
)
// 构造 this.audit(amount)
def ifBlock = new ExpressionStatement(
new MethodCallExpression(
new VariableExpression('this'),
'audit',
new ArgumentListExpression(
new VariableExpression('amount')
)
)
)
// 没有 else 块则使用 EmptyStatement() 代替。
// def elseBlock = new EmptyStatement()
// this.println("No need to audit")
def elseBlock = new ExpressionStatement(
new MethodCallExpression(
new VariableExpression('this'),
'println',
new ArgumentListExpression(
new ConstantExpression("No need to audit")
)
)
)
def insert = new IfStatement(condition , ifBlock, elseBlock)
复制代码
其中,二元符号 ( 这里需要一个表示大于的 >
符号 ) 需要借助 org.codehaus.groovy.syntax
包下的 Types
和 Token
类创建。如果需要其它表达符号,可以进入到源代码内进行查看。
初次尝试 AST 语法变换是比较困难的事情,但是 IntelliJ IDE 给出的代码提示也足够用了。在创建各个 XXXStatement 实例时,可以多加留意每个 Statement 的创建需要哪些内容。最终的 insert
代表了我们想要插入的那段 if
语句块。最后的工作则是将它插入到原方法节点的 BlockStatement 内部。
((BlockStatement)methodNode.code).statements.add(0, insert)
// 更偷懒的写法是:
// methodNode.code.statements.add(0,insert),不过 IDE 无法对此给出代码提示。
复制代码
.statements
实际上获得的是一组存储方法原语句块的 List<Statement>
链表,我们后文总是通过修改它来实现编译时方法注入或变换。这里将 insert
语句块头插入到原语句块,以此实现一个前置通知。反之,实现的则是后置通知。
最后,不要忘记到 META-INF/services/org.codehaus.groovy.transform.ASTTransforamtion
那里注册这个 AST 变换。当然,这个演示有明显的不合理之处:这个全局变换实际上只应用在了特定类 CheckAccountUsing
上,我们应用全局变换后还得特地花些功夫重新将它从源单元 sourceUnit
挑出来,颇有买椟还珠之嫌。在后文会介绍如何结合注解实现一个局部 AST 变换。
从这个例子可以看出来,手动实现 AST 转换其实并非一件易事,编译时元编程明显要比运行时元编程要来的复杂。然而,它为用户提供带来的便捷可以说是一劳永逸的。
8.4 编译期注入属性
注,ASTTransformation
的实现者可以是任何一门 JVM 语言。如果想要增强编译器的执行效率,那么也可以使用原生的 Java 语言来实现它,或者在 Groovy Class 上加上 @ComplieStatic
( 但是这样就写不了动态代码了,那还不如直接使用 Java ) 。注入属性只需要两步:
- 找到目标 ClassNode 节点,调用其提供的
addField
增加属性方法。 - 构造方法的名称,修饰符,类型 ( 需要 ClassHelper 类辅助构造,详细可以翻阅源代码 ),初始值 ( Expression ) 。
import org.codehaus.groovy.ast.ASTNode;
import org.codehaus.groovy.ast.ClassHelper;
import org.codehaus.groovy.ast.ClassNode;
import org.codehaus.groovy.ast.expr.ConstantExpression;
import org.codehaus.groovy.control.CompilePhase;
import org.codehaus.groovy.control.SourceUnit;
import org.codehaus.groovy.transform.ASTTransformation;
import org.codehaus.groovy.transform.GroovyASTTransformation;
import java.util.List;
import static groovyjarjarasm.asm.Opcodes.ACC_FINAL;
import static groovyjarjarasm.asm.Opcodes.ACC_PUBLIC;
import static groovyjarjarasm.asm.Opcodes.ACC_STATIC;
// build from YouToBe
@GroovyASTTransformation(phase = CompilePhase.SEMANTIC_ANALYSIS)
public class CodeBuilder implements ASTTransformation {
@Override
public void visit(ASTNode[] astNodes, SourceUnit sourceUnit) {
// 增加字段,相当于 public static final String AUTHOR = me;
List<ClassNode> classes = sourceUnit.getAST().getClasses();
classes.forEach(classNode -> classNode.addField(
"AUTHOR",
ACC_PUBLIC | ACC_FINAL | ACC_STATIC,
ClassHelper.STRING_TYPE,
new ConstantExpression("me")
));
}
}
复制代码
8.5 编译期注入方法
我们已经了解如何利用 AST 对已有的方法进行改造。结合编译期注入属性章节,现在不妨尝试着在编译期为目标构建一个全新的方法。这需要三个步骤:
- 找到目标 ClassNode 节点,调用
addMethod
插入方法。 - 设置方法的修饰符,返回值,参数列表,异常抛出列表等。
- 准备好方法对应函数体的 AST。
或者说,和编译期注入属性相比,额外的工作就是准备一个符合语义怎样算符合语义?的 AST 以及方法的其它信息一起传入到 addMethod
方法内。
这个例子演示了如何为所有类注入一个 getAuthor
方法:
import groovyjarjarasm.asm.Opcodes
import org.codehaus.groovy.ast.ASTNode
import org.codehaus.groovy.ast.ClassHelper
import org.codehaus.groovy.ast.ClassNode
import org.codehaus.groovy.ast.Parameter
import org.codehaus.groovy.ast.expr.ConstantExpression
import org.codehaus.groovy.ast.stmt.BlockStatement
import org.codehaus.groovy.ast.stmt.ExpressionStatement
import org.codehaus.groovy.ast.stmt.ReturnStatement
import org.codehaus.groovy.control.CompilePhase
import org.codehaus.groovy.control.SourceUnit
import org.codehaus.groovy.transform.ASTTransformation
import org.codehaus.groovy.transform.GroovyASTTransformation
@GroovyASTTransformation(phase = CompilePhase.SEMANTIC_ANALYSIS)
class getMethodInserter implements ASTTransformation{
@Override
void visit(ASTNode[] nodes, SourceUnit source) {
// 为编译期注入的方法准备语句块部分
BlockStatement code = new BlockStatement()
/*
* { return "author:me" }
*/
def returnString = new ReturnStatement(
new ExpressionStatement(
new ConstantExpression("author:me")
)
)
// 将语句插入到语句块内
code.addStatement(returnString)
// 向各个 ClassNode 注入一个新的 Method 节点。
source.getAST().classes.each {
classNode -> classNode.addMethod(
// 表示注入的方法名称
"getAuthor",
// 修饰符
Opcodes.ACC_PUBLIC,
// 表示返回值类型。
ClassHelper.STRING_TYPE,
// 表示不接受参数
Parameter.EMPTY_ARRAY,
// 表示不抛出异常
ClassNode.EMPTY_ARRAY,
code
)
}
}
}
复制代码
将它注册到 SPI 之后,任何一个类都可以在不改动源代码的情况下直接获得 getAuthor
:
println new Foo().getAuthor()
复制代码
当然,根据 Groovy 的统一访问原则,对于 getXXX
方法可以直接作为一个属性访问:
println new Foo().author
复制代码
8.6 基于注解的局部变换
本章需要对 Java 注解的创建有一个基本认识,具体可查阅笔者的 简单回顾Java注解 (juejin.cn) 。
到目前为止,所有的案例全都是全局的 AST 变换。如果这个 AST 并不是通用的,那么它最好是局部变换而非全局变换。
一个好的解决方案是将一个 AST 变换和一个标记型注解关联起来 ( 后文为了方便叙述,称这样的注解为 trigger 注解 )。这样,编译器如果在解析类的过程中发现了这些注解,那么就能够根据线索对标记类进行指定的 AST 变换,而被未标记的类则不会受到任何影响。和全局 AST 变换的另一个显著区别是:局部变换不需要注册到 SPI 接口,如果不小心这么做了,那么程序反而会报错。
现在创建一个 Messenger
注解,并为它赋予这样的功能:一旦类的定义被该注解标记,那么编译期这个类就会新增一个 message
方法。该方法接收一个字符串,并将它输出到控制台。同时,Messenger
注解有一个 boolean
类型的选项:shout
。如果该值为 true
,那么 message
方法在输出到控制台之前,会将字符串内的英文全部转换为大写形式,否则,不采取任何额外操作。
一个 Java 注解至少需要两个元注解 @Retention
和 @Target
。除此之外,这里需要额外标记一个元注解 GroovyASTTransformClass
去关联指定的 AST 变换。
// 这个注解仅在编译期用于生成方法,因此生命周期保持到源码级别即可。
@Retention(RetentionPolicy.SOURCE)
// 指定这是个标记在类上面的注解。
@Target(ElementType.TYPE)
// 将该注解和指定的 AST 变换关联起来。
@GroovyASTTransformationClass(classes = MessageAdderAstTransformation.class)
public @interface Messenger {
boolean shout() default false;
}
复制代码
下一步便是实现这个 MessageAdderAstTransformation
。再次提醒,在局部变换中,这个 AST 变换不需要注册到 SPI 接口。
import groovyjarjarasm.asm.Opcodes;
import org.codehaus.groovy.ast.*;
import org.codehaus.groovy.ast.expr.*;
import org.codehaus.groovy.ast.stmt.ExpressionStatement;
import org.codehaus.groovy.control.CompilePhase;
import org.codehaus.groovy.control.SourceUnit;
import org.codehaus.groovy.transform.AbstractASTTransformation;
import org.codehaus.groovy.transform.GroovyASTTransformation;
@GroovyASTTransformation(phase = CompilePhase.SEMANTIC_ANALYSIS)
public class MessageAdderAstTransformation extends AbstractASTTransformation {
@Override
public void visit(ASTNode[] astNodes, SourceUnit sourceUnit) {
/*
`nodes` - The ASTnodes when the call was triggered.
Element 0 is the AnnotationNode that triggered this annotation to be activated.
Element 1 is the AnnotatedNode decorated, such as a MethodNode or ClassNode.
For global transformations it is usually safe to ignore this parameter.
*/
// 这里的类型来自 org.codehaus.groovy.ast.* .
AnnotationNode msgAnnotation = (AnnotationNode) astNodes[0];
// 从注解中获取 shout 选项的值并设定为常量 (因为是编译期就设定好的值)。
// 它将决定 message 如何操作。
ConstantExpression shout = (ConstantExpression) msgAnnotation.getMember("shout");
ClassNode annotatedClass = (ClassNode) astNodes[1];
//TODO
}
}
复制代码
在以往全局变换的例子中,我们总是从源单元 sourceUnit
获取源码的 AST 树。但在局部变换中,使用第一个参数更合适一些。astNodes
参数只有固定的两个元素,下面是 Groovy Doc 的提供的解释:
astNodes[0]
代表了能够引发 AST 变换的 trigger 注解,可以从该元素中提取 trigger 注解内部的信息。astNodes[1]
代表了被 trigger 注解标记的 ClassNode,可以从该元素中提取出被 trigger 注解标记的类的 AST 树并施加变换。
显然,每一个局部 AST 变换只会和一个 trigger 注解关联,这是 @GroovyASTTransformationClass
元注解里定义的。这里为了方便操作,将 astNodes[0]
强制转换为 AnnotationNode,将 astNodes[1]
强制转换成 ClassNode。
剩下的步骤大概都比较明确了:那就是分析下面这段程序的 AST。类似的套路,如果觉得手动分析内部 AST 太过麻烦,可以直接使用 GroovyConsole 工具进行生成。
// 标识符: ACC_PUBlIC,
// 返回值: ClassHelper.VOID_TYPE
// 参数: [Parameter(ClassHelper.STRING_TYPE,"message")]
// 抛出异常: ClassNode.EMPTY_ARRAY ( 表示不抛出异常 )
// 语法块: {this.println(shout?message.toUpperCase:message)}
public void message(String message){
// shout 是一个 boolean 值,源自于 Messengers 注解。
this.println(shout?message.toUpperCase:message)
}
复制代码
完整的代码如下:
package astTest.javaImpl;
import groovyjarjarasm.asm.Opcodes;
import org.codehaus.groovy.ast.*;
import org.codehaus.groovy.ast.expr.*;
import org.codehaus.groovy.ast.stmt.ExpressionStatement;
import org.codehaus.groovy.control.CompilePhase;
import org.codehaus.groovy.control.SourceUnit;
import org.codehaus.groovy.transform.AbstractASTTransformation;
import org.codehaus.groovy.transform.GroovyASTTransformation;
@GroovyASTTransformation(phase = CompilePhase.SEMANTIC_ANALYSIS)
public class MessageAdderAstTransformation extends AbstractASTTransformation {
@Override
public void visit(ASTNode[] astNodes, SourceUnit sourceUnit) {
/*
`nodes` - The ASTnodes when the call was triggered.
Element 0 is the AnnotationNode that triggered this annotation to be activated.
Element 1 is the AnnotatedNode decorated, such as a MethodNode or ClassNode.
For global transformations it is usually safe to ignore this parameter.
*/
// 这里的类型来自 org.codehaus.groovy.ast.* .
AnnotationNode msgAnnotation = (AnnotationNode) astNodes[0];
ConstantExpression shout = (ConstantExpression) msgAnnotation.getMember("shout");
ClassNode annotatedClass = (ClassNode) astNodes[1];
// 这个局部变量 message 和构造的函数体的 message 变量相对应。
VariableExpression message = new VariableExpression("message");
MethodCallExpression toUpperCaseCall = new MethodCallExpression(message, "toUpperCase", ArgumentListExpression.EMPTY_ARGUMENTS);
// 构造了一个这样的一条语句 statement:
// this.println(shout?message.toUpperCase:message)
ExpressionStatement code = new ExpressionStatement(
new MethodCallExpression(new VariableExpression("this"),
"println",
new TernaryExpression(new BooleanExpression(shout), toUpperCaseCall, message))
);
// 创建 message
annotatedClass.addMethod("message",
Opcodes.ACC_PUBLIC,
ClassHelper.VOID_TYPE,
new Parameter[]{
// 参数 message 和局部变量的 message 相对应。
new Parameter(ClassHelper.STRING_TYPE,"message")
},
// 指代不抛出任何异常
ClassNode.EMPTY_ARRAY,
code
);
/*
最终相当于构造了这样的函数:
public void message(String message){
// shout 是一个 boolean 值,源自于 Messengers 注解。
this.println(shout?message.toUpperCase:message)
}
*/
}
}
复制代码
8.7 AST 杀手锏:AstBuilder
注意,一但使用 AstBuilder,那么 AST 变换就必须选择 Groovy 语言进行实现。
AstBuilder 提供了三种风格的 AST 辅助转换:nuildFromSpec
,buildFromString
,buildFromCode
。我们将在一个例子中对这三种 AST 构建方式做出对比:
创建一个局部 AST 变换,使得所有被名为 @InjectAOP
的 trigger 注解标记的类,它内部所有其它方法块内部会被注入一条调用自身 before
方法的语句。为了确保目标类实现了 before
方法,这里可以使用接口做一些强制性约束。
给出 trigger 注解的实现:
package astBuilderTest
import org.codehaus.groovy.transform.GroovyASTTransformationClass;
import java.lang.annotation.ElementType;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;
@Retention(RetentionPolicy.SOURCE)
@Target(ElementType.TYPE)
@GroovyASTTransformationClass(classes = InjectAopASTTransformation.class)
public @interface InjectAOP {}
复制代码
给出目标脚本的实现:
package astBuilderTest
@InjectAOP
class Foo implements AOP{
@Override
void before() {println("这段逻辑会被注入到其它方法")} // 通过接口强制令该类实现 before 方法。
void run(){println("测试的方法")}
}
// 目标:执行此脚本,控制台应当输出:
// 这段逻辑会被注入到其它方法
// 测试的方法
new Foo().run()
复制代码
核心目标是 InjectAopASTTransformation
的实现。首先给出传统实现:
package astBuilderTest
import org.codehaus.groovy.ast.ASTNode
import org.codehaus.groovy.ast.ClassNode
import org.codehaus.groovy.ast.expr.ArgumentListExpression
import org.codehaus.groovy.ast.expr.MethodCallExpression
import org.codehaus.groovy.ast.expr.VariableExpression
import org.codehaus.groovy.ast.stmt.BlockStatement
import org.codehaus.groovy.ast.stmt.ExpressionStatement
import org.codehaus.groovy.control.CompilePhase
import org.codehaus.groovy.control.SourceUnit
import org.codehaus.groovy.transform.ASTTransformation
import org.codehaus.groovy.transform.GroovyASTTransformation
@GroovyASTTransformation(phase = CompilePhase.SEMANTIC_ANALYSIS)
class InjectAopASTTransformation implements ASTTransformation {
@Override
void visit(ASTNode[] nodes, SourceUnit source) {
def classNode = nodes[1] as ClassNode
// 确保它实现了 AOP 接口。
// 在 AST 内部,所有信息全部是 ASTNode 形式,因此需要将等待判断的 AOP 接口类型转换为 ClassNode 并比较。
// 这和一般的判别方式有所不同。
def c = new ClassNode(AOP.class)
println classNode.interfaces.contains(c)
// 找出所有其它方法
def methods = classNode.methods.findAll {
it.name != "before"
}
// -------------AstBuilder 主要负责重构的部分------------------//
def beforeCall = new ExpressionStatement(
new MethodCallExpression(
new VariableExpression('this'),
'before',
ArgumentListExpression.EMPTY_ARGUMENTS
)
)
methods.each {
((BlockStatement) it.code).statements.add(0, beforeCall)
}
// ---------------------------------------------------------//
}
}
复制代码
第一种:buildFromSpec
。它本质上是创建了一个内部 DSL,使我们在创建各种 ASTNode 的过程中免去了频繁的 new
关键字声明。
package astBuilderTest
import org.codehaus.groovy.ast.ASTNode
import org.codehaus.groovy.ast.ClassNode
import org.codehaus.groovy.ast.builder.AstBuilder
import org.codehaus.groovy.ast.stmt.BlockStatement
import org.codehaus.groovy.ast.stmt.Statement
import org.codehaus.groovy.control.CompilePhase
import org.codehaus.groovy.control.SourceUnit
import org.codehaus.groovy.transform.ASTTransformation
import org.codehaus.groovy.transform.GroovyASTTransformation
@GroovyASTTransformation(phase = CompilePhase.SEMANTIC_ANALYSIS)
class InjectAopASTTransformation implements ASTTransformation {
@Override
void visit(ASTNode[] nodes, SourceUnit source) {
def classNode = nodes[1] as ClassNode
// 确保它实现了 AOP 接口。
// 在 AST 内部,所有信息全部是 ASTNode 形式,因此需要将等待判断的 AOP 接口类型转换为 ClassNode 并比较。
// 这和一般的判别方式有所不同。
def c = new ClassNode(AOP.class)
assert classNode.interfaces.contains(c)
// 找出所有其它方法
def methods = classNode.methods.findAll {
it.name != "before"
}
// -------------AstBuilder 主要负责重构的部分------------------//
def beforeCall = new AstBuilder().buildFromSpec {
// ExpressionStatement
expression {
methodCall {
variable 'this'
constant 'before'
argumentList {}
}
}
} as List<Statement>
methods.each {
((BlockStatement) it.code).statements.add(0, beforeCall[0])
}
// ---------------------------------------------------------//
}
}
复制代码
其 DSL 的构建方式只是在写法上减少了一点构建 AST 的负担,但是仍然要求我们熟练掌握 Groovy 提供的各种 AST 节点及其构建方式。对于不熟悉 org.codehaus.groovy.ast
包下各种类型的开发者而言,构建 AST 仍然是一个繁琐且困难的工作。
假如有这样一个工具,能够允许我们按照日常的习惯编写源代码,然后由它自动转换为 AST 树就好了 ( 这样的话,我们甚至都不用深入了解 AST 树了! )。AstBuilder 正好解决了这个痛点。下面介绍 AstBuilder 第二种使用方式:buildFromCode
。
package astBuilderTest
import org.codehaus.groovy.ast.ASTNode
import org.codehaus.groovy.ast.ClassNode
import org.codehaus.groovy.ast.builder.AstBuilder
import org.codehaus.groovy.ast.stmt.BlockStatement
import org.codehaus.groovy.ast.stmt.Statement
import org.codehaus.groovy.control.CompilePhase
import org.codehaus.groovy.control.SourceUnit
import org.codehaus.groovy.transform.ASTTransformation
import org.codehaus.groovy.transform.GroovyASTTransformation
@GroovyASTTransformation(phase = CompilePhase.SEMANTIC_ANALYSIS)
class InjectAopASTTransformation implements ASTTransformation {
@Override
void visit(ASTNode[] nodes, SourceUnit source) {
def classNode = nodes[1] as ClassNode
// 确保它实现了 AOP 接口。
// 在 AST 内部,所有信息全部是 ASTNode 形式,因此需要将判断的 AOP 接口类型装入到 ClassNode 并比较。
// 这和一般的判别方式有所不同。
def c = new ClassNode(AOP.class)
assert classNode.interfaces.contains(c)
// 找出所有其它方法
def methods = classNode.methods.findAll {
it.name != "before"
}
// -------------AstBuilder 主要负责重构的部分------------------//
// AstBuilder 独立于 InjectAopASTTransformation 进行编译。
def beforeCall = new AstBuilder().buildFromCode(CompilePhase.SEMANTIC_ANALYSIS){
this.before()
} as List<Statement>
methods.each {
// ((BlockStatement) it.code).statements.addAll(0,beforeCall)
((BlockStatement) it.code).statements.add(0,beforeCall[0])
}
// ---------------------------------------------------------//
}
}
复制代码
这里有很多细节需要说明。如我们所见,buildFromCode
方法的闭包内允许直接放入一段代码块,我们不再需要在各种复杂的 AST 结构当中挣扎。除此之外,该方法还指明 AstBuilder 在某个阶段 ( 此处是 CompilePhase.SEMANTIC_ANALYSIS
) 将闭包的内代码转为等价的 AST ( 笔者推测 AstBuilder 的 AST 变换过程和 InjectAopASTTransformation
的 AST 变换是分开且独立的,但是 Groovy Doc 给出的解释令我云里雾里,网上也没有足够资料 ) 。
实际上,buildFromCode
返回的是一个抽象的 List<ASTNode>
类型。为了便于操作,这里通过 as
符号将其转换为了 List<Statement>
类型 。 注,as
操作符依赖调用者的 asType
方法,和强制转换的概念有区别。无论我们在 buildFromCode
内创建了多少语句块,它们最终总是被包装为一个 BlockStatement,然后再装入到一个 List 内部。
换句话说,通过访问 beforeCall[0]
就可以获取到 AstBuilder 为我们转换好的 AST 了。注意,一旦进入到 buildFromCode
( 还有后文的 buildFromString
) 方法的闭包内部,IntelliJ IDE 就无法再为我们提供有效的代码提示,甚至有时还会出现 “误导” 的情况。比如:上述代码块内的 this
可不是指代这个 AST 变换类本身,而是指代上下文中被注入的那个对象 “that”,调用的是 “that” 的 before
方法。
第三种,buildFromString
。这种方式和 buildFromCode
非常接近,只不过 AstBuilder 这一次转化的目标是 String
类型。
package astBuilderTest
import org.codehaus.groovy.ast.ASTNode
import org.codehaus.groovy.ast.ClassNode
import org.codehaus.groovy.ast.builder.AstBuilder
import org.codehaus.groovy.ast.stmt.BlockStatement
import org.codehaus.groovy.ast.stmt.Statement
import org.codehaus.groovy.control.CompilePhase
import org.codehaus.groovy.control.SourceUnit
import org.codehaus.groovy.transform.ASTTransformation
import org.codehaus.groovy.transform.GroovyASTTransformation
@GroovyASTTransformation(phase = CompilePhase.SEMANTIC_ANALYSIS)
class InjectAopASTTransformation implements ASTTransformation {
@Override
void visit(ASTNode[] nodes, SourceUnit source) {
def classNode = nodes[1] as ClassNode
// 确保它实现了 AOP 接口。
// 在 AST 内部,所有信息全部是 ASTNode 形式,因此需要将等待判断的 AOP 接口类型转换为 ClassNode 并比较。
// 这和一般的判别方式有所不同。
def c = new ClassNode(AOP.class)
assert classNode.interfaces.contains(c)
// 找出所有其它方法
def methods = classNode.methods.findAll {
it.name != "before"
}
// -------------AstBuilder 主要负责重构的部分------------------//
// AstBuilder 独立于 InjectAopASTTransformation 进行编译。
def info = "println \"this source build by AstBuilder\" "
def beforeCall = new AstBuilder().buildFromString(CompilePhase.SEMANTIC_ANALYSIS,"""
this.before()
${info}
""") as List<Statement>
methods.each {
((BlockStatement) it.code).statements.add(0,beforeCall[0])
}
// -------------等待 AstBuilder 重构的部分------------------//
}
}
复制代码
和 buildFromCode
相比,它的优势是允许我们能够像拼凑 GString 一样自由地组合源代码 ( 并且不再需要关心它的 AST 结构,因为 AstBuilder 会替我们搞定 ),而缺点则是可能会遇到一些字符串转义的烦恼。
8.8 补充:部分 AST 节点表示为 “空” 的方式
如果要构造一个空语句块,则需要创建 EmptyStatement 实例。
当构造 MethodCallExpression 时,如果遇到空括号方法的调用,那么参数列表应表示为 ArgumentListExpression.EMPTY_ARGUMENTS
。
当进行编译期方法构造 ( 对某个 ClassNode 调用 addMethod
方法 ) 时,如果此方法是空括号方法,那么参数列表应表示为 Parameter.EMPTY_ARRAY
。如果此方法不抛出任何异常,那么应表示为 ClassNode.EMPTY_ARRAY
。如果该方法不返回值,应表示为 ClassHelper.VOID_TYPE
。
在构造方法返回值类型,或是属性类型时,使用 Groovy 提供 ClassHelper 去完成。
- Groovy 给所有的 GroovyObject 都绑定了 print 方法,因此调用者自然就是 this 。↩
- 常见的代码异味包括 Duplicated Code,Long Method,Switch Statement,以及其它可以想到的不良代码,详情可见一些常见的代码异味 Code Smell — 简明现代魔法 (nowamagic.net)。↩
- 比如插入方法时设定返回值为
String
,那么该方法的 BlockStatement 的最后一个元素应当是返回字符串类型的 ReturnStatement。↩