第一个编译器插件:创建Gradle项目、注册编译器插件、编写简单的IR转换
好,咱们正式开始动手了。
前面聊了那么多理论,说实话,我自己写第一个Kotlin编译器插件的时候,也踩了不少坑。今天这一章,我带你从零搭建一个完整的项目。目标很明确:创建一个Gradle项目,注册一个编译器插件,再写一个最简单的IR转换——把函数调用日志打印出来。
你想想看,这就像学开车,先点火、挂挡、踩油门。咱们先把流程跑通,后面再玩花活。
1. 创建Gradle项目
首先,你得有个Gradle项目。我个人习惯用IntelliJ IDEA来创建,但用命令行也一样。
项目结构大概长这样:
my-first-plugin/
├── build.gradle.kts
├── settings.gradle.kts
├── src/
│ ├── main/
│ │ ├── kotlin/
│ │ │ └── com/example/
│ │ │ └── MyFirstPluginRegistrar.kt
│ │ └── resources/
│ │ └── META-INF/
│ │ └── services/
│ │ └── org.jetbrains.kotlin.compiler.plugin.CompilerPluginRegistrar
│ └── test/
│ └── kotlin/
│ └── com/example/
│ └── TestCase.kt
嗯,这里要注意:META-INF/services 这个目录是Java SPI机制用的,Kotlin编译器插件也是通过它来发现你的插件。我第一次写的时候忘了加这个文件,结果插件死活不生效,折腾了半天。
2. 配置build.gradle.kts
接下来是构建脚本。这里有几个关键点:
- 必须依赖
kotlin-compiler-embeddable,这是编译器的核心库 - 需要
kotlin-stdlib作为基础库 - 插件本身要打包成JAR,然后被其他项目引用
plugins {
kotlin("jvm") version "1.9.0"
}
dependencies {
implementation("org.jetbrains.kotlin:kotlin-compiler-embeddable:1.9.0")
implementation("org.jetbrains.kotlin:kotlin-stdlib:1.9.0")
}
tasks.jar {
manifest {
attributes["Implementation-Title"] = "MyFirstPlugin"
}
}
我建议你直接用Kotlin DSL,可读性更好。如果你用Groovy DSL,其实也行,但容易写错括号。
3. 注册编译器插件
这一步是核心。你需要实现 CompilerPluginRegistrar 接口,告诉编译器:嘿,我来了,我要干活。
package com.example
import org.jetbrains.kotlin.compiler.plugin.CompilerPluginRegistrar
import org.jetbrains.kotlin.compiler.plugin.ExperimentalCompilerApi
import org.jetbrains.kotlin.config.CompilerConfiguration
@OptIn(ExperimentalCompilerApi::class)
class MyFirstPluginRegistrar : CompilerPluginRegistrar() {
override val supportsK2: Boolean
get() = true
override fun ExtensionStorage.registerExtensions(configuration: CompilerConfiguration) {
println("MyFirstPlugin: 注册成功!")
// 这里后面会注册IR转换
}
}
然后,在 META-INF/services/org.jetbrains.kotlin.compiler.plugin.CompilerPluginRegistrar 文件中,写上你的全限定类名:
com.example.MyFirstPluginRegistrar
我曾经犯过一个低级错误:文件名拼写错了,少写了一个字母。结果编译器完全没反应,我还以为是代码写错了。所以,文件名一定要一字不差。
4. 编写简单的IR转换
好了,现在插件已经注册成功了。但光注册没意义,咱们得干点实事。
最简单的IR转换是什么?我选的是:在每个函数调用之前,插入一条打印日志的IR指令。说白了,就是给每个函数调用加个“前置通知”。
先定义一个IR转换类:
package com.example
import org.jetbrains.kotlin.ir.IrElement
import org.jetbrains.kotlin.ir.visitors.IrElementTransformerVoid
import org.jetbrains.kotlin.ir.expressions.IrCall
import org.jetbrains.kotlin.ir.builders.*
import org.jetbrains.kotlin.ir.declarations.IrFunction
import org.jetbrains.kotlin.ir.expressions.IrExpression
import org.jetbrains.kotlin.ir.types.classOrNull
import org.jetbrains.kotlin.name.FqName
class LoggingTransformer : IrElementTransformerVoid() {
override fun visitCall(expression: IrCall): IrExpression {
// 先处理子节点,确保递归
val transformed = super.visitCall(expression) as IrCall
// 获取被调用的函数名
val calleeName = transformed.symbol.owner.name.asString()
// 构建一个打印日志的IR
val irBuilder = context.createIrBuilder(transformed.symbol)
val logCall = irBuilder.irCall(
symbol = context.referenceFunctions(
FqName("kotlin.io.println")
).first()
).apply {
putValueArgument(0, irBuilder.irString("调用函数: $calleeName"))
}
// 返回一个包含日志和原始调用的复合表达式
return irBuilder.irBlock {
+logCall
+transformed
}
}
}
你可能会问:为什么要用 IrElementTransformerVoid?因为它可以遍历整个IR树,并且允许你替换节点。我刚开始用 IrElementVisitor,结果发现只能看不能改,白忙活一场。
然后,在注册器里把这个转换挂上去:
override fun ExtensionStorage.registerExtensions(configuration: CompilerConfiguration) {
IrGenerationExtension.registerExtension(object : IrGenerationExtension {
override fun generate(moduleFragment: IrModuleFragment, pluginContext: IrPluginContext) {
moduleFragment.transform(LoggingTransformer(), null)
}
})
}
5. 测试你的插件
写完了怎么验证?我一般写一个简单的测试文件:
fun main() {
sayHello()
add(1, 2)
}
fun sayHello() {
println("Hello!")
}
fun add(a: Int, b: Int): Int {
return a + b
}
编译运行后,你应该能看到类似这样的输出:
调用函数: sayHello
Hello!
调用函数: add
3
注意:这里有个坑。如果你的插件没有生效,先检查 META-INF/services 文件是否正确。我曾经把文件名写成了 CompilerPluginRegistrar 而不是全限定名,结果编译器直接忽略了我。
6. 避坑指南
- 插件不加载:检查
META-INF/services文件路径和内容,一个字符都不能错 - IR转换不生效:确认你注册的是
IrGenerationExtension而不是IrElementVisitor - 编译报错:确保你的Kotlin版本和编译器插件版本一致,我见过太多版本不匹配的悲剧
调试IR转换时,可以用 IrElement.dump() 打印IR树结构。这招帮我找到了很多隐藏的bug。
7. 总结
这一章我们完成了三件事:
- 创建了Gradle项目,配置了依赖
- 注册了编译器插件,让Kotlin编译器认识我们
- 写了一个简单的IR转换,给每个函数调用加上了日志
说实话,这个插件还很简陋。但万丈高楼平地起,你想想看,现在你已经掌握了编译器插件的完整开发流程。后面几章,我们会在这个基础上做更复杂的转换,比如修改函数体、插入新的类、甚至生成全新的代码。
下一章,我会带你深入IR的内部结构,看看那些节点到底长什么样。到时候,你就能真正理解“元编程”的威力了。
核心要点回顾
| 步骤 | 关键文件/类 | 作用 |
|---|---|---|
| 1. 创建项目 | build.gradle.kts | 配置依赖和打包 |
| 2. 注册插件 | CompilerPluginRegistrar + SPI文件 | 让编译器发现插件 |
| 3. 编写IR转换 | IrElementTransformerVoid | 修改IR树 |
| 4. 挂载转换 | IrGenerationExtension | 在编译阶段执行转换 |
好,今天就到这里。动手试试吧,遇到问题别怕,编译器插件的调试虽然麻烦,但每次成功运行时的成就感,绝对值得。