MicroAd Developers Blog

マイクロアドのエンジニアブログです。インフラ、開発、分析について発信していきます。

kotlinx-metadata-jvmを使って、kotlin-reflect無しでreflectする

はじめに

こんにちは、マイクロアドでソフトウェアエンジニアをしている宮田です。

この記事では、kotlin-reflectの機能を代替しうるライブラリとして、kotlinx-metadata-jvmについて紹介します。
Maven Repository: org.jetbrains.kotlinx » kotlinx-metadata-jvm

この記事を作成した時点でのバージョンは以下の通りです。

  • Kotlin: 1.5.21
  • kotlinx-metadata-jvm: 0.3.0

Kotlin(JVM)でのリフレクションについて

kotlinx-metadata-jvmに関する話の前に、JVM環境のKotlinでのリフレクションについて軽く触れます。

Kotlinでリフレクションをフルに活用するためには、kotlin-reflectを依存に追加する必要が有ります。
Maven Repository: org.jetbrains.kotlin » kotlin-reflect

これを利用しない場合、例えばKFunctionKotlin上のコンストラクタやメソッド)の引数や戻り値の型といった情報を取得することもできません。

kotlin-reflectを用いる際の問題点

このように、Kotlinでリフレクションを取り扱う際にはほぼ必須と言えるkotlin-reflectですが、利用にあたっては幾つかの問題点が有ります。

まず、kotlin-reflect1.5.21の時点で3MB弱ものサイズがある、非常に大きなライブラリです。
単純比較できるものでもありませんが、kotlin-stdlib-jdk8のサイズが15KBspring-bootのサイズですら1.3MBという点からも、その巨大さが伝わるかなと思います。

このサイズは、例えばAndroidのような利用可能なリソースに大きな制限のある環境では非常に大きな問題となります。
そうでなかったとしても、これだけサイズが大きいというのはあまり好ましいものではありません。

また、kotlin-reflect経由の処理はJavaのリフレクションと比べて非常に遅く、処理速度面も良いとは言えません。

Javaリフレクションでの代替について

kotlin-reflectがダメなら、Javaのリフレクションだけでなんとかならないのか」と思われるかもしれません。

これに関しては、単純な内容はともかく、網羅的な処理を作ることは難しいです。
理由は、Kotlin上に定義した情報が必ずしもJava上で取得できるとは限らないためです。

kotlin-reflectを使わなかった場合取得の難しい情報としては、例えば以下が有ります。

  • コンストラクタのパラメータ名1
  • get~というメソッドが、Kotlin上でプロパティとして定義されていたのか、単純にそのような名前の関数として定義されていたのか
  • 関数がJvmNameで名付けられていた場合の元の名前
  • value class(inline class)がunboxされて使われていた場合の、unbox前の型

kotlinx-metadata-jvmについて

ここまで説明してきた通り、kotlin-reflect無しでKotlinのリフレクションをフルに活用することは困難が伴い、一方でkotlin-reflectを用いることにも幾つかの課題が有ります。
他の回避方法としてはコード生成を用いることも考えられますが、リフレクションを用いるのに比べれば敷居が高く、こちらも困難を伴います。

そんな状況を解決しうるのが、kotlinx-metadata-jvmというライブラリです。

kotlinx-metadata-jvmは何をできるのか

kotlinx-metadata-jvmは、kotlin.Metadataアノテーション(以後Metadataアノテーションとのみ記述します)の中身を読んで、そこから情報を読み出すことができるライブラリです。

Metadataアノテーションとは、Kotlin上の定義に関する情報を保持するためのアノテーションです。
この中に保持されている情報は、Kotlin上の当該クラスに定義されているプロパティや関数、型や名前などで、先ほど紹介したようなJavaにコンパイルされた際に失われる情報もこのアノテーションの中に入っています。

Metadataアノテーションは、Kotlinのソースをコンパイルした際に、クラスに対して自動的に付与されます。
Kotlinのソースのみ触っている場合Metadataアノテーションを目にすることはほぼ有りませんが、デコンパイルすることで直接確認することもできます。

例えば、kotlin.reflect.KClassをデコンパイルすると、以下のようにMetadataアノテーションが付与されていることを確認できます。
d1にはバイト列が格納されていますが、これはprotobufに変換されたメタデータです。

/* 略 */
import kotlin.Metadata;
/* 略 */

// d1, d2に関しては長すぎるため省略
@Metadata(
   mv = {1, 5, 1},
   k = 1,
   d1 = {"\u0000d\n\u0002\u0018\u0002\n\u0000\n\u0002\u0010\u0000\n\u0002..."},
   d2 = {"Lkotlin/reflect/KClass;", "T", "", "Lkotlin/reflect/KDeclarationContainer;", ...}
)
public interface KClass extends KDeclarationContainer, KAnnotatedElement, KClassifier {

このメタデータから読みだした情報に基づいてJavaリフレクションによって処理を行うことで、kotlin-reflect無しでも処理を行うことができるようになります。

kotlinx-metadata-jvmを使うメリット

kotlinx-metadata-jvmを使うメリットは、先程紹介したkotlin-reflectの問題点を解決できることです。

まず、kotlinx-metadata-jvmのサイズは1MB程度であり、kotlin-reflectの3MBを置き換えることができれば、リソースの節約に繋がります。

また、kotlin-reflectに比べると、Javaのリフレクションを直接用いて処理する部分が多くなるため、処理速度の向上も見込めます。

kotlinx-metadata-jvmを使ってみる

kotlinx-metadata-jvmを使うサンプルとして、引数名 -> 引数のMapからプライマリコンストラクタを呼び出すシンプルなマッパーの実装例を紹介します。

import java.lang.reflect.Constructor
import kotlinx.metadata.KmClass
import kotlinx.metadata.KmConstructor
import kotlinx.metadata.jvm.KotlinClassHeader
import kotlinx.metadata.jvm.KotlinClassMetadata
import kotlinx.metadata.jvm.signature

fun Metadata.toKotlinClassHeader() = KotlinClassHeader(
    this.kind,
    this.metadataVersion,
    this.data1,
    this.data2,
    this.extraString,
    this.packageName,
    this.extraInt
)

class SimpleMapper<T : Any>(private val clazz: Class<T>) {
    private val kmConstructor: KmConstructor
    private val actualConstructor: Constructor<T>

    init {
        // メタデータアノテーションの取得(簡単のため取得できなかった場合は無視している)
        val metadata: Metadata = clazz.getAnnotation(Metadata::class.java)!!

        // メタデータ -> KmClass(kotlinx-metadata-jvmにおけるKClass的な立ち位置)への変換
        val kmClass: KmClass = metadata
            .toKotlinClassHeader()
            .let { // KotlinClassMetadataにはいくつか継承先があるが、ここでは簡単のためClassで決め打ちしている
              KotlinClassMetadata.read(it) as KotlinClassMetadata.Class
            }.toKmClass()

        // 呼び出し対象とするコンストラクタの情報取得(簡単のため無条件にプライマリコンストラクタを対象にしている)
        kmConstructor = kmClass.constructors.first()

        // リフレクションで呼び出し対象コンストラクタの実体の取得(jvmMethodSignatureに関しては別途解説)
        @Suppress("UNCHECKED_CAST")
        actualConstructor = clazz.declaredConstructors
            .first { it.jvmMethodSignature == kmConstructor.signature!!.asString() } as Constructor<T>
    }

    fun map(args: Map<String, *>): T {
        // 名前をキーに引数の読み出し
        val args = kmConstructor.valueParameters.map { args[it.name] }.toTypedArray()
        return actualConstructor.newInstance(*args)
    }
}

実際にこのマッパーを利用するコードです。

data class Sample(val foo: Int, val bar: String, val baz: Boolean)

fun main() {
    val mapper = SimpleMapper(Sample::class.java)
    val args = mapOf("foo" to 1, "bar" to "bar", "baz" to true)

    val result = mapper.map(args) // -> Sample(foo=1, bar=bar, baz=true)
}

補足: jvmMethodSignatureについて

この拡張プロパティは下記のコードをお借りする想定です。
MoshiX/JvmDescriptors.kt at ZacSweers/MoshiX

このコードに関しては、長くなるためこの記事では触れませんが、引数の型に関するJVM上での表現を比較して一致を見ています。

kotlinx-metadata-jvmが適さない場面

ここまでkotlinx-metadata-jvmの利点に関して触れてきましたが、kotlinx-metadata-jvmが適さない場面も有ります。

まず、kotlinx-metadata-jvmを使ったコードとkotlin-reflectを使ったコードを比べた場合、後者の方が簡単で、求められる知識も少なくなります。
従って、kotlin-reflectで十分な場合には、わざわざkotlinx-metadata-jvmを使う必要は無いと言えるでしょう。

また、極限まで性能を求める必要のある場面では、その部分だけ手動で作成したり、コード生成を用いるなどして、一切のリフレクション処理を行わない形にするべきです。

終わりに

この記事ではkotlinx-metadata-jvmについて紹介しました。
このライブラリは、kotlin-reflectを用いずにKotlin向けのリフレクション処理を書くことができる非常に強力なライブラリで、適切な場面で使えば強力な成果を得ることができます。

また、このライブラリを使いこなすためにはKotlinJVMの深い部分に触れることになるので、興味のある方は是非触って頂ければと思います。

マイクロアドではKotlinを用いた管理画面のサーバーサイド開発にチャレンジしたいという仲間を募集しています。
また、サーバサイドエンジニアだけでなく機械学習エンジニア、フロントエンジニア、インフラエンジニアなど幅広く募集しています。気になった方は以下からご応募いただければと思います。

recruit.microad.co.jp


  1. 一応-parametersオプションを付けてコンパイルすれば、java.reflect.Constructorから取得可能です。