KINTO Tech Blog


[Server side Kotlin] KtorのシリアライザーにMoshiを使う

Leona Shiode
Leona Shiode
Cover Image for [Server side Kotlin] KtorのシリアライザーにMoshiを使う

はじめに

こんにちは。Woven Payment Solution Groupで決済関係のバックエンド開発を担当している塩出です。

本Groupでは開発にKotlinを使用しており、webフレームワークにはKtorを使用しています。KtorではJSONのリクエストボディーを処理したり、JSONのレスポンスを返却するために、JSONのライブラリが別途必要となります。
当初、JSONライブラリとしてGsonを使用していましたが、Gsonを使う上で問題に直面したのでMoshiに切り替えました。

今回はなぜMoshiを選んだのか、KtorでMoshiを使う際の問題点とその解決方法についてまとめました。

なお、この記事で使用しているKtorのバージョンは2.x系です。

GsonからMoshiへの切り替え

Gsonの問題点

当初はKtorのJSONライブラリとしてGsonを使っていました。しかしリクエストボディーを受け取る際、必須キーが抜けている場合に問題が発生することが分かりました。

例として、以下のようなinputを処理するエンドポイントを考えます。

Input.kt
data class Input (
  val required: String,
  val optional: String? = null,
)
Router.kt
post("/") {
    val input = call.receive<Input>()
    call.respondText("required was ${input.required}", status = HttpStatusCode.OK)
}

InputclassのrequiredString型なのでnullを許容しません。一方optionalはnull許容型です。
このエンドポイントはリクエストボディーとして上記 Input を受け取り、受け取ったbodyのrequiredを返却する、単純なものです。

このエンドポイントに対して requiredがない空JSON{}を送信するとNullPointerException(通称ぬるぽ)が発生します。後の処理に影響を与えないように、デシリアライズ時に例外が発生してほしいところですが、その変数へのアクセス時、ここではcall.respondText()に例外が発生してしまいます。

解決策としてInput classのrequiredをnull許容型にしてnullチェックをする方法もあるかもしれません。しかしKotlinを使っている以上nullを許容したくないところはなるべく型で表現して、Kotlinっぽく処理を書きたいところです。

したがって、デシリアライズ時に例外を発生させてくれるJSONライブラリの選定が必要になりました。

なぜMoshiを選んだか

このプロジェクトではOpenAPIを使用したschema baseで開発しており、やりとりするclassの生成はOpenAPI Generatorに依存しています。またclientもOpenAPI Generatorで生成されるものを使用しているので、乗り換え候補としてOpenAPIが対応しているJSON ライブラリが挙がりました。

OpenAPI Generatorが対応しているJSONライブラリは以下の通りです。Gsonは除外しています。

JacksonはOptionを有効にすることでnull safeに対応できますが、該当するソースコードを見るとパフォーマンスに影響がある、という記述がありました。パフォーマンスの比較は行っていませんが、候補からは外すことにしました。

kotlinx.serializationはJetBrainsが開発しているライブラリであり、KotlinネイティブかつKtorとの相性も良さそうで候補としては良いと思います。しかし、Moshiの方がstar数がkotlinx.serializationよりも倍くらい多く(執筆時でMoshi: 8.5k, kotlinx.serialization: 4.1k)、また MoshiがGson v3であるとのことだったので、Moshiを採用することにしました。

KtorでMoshiを使う際の問題点

KtorのPR#2004によれば、執筆時点ではKtorによるMoshiのサポートはありません。またPR#2005によれば今後ともKtorのcore機能としてMoshiを提供する方針は今の所ないようです。

一方で、Ktorで Marketplace なるものを提供する予定があるようなので、将来的に3rdパーティーからMoshiを使えるようにするKtorのプラグインが供給される可能性はあります。しかし今はサポートがないので、Moshiを使えるようにするためのKtorプラグインを自分で作る必要があります。

なお、執筆時点ではまだMarketplaceの情報はありませんでした。続報に期待したいと思います。

解決方法

Moshiを使用可能にするKtorプラグインの作成

以下はKtorでMoshiを使用するために必要なクラスです。この実装はKtorのGsonConverterとほぼ同じです。

MoshiConverter.kt
class MoshiConverter(
    private val moshi: Moshi = Moshi::Builder().build(),
) : ContentConverter {
    override suspend fun serialize(
        contentType: ContentType,
        charset: Charset,
        typeInfo: TypeInfo,
        value: Any
    ): OutgoingContent? {
        return TextContent(
            moshi.adapter(value.javaClass).toJson(value),
            contentType.withCharsetIfNeeded(charset)
        )
    }

    override suspend fun deserialize(charset: Charset, typeInfo: TypeInfo, content: ByteReadChannel): Any? {
        return withContext(Dispatchers.IO) {
            val body = content.toInputStream().reader(charset).buffered().use { it.readText() }
            moshi.adapter(typeInfo.type.java).fromJson(body)
        }
    }
}

使い方は以下のようになります。

install(ContentNegotiation) {
    register(ContentType.Application.Json, MoshiConverter())
}

MoshiのArrayList対応

上記のKtorプラグインを使用すると、デシリアライズ時にKotlin typeではなく JAVA typeとしてデシリアライズしてしまいます。例えば、Listに格納したいのに、ArrayListとしてデシリアライズが動いてしまうということになります。
MoshiはデフォルトでArrayListのデシリアライズに対応していないので、Listをデシリアライズしようとしたときに例外が発生してしまいます。
Moshiで任意の型に対応するためには、その型のAdapterを用意する必要があります。今回はArrayListに対応したいので、ArrayList用のMoshi adapterを自分で用意しないといけません。

以下はArrayListに対応するadapterです。これはMoshiのCollectionJsonAdapterの実装とほぼ同じです。

ArrayListJsonAdapter.kt
abstract class ArrayListJsonAdapter<C : MutableCollection<T?>, T> private constructor(
    private val elementAdapter: JsonAdapter<T>
) : JsonAdapter<C>() {
    abstract fun newCollection(): C

    override fun fromJson(reader: JsonReader): C {
        val result = newCollection()
        reader.beginArray()
        while (reader.hasNext()) {
            result.add(elementAdapter.fromJson(reader)!!)
        }
        reader.endArray()
        return result
    }

    override fun toJson(writer: JsonWriter, value: C?) {
        writer.beginArray()
        for (element in value!!) {
            elementAdapter.toJson(writer, element)
        }
        writer.endArray()
    }

    override fun toString(): String {
        return "$elementAdapter.collection()"
    }

    companion object Factory : JsonAdapter.Factory {
        override fun create(type: Type, annotations: Set<Annotation>, moshi: Moshi): JsonAdapter<*>? {
            if (annotations.isNotEmpty()) return null
            return when (type.rawType) {
                ArrayList::class.java -> {
                    newArrayListAdapter<Any>(type, moshi).nullSafe()
                }
                Set::class.java -> {
                    newLinkedHashSetAdapter<Any>(type, moshi).nullSafe()
                }
                else -> null
            }
        }

        private fun <T> newArrayListAdapter(type: Type, moshi: Moshi): JsonAdapter<MutableCollection<T?>> {
            val elementType = Types.collectionElementType(type, Collection::class.java)
            val elementAdapter = moshi.adapter<T>(elementType)
            return object : ArrayListJsonAdapter<MutableCollection<T?>, T>(elementAdapter) {
                override fun newCollection(): MutableCollection<T?> = ArrayList()
            }
        }

        private fun <T> newLinkedHashSetAdapter(type: Type, moshi: Moshi): JsonAdapter<MutableSet<T?>> {
            val elementType = Types.collectionElementType(type, Collection::class.java)
            val elementAdapter = moshi.adapter<T>(elementType)
            return object : ArrayListJsonAdapter<MutableSet<T?>, T>(elementAdapter) {
                override fun newCollection(): MutableSet<T?> = LinkedHashSet()
            }
        }
    }
}

動作確認

KtorでMoshiを使えるようにするためのKtorのプラグインと、MoshiでArrayListをデシリアライズするためのMoshi adapterが作成できたので、動作確認します。
上記で紹介したものを使うには以下のように記述する必要があります。

install(ContentNegotiation) {
    register(ContentType.Application.Json, MoshiConverter(
      moshi = Moshi::Builder().add(ArrayListJsonAdapter.Factory)
    ))
}

その上でこの記事の最初の例で紹介したエンドポイントに対して、空JSON{}を送信してみます。
そのエンドポイントを再掲します。

Input.kt
data class Input (
  val required: String,
  val optional: String? = null,
)
Router.kt
post("/") {
    val input = call.receive<Input>()
    call.respondText("required was ${input.required}", status = HttpStatusCode.OK)
}

最初はrequiredにアクセスする際にぬるぽが発生していましたが、今度は期待通りにデシリアライズ時のcall.receive<Input>()のところで次の例外が発生するようになりました。

com.squareup.moshi.JsonDataException: Required value ‘required’ missing at $

まとめ

  • KtorでGsonを使用するとぬるぽが発生することがあります
  • ぬるぽの発生を嫌うのであれば別のJSONライブラリを選択する必要があります
  • KtorでMoshiを使用するために、自分でKtorのpluginを作る必要があります
    • その場合、ArrayListをデシリアライズするためのMoshi adapterを作る必要があります

参考