$30 off During Our Annual Pro Sale. View Details »

Kotlin Symbol Processing API (KSP) を使って Kotlin ア プリケーションの開発を効率化する

Kotlin Symbol Processing API (KSP) を使って Kotlin ア プリケーションの開発を効率化する

Takuji Nishibayashi

December 10, 2022
Tweet

More Decks by Takuji Nishibayashi

Other Decks in Technology

Transcript

  1. Kotlin Symbol Processing API (KSP) を使って Kotlin ア
    プリケーションの開発を効率化する
    Kotlin Fest 2022
    @takuji31

    View Slide

  2. 自己紹介
    西林 拓志(にしばやし たくじ)
    Twitter/GitHub takuji31
    Android アプリケーションエンジ
    ニア
    株式会社はてな
    テクノロジーソリューション
    本部第 2 グループ マンガアプ
    リチーム所属
    Kotlin 歴 8 年くらい
    2

    View Slide

  3. Kotlin Symbol Processing API (以下 KSP) 使ってます
    か?
    3

    View Slide

  4. 今日は KSP のプロセッサーの作り方(≠ 使い方)について話
    します
    4

    View Slide

  5. 今日話すこと
    KSP とは
    KSP を使う理由
    プロセッサーの作り方
    インクリメンタル処理
    マルチラウンド処理
    マルチモジュールでの活用方法
    テスト
    5

    View Slide

  6. 今日話さないこと
    KSP の使い方、セットアップ方法
    Gradle 以外で KSP のプロセッサーを開発する方法
    Kotlin Multiplatform での使い方
    どういった時に KSP を使うとよいか (質問があれば答えます)
    etc.
    6

    View Slide

  7. 注意点
    スライド内に出てくる Gradle script は Kotlin DSL です、Groovy は公式ドキュメントみ
    てください
    https://kotlinlang.org/docs/ksp-overview.html
    スライド内のコードは import や package を省略しています
    7

    View Slide

  8. KSP とは
    8

    View Slide

  9. KSP とは
    Kotlin でシンボルを処理するための軽量コンパイラープラグイン
    Java の Pluggable Annotation Processing API と同様にアノテーションを使う
    Kotlin の言語機能に対応している
    インクリメンタル処理対応
    Kotlin Multiplatform 対応
    https://github.com/google/ksp
    9

    View Slide

  10. KSP を使う理由
    10

    View Slide

  11. KSP を使う理由 - 課題解決
    アノテーションを処理してコード生成をすることで普段の開発に潜む問題を解決する
    頑張って型作って解決していたことをコード生成でカバーしたり
    Boilerplate コードを自動生成することでコード内のノイズを減らしたり
    規模が大きなアプリケーションでモジュールごとに必要な設定を集約したり
    型安全なクエリービルダーを作ったり
    型安全な画面遷移を実現したり
    etc.
    11

    View Slide

  12. KSP を使う理由 - kapt と比べて
    kapt と比べて高速
    kapt は一旦 Kotlin から Java のスタブコードを生成しているがこれがとても遅い
    現在開発しているアプリの場合だいたい全体のビルド時間の 20%くらいがスタブコ
    ード生成の時間
    Kotlin の言語機能に対応した構造を返してくれるので、Pluggable Annotation
    Processing API で Kotlin のメタデータを頑張って読むみたいなことが不要
    kotlinx-metadata-jvm
    なるものを使えばできるけどまあまあ難しい
    メンテナンスモードになってるのでこれ以上更新されない
    今から Kotlin で新しくプロセッサーを作って使うのは厳しい
    12

    View Slide

  13. KSP を使う理由 - リフレクションと比べて
    ビルド時に型のチェックが行われるので安全
    より高速
    バイナリーサイズに影響なし
    (Android 的文脈で)難読化に強い
    13

    View Slide

  14. KSP を使う理由 - コンパイラープラグインと比べて
    簡単
    Kotlin のバージョンの影響を受けない(KSP のライブラリー自体は Kotlin のバージョンに
    合わせる必要がある)
    14

    View Slide

  15. KSP を使う理由 - その他
    Multiplatform 対応
    Kotlin Multiplatform Mobile とかやっていると便利そう
    私はやっていないので実際の使用感は不明
    公式ドキュメントが充実していて始めやすい
    https://kotlinlang.org/docs/ksp-overview.html
    KotlinPoet と組み合わせることでプログラムの構造を簡単に作ることができる
    https://square.github.io/kotlinpoet/
    15

    View Slide

  16. プロセッサーの
    作り方
    16

    View Slide

  17. 今回作るもの
    インターフェースにアノテーションをつけるとそのインターフェースを実装した抽象ク
    ラスを吐き出す
    実用性はないが簡単な例ということで
    //
    これを
    @SimpleGeneration
    interface Hoge
    //
    こうだ
    abstract class AbstractHoge: Hoge
    17

    View Slide

  18. プロセッサーの作り方
    Gradle モジュールを作る
    SymbolProcessor を実装する
    Visitor を実装する
    SymbolProcessorProvider を作る
    サービスプロバイダーの設定をする
    18

    View Slide

  19. Gradle モジュールを作る
    19

    View Slide

  20. Gradle モジュールを作る
    好きな IDE やツールでプロジェクトに Kotlin モジュールを新しく作りましょう
    名前は自由
    compiler
    processor
    ksp-hogehoge
    処理に使うアノテーションや生成されるコードで使うクラスは別のモジュールに分けて
    もよい
    分かれている方が無駄なクラスがアプリケーションの classpath に含まれない
    etc.
    20

    View Slide

  21. build.gradle.kts
    // ...
    dependencies {
    // KSP
    のAPI
    implementation("com.google.devtools.ksp:symbol-processing-api:1.7.21-1.0.8")
    // KotlinPoet
    implementation("com.squareup:kotlinpoet:1.12.0")
    // KotlinPoet
    のKSP
    用拡張
    implementation("com.squareup:kotlinpoet-ksp:1.12.0")
    }
    21

    View Slide

  22. SymbolProcessor を実装する
    22

    View Slide

  23. SymbolProcessor
    class ExampleSymbolProcessor(
    private val codeGenerator: CodeGenerator,
    private val logger: KSPLogger
    ) : SymbolProcessor {
    override fun process(resolver: Resolver): List {
    resolver
    .getSymbolsWithAnnotation(SimpleGeneration::class.qualifiedName!!)
    .filterIsInstance()
    .forEach { it.accept(SimpleGenerationVisitor(codeGenerator, logger), Unit) }
    return emptyList()
    }
    }
    23

    View Slide

  24. Visitor
    class SimpleGenerationVisitor(
    private val codeGenerator: CodeGenerator,
    private val logger: KSPLogger
    ) : KSVisitorVoid() {
    override fun visitClassDeclaration(classDeclaration: KSClassDeclaration, data: Unit) {
    if (classDeclaration.classKind != ClassKind.INTERFACE) {
    logger.error("Only interface allowed", classDeclaration)
    return
    }
    val packageName = classDeclaration.packageName.asString()
    val className = ClassName(packageName, "Abstract" + classDeclaration.simpleName.asString())
    val typeSpec = TypeSpec.classBuilder(className)
    .addModifiers(KModifier.ABSTRACT)
    .addSuperinterface(classDeclaration.toClassName())
    FileSpec.builder(packageName, className.simpleName)
    .addType(typeSpec.build())
    .build()
    .writeTo(
    codeGenerator,
    Dependencies(
    aggregating = false,
    classDeclaration.containingFile!!
    )
    )
    }
    } 24

    View Slide

  25. SymbolProcessorProvider を作る
    25

    View Slide

  26. SymbolProcessorProvider
    class ExampleSymbolProcessorProvider : SymbolProcessorProvider {
    override fun create(environment: SymbolProcessorEnvironment): SymbolProcessor {
    return ExampleSymbolProcessor(environment.codeGenerator, environment.logger)
    }
    }
    26

    View Slide

  27. サービスプロバイダーの設定をする
    27

    View Slide

  28. サービスプロバイダーの設定
    // resources/META-INF/services/com.google.devtools.ksp.processing.SymbolProcessorProvider
    jp.takuji31.kotlinfest2022.compiler.ExampleSymbolProcessorProvider
    28

    View Slide

  29. インクリメンタル
    処理
    29

    View Slide

  30. インクリメンタル処理
    KSP はインクリメンタル処理に対応している
    インクリメンタル処理のモードは 2 つ
    Isolated (分離)
    Aggregated (集約)
    ファイルを書き出すときに Dependencies で依存を指定すれば、依存に変更があった時
    に再処理される
    生成されるファイルごとにモードが変えられる
    30

    View Slide

  31. Isolated (分離)
    生成されたファイルが単一もしくは複数のファイルと紐付いている時のモード
    依存に指定されたファイルが変更されると再処理される
    ↑ 以外のファイルが変更された時は処理されない
    1 つのアノテーションで 1 つのファイルを生成したいときによく使う
    例) アノテーションがつけられたインターフェースの実装クラスを生成する
    31

    View Slide

  32. Isolated (分離)
    Dependencies(aggregating = false, classDeclaration.containingFile!!)
    32

    View Slide

  33. Aggregated (集約)
    生成されたファイルが不特定の複数のファイルと紐付いている時のモード
    依存に指定されたファイル以外のものが変更されると依存に指定されたファイルとまと
    めて再処理される
    基本的に変更があれば毎回処理されるイメージ
    1 つのアノテーションで複数のファイルをまとめたファイルを作る時に使われる
    例 1) アノテーションがつけられたインターフェースをまとめたインターフェースを
    作る
    例 2) DI コンテナーで DI したいインスタンスの情報を収集する
    例 3) 同じアノテーションがつけられた複数のクラスから特定のクラスだけを選んで
    何か処理する
    33

    View Slide

  34. Aggregated (集約)
    val dependencies: Array = // ...
    Dependencies(aggregating = true, *dependencies.mapNotNull { it.containingFile })
    34

    View Slide

  35. 依存をちゃんと指定しないと「なぜかコード生成されな
    い」みたいな事態になる
    35

    View Slide

  36. マルチラウンド処理
    36

    View Slide

  37. マルチラウンド処理
    処理できないシンボルの処理を後回しにする仕組み
    KSP のプロセッサーで生成したコードはすぐにプロセッサーで認識できない
    ラウンドを複数回繰り返して、そのラウンドで生成されたファイルがなくなったら終わ

    生成されたファイルがなくなったけど遅延されているシンボルがあればエラー
    マルチラウンド処理で活用するためにシンボルのバリデーションを行う仕組みがある
    カスタムでバリデーションルールを作れるが今回は説明しません
    37

    View Slide

  38. 処理の遅延方法
    override fun process(resolver: Resolver): List {
    val symbolsWithAnnotation = resolver
    .getSymbolsWithAnnotation(SimpleGeneration::class.qualifiedName!!)
    .filterIsInstance()
    symbolsWithAnnotation
    .filter { it.validate() }
    .forEach { it.accept(SimpleGenerationVisitor(codeGenerator, logger), Unit) }
    return symbolsWithAnnotation.filter { !it.validate() }.toList()
    }
    38

    View Slide

  39. マルチモジュールでの活用方法
    39

    View Slide

  40. 依存しているモジュールやライブラリーのシンボルは
    KSP の getSymbolsWithAnnotation
    で取得できな

    google/ksp#1075
    40

    View Slide

  41. じゃあどうするのか?
    41

    View Slide

  42. getDeclarationsFromPackage
    を使う
    42

    View Slide

  43. getDeclarationsFromPackage
    を使う
    特定のパッケージに含まれる定義を全部取り出すメソッド
    特定のパッケージに何かしらのメタデータを書き出す
    集約するモジュールで ↑ のパッケージに書かれた定義を読んでコード生成する
    集約に使う用のアノテーションを作って集約したいモジュールで使う
    43

    View Slide

  44. テスト
    44

    View Slide

  45. コンパイルのテストってどうやんの?
    45

    View Slide

  46. tschuchortdev/kotlin-compile-testing
    46

    View Slide

  47. テストコード
    val source = SourceFile.kotlin(
    "ExampleClass.kt", """
    package jp.takuji31.kotlinfest2022.compiler
    import jp.takuji31.kotlinfest2022.compiler.annotation.SimpleGeneration
    @SimpleGeneration
    interface SimpleInterface {
    fun printHelloWorld()
    }
    """.trimIndent()
    )
    val compilation = KotlinCompilation().apply {
    sources = listOf(source)
    inheritClassPath = true
    symbolProcessorProviders = listOf(ExampleSymbolProcessorProvider())
    kspWithCompilation = true
    }
    val result = compilation.compile()
    assertThat(result.exitCode)
    .isEqualTo(KotlinCompilation.ExitCode.OK) 47

    View Slide

  48. まとめ
    SymbolProcessor、Visitor で KSP のプロセッサーは作れる
    Kotlin のコード生成は KotlinPoet でやると楽
    インクリメンタル処理のための依存は Dependencies で指定
    マルチラウンド処理をするためには process メソッドで遅延したいシンボルを渡す
    複数モジュールのコードを集約したい時は getDeclarationsFromPackage
    プロセッサーのテストは kotlin-compile-testing

    48

    View Slide

  49. ドキュメント/サンプルコード
    https://kotlinlang.org/docs/ksp-overview.html
    公式ドキュメント
    https://github.com/google/ksp/tree/main/examples/playground
    公式サンプル
    https://github.com/takuji31/kotlinfest2022-ksp-example
    今回のスライドに出てきたソースコード
    https://github.com/takuji31/navigation-compose-screen
    もうちょっと複雑な例
    49

    View Slide

  50. Enjoy KSP Life!
    50

    View Slide