我与 Groovy 不共戴天

5,035 阅读8分钟

来到新公司后,小灵通开始接手了核心技术-快编插件,看到传说中的核心技术,小灵通傻眼了,啊这,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)
}

跑一发,没有意外的话,你会看到这个报错。

image-20220404182245279.png

诶,为啥我照着抄就跑不起来呢?我怀疑是 kotlin classesDiretory 有问题,断点看一波 compileGroovy 这个 task 的 sourceSets.main.kotlin.classesDirectory 是个啥。大概长这样, 是个 DefaultDirectoryVar 类。

image-20220404183057111.png

诶,这是个啥,一开始我也看不太懂,觉得这里的 value 是 undefined 怪怪的,也不确定,那我看看其他正常的 classesDirectory 是啥

image-20220404183309092.png

其实到这里可以确定应该是 kotlin 的 classDirectory 在此时是不可用的状态,印证下自己猜想,尝试添加 catch 的断点,确实是这样

image-20220404183633448.png 具体为啥此时还不可用,我没有更详细的深入了,有大佬知道的,可以不吝赐教下。

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 目录

java sourcesets compilation

第二个 编译时的 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 use SourceDirectorySet.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

image-20220405151016867.png 可以看到 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 a reverse() method for String.

看样子这个语法糖是 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 会更快,更简洁,那为什么不呢?这对他来说是种善,语言没有优劣,动态性和静态语言优劣我不想较高下,这因人而异。 image.png 我选择 kotlin 是俺不擅长写 groovy 啊,我写了几个月 groovy 每次改动插件发布后再应用第一次都会有语法错误,调试的头皮发麻,所以最后搞了个折衷方案,新代码用 kotlin, 旧代码用 groovy 继续写。而且参考了 KOGE@2BAB 文档,发现咦,gradle 正面回应过 groovy 与 kotlin 之争. "Prefer using a statically-typed language to implement a plugin"@Gradle。嗯, 我还是继续写 Kotlin 吧。