こんにちは、こんばんは、kitakkunです。
今回は大学の卒業研究で開発した、Kotlin Compiler Pluginを活用したデバッグ支援ツールについてご紹介してみようと思います。
はじめに
包み隠さず申し上げますが、こちらの記事はGitHubリポジトリにスターが欲しいなという下心を持って書いております。予めご了承ください。
もし興味を持っていただけましたら、スターをいただけると励みになります・・!
卒業研究で開発したツールは以下の2つのリポジトリで構成されています。
Kotlin Compiler Plugin ↓
デバッグツール(Flipper Plugin) ↓
時を巻き戻すデバッガ
卒業研究のテーマ探しのため論文をあさっていたときのことです。以下の「back-in-time debugger in Kotlin」という論文を見つけました。
こちらで「MVIKotlin」というフレームワーク上で扱うことのできる、「MVIKotlin Time Travel Plugin」という状態巻き戻しプラグインについて言及されていました。
どんなプラグインかは次のデモ動画をご覧ください ↓
簡単に説明しますと、「アプリケーションの内部状態の変化を追跡しておき、自由自在に状態を過去へ巻き戻して繰り返しアプリケーションの動作検証を行える」プラグインとなっています。
繰り返しの動作検証を行うことでアプリケーション挙動を深く理解し、バグの修正などに役立てることが期待できます。
しかしながら、このようなプラグインを実現できるのは、MVIアーキテクチャのように、厳格に状態変更の手続きが規定される場合に限定されています。
先行事例であった制限
前述のように、状態を巻き戻すデバッガは、特定のアーキテクチャに対してのみ実装が可能です。
(実際の内部実装と厳密には異なることを述べていると思いますが)MVIでは、Modelの持っている状態はViewから発行されるIntentを経由してのみ変更が起きるため、Intentのオブジェクトをキャプチャしておき、Intentを再生すれば同じような状態変更を再度引き起こすことが可能なわけです。
しかし、現在Androidアプリにおいて主流となっているMVVMアーキテクチャでは、ソースプログラムの段階でこのようなデバッグ機構を組み込むことは困難です。
なぜなら、ViewがViewModelのメソッドを直接呼び出す形で状態の変更を引き起こすためです。状態の変更経路はメソッドの数だけ存在することになり、状態の変更が起こる際に必ず通るコードパスも存在しません。
@Composable
fun SampleScreen(viewModel: SampleViewModel) {
Button(onClick = viewModel::onButtonClick) {
Text("Clicked ${viewModel.counter.value} times")
}
}
class SampleViewModel : ViewModel() {
private val _counter = mutableIntStateOf(0)
val counter: IntState = _counter
fun onButtonClick() {
counter.value++
}
}
また、全ての手続きが型で厳格に表現されているわけではないので、状態を追跡することも、後で再現することも難しいです。
ですから、私の研究ではコンパイラの力を借りることでどんなアーキテクチャであっても前節でご紹介したようなアプリケーション状態を巻き戻すデバッガを扱えるようにすることを目指しました。
開発したデバッガについて
細かい話をする前に、まずはデモを見ていただいた方が早いんじゃないかと思いますので、デモの動画をご紹介します。
Flipper Pluginのリポジトリの方にデモ動画がありますのでそちらをご覧ください!
開発したデバッガのアーキテクチャ
デバッガが全体としてどのように動作するのか、簡単な概念図をお見せします。
-
Modified Class
: コンパイラプラグインによって内部のイベント通知処理の挿入、外部からの状態操作用インタフェースの実装が行われたクラス -
DebugService
: デバッグ対象アプリに常駐する、Modified Class
とDebuggingTool
間のイベントを処理するクラス -
DebuggingTool
: ホストPC上で起動する back-in-time デバッグ用のツール(現在は Flipper Plugin として実装)
大きく上に示した4つの要素が要素が協調することで back-in-time デバッグを実現します。
Kotlin Compiler Plugin で行っていること
コンパイラプラグイン側では、実に頭の悪いことをしています。状態の変更やインスタンス内部での各種イベントをトラッキングするために、中間表現であるIRレベルでざっくり次のようなコード変換を行っています。
@DebuggableStateHolder
class SampleStateHolder {
private var mutableCounter = 0
val counter get() = mutableCounter
fun increment() {
mutableCounter++
}
fun decrement() {
mutableCounter--
}
}
↓
@DebuggableStateHolder
class SampleStateHolder : BackInTimeDebuggable {
private var mutableCounter = 0
val counter get() = mutableCounter
override val backInTimeInstanceUUID: String
override val backInTimeInitializedPropertyMap: MutableMap<String, Boolean>
init {
/* その他必要な初期化処理... */
BackInTimeDebugService.emitEvent(DebuggableStateHolderEvent.RegisterInstance(...))
}
fun increment() {
mutableCounter++
BackInTimeDebugService.emitEvent(DebuggableStateHolderEvent.PropertyValueChange(...))
}
fun decrement() {
mutableCounter--
BackInTimeDebugService.emitEvent(DebuggableStateHolderEvent.PropertyValueChange(...))
}
override fun forceSetValue(propertyName: String, value: Any?) { ... }
override fun serializeValue(propertyName: String, value: Any?): String { ... }
override fun deserializeValue(propertyName: String, value: String): Any? { ... }
}
BackInTimeDebuggable
というデバッグに必要なインタフェースを持たせ、さらに値のトラッキング処理を挿入しています。
Kotlin Compiler Plugin では Extension
という単位で拡張ポイントを作成することが可能なのですが、ざっくり次のような用途でそれぞれの Extension
を使用しています。
- インタフェースの追加:
FirSupertypeGenerationExtension
- インタフェースで宣言が必要なプロパティとメソッド宣言の追加:
FirDeclarationGenerationExtension
- インタフェースの実装部および値のトラッキング処理などの挿入:
IrGenerationExtension
Flipper Plugin(デバッグツール)で行っていること
デバッグ対象のアプリケーションから飛んできたイベントを適切に処理し、グラフィカルなユーザインタフェースに表示したり、GUIを通じて発行したイベントをデバッグ対象アプリケーションへと送信する責務を負っています。
デバッグツール側では Redux というアーキテクチャを採用しており、状態は Store で一元管理されています。
こちらの実装に関して詳しく説明することは本研究の趣旨とは異なるので割愛します!
開発にあたり苦労した点(Kotlin Compiler Pluginのみ)
開発にあたっての苦労話をここでいくつか紹介します。
情報源の不足
一番大変だったのはズバリ「情報源の不足」です。Kotlin Compiler自体も、Kotlin Compiler Pluginも、情報源が極めて限られていました。
大きく役立ったのは以下の3つのセッションです。
1つ目のKevin Mostさんによるセッションは、少々情報が古いですが Kotlin Compiler Plugin の開発に取り掛かる上でかなり重要な出発点となりました。実装すべきクラスが若干変わっていたりと戸惑うこともありましたが、コンパイラプラグイン開発に取り組む上で欠かすことのできないセッションです。
2つ目のAmandaさんによるセッションでは、「Kotlin Compilerがどのようにソースプログラムをコンパイルし、実行可能なプログラムへと変換していくのか」という一連の流れが説明されています。Jetpack Composeなどの一部がコンパイラプラグインを用いてどのように実現されているのかという点に関しても言及があり、深い洞察を得ることができました。
3つ目のMikhailさんによるセッションは、K2コンパイラプラグインにフォーカスした内容となっており、 K2で導入されたFIRで扱える各種Extensionの理解や、Predicateなど特殊な概念に対する理解に役立ちました。
情報源が限られる一方で、良質な情報ばかりだったため、じっくり時間をかけて読み解けば十分開発を進めることが可能でした。この場を借りて改めて感謝いたします。
上記3つのセッションから得られた知見を簡単にまとめた記事もありますので、興味のある方はそちらも読んでみてください。
中間表現を操作において考えなければならないことが山のようにある
Kotlin Compiler Plugin の IrGenerationExtension
では、コンパイル中のソースプログラムの中間表現に対して直接改変を加えていくことになります。
Kotlin のプログラムがどのように IR上で表現されているのかを正しく理解して改変していくのは至難の業です。
例えば、KotlinのソースプログラムとIRノードの対応を簡単に表にまとめてみると次のようになります(一部のみ)。
ソースプログラム | 対応するIRノード |
---|---|
fun hoge() { ... } |
IrSimpleFunction |
val hoge = ... |
IrProperty |
hoge(fuga) |
IrCall |
class Hoge { ... } |
IrClass |
これらのIRノードの内部も事細かに定義されていて、例えば関数呼び出しに対応する IrCall
に関しては、それがクラスのメソッドだった場合 dispatchReceiver
にクラスのインスタンスを取得する IrExpression
がきて、拡張関数だった場合は extensionReceiver
に拡張対象のクラスのインスタンスを取得する IrExpression
が来るなど、複雑です。
また、関数の引数は呼び出し側では valueArguments
、関数定義側では valueParameters
として表現されるように、実引数と仮引数の区別があるなど、極めて厳密な世界です。
最もコンパイラ拡張を作る上で困ったことは、ラムダ関数やスコープ関数の扱いです。これらの関数は IR変換を行う上で大きな障壁となります。
私が開発したコンパイラプラグインでは、状態の変更処理を行っている IrCall
の直後に状態をキャプチャする IrCall
を挿入するという対応で状態変更のトラッキングを実現しています。しかし、with
やapply
などスコープ関数を用いて状態が変更されてしまうと、困ったことが起きます。
次の例を考えてみましょう。
val mutableState = mutableStateOf("hogehoge")
mutableState.value = "fugafuga"
この場合、状態変更を行う2行目のコードは IR では IrCall
として表現され、その dispatchReceiver
は mutableState
になります。
しかし、次の場合はどうでしょうか。
val mutableState = mutableStateOf("hogehoge")
with (mutableState) {
value = "fugafuga"
}
こちらの場合、状態を変更する3行目に対応する IrCall
の extensionReceiver
は with
関数のスコープにある this
となります。
つまり、単純に IrCall
のレシーバーが監視したいプロパティかどうか、といった判定ではすり抜けてしまうケースが出てくるわけです。
以上のように、コンパイラの中間表現レベルで状態をトラッキングする、というのはやりたいことはシンプルだが実際に高精度で行うにはかなりの労力を要しました。
まとめ
以上が、私が大学で研究テーマとした内容と、その振り返りとなります。
正直なところ、社会人になってからこのような無謀な挑戦をするのはなかなか難しかったと思いますので、学生の段階で限られた情報を活用し欲しいものを作り出すという体験ができたのは良かったと思っています。
Kotlin Compiler Plugin の開発は非常に難易度が高く、骨の折れるような作業が必要になります。しかし、コンパイラを拡張することで、何か面白いことができるのは確かです。なぜなら、ソースプログラムに対して、(基本)なんでもできるのですから。
現在、back-in-time-plugin のKMP対応を進めています。具体的には以下の作業を進めています。
-
WeakRefererence
など一部JVM固有のクラスをKMP用に実装を分けて対応する - デバッガとデバッグ対象アプリ間の通信を純粋なWebSocket実装に置き換える
- デバッガUIの実装を Flipper Plugin から引き剥がし、Compose for Desktop へと移行する
GitHub上での更新は2ヶ月前のコミットを最後に途絶えているのですが、実は手元でコツコツ作業しています。
また、この研究で得られた知見をもとに、Kotlin Fest 2024向けに Kotlin Compiler Plugin を題材としたセッションをプロポーザルとして出しておりますので、もし興味がありましたらスターをいただけますと採択される可能性が上がるかもなので応援をお願いします・・!
殴り書きのような記事となってしまいましたが、ここまで読んでいただき、ありがとうございます。改めて、GitHubの方も見ていただけると励みになりますので、よろしくお願いします!