KSPについて導入を学んだので記事にします。
公式ドキュメントを読んでのまとめです。KotlinPoetとの併用を一部書きますが、詳しくはドキュメントをみてくださいm(_ _)m
概要
軽量なコンパイラ・プラグインを開発するためのAPI
クラス、クラス・メンバー、関数、関連するパラメータにアクセスし、解析してコードを生成することができる。
メリット
- KSPはKotlinのコンパイラと直接統合されいるため、Javaのアノテーションプロセッサやkaptよりも高速に動作
- Kotlinの型システムにより、型の安全性を保持しながらコード生成や解析を行うことができるとのこと。
デメリット
- コンパイラの背景知識や、特定のコンパイラの実装の詳細にある程度精通している必要がある
- プラグインは特定のコンパイラのバージョンと密接に結びついていることが多いため、コンパイラのバージョンアップは都度プラグインを更新を必要とすることが多い
( が、KSPはコンパイラの変更を隠蔽するように設計されているとのことなので、メンテのコストは他のコンパイラプラグインより低いことを謳っている )
他のコンパイラプラグインとの比較
- kotlinc
kotlincとの比較。KSPはkotlincのサブセット。kotlincではできてKSPではできないことがある代わりに、一般的なユースケースを満たそうとしている。
例えば、kotlincでは、コードの改変ができるがKSPではできない。KSPで提供している主な機能は、アノテーションなどのシンボルからコードを生成することに絞られている
KSPはソースコードの式レベルの情報を調査したり、ソースコードを動的に変更したりすることはできず、そもそもそれを目的にしてない。
- reflection
reflectionとの比較 。KSPは型参照を明示的に解決する必要がある。reflectionより型安全によっている。
- kapt
kaptとの比較。kaptはJavaアノテーションプロセッサを使用しているのでJVMに縛られる。KSPはJVMに縛られないビルドパフォーマンスの向上、より慣用的なKotlin API、Kotlin専用のシンボルを理解できる。
要するに、kaptよりもKotlinに最適化されている。
使用方法
エントリポイントとなるSymbolProcessorProviderを定義
interface SymbolProcessorProvider {
fun create(environment: SymbolProcessorEnvironment): SymbolProcessor
}
このProviderはMETAINFを定義してコンパイラに伝えるとコード生成を行なってくれる。
SymbolProcessorを実装する
interface SymbolProcessor {
fun process(resolver: Resolver): List<KSAnnotated>
fun finish() {}
fun onError() {}
}
processに処理を記述する
- Resolverはシンボル、ソースコード内の各要素(クラス、関数、プロパティ、型パラメータ、コンストラクタなど。)にアクセスするためのインターフェース
- 以下のように、MyAnnotaionがついたシンボル( KSAnnotated)を取得したりできる
override fun process(resolver: Resolver): List<KSAnnotated> {
val symbols = resolver.getSymbolsWithAnnotation("com.example.MyAnnotation")
symbols.forEach { symbol ->
// 何らかの処理
}
return emptyList() // この例では空のリストを返していますが、実際には処理すべきアノテーションを含むシンボルのリストを返すことができます。
}
シンボルはKSNode
という基底インターフェースがあり、
interface KSNode {
val origin: Origin
val location: Location
val parent: KSNode?
fun <D, R> accept(visitor: KSVisitor<D, R>, data: D): R
}
対象のsymbolをKSVisiterを用いてコード生成処理をacceptできる。これは以下サンプルのBuilderVisitorが良い例で、サンプルではクラスのビルダーをKSPで生成している。
ちなみに、ここの設計思想はVisiterパターン というGofのものがベース
KSVisiterの訪問処理をざっくりまとめると以下になる
defaultのハンドラ。
defaultHandler(node: KSNode, data: D): R
アノテーションが付与されたシンボルを訪問する際の処理。
visitAnnotated(annotated: KSAnnotated, data: D): R
クラス宣言を訪問する際の処理。
visitClassDeclaration(classDeclaration: KSClassDeclaration, data: D): R
関数宣言を訪問する際の処理。
visitFunctionDeclaration(function: KSFunctionDeclaration, data: D): R
プロパティ宣言を訪問する際の処理。
visitPropertyDeclaration(property: KSPropertyDeclaration, data: D): R
タイプエイリアスを訪問する際の処理。
visitTypeAlias(typeAlias: KSTypeAlias, data: D): R
タイプパラメータを訪問する際の処理。
visitTypeParameter(typeParameter: KSTypeParameter, data: D): R
関数やコンストラクタの値パラメータを訪問する際の処理。
visitValueParameter(valueParameter: KSValueParameter, data: D): R
各シンボルへの参照方法は、公式でダイアグラムをまとめてくれているので参考になる
Kotlin Poet
というKotlinのコードをプログラム的に生成するための強力なライブラリがあり、以下のようなに(比較的)型安全にコード生成用のコードが書ける
val helloWorld = TypeSpec.classBuilder("HelloWorld")
.addFunction(FunSpec.builder("printHello")
.addStatement("println(%S)", "Hello, KotlinPoet!")
.build())
.build()
val file = FileSpec.builder("com.example", "HelloWorld")
.addType(helloWorld)
.build()
file.writeTo(System.out)
// 上記から以下を生成できる
package com.example
class HelloWorld {
fun printHello() {
println("Hello, KotlinPoet!")
}
}
これと組み合わせると、Visiter内を型安全に生成コードを書きつつ、可読性が上がる。
サンプルのBuilderVisitorのvisitFunctionDeclaration
をKotlin Poetで書き直してみると以下になる
val parent = function.parentDeclaration as KSClassDeclaration
val packageName = parent.containingFile!!.packageName.asString()
val className = "${parent.simpleName.asString()}Builder"
val fileSpecBuilder = FileSpec.builder(packageName, className)
.addImport("HELLO", "")
val classBuilder = TypeSpec.classBuilder(className)
function.parameters.forEach { parameter ->
val name = parameter.name!!.asString()
val typeName = parameter.type.resolve().toTypeName()
// Property
classBuilder.addProperty(
PropertySpec.builder(name, typeName.copy(nullable = true))
.mutable(true)
.initializer("null")
.build()
)
// Function
classBuilder.addFunction(
FunSpec.builder("with${name.replaceFirstChar { it.uppercase() }}")
.returns(ClassName(packageName, className))
.addParameter(name, typeName)
.addStatement("this.$name = $name")
.addStatement("return this")
.build()
)
}
// Build function
classBuilder.addFunction(
FunSpec.builder("build")
.returns(ClassName(packageName, parent.simpleName.asString()))
.addStatement(
"return %T(${function.parameters.joinToString { "${it.name!!.asString()}!!" }})",
ClassName(packageName, parent.simpleName.asString())
)
.build()
)
fileSpecBuilder.addType(classBuilder.build())
val fileSpec = fileSpecBuilder.build()
fileSpec.writeTo(codeGenerator, Dependencies(true, function.containingFile!!))