也许你对gradle是如何运行感到好奇,我们写的build.gradle
脚本是如何运行起来的呢?apply
,buildscript
,repositories
等gradle提供的API我们在脚本中经常使用,它们究竟做了什么呢?plugins
又是如何被加载的呢?
如果你对这些问题也感兴趣,那我们就来探究一下gradle加载groovy script
的流程吧
除非特殊声明,本文及后续篇章对gradle的分析都基于gradle 8.0源码
Build scripts are code
首先需要明白build scripts本身就是代码,我们写在build.gradle
脚本内的配置其实就是在对api进行调用
script可以分为4种
init.gradle
settings.gradle
build.gradle
precompiled script(buildSrc/includeBuild下面的script)
- gradle core api提供的,例如
files
,project
,repository
等 - DSL内定义的block,如
buildscript
,plugins
,pluginManagement
,apply
等 - plugin定义的,如java extension, dependency里面可以使用的
configuration
,如java plugin提供的implementation
,api
等
Script的加载流程
groovy.lang.Script
gradle脚本实际上是借助了groovy
语言本身的能力来完成的,了解了groovy
的语言特性会更容易理解
groovy脚本编译
Integrating Groovy into applications
groovy闭包相关
Closures
.gradle
后缀的脚本(即groovy
脚本)的script加载是借助于groovy
本身的脚本能力支持。简单概括就是GroovyClassLoader提供了能力去解析脚本文件,并且可以将它的Class文件保存到本地,之后可以利用反射实例化它,调用它的run方法即可运行脚本
主要有3个概念需要了解
- groovy.lang.Script
- CompilerConfiguration
- GroovyClassLoader
Groovy能加载的脚本必须是继承自groovy.lang.Script
的,它是一个抽象类,有且只有一个abstract方法run
,脚本编译完后所有内容都被包裹在这个run
方法中,随后运行脚本就只用调用这个run
方法
GroovyClassLoader
的parseClass
方法可以对我们编写的脚本文件进行解析,这个方法接收的codeSource
参数其实就是我们的脚本内容及生成的class的名字
CompilerConfiguration
用来设置编译的一些参数,例如脚本的基类,生成的class所在目录等 为什么要设置自己的脚本基类而不直接使用groovy.lang.Script
呢?
因为groovy.lang.Script
本身没有什么方法,除了groovy的一些默认的导包外调用不了什么方法,我们实际上可以继承它来实现自己的基类,添加一些方法以便我们可以在自己编写的脚本中直接调用,类似于我们可以在gradle脚本中调用buildscript
,apply
,files
等等方法一样
下面用最简单的代码来实现grooy脚本加载并运行的例子。完整代码可以参见[Gradle Script加载](GradleInDeep/GroovyScriptLoader.java at master · neas-neas/GradleInDeep · GitHub)
首先定义一个DefaultScript继承自groovy.lang.Script,并且它有一个buildscript方法,其接收一个闭包并直接运行该闭包。你可能注意到这里使用了println却并没有导包,实际上它是groovy.lang.Script里面的方法。
DefaultScript.java
public abstract class DefaultScript extends Script {
public void buildscript(Closure configureClosure) {
println("DefaultScript:buildscript parameters:" + configureClosure.getMaximumNumberOfParameters());
configureClosure.call();
}
}
我们对一个非常简单的脚本进行测试,内容为:
buildscript {
println 'test'
}
它的执行就是调用了DefaultScript中的buildscript,然后输出 test
下面的代码就是创建GroovyClassLoader去加载这个脚本,我们在CompilerConfiguration中设置了ScriptBaseClass为DefaultScript.class,并且将其输出到当前项目的根目录,名称在GroovyCodeSource中设置了为build_xxx,运行起来它就会在项目根目录下生成一个叫这个名字的Class文件。
GroovyScriptLoader
public static void main(String[] args) {
CompilerConfiguration configuration = createBaseCompilerConfiguration(DefaultScript.class);
ClassLoader systemClassLoader = ClassLoader.getSystemClassLoader();
GroovyClassLoader gcl = new GroovyClassLoader(systemClassLoader, configuration, false);
GroovyCodeSource codeSource = new GroovyCodeSource(scriptText, "build_xxx", "/groovy/script");
Class scriptClass = gcl.parseClass(codeSource, false);
try {
DefaultScript script = (DefaultScript) scriptClass.newInstance();
script.run();
} catch (Exception e) {
e.printStackTrace();
}
}
private static CompilerConfiguration createBaseCompilerConfiguration(Class<? extends Script> scriptBaseClass) {
CompilerConfiguration configuration = new CompilerConfiguration();
configuration.setScriptBaseClass(scriptBaseClass.getName());
configuration.setTargetBytecode(CompilerConfiguration.JDK8);
configuration.setTargetDirectory(new File("."));
return configuration;
}
private static String scriptText =
"buildscript {" +
" println 'test'" +
"}";
可以简单看一下生成的,代码是反编译的,经过一些删减。
build_xxx.java
public class build_xxx extends DefaultScript {
public build_xxx() {
CallSite[] var1 = $getCallSiteArray();
super();
}
public static void main(String... args) {
CallSite[] var1 = $getCallSiteArray();
var1[0].callStatic(InvokerHelper.class, build_xxx.class, args);
}
public Object run() {
CallSite[] var1 = $getCallSiteArray();
final class _run_closure1 extends Closure implements GeneratedClosure {
public _run_closure1(Object _outerInstance, Object _thisObject) {
CallSite[] var3 = $getCallSiteArray();
super(_outerInstance, _thisObject);
}
public Object doCall(Object it) {
CallSite[] var2 = $getCallSiteArray();
return var2[0].callCurrent(this, "test");
}
@Generated
public Object doCall() {
CallSite[] var1 = $getCallSiteArray();
return this.doCall((Object)null);
}
}
return var1[1].callCurrent(this, new _run_closure1(this, this));
}
}
gradle script编译及运行
gradle脚本的编译会进行2轮,称为Pass。
Pass 1. CLASSPATH 只编译并运行buildscript/initscript(根据脚本来的)的pluginsManagement,plugins部分。
Pass 2. BODY 编译并运行除Pass1以外的部分。
分为2个pass的原因是buildscript
、plugins
等顶层block需要优先运行,其运行结果是作为其余部分的prelude的,实际gradle提供的能力并不多,它仅定义了一些基础的概念,如Task,整体的生命周期,Repository,Configuration,Dependencies这些
如何基于这些去构建是由Plugin内部处理的,例如Java plugin添加了implementation
、api
等configuration,为build等lifecycle task绑定了compileJava
等action task,来串起整个java的编译
gradle中groovy script核心加载代码入口在FileCacheBackedScriptClassCompiler#compile
,这一块主要包含编译以及加载编译后的class文件,编译是在CompileToCrossBuildCacheAction
中处理,最后是委托给DefaultScriptCompilationHandler#compileToDir
来完成的
compileToDir
是核心的部分,看懂了这部分代码基本就掌握了groovy脚本的编译
compileToDir
从其参数入手进行分析
source: ScriptSource
脚本来源classLoader: ClassLoader
configuration: CompilerConfiguration
classesDir: File
编译后的classes文件的目录metadataDir: File
extractingTransformer: CompileOperation
scriptBaseClass: Class<? extends Script>
verifier: Action<? super ClassNode>
ScriptSource
脚本源,脚本的源文件,脚本内容的字符串
- 约定俗成的默认文件
build.gradle/settings.gradle/init.gradle
apply from
时引入,脚本文件可以是本地的,也可以是remote的
classesDir
保存的目录为 .gradle/caches/8.0(gradle version)/scripts/xxx
从cache新建文件,GrooryClassLoader
支持传入target directory,最后编译出来的class序列化到指定目录
具体的script对应保存的目录不一样
settings.gradle -> settings build.gradle -> proj
如果带有 cp_ 前缀(例如cp_settings),表示是Pass 1阶段编译的产物,cp表示classpath的意思,代码细节见getPluginsBlockCompileOperation#getPluginsBlockCompileOperation
metadataDir
metadataDir
保存的位置为 .gradle/caches/8.0/scripts/xxx/metadata/metadata.bin
目前只会在Pass 2阶段由BuildScriptDataSerializer
处理,保存了一个boolean值hasImperativeStatements
,表示script是否包含命令式语句,如果不包含表示此脚本只含model rule statements,可以延后到rule execution时再执行
例如在script最外层有println
这种,就表示带有命令式的代码
metadata.bin的序列化的编解码是由KryoBackedEncoder
,KryoBackedDecoder
处理的,其内部使用到的是一个三方库kryo,这是一个高效的binary object graph serialization框架
extractingTransformer
类型为CompileOperation
, 注释为A stateful “backing” for a compilation operation. 其泛型表示的是此操作会被提取的数据,这里提取的数据最后就是保存在上面所诉的metadata里面的
有2个实现类,NoDataCompileOperation
(Pass 1阶段)和FactoryBackedCompileOperation
(Pass 2阶段),前者顾名思义不会保存任何数据
2者都有带有Transformer
,GroovyClassLoader
在编译groovy脚本时支持对CompilationUnit
进行hook addPhaseOperation
,详见Integrating Groovy into applications#compilationunit
hook有2个参数,一个是具体的operation,一个是编译的阶段,编译过程的所有阶段列在org.codehaus.groovy.control.Phases
中, 也就是所支持的hook点,CompilationUnit
本身在初始化时就会注入许多operation, 例如构建AST、进行一些语义分析等
2个阶段的operation不同
- Pass 1
InitialPassStatementTransformer
SubsetScriptTransformer
Pass 1阶段只编译
initscript(只有init.gradle有)
buildscript(build.gradle,settings.gradle有)
pluginManagement
plugins
这几个block,并有一些规则校验
只有setting.gradle
,build.gradle
支持plugins
只有settings.gradle
内才能有pluginManagement
上述的block前不能出现任何其他的block或statement
优先级 pluginManagement
> initscript
或buildscript
> plugins
如果不满足的话会编译失败
对于 plugins block,在PluginUseScriptBlockMetadataCompiler中有具体的处理细节,针对 plugins closure处理,对plugins closure代码部分设置了一个visitor,对于AST分析这是一种很常见的方式。
1. 仅允许id、alias、version、apply方法调用,并对其调用参数进行校验。
2. 对id调用添加行号信息,如果有重复会报错。
// Plugin with id 'java' was already requested at line 2
plugins {
id('java')
id('java')
}
在id方法调用的时候传入当前行的行号。
ConstantExpression lineNumberExpression = new ConstantExpression(call.getLineNumber(), true);
call.setArguments(new ArgumentListExpression(argumentExpression, lineNumberExpression));
生成的代码,callCurrent最后一个参数即是行号 。
- Pass 2
Pass 2涉及到的Transformer数量更多,但逻辑更简单清晰
BuildScriptTransformer
为入口,下面的transformer都是收拢在这里面的FilteringScriptTransformer
将Pass 1中编译过的block块全部过滤掉,因为在第一步已经处理过了,这一步不用在处理了TaskDefinitionScriptTransformer
对task
方法调用进行校验FixMainScriptTransformer
默认groovy脚本编译完后会带有main函数,如果在closure里面有main { }
这样的代码会默认调用到static的main函数,这一步是将static main函数去掉StatementLabelsScriptTransformer
labeled_statements是groovy语言的特,在Spock测试库有大量应用。在gradle脚本中没有作用,这一步是将其去掉ModelBlockTransformer
model是较为古早的东西,算是废弃掉了,不管这个ImperativeStatementDetectingTransformer
判断脚本是否含有命令式语句,这关系到脚本是否可以延后执行
除此之外还有2个detector
有校验的功能
packageDetector
来检测是否有package
关键字声明,gradle脚本不允许出现emptyScriptDetector
来检测脚本是否是empty的,这里的empty并不是指脚本内什么内容都没有,如果脚本内全是注释,或者有statement,但是这些statement都没有作用,那么也会被认为是empty的。对于所有的empty脚本,gradle是用同一个文件缓存的,避免产生过多的文件
scriptBaseClass
主要功能其实在DefaultScript
里面,其他脚本只是改写某些方法
build.gradle
,settings.gradle
等脚本由groovy编译,都是需要继承自groovy.lang.Script
的,buildscript
、plugins
、apply
等实际上都是调用的DefaultScript
的方法
对于build.gradle
实际上是会对应的生成一个Project对象与之一一对应的,task
、dependencies
、files
等方法就是来自这里,具体调用的方法是由groovy的方法调用派发来处理的,有优先级关系
外层的block,如apply
,buildscript
等实际上就是方法调用
这些代码都会被包裹在groovy编译后的Script类的run方法里面 Project
创建部分代码见ProjectFactory#createProject
,通过反射来创建Project
对象
最重要的方法evaluate
,这是一个抽象方法,最后会委托给BuildScriptProcessor#execute
来处理,其创建了ScriptPlugin
,加载build.gradle时由DefaultScriptPluginFactory.ScriptPluginImpl
这个实现来处理,它的apply方法里面会编译脚本,并进行Pass 1,Pass 2来执行脚本
verifier
Pass 2阶段,对Rules的closure做处理,hook groovy脚本编译期的codegen时期,Rules配合model,是早期编写plugin的方式,现已属于是废弃状态了,所以这里不深入了
Script的动态能力
Plugin的加载
plugin有2种方式引入
- plugins block
- apply plugin
apply plugin
是之前引入插件的方式,plugins
block这种配置方式是后面出现的,也是官方现在更推荐的方式
2者虽然最终都会将plugin引入进来,但还是有所差异,plugins
block的能力更强一些,includeBuild内的插件要被用到,需要通过这种方式引入,其内部实现会先去构建includeBuild,确保plugin的存在。plugins
block在Pass 1的分析中有说过了这里就不再赘述了
再看apply的方式,项目里面可能更常见的是这种方式引入
apply plugin: 'java'
这实际上是groovy的语法糖,plugin: 'java'
其实是创建了一个key为'plugin',值为'java'的map,然后传入这个map去调用apply方法
这里最终会走到DefaultPluginManager.doApply
来,初始化id对应的plugin,然后调用plugin的apply方法,如果有过自定义plugin经验的话,应该容易发现实现Plugins时需要实现其抽象方法apply,这里也就是调用的那个方法
在这里我们也可以给project注册task,添加我们自定义的extension等,Java Plugin就是在这里注册了java
extension
DefaultProject
从gradle脚本的加载流程中可以知道build.gradle
本身是被编译为Script
的,且对于每个build.gradle
是会生成一个对应的Project
对象的,而Script
内可以使用tasks
等在Project中的属性和方法,这又是如何做到的呢
其实这也是利用了groovy语言本身的特性,详见 Runtime and compile-time metaprogramming
build.gradle编译成的Script实际是一个GroovyObject
,groovy的动态性也基于此。当调用groovy对象上的方法时,可以通过invokeMethod(String name, Object args)
进行拦截,也就是调用groovy对象上的方法时,先是会走到invokeMethod
来的
对属性的访问及修改也有对应的方法,setProperty(String property, Object newValue)
和getProperty(String property)
,还有methodMissing
配合invokeMethod
进行兜底
DefaulScript
继承自BasicScript
,而BasicScript
就实现了这些方法,BasicScript
内有一个dynamicObject
对象,Script
和Project
就是通过这个对象进行了关联,对于脚本中调用的方法,如果在Script
自身没有找到的话,就会通过dynamicObject
去找到Project
的方法了
这里还有一个问题,需要Project也是groovy对象,否则Project是没有动态能力,无法作为一个"dynamicObject"的。
所以DefaultProject对象也需要继承自groovy.lang.GroovyObject,这是通过一个特殊的classloader完成的,实际源码它就是一个普通的Java类,Project本就是通过反射进行的初始化,这个特殊的classloader在运行时通过ASM技术修改了其父类,具体的细节代码见AsmBackedClassGenerator,Project的mixInDsl为true,为其添加了GROOVY_OBJECT_TYPE,这样Project才能有动态派发方法调用invokeMethod,动态属性property的能力。
if (mixInDsl) {
interfaceTypes.add(DYNAMIC_OBJECT_AWARE_TYPE.getInternalName());
interfaceTypes.add(GROOVY_OBJECT_TYPE.getInternalName());
}
参考链接
Integrating Groovy into applications
https://groovy-lang.org/integrating.html#groovyclassloader
Closures
https://groovy-lang.org/closures.html
Runtime and compile-time metaprogramming
https://groovy-lang.org/metaprogramming.html#_invokemethod
作者:近地小行星
链接:https://juejin.cn/post/7219925045228240954
来源:稀土掘金
著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。