我与 Groovy 不共戴天
来到新公司后,小灵通开始接手了核心技术-快编插件,看到传说中的核心技术,小灵通傻眼了,啊这,groovy 写的插件,groovy 认真的嘛,2202 年了,插件咋还用 groovy 写呢,我新手写插件也换 kotlin 了,张嘴就是 这辈子都不可能写 groovy,甭想了。 但是嘛,工作不寒碜,学学呗。
一开始和组里几个大佬聊下来,磨刀霍霍准备对历史代码动刀,全迁移到 kotlin 上爽一发,但发现。。。咦,代码好像看不懂诶,我不知道 kt 对应的写法是啥样的。文章结束,小灵通因此被辞退。
开个玩笑,我现在还是在岗状态。工作还是要继续的。既然能力有限我全部迁不过去,那我可以做到新需求用 kotlin 来写嘛,咦,这就有意思了。
Groovy 和 java 以及 kotlin 如何混编
怎么实现混编
我不会嘛,看看官方怎么写的。gradle 源码有这么段代码来阐释了是怎么优先 groovy 编译 而非 java 编译.
// tag::compile-task-classpath[]
tasks.named('compileGroovy') {
// Groovy only needs the declared dependencies
// (and not longer the output of compileJava)
classpath = sourceSets.main.compileClasspath
}
tasks.named('compileJava') {
// Java also depends on the result of Groovy compilation
// (which automatically makes it depend of compileGroovy)
classpath += files(sourceSets.main.groovy.classesDirectory)
}
// end::compile-task-classpath[]
噢,可以这么写啊,那我是不是抄下就可以了,把名字改改。我就可以写 kotlin 了,欧耶!
compileKotlin {
classpath = sourceSets.main.compileClasspath
}
compileGroovy {
classpath += files(sourceSets.main.kotlin.classesDirectory)
}
跑一发,没有意外的话,你会看到这个报错。
诶,为啥我照着抄就跑不起来呢?我怀疑是 kotlin classesDiretory 有问题,断点看一波 compileGroovy 这个 task 的 sourceSets.main.kotlin.classesDirectory 是个啥。大概长这样, 是个 DefaultDirectoryVar 类。
诶,这是个啥,一开始我也看不太懂,觉得这里的 value 是 undefined 怪怪的,也不确定,那我看看其他正常的 classesDirectory 是啥
其实到这里可以确定应该是 kotlin 的 classDirectory 在此时是不可用的状态,印证下自己猜想,尝试添加 catch 的断点,确实是这样
具体为啥此时还不可用,我没有更详细的深入了,有大佬知道的,可以不吝赐教下。
SO 搜了一波解答,看到一篇靠谱的回复 compile-groovy-and-kotlin.
compileGroovy.dependsOn compileKotlin
compileGroovy.classpath += files(compileKotlin.destinationDir)
复制代码
试了一下确实是可以的,但为啥这样可以了呢?以及最上面官方的代码是啥意思呢?还有一些奇奇怪怪的名词是啥,下面吹一下
关于 souceset
我们入门写 android 时,都看到 / 写过类似这样的代码
sourceSets {
main.java.srcDirs = ['src/java']
}
我对他的理解是指定 main sourceset 下的 java 的源码目录。 SourceSets 是一个 Sourset 的容器用来创建一个个的 SourceSet, 比如 main, test. 而 main 下的 java, groovy, kotlin 目录是一个编译目录(SourceDirectorySet),编译实质是找到一个个的编译目录,然后将他们变成 .class 文件放在 build/classes/sourceDirectorySet
下面, 也就是 destinationDirectory。
像 main 对应的是 SourceSet 接口,其实现是 DefaultSourceSet。而 main 下面的 groovy, java, kotlin 是 SourceDirectorySet 接口,其实现是 DefaultSourceDirectorySet。
官方 gradle 对于 sourceset 的定义是:
the source files and where they’re located 定位源码的位置
the compilation classpath, including any required dependencies (via Gradle configurations) 编译时的 class path
where the compiled class files are placed 编译出的 class 放在哪
输入文件 + 编译时 classpath 经过 AbstractCompile Task 得到 输出的 class 目录
第二个 编译时的 classpath,在项目里也见过,sourceSetImplementation 声明 sourceSet 的依赖。第三个我很少见到,印象不深,SourceDirectorySet#destinationDirectory 用来指定 compile task 的输出目录。而 SourceDirectorySet#classesDirectory 和这个值是一致的。再重申一遍这里的 SourceDirectorySet 想成是 DSL 里写的 java, groovy,kt 就好了。
官方文档对于 classesDirectory 的描述是
The directory property that is bound to the task that produces the output via
SourceDirectorySet.compiledBy(org.gradle.api.tasks.TaskProvider, java.util.function.Function)
. Use this as part of a classpath or input to another task to ensure that the output is created before it is used. Note: To define the path of the output folder useSourceDirectorySet.getDestinationDirectory()
大意是 classesDirectory 与这个 compile task 的输出是相关联的,具体是通过 SourceDirectorySet.compiledBy() 方法,这个字段由 destinationDirectory 字段决定。查看 DefaultSourceDirectorySet#compiledBy 方法
public <T extends Task> void compiledBy(TaskProvider<T> taskProvider, Function<T, DirectoryProperty> mapping) {
this.compileTaskProvider = taskProvider;
taskProvider.configure(task -> {
if (taskProvider == this.compileTaskProvider) {
mapping.apply(task).set(destinationDirectory);
}
});
classesDirectory.set(taskProvider.flatMap(mapping::apply));
}
雀食语义上 classesDirectory == destinationDirectory。
现在我们可以去理解下 官方的 demo 了,官方的 demo 简单说就是优先执行 Compile Groovy task, 再去执行 Compile Java task.
tasks.named('compileGroovy') {
classpath = sourceSets.main.compileClasspath // 1
}
tasks.named('compileJava') {
classpath += files(sourceSets.main.groovy.classesDirectory) // 2
}
可能看不懂的地方是 1,2 注释处做了啥, 1 处我问了我们组大佬,这是重置了 compileGroovy task 的 classpath 使其不依赖 compile java classpath,在 GroovyPlugin 源码中有那么一句代码
classpath.from((Callable<Object>) () -> sourceSet.getCompileClasspath().plus(target.files(sourceSet.getJava().getClassesDirectory())));
可以看到 GroovyPlugin 其实是依赖于 java 的 classpath 的。这里我们需要改变 groovy 和 java 的编译时序需要把这层依赖断开。
2呢,使 compileJava 依赖上 compileGroovy 的 output property,间接使 compileJava dependson compileGroovy 任务。
具体为啥 Kotlin 的不行,俺还没搞清楚,知道的大佬可以指教下。
而 SO 上的这个答复其实也是类似的,而且更直接
compileGroovy.dependsOn compileKotlin
compileGroovy.classpath += files(compileKotlin.destinationDir)
使 compileGroovy 依赖于 compileKotlin 任务,再让 compileGroovy 的 classPath 添加上 compileKotlin 的 output. 既然任务的 classPath 添加 另一个任务的 output 会自动依赖上另一个 task。那其实这么写也是可以的
compileGroovy.classpath += files(compileKotlin.destinationDir)
实验了下雀食是可以跑的. 那既然 Groovy 和 Java 都包含 main 的 classpath,是不是 compileKotlin 的 classpath 置为 main,那 compileGroovy 会自动依赖上 compileKotlin。试试呗
compileKotlin.classpath = sourceSets.main.compileClasspath
可以看到 kotlin 的执行顺序雀食跑到了最前面。
在项目实操中,我发现 Kotlin 跑在了 compile 的最前面,那其实 kotlin 的类里面是不能依赖 java 或者 groovy 的任何依赖的。这也符合预期,不然就会出现依赖成环,报 Circular dependsOn hierarchy found in the Kotlin source sets 错误。我个人观点这是一种对历史代码改造的折衷,在新需求上使用 kotlin 进行开发,一些功能相同的工具类能翻译成 kt 就翻译,不能就重写一套。
小结
在这节讲了两种实现混编的方案。写法不同,本质都是使一个任务依赖另一个任务的 output
// 1
compileGroovy.classpath += files(compileKotlin.destinationDir)
// 2
compileKotlin.classpath = sourceSets.main.compileClasspath
我对于 SourceSet 和 SourceDirectorySet 的理解
项目中实践混编方案的现状
Groovy 有趣的语法糖
在写 Groovy 的过程中,我遇到一个头大的问题,代码看不懂,里面有一些奇奇怪怪没见过的语法糖,乍一看就懵了,你要不一起瞅瞅。
includes*.tasks
我司的仓库是大仓的结构,仓库和子仓之间是通过 Composite build 构建联系的。那么怎么使主仓的 task 触发 includeBuild 的仓库执行对应仓库呢?是通过这行代码实现的
tasks.register('publishDeps') {
dependsOn gradle.includedBuilds*.task(':publishIvyPublicationToIvyRepository')
}
这里的 includeBuilds*.task 后面的 *.task 是啥?includeBuilds 看源码发现是个 List。我不懂 groovy,但好歹我能看懂 kotlin, 我看看官方文档右边对应的 kt 写法是啥?
tasks.register("publishDeps") {
dependsOn(gradle.includedBuilds.map { it.task(":publishMavenPublicationToMavenRepository") })
}
咦嘿,原来是个 List 的 map 操作,骚里骚气的。翻了翻原来是个 groovy 的语法糖,写个代码试试看看他编译到 class 是啥样子
def list = ["1", "22", "333"]
def lengths = list*.size()
lengths.forEach{
println it
}
编译成 class
Object list = ScriptBytecodeAdapter.createList(new Object[]{"1", "22", "333"});
Object lengths = ScriptBytecodeAdapter.invokeMethod0SpreadSafe(Groovy.class, list, (String)"size");
var1[0].call(lengths, new Groovy._closure1(this, this));
在 ScriptBytecodeAdapter.invokeMethod0SpreadSafe 实现内部其实还是新建了一个 List 再逐个对 List 中元素进行 map.
String.execute
这是执行一个 shell 指令,比如 "ls -al".execute(), 刚看到这个的时候认为这个东西类似 kotlin 的扩展函数,点进去看实现发现不一样
public static Process execute(final String self) throws IOException {
return Runtime.getRuntime().exec(self);
}
可以看到 receiver 是他的第一个参数,莫非这是通用的语法糖,我试试写了个
public static String deco(final String self) throws IOException {
return self + "deco"
}
// println "".deco()
运行下,哦吼,跑不了,报了 MissingMethodException。看样子是不通用的。翻了翻 groovy 文档,找到了这个文档
Static methods are used with the first parameter being the destination class, i.e.
public static String reverse(String self)
provides areverse()
method forString
.
看样子这个语法糖是 groovy 内部定制的,我不清楚有没有支持开发定制的方式,知道的大佬可以评论区留言下。
Range 怎么写
groovy 也有类似 kotlin 的 Range 的概念,包含的 Range 是 ..
, 不包含右边界(until)的是 ..<
Try with resources
我遇到过一个 OKHttp 连接泄露的问题,代码原型大概是这样
if (xxx) {
response.close()
} else {
// behavior
}
定位到是 Response 没有在 else 的分支上进行 close,当然可以简单在 else 分支上进行 close, 并在外层补上 try, catch 兜底,但在 Effective Java
一书提及针对资源关闭 try-with-resource 优于 try cactch。但我尝试像 java 一样写 try-with-resource,发现嗝屁了,直接报红,我去 SO 上搜了一波 groovy 的 try-with-resource. Groovy 是通过 withCloseable 扩展来实现,看这个方法的声明与 Process#execute 语法糖类似—public static def withCloseable(Closeable self, Closure action) . 最终改造后的代码是这样的
Response.withCloseable { reponse ->
if (xxx) {
} else {
}
}
<<
这个是 groovy 中的左移运算符也是可以重载的,而 kotlin 是不支持的。他运用比较多的场景。起初我印象中 Task 的 是覆写了这个运算符作为 doLast 简易写法,现在 gradle7.X 的版本上是没有了。其它常见的是文件写入操作, 列表添加元素。
def file = new File("xxx")
file << "text"
def list = []
list << "aaa"
Groovy 的一家之言
如果 kotlin 是 better java, 那么 groovy 应该是 more than java,它的定位更加偏向脚本一些,更加动态化(从它反编译的字节码可见一斑),上手曲线较高,但一个人精通这个语言,并且独立维护一个项目,其实 groovy 的开发效率并不会比 kotlin 和 java 差,感受比较深切的是 maven publish 的例子,看看插件中 groovy 和 kotlin 的写法上的不同。
// Groovy
def mavenSettings = {
groupId 'org.gradle.sample'
artifactId 'library'
version '1.1'
}
def repSettings = {
repositories {
maven {
url = mavenUrl
}
}
}
afterEvaluate {
publishing {
publications {
maven(MavenPublication) {
ConfigureUtil.configure(mavenSettings, it)
from components.java
}
}
ConfigureUtil.configure(repoSettings, it)
}
def publication = publishing.publications.'maven' as MavenPublication
publication.pom.withXml {
// inject msg
}
}
// Kotlin
// Codes are borrowed from (sonatype-publish-plugin)[https://github.com/johnsonlee/sonatype-publish-plugin/]
fun Project.publishing(
config: PublishingExtension.() -> Unit
) = extensions.configure(PublishingExtension::class.java, config)
val Project.publishing: PublishingExtension
get() = extensions.getByType(PublishingExtension::class.java)
val mavenClosure = closureOf<MavenPublication> {
groupId = "org.gradle.sample"
artifactId = "library"
version = "1.1"
}
val repClosure = closureOf<PublishingExtension> {
repositories {
maven {
url = mavenUrl
}
}
}
afterEvaluate {
publishing {
publications {
create<MavenPublication>("maven") {
ConfigureUtil.configure(mavenClosure, this)
from(components["java"])
}
}
ConfigureUtil.configure(repoClosure, this)
}
val publication = publishing.publications["maven"] as MavenPublication
publication.pom.withXml {
// inject msg
}
}
我觉得吧,如果像我们大佬擅长 groovy 的话,而且是一个人开发的商业项目,插件里的确写 groovy 会更快,更简洁,那为什么不呢?这对他来说是种善,语言没有优劣,动态性和静态语言优劣我不想较高下,这因人而异。
我选择 kotlin 是俺不擅长写 groovy 啊,我写了几个月 groovy 每次改动插件发布后再应用第一次都会有语法错误,调试的头皮发麻,所以最后搞了个折衷方案,新代码用 kotlin, 旧代码用 groovy 继续写。而且参考了 KOGE@2BAB 文档,发现咦,gradle 正面回应过 groovy 与 kotlin 之争. "Prefer using a statically-typed language to implement a plugin"@Gradle。嗯, 我还是继续写 Kotlin 吧。
作者:小灵通
来源:juejin.cn/post/7084949825866694686