我與 Groovy 不共戴天

語言: CN / TW / HK

來到新公司後,小靈通開始接手了核心技術-快編外掛,看到傳說中的核心技術,小靈通傻眼了,啊這,groovy 寫的外掛,groovy 認真的嘛,2202 年了,外掛咋還用 groovy 寫呢,我新手寫外掛也換 kotlin 了,張嘴就是 這輩子都不可能寫 groovy,甭想了。 但是嘛,工作不寒磣,學學唄。

一開始和組裡幾個大佬聊下來,磨刀霍霍準備對歷史程式碼動刀,全遷移到 kotlin 上爽一發,但發現。。。咦,程式碼好像看不懂誒,我不知道 kt 對應的寫法是啥樣的。文章結束,小靈通因此被辭退。

開個玩笑,我現在還是在崗狀態。工作還是要繼續的。既然能力有限我全部遷不過去,那我可以做到新需求用 kotlin 來寫嘛,咦,這就有意思了。

Groovy 和 java 以及 kotlin 如何混編

怎麼實現混編

我不會嘛,看看官方怎麼寫的。gradle 原始碼有這麼段程式碼來闡釋了是怎麼優先 groovy 編譯 而非 java 編譯. groovy // 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 了,歐耶!

groovy 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 時,都看到 / 寫過類似這樣的程式碼

groovy 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 方法

java    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.

groovy 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 原始碼中有那麼一句程式碼

groovy        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 上的這個答覆其實也是類似的,而且更直接

groovy compileGroovy.dependsOn compileKotlin compileGroovy.classpath += files(compileKotlin.destinationDir)

使 compileGroovy 依賴於 compileKotlin 任務,再讓 compileGroovy 的 classPath 新增上 compileKotlin 的 output. 既然任務的 classPath 新增 另一個任務的 output 會自動依賴上另一個 task。那其實這麼寫也是可以的

groovy 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

groovy // 1 compileGroovy.classpath += files(compileKotlin.destinationDir) // 2 compileKotlin.classpath = sourceSets.main.compileClasspath

  • 我對於 SourceSet 和 SourceDirectorySet 的理解
  • 專案中實踐混編方案的現狀

Groovy 有趣的語法糖

在寫 Groovy 的過程中,我遇到一個頭大的問題,程式碼看不懂,裡面有一些奇奇怪怪沒見過的語法糖,乍一看就懵了,你要不一起瞅瞅。

includes*.tasks

我司的倉庫是大倉的結構,倉庫和子倉之間是通過 Composite build 構建聯絡的。那麼怎麼使主倉的 task 觸發 includeBuild 的倉庫執行對應倉庫呢?是通過這行程式碼實現的

groovy tasks.register('publishDeps') {    dependsOn gradle.includedBuilds*.task(':publishIvyPublicationToIvyRepository') }

這裡的 includeBuilds.task 後面的 .task 是啥?includeBuilds 看原始碼發現是個 List。我不懂 groovy,但好歹我能看懂 kotlin, 我看看官方文件右邊對應的 kt 寫法是啥?

groovy tasks.register("publishDeps") {    dependsOn(gradle.includedBuilds.map { it.task(":publishMavenPublicationToMavenRepository") }) }

咦嘿,原來是個 List 的 map 操作,騷裡騷氣的。翻了翻原來是個 groovy 的語法糖,寫個程式碼試試看看他編譯到 class 是啥樣子

groovy def list = ["1", "22", "333"] def lengths = list*.size() lengths.forEach{    println it }

編譯成 class

java       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 的擴充套件函式,點進去看實現發現不一樣

java public static Process execute(final String self) throws IOException {        return Runtime.getRuntime().exec(self); }

可以看到 receiver 是他的第一個引數,莫非這是通用的語法糖,我試試寫了個

java 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 連線洩露的問題,程式碼原型大概是這樣

groovy 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) . 最終改造後的程式碼是這樣的

groovy Response.withCloseable { reponse ->  if (xxx) {     } else {     } }

<<

這個是 groovy 中的左移運算子也是可以過載的,而 kotlin 是不支援的。他運用比較多的場景。起初我印象中 Task 的 是覆寫了這個運算子作為 doLast 簡易寫法,現在 gradle7.X 的版本上是沒有了。其它常見的是檔案寫入操作, 列表新增元素。

groovy 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 // 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 // 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 {   groupId = "org.gradle.sample" artifactId = "library" version = "1.1" } val repClosure = closureOf {    repositories {        maven {            url = mavenUrl       }   } } afterEvaluate { publishing {    publications {        create("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 吧。