開發你的第一個 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