开发你的第一个 Kotlin 编译器插件

2022-09-20  本文已影响0人  super可乐

前言

之前简单介绍了Kotlin编译器的主要结构以及K2编译器是什么,在此基础上,我们一起来看下如何开发第一个Kotlin编译器插件(即KCP),本文主要包括以下内容:

  1. KCP是什么?为什么使用KCP?
  2. KCP实战示例
  3. KCP接入与测试

KCP是什么?为什么使用KCP?

KCP是什么?

Kotlin的编译过程,简单来说就是将Kotlin源代码编译成目标产物的过程,具体步骤如下图所示:

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插件(如果需要代码提示的话)三部分组成

总得来说,KCP的优势在于功能强大,缺点则在于目前还没有稳定API,以及开发成本较高,各位可根据情况选择是否使用

KCP实战示例

接下来我们就一起来看看怎么一步步实现一个编译器插件,首先来看下目标

技术目标

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

我们要实现的目标很简单,就是给所有添加了@DebugLog注解的方法,在方法执行前后打印一行日志,即编译后变成以下代码

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

代码其实很简单,用ASM字节码插桩也可以实现同样的效果,我们在这里用KCP实现

KCP总体结构

如果我们的插件不需要代码提示的话,通常由两部分组成,即Gradle插件与编译器插件,如下图所示:

在了解了总体结构后,我们接下来就一步一步地实现一个KCP插件

Gradle插件部分

Gradle插件部分之前分为PluginSubplugin两部分,现在在新版本中已经统一为KotlinCompilerPluginSupportPlugin,代码如下所示:

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<List<SubpluginOption>> {
    // 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

@AutoService(CommandLineProcessor::class)
class DebugLogCommandLineProcessor : CommandLineProcessor {
   // 1\. 配置 Kotlin 插件唯一 ID
  override val pluginId: String = BuildConfig.KOTLIN_PLUGIN_ID

  // 2\. 读取 `SubpluginOptions` 参数,并写入 `CliOption`
  override val pluginOptions: Collection<CliOption> = listOf(
    CliOption(
      optionName = OPTION_ENABLE, valueDescription = "<true|false>",
      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

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

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

自定义ComponentRegistrar的作用就是注册各种extension,并在编译器编译的各种时机回调,常用的extension包括:

我们这里使用的是ClassBuilderInterceptorExtension

自定义ClassBuilderInterceptorExtension

class DebugLogClassGenerationInterceptor(
    val debugLogAnnotations: List<String>
) : 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加载生成的编译产物

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也可以方便你看懂它们的源码,了解它们到底是怎么实现的,希望本文对你有所帮助~

示例代码

本文所有代码可见:github.com/RicardoJian…

参考资料

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

作者:程序员江同学
链接:https://juejin.cn/post/7144873690319028255

上一篇 下一篇

猜你喜欢

热点阅读