Gradle深入解析 - Groovy Script加载流程

也许你对gradle是如何运行感到好奇,我们写的build.gradle脚本是如何运行起来的呢?
applybuildscriptrepositories等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)

  1. gradle core api提供的,例如filesprojectrepository
  2. DSL内定义的block,如buildscript, plugins, pluginManagement, apply
  3. plugin定义的,如java extension, dependency里面可以使用的configuration,如java plugin提供的implementationapi

Script的加载流程

groovy.lang.Script

gradle脚本实际上是借助了groovy语言本身的能力来完成的,了解了groovy的语言特性会更容易理解
groovy脚本编译
Integrating Groovy into applications
groovy闭包相关
Closures

.gradle后缀的脚本(即groovy脚本)的script加载是借助于groovy本身的脚本能力支持。简单概括就是GroovyClassLoader提供了能力去解析脚本文件,并且可以将它的Class文件保存到本地,之后可以利用反射实例化它,调用它的run方法即可运行脚本

主要有3个概念需要了解

  1. groovy.lang.Script
  2. CompilerConfiguration
  3. GroovyClassLoader

Groovy能加载的脚本必须是继承自groovy.lang.Script的,它是一个抽象类,有且只有一个abstract方法run,脚本编译完后所有内容都被包裹在这个run方法中,随后运行脚本就只用调用这个run方法

GroovyClassLoaderparseClass方法可以对我们编写的脚本文件进行解析,这个方法接收的codeSource参数其实就是我们的脚本内容及生成的class的名字

CompilerConfiguration用来设置编译的一些参数,例如脚本的基类,生成的class所在目录等 为什么要设置自己的脚本基类而不直接使用groovy.lang.Script呢?
因为groovy.lang.Script本身没有什么方法,除了groovy的一些默认的导包外调用不了什么方法,我们实际上可以继承它来实现自己的基类,添加一些方法以便我们可以在自己编写的脚本中直接调用,类似于我们可以在gradle脚本中调用buildscriptapplyfiles等等方法一样

下面用最简单的代码来实现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的原因是buildscriptplugins等顶层block需要优先运行,其运行结果是作为其余部分的prelude的,实际gradle提供的能力并不多,它仅定义了一些基础的概念,如Task,整体的生命周期,Repository,Configuration,Dependencies这些
如何基于这些去构建是由Plugin内部处理的,例如Java plugin添加了implementationapi等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

脚本源,脚本的源文件,脚本内容的字符串

  1. 约定俗成的默认文件 build.gradle/settings.gradle/init.gradle
  2. 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者都有带有TransformerGroovyClassLoader在编译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.gradlebuild.gradle支持plugins
只有settings.gradle内才能有pluginManagement
上述的block前不能出现任何其他的block或statement
优先级 pluginManagement > initscriptbuildscript > 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数量更多,但逻辑更简单清晰

  1. BuildScriptTransformer为入口,下面的transformer都是收拢在这里面的
  2. FilteringScriptTransformer 将Pass 1中编译过的block块全部过滤掉,因为在第一步已经处理过了,这一步不用在处理了
  3. TaskDefinitionScriptTransformertask方法调用进行校验
  4. FixMainScriptTransformer 默认groovy脚本编译完后会带有main函数,如果在closure里面有main { } 这样的代码会默认调用到static的main函数,这一步是将static main函数去掉
  5. StatementLabelsScriptTransformer labeled_statements是groovy语言的特,在Spock测试库有大量应用。在gradle脚本中没有作用,这一步是将其去掉
  6. ModelBlockTransformer model是较为古早的东西,算是废弃掉了,不管这个
  7. ImperativeStatementDetectingTransformer 判断脚本是否含有命令式语句,这关系到脚本是否可以延后执行

除此之外还有2个detector有校验的功能

packageDetector来检测是否有package关键字声明,gradle脚本不允许出现
emptyScriptDetector来检测脚本是否是empty的,这里的empty并不是指脚本内什么内容都没有,如果脚本内全是注释,或者有statement,但是这些statement都没有作用,那么也会被认为是empty的。对于所有的empty脚本,gradle是用同一个文件缓存的,避免产生过多的文件

scriptBaseClass

主要功能其实在DefaultScript里面,其他脚本只是改写某些方法

build.gradlesettings.gradle等脚本由groovy编译,都是需要继承自groovy.lang.Script的,buildscriptpluginsapply等实际上都是调用的DefaultScript的方法

对于build.gradle实际上是会对应的生成一个Project对象与之一一对应的,taskdependenciesfiles等方法就是来自这里,具体调用的方法是由groovy的方法调用派发来处理的,有优先级关系

外层的block,如applybuildscript等实际上就是方法调用

这些代码都会被包裹在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对象,ScriptProject就是通过这个对象进行了关联,对于脚本中调用的方法,如果在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
来源:稀土掘金
著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值