Kotlin Symbol Processing APIについて
概要
Kotlin Symbol Processing(以下KSP)は、軽量なコンパイラプラグイン(もしくはプリプロセッサ)の1つ。
アノテーション情報等を元にコードの生成を行うプロセッサを作成することができる。
ただし、もともと存在するソースコードは読み取り限定として扱うため、上書き及び編集は不可能である。(作成者および利用者が混乱するのを避けるため)。これをやりたいならkotlinc compiler pluginに手を出す必要がある。
大体以下のような流れで処理が進む。
1. KSPで作成したプロセッサがアノテーションを読み取り、解析する
2. 解析情報を元に、ソースコード等を生成する
3. 生成されたコードも含めてコンパイルされる
利点
KSPを用いることでParcelableのようなものや、ボイラーコードを書いてくれるプロセッサが作れる
他のコンパイラプラグインと比べて、JVMのバージョンなどに依存しない
Javaのプラグインを利用するkaptよりもパフォーマンスがいい(Gildeの場合、コンパイル時間を25%削減)。 kotlinc compiler pluginを使う時よりも簡単にかけて、またAPIの変更がないように努力してくれるらしい
JVMに依存しないため、他のプラットフォームでも利用可能
欠点
ソースコードの編集は不可能
ソースコードを式レベルで解析することは不可能
Java Annotation Processing APIで書かれたものに対する互換性はない
IDEが生成されたコードを識別しないことがある(IDEA系に関しては、以下にある「IDEAに検知されないとき」で対処可能)
実装方法
簡単に以下のステップに分かれる
1. 依存関係を追加
実装するときは以下の依存関係を追加する。
code:build.gradle.kts
dependencies {
implementation("com.google.devtools.ksp:symbol-processing-api:1.8.10-1.0.9")
}
2. SymbolProcessorを実装する
SymbolProcessorというインターフェイスが用意されているため、こちらを実装する。
code:SymbolProcessor.kt
interface SymbolProcessor {
// コード生成時に呼ばれる部分で、コードが生成されなくなるまで呼ばれ続ける
fun process(resolver: Resolver): List<KSAnnotated>
// 終了時に呼ばれるコード
fun finish() {}
// processでのエラー時に呼ばれるコード
fun onError() {}
}
こうすることで、実際の処理を行うプロセッサを作成する。
例:Sample.ktを生成するコード
code:SampleSymbolProcessor.kt
class SampleSymbolProcessor(
private val codeGenerator: CodeGenerator,
private val logger: KSPLogger,
) : SymbolProcessor {
var invoked = false
override fun process(resolver: Resolver): List<KSAnnotated> {
if (invoked) return emptyList()
codeGenerator.createNewFile(
Dependencies(false),
"com.example",
"Sample",
).use { it.write("val sample = 1".toByteArray()) }
invoked = true
return emptyList()
}
}
3. SymbolProcessorProviderを実装する
SymbolProcessorProviderというインターフェイスが用意されているため、こちらを実装する(Providerになっている)。
code:SymbolProcessorProvider.kt
fun interface SymbolProcessorProvider {
fun create(environment: SymbolProcessorEnvironment): SymbolProcessor
}
こうすることで1で作成したSymbolProcessorの実装クラスのファクトリーを実装する。
例:1で記述したSampleSymbolProcessorの場合
code:SampleSymbolProcessorProvider.kt
class SampleSymbolProcessorProvider : SymbolProcessorProvider {
override fun create(environment: SymbolProcessorEnvironment): SymbolProcessor =
SampleSymbolProcessor(
codeGenerator = environment.codeGenerator,
logger = environment.logger,
)
}
4. META-INFに登録する
3で作成したSymbolProcessorProviderの実装クラスをMETA-INFに登録する。
登録場所はMETA-INF/services/com.google.devtools.ksp.processing.SymbolProcessorProvider
ここに実装クラスのルートモジュールからのパス(qualified name)を記述しておく
例
code:com.google.devtools.ksp.processing.SymbolProcessorProvider
com.example.SampleSymbolProcessorProvider
KSPを利用する方法
依存関係を追記すればよい。
code:build.gradle.kts
plugins {
id("com.google.devtools.ksp") version "1.8.10-1.0.9"
kotlin("jvm")
}
dependencies {
implementation(project(":sample-processor"))
ksp(project(":sample-processor"))
}
オプション機能
KSPでは、実行時にオプションの値を指定することが可能である。
利用者側のbuild.gradle.ktsで以下のように記述する。
code:build.gradle.kts
ksp {
arg("option1", "value1")
arg("option2", "value2")
...
}
実装側ではSymbolProcessorProviderのcreate関数から受け取れるSymbolProcessorEnvironment.optionsからアクセス可能。
ソースコードから生成される型
KSPを通してソースコードから情報を取る際、以下のような型に入れられる。
code:type
KSFile
packageName: KSName
fileName: String
annotations: List<KSAnnotation> (File annotations)
declarations: List<KSDeclaration>
KSClassDeclaration // class, interface, object
simpleName: KSName
qualifiedName: KSName
containingFile: String
typeParameters: KSTypeParameter
parentDeclaration: KSDeclaration
classKind: ClassKind
primaryConstructor: KSFunctionDeclaration
superTypes: List<KSTypeReference>
// contains inner classes, member functions, properties, etc.
declarations: List<KSDeclaration>
KSFunctionDeclaration // top level function
simpleName: KSName
qualifiedName: KSName
containingFile: String
typeParameters: KSTypeParameter
parentDeclaration: KSDeclaration
functionKind: FunctionKind
extensionReceiver: KSTypeReference?
returnType: KSTypeReference
parameters: List<KSValueParameter>
// contains local classes, local functions, local variables, etc.
declarations: List<KSDeclaration>
KSPropertyDeclaration // global variable
simpleName: KSName
qualifiedName: KSName
containingFile: String
typeParameters: KSTypeParameter
parentDeclaration: KSDeclaration
extensionReceiver: KSTypeReference?
type: KSTypeReference
getter: KSPropertyGetter
returnType: KSTypeReference
setter: KSPropertySetter
parameter: KSValueParameter
また、以下の画像にKSPでの型を表すクラスの依存関係が記されている。
https://scrapbox.io/files/642e43e6a5fdd7001b513883.svg
この通り、KS系ではメンバを呼び出す、もしくは自分自身をキャストすることにより要素を調べていくことが可能である。
例えば以下のコード。
code:sample.kt
annotation class Sample(val value: String)
@Sample("foo")
class Foo
ここから@Sampleの引数であるfooを取得するには以下のコードを叩けばよい。
code:solution.kt
val symbols: Sequence<KSAnnotated> = resolver.getSymbolsWithAnnotation(Sample::class.qualifiedName!!)
symbols.map { it.annotations.first().arguments.first().value as String }
また、たまに以下のようにKSTypeReferenceを返り値に持つものがある。
code:KSFunctionDeclaration.kt
interface KSFunctionDeclaration : ... {
val returnType: KSTypeReference?
// ...
}
この型は、まだ型解決をしていない・・・ということを表す型である。
この場合、以下のようにして型解決を行うことができる。
code:resolve.kt
val ksType: KSType = ksTypeReference.resolve()
val ksDeclaration: KSDeclaration = ksType.declaration
型解決を行うことで先ほどまでのように要素を調べていくことが可能になる。
ただし、型解決は時間のかかる処理となっているため、できるだけ行わないようにすることが最善である。
そこで、一部だけ参照したいという時はKSClassifierReference.referencedNameのような関数を利用することが望ましい。
この関数を利用することで、他の要素は取得しないため、コストを最小限に抑えることが可能である。
KS系はReflectionでの型と似ているが別物として定義してある(コンパイラプラグインの場合、明示的に型を定義する必要があるため。引用元はこちら。) Dependencies
KSPではDependenciesというクラスがある。これはファイル生成の時などで使われる。
code:sample.kt
codeGenerator.createNewFile(
Dependencies(false), <- これ
"com.example",
"Sample",
).use { it.write("val sample = 1".toByteArray()) }
適切に指定してやることで、ファイルの再生成を抑えることができるため、効率化につながる。
Dependenciesのコンストラクタは以下の通り。
code:Dependencies.kt
constructor(aggregating: Boolean, vararg sources: KSFile)
aggregating
aggregateにするかisolateにするかを表すフラグ。
aggregate -> どの入力ファイルが変更したとしても、再生成しなおす
isolate -> 関連する入力ファイルが変更されたとき以外は再生成しない
例えば以下のコード
code:sample.kt
val a = Dependencies(aggregating = true, SourceA)
val b = Dependencies(aggregating = false, SourceB)
この時、再処理のトリガーとなるアクションは以下の通り。
table:変更
アクション aの再処理 bの再処理
SourceAが変更された 〇 ×
SourceBが変更された 〇 〇
SourceCが追加された 〇 ×
SourceAが削除された × ×
SourceBが削除された × ×
sources
再処理のトリガーとなるファイルを指定する。
ここで指定されたファイルが変更された場合、トリガーが走ることになる。
なお、登録してもしなくても挙動が変わらない場合も存在する。
例えば以下のようにクラス定義がしてあるとする。
code:A.kt
@Interesting
class A : B()
code:B.kt
open class B
そして以下のようにソースコードを参照する。
code:sample.kt
val declA = resolver.getSymbolsWithAnnotation("Interesting").first() as KSClassDeclaration
val declB = declA.superTypes.first().resolve().declaration
この時、Aはトリガーの対象になるが、Bはトリガーの対象にしなくても良い(Aの情報からプログラム的に取得しているため)。
code:sample.kt
val dependencies = Dependencies(aggregating = true, declA.containingFile!!)
マルチラウンドプロセス
自分で書いたコードだけではなく、生成されたコードに対してもプロセスを適用したい場合がある。
そのときはマルチラウンドプロセスを使う必要がある。
使用例:自動生成によって作られるクラスに依存したコードを記述しており、そのクラスが生成されるまで待つ必要がある
やり方は簡単で、以下のようにすればよい。
code:sample.kt
override fun process(resolver: Resolver): List<KSAnnotated> {
val symbols = resolver.getSymbolsWithAnnotation("com.example.annotation.Builder")
val result = symbols.filter { !it.validate() }
symbols
.filter { it is KSClassDeclaration && it.validate() }
.map { it.accept(BuilderVisitor(), Unit) }
return result
}
このように、まだ利用できないシンボルをvalidate関数で弾き、はじいた値を返り値に渡してやることである。
このvalidate関数はライブラリで用意されているもの。レシーバーのオブジェクトに直接生えているシンボルがすべて参照可能かどうかを検証する。もし、独自のvalidateを書きたい場合はKSValidateVisitorから記述可能。
ちなみに、処理されていないシンボルが存在するのにプロセスが完全に終了した(どのプロセスでもファイルを出力しなくなった)場合、エラーが投げられる。
また、プロセッサはインスタンスとして保存されているため、プロセッサ内に変数を保存して利用することは可能である。
エラーハンドリング
プロセス中でエラーが発生した場合、onErrorメソッドが呼ばれ、finishメソッドは呼ばれない。
ただ、onErrorメソッドが呼ばれるタイミングには少し癖があり、
1. エラー発生源のプロセスは、process関数の実行が中断され、待機状態になる
2. 同時に動いているプロセスは、現在動いているラウンドを最後まで動かしたあと、待機状態になる。
3. すべてのプロセスが待機状態になった時に、すべてのプロセッサでonErrorが呼び出される。
このようになる。
IDEAで検知されないとき
バージョンが新しいと、生成されたコードがIDEAで検知されないことがあるらしい。
その時はこちらに書いてあることを参考にして登録する必要がある。