開發你的第一個 Kotlin 編譯器外掛

語言: CN / TW / HK

theme: smartblue highlight: a11y-dark


⚠️本文為稀土掘金技術社群首發簽約文章,14天內禁止轉載,14天后未獲授權禁止轉載,侵權必究!

前言

之前簡單介紹了Kotlin編譯器的主要結構以及K2編譯器是什麼,在此基礎上,我們一起來看下如何開發第一個Kotlin編譯器外掛(即KCP)。

其實Kotlin編譯器外掛也不是特別新的東西了,早在KotlinConf 2018上就有人做了相關的分享,本文主要內容也是學習這個分享的輸出,感興趣的同學可以直接去看視訊,連結見文末。

本文主要包括以下內容:

  1. KCP是什麼?為什麼使用KCP?
  2. KCP實戰示例
  3. KCP接入與測試

KCP是什麼?為什麼使用KCP?

KCP是什麼?

Kotlin的編譯過程,簡單來說就是將Kotlin原始碼編譯成目標產物的過程,具體步驟如下圖所示:

p12.png

KCP即Kotlin編譯器外掛,KCP在編譯過程中提供了Hook時機,讓我們可以在編譯過程中插入自己的邏輯,以達到修改編譯產物的目的。比如我們可以通過IrGenerationExtension來修改IR的生成,可以通過ClassBuilderInterceptorExtension修改位元組碼生成邏輯

為什麼使用KCP?

在簡單瞭解了KCP是什麼之後,你可能會問,那麼KCPKSP的區別是什麼?我們為什麼要使用KCP呢?

KCP的主要優勢在於它的功能強大,KSP只能生成程式碼,不能修改已有的程式碼,而KCP不僅可以生成程式碼,也可以通過修改IR,或者修改位元組碼等方式修改已有的程式碼邏輯

比如大家常用的kae外掛就是一個Kotlin編譯器外掛,接入kae外掛後,我們通過控制元件的id就可以獲取對應的View,其實控制元件的id在編譯後會被自動轉化成findCacheViewById方法,這是KSP或者其他註解處理器工具所不能實現的

還有在Compose中,給方法新增一個@Compose註解就可以將普通函式轉化為Compose函式,這也是通過KCP實現的

KCP同時具有優秀的IDE支援,比如kae可以直接從id跳轉到佈局,這是其它工具所不能實現的。比如ASM同樣可以修改位元組碼將id轉化成findCacheViewById方法,卻無法讓IDE支援

總得來說,KCP的主要有以下優勢

  1. 功能強大:不僅可以生成程式碼也可以修改已有的程式碼邏輯
  2. 優秀的IDE支援:Kotlin畢竟是Jetbrians的親兒子

為什麼不使用KCP?

  1. KCP目前還沒有穩定的公開API,需要等K2編譯器正式釋出後才會提供
  2. 開發成本較高,如下圖所示,一個KCP外掛通常包括Gradle外掛,編譯器外掛,IDE外掛(如果需要程式碼提示的話)三部分組成

p13.png

總得來說,KCP的優勢在於功能強大,缺點則在於目前還沒有穩定API,以及開發成本較高,各位可根據情況選擇是否使用

KCP實戰示例

接下來我們就一起來看看怎麼一步步實現一個編譯器外掛,首先來看下目標

技術目標

kotlin @DebugLog private fun simpleClick() { Thread.sleep(2000) }

我們要實現的目標很簡單,就是給所有添加了@DebugLog註解的方法,在方法執行前後列印一行日誌,即編譯後變成以下程式碼

kotlin private fun simpleClick() { DebugLogHelper.startMethod("simpleClick") Thread.sleep(2000) DebugLogHelper.stopMethod("simpleClick") }

程式碼其實很簡單,用ASM位元組碼插樁也可以實現同樣的效果,我們在這裡用KCP實現

KCP總體結構

如果我們的外掛不需要程式碼提示的話,通常由兩部分組成,即Gradle外掛與編譯器外掛,如下圖所示:

p14.png

  • PluginGradle 外掛用來讀取 Gradle 配置傳遞給 KCP
  • Subplugin:為 KCP 提供自定義 KPmaven 庫地址等配置資訊
  • CommandLineProcessor:負責將Gradle Plugin傳過來的引數轉換並校驗
  • ComponentRegistrar:負責將使用者自定義的各種Extension註冊到KP中,並在合適時機呼叫
  • Extension: 編譯器提供的hook時機,可在編譯過程中插入自定義的邏輯

在瞭解了總體結構後,我們接下來就一步一步地實現一個KCP外掛

Gradle外掛部分

Gradle外掛部分之前分為PluginSubplugin兩部分,現在在新版本中已經統一為KotlinCompilerPluginSupportPlugin,程式碼如下所示:

```kotlin class DebugLogGradlePlugin : KotlinCompilerPluginSupportPlugin { // 1. 讀取Gradle擴充套件配置資訊
override fun apply(target: Project): Unit = with(target) { extensions.create("debugLog", DebugLogGradleExtension::class.java) }

// 2. 定義編譯器外掛的唯一id,需要與後面編譯器外掛中定義的pluginId保持一致 override fun getCompilerPluginId(): String = BuildConfig.KOTLIN_PLUGIN_ID

// 3. 定義編譯器外掛的 Maven 座標資訊,便於編譯器下載它 override fun getPluginArtifact(): SubpluginArtifact = SubpluginArtifact( groupId = BuildConfig.KOTLIN_PLUGIN_GROUP, artifactId = BuildConfig.KOTLIN_PLUGIN_NAME, version = BuildConfig.KOTLIN_PLUGIN_VERSION )

override fun applyToCompilation( kotlinCompilation: KotlinCompilation<*> ): Provider> { // 4. 將extension的配置寫入SubpluginOptions,後續供kcp讀取 val annotationOptions = extension.annotations.map { SubpluginOption(key = "debugLogAnnotation", value = it) } val enabledOption = SubpluginOption(key = "enabled", value = extension.enabled.toString()) return project.provider { annotationOptions + enabledOption } } } ```

可以看出KotlinCompilerPluginSupportPlugin的主要有以下作用:

  1. 新增Gradle入口
  2. 讀取Gradle擴充套件配置資訊
  3. 定義KCP外掛idmaven座標
  4. Gradle的擴充套件配置資訊傳遞給KCP

自定義CommandLinProcessor

在定義了Gradle外掛之後,接下來就是編譯器外掛,編譯器外掛的入口是CommandLineProcessor

```kotlin @AutoService(CommandLineProcessor::class) class DebugLogCommandLineProcessor : CommandLineProcessor { // 1. 配置 Kotlin 外掛唯一 ID override val pluginId: String = BuildConfig.KOTLIN_PLUGIN_ID

// 2. 讀取 SubpluginOptions 引數,並寫入 CliOption override val pluginOptions: Collection = listOf( CliOption( optionName = OPTION_ENABLE, valueDescription = "", description = "whether to enable the debuglog plugin or not" ) )

// 3. 處理 CliOption 寫入 CompilerConfiguration override fun processOption( option: AbstractCliOption, value: String, configuration: CompilerConfiguration ) { return when (option.optionName) { OPTION_ENABLE -> configuration.put(ARG_ENABLE, value.toBoolean()) OPTION_ANNOTATION -> configuration.appendList(ARG_ANNOTATION, value) else -> throw IllegalArgumentException("Unexpected config option ${option.optionName}") } } } ```

可以看出,CommandLinProcessor的主要作用就是定義外掛ID與讀取Gradle外掛傳遞過來的引數,並存儲在CompilerConfiguration

你可能會好奇,為什麼這個類的名字叫CommandLineProcessor,這應該是因為Kotlin編譯器也可以直接通過命令列呼叫,然後可以通過引數呼叫編譯器外掛,比如官方提供的all-open外掛可通過以下方式呼叫

-Xplugin=$KOTLIN_HOME/lib/allopen-compiler-plugin.jar -P plugin:org.jetbrains.kotlin.allopen:annotation=com.my.Annotation -P plugin:org.jetbrains.kotlin.allopen:preset=spring

CommandLinProcessor應該最開始就是用來處理命令列的輸入引數的,因此起了這樣的名字

自定義ComponentRegistrar

```kotlin @AutoService(ComponentRegistrar::class) class DebugLogComponentRegistrar : ComponentRegistrar {

override fun registerProjectComponents( project: MockProject, configuration: CompilerConfiguration ) { ClassBuilderInterceptorExtension.registerExtension( project, DebugLogClassGenerationInterceptor( debugLogAnnotations = configuration[ARG_ANNOTATION] ) ) } } ```

自定義ComponentRegistrar的作用就是註冊各種extension,並在編譯器編譯的各種時機回撥,常用的extension包括:

  • IrGenerationExtension:在編譯器生成ir時回撥,可以在這個階段對ir進行修改
  • ClassBuilderInterceptorExtension:在生成位元組碼時回撥,可以在這個階段對位元組碼進行修改

我們這裡使用的是ClassBuilderInterceptorExtension

自定義ClassBuilderInterceptorExtension

```kotlin class DebugLogClassGenerationInterceptor( val debugLogAnnotations: List ) : ClassBuilderInterceptorExtension { override fun interceptClassBuilderFactory(): ClassBuilderFactory = object : ClassBuilderFactory by interceptedFactory { override fun newClassBuilder(origin: JvmDeclarationOrigin) = DebugLogClassBuilder(debugLogAnnotations, interceptedFactory.newClassBuilder(origin)) }

}

internal class DebugLogClassBuilder() : DelegatingClassBuilder(delegateBuilder) { override fun newMethod(): MethodVisitor { // ... return object : MethodVisitor(Opcodes.ASM5, original) { override fun visitCode() { // 進入方法時 InstructionAdapter(this).onEnterFunction(function) }

        override fun visitInsn(opcode: Int) {
            when (opcode) {
                // 退出方法時
                Opcodes.ARETURN-> {
                    InstructionAdapter(this).onExitFunction(function)
                }
            }
        }
    }
}

}

// 修改位元組碼 private fun InstructionAdapter.onEnterFunction(function: FunctionDescriptor) { visitLdcInsn("${function.name}") invokestatic("com/zj/kcp_start/DebugLogHelper", "startMethod", "(Ljava/lang/String;)V", false) }

private fun InstructionAdapter.onExitFunction(function: FunctionDescriptor) { visitLdcInsn("${function.name}") invokestatic("com/zj/kcp_start/DebugLogHelper", "stopMethod", "(Ljava/lang/String;)V", false) } ```

可以看出,這一步主要是通過位元組碼在方法進入與退出時分別插入了一段程式碼,而且這裡操作位元組碼的APIASM基本一致,只是換了包名,在這裡就不綴述ASM的用法了

最後,到了這裡,一個簡單的KCP外掛也就完成了

KCP接入與測試

KCP外掛開發完成後,該怎麼接入與測試呢?

接入的話,其實比較簡單,你可以直接把外掛釋出,或者includeBuild外掛專案,這兩種方式都可以通過plugin id引入

```

build.gradle

plugins { id("com.zj.debuglog.kotlin-plugin") apply false } ```

不過如果你的外掛還在開發階段,通過以上方式測試就有些麻煩了,我們可以使用kotlin-compile-testing庫來為自定義KCP開發單元測試

該庫允許你在測試中使用自定義KCP編譯Kotlin原始碼,這使除錯變得容易,如果你想執行這些原始檔,也可以使用ClassLoader載入生成的編譯產物

```kotlin class PluginTest { // 1. 定義原始碼 private val main = SourceFile.kotlin( "main.kt", """ import com.zj.kcp_start.DebugLog fun main() { doSomething() } @DebugLog fun doSomething() { Thread.sleep(15) } """ )

@Test
fun simpleTest() {
    // 2. 傳入自定義編譯器外掛,呼叫`Kotlin`編譯器編譯原始碼
    val result = compile(
        sourceFile = main,
        DebugLogComponentRegistrar() // 自定義KCP
    )
    assertEquals(KotlinCompilation.ExitCode.OK, result.exitCode)
    // 3. 執行編譯生成的MainKt檔案並獲取輸出
    val out = invokeMain(result, "MainKt").trim().split("""\r?\n+""".toRegex())
    // 4. 驗證輸出是否與預期一致 
    assert(out.size == 2)
    assert(out[0] == "doSomething 方法開始執行")
    assert(out[1] == "doSomething 方法執行結束")
}

} ```

如上就是一個簡單的單測,主要做了這麼幾件事:
1. 定義測試用例原始碼 2. 傳入自定義編譯器外掛,呼叫Kotlin編譯器編譯原始碼 3. 執行編譯生成的MainKt位元組碼檔案並獲取輸出 4. 獲取執行程式碼的輸出,看看是否與預期一致,比如我們這裡預期方法有兩個輸出,在方法開始與結束時會分別列印一串字串

通過這種方式就可以在開發KCP階段快速驗證,及時發現問題

總結

本文主要介紹瞭如何一步一步地開發自定義KCP外掛,自定義編譯器外掛功能非常強大,當你需要做一些“黑科技”操作的時候或許會用得上。

同時KotlinCompose的原始碼中也大量用到了KCP外掛,瞭解KCP也可以方便你看懂它們的原始碼,瞭解它們到底是怎麼實現的,希望本文對你有所幫助~

示例程式碼

本文所有程式碼可見:https://github.com/RicardoJiang/KCP-Start

參考資料

Writing Your Second Kotlin Compiler Plugin, Part 1 — Project Setup
KotlinConf 2018 - Writing Your First Kotlin Compiler Plugin by Kevin Most