読者です 読者をやめる 読者になる 読者になる

kotlinで快適なJSONパース。Klaxon: JSON for Kotlinを使ってみた。

前回の記事ではMoshiライブラリを使ったJSON文字列からのオブジェクト変換、オブジェクトからのJSON文字列変換の話でした。

naruto-io.hatenablog.com

JSONが複雑な構造でもあってもMoshiのCustom Type Adaptersを使って@ToJsonと@FromJsonを実装すればJSON←→オブジェクトの変換が難なく行えます。

難なく行えますと書きましたが、Moshiのカスタムアダプタを利用する難点もあります。
次のような難点が考えられます。

  • 複数のカスタムアダプタの実装が必要になると骨が折れる作業となる
  • 複雑ではないJSON構造でもカスタムアダプタが必要な場合には実装コストがかかる
  • 実装したカスタムアダプタの整合性テストが必要になる

カスタムアダプタを実装して整合性テストまでのコストを考えると多くのカスタムアダプタの実装は避けたいところです。
開発の過程でJSONパースの処理は多く登場します。JSON文字列をオブジェクトに変換して値を取り出すような良くあるケースでカスタムアダプタの実装は避けたいなぁ・・・と考え調べていたところ「Klaxon: JSON for Kotlin」を見つけました。

github.com

Klaxon: JSON for Kotlin

Klaxonはkotlin製のJSONパースライブラリです。簡易的な使い方の紹介をします。

次のようなJSON構造を例にします。

{
    "name": "Sakib Sami",
    "age": 23
}

次は使い方です。

val parser: Parser = Parser()
val stringBuilder: StringBuilder = StringBuilder("{\"name\":\"Sakib Sami\", \"age\":23}")
val json: JsonObject = parser.parse(stringBuilder) as JsonObject
println("Name : ${json.string("name")}, Age : ${json.int("age")}") // Name : Sakib Sami, Age : 23

KlaxonのParsrにJSON文字列の入力ストリームを渡しJsonObjectでキャストします。あとはキャストしたJsonObjectのdata classに用意されたstiringやintメソッドに取得したいJSON構造のkeyを渡せば値を参照することができます。
stringやintなどのメソッドはJSON値の型に合わせたメソッドが用意されています。その他にlongやbooleanなどがあり、値が新たなJSON構造であればobj、配列の場合はarrayのメソッドを利用します。
as JsonObjectのようにJSON値をキャストする型はobjなどのメソッドと対になっています。詳しくはドキュメントを参照してください。

AggregationBuildersクラスをJSON化してパースしてみる

以前の記事で紹介したAggregationBuildersクラスのオブジェクト構造をJSON文字列にして、KlaxonでJSONパースした例を紹介します。
AggregationBuildersクラスのオブジェクト構造は次のように表せます。

{
    "aggs_post_id": {  "terms": { "field": "post_id", "size": 1000 }, "aggregations": {
            "aggs_category_id": { "terms": { "field": "category_id", "size": 20 }, "aggregations": {
                    "aggs_user_id": { "terms": { "field": "user_id", "size": 2500 } } } } }
}

上記のようなJSONをKlaxonでパースして値を検証してみました。

val parser: Parser = Parser()
val jsonObject = parser.parse(json.byteInputStream()) as JsonObject

should("valid aggs_post_id.terms") {
    val aggsPostIdTerms = (jsonObject.get("aggs_post_id") as JsonObject).get("terms") as JsonObject

    aggsPostIdTerms.string("field") shouldBe "post_id"
    aggsPostIdTerms.int("size") shouldBe 1000
}

should("valid aggs_category_id.terms") {
    val aggsCategoryId = ((jsonObject.get("aggs_post_id") as JsonObject).get("aggregations") as JsonObject).get("aggs_category_id") as JsonObject
    val aggCategoryIdTerms = aggsCategoryId.get("terms") as JsonObject

    aggCategoryIdTerms.string("field") shouldBe "category_id"
    aggCategoryIdTerms.int("size") shouldBe 20
}

should("valid aggs_user_id.terms") {
    val aggsUserId = ((((jsonObject.get("aggs_post_id") as JsonObject).get("aggregations") as JsonObject).get("aggs_category_id") as JsonObject).get("aggregations") as JsonObject).get("aggs_user_id") as JsonObject
    val aggUserIdTerms = aggsUserId.get("terms") as JsonObject

    aggUserIdTerms.string("field") shouldBe "user_id"
    aggUserIdTerms.int("size") shouldBe 2500
}

構造が深いためas JsonObjectが連続して可読性が損なわれていますが、json構造を辿れることが伝えたく敢えて上記のように書きました。

fieldは文字列なのでstringメソッドで、sizeは数値なのでintメソッドで取得できます。

aggsPostIdTerms.string("field") shouldBe "post_id"
aggsPostIdTerms.int("size") shouldBe 1000

APIのレスポンスをKlaxonでパースして値を検証してみる

次のようなレスポンスを返すAPIを想定してKlaxonでパースして値を検証してみます。

[
    {
        "postId": 1324231431,
        "categoryId": 11,
        "user": { "userId": 1413241, "name": "John", "age": 20 }
    },
    {
        "postId": 1321231341,
        "categoryId": 22,
        "user": { "userId": 1453124, "name": "Amy", "age": 25 }
    },
    {
        "postId": 1329709858,
        "categoryId": 33,
        "user": { "userId": 1409709, "name": "Jessica", "age": 38 }
    }
]

kotlintestを使ってSpecテストをしていますのでそれぞれの値の検証意図はSpecのnameを参照してください。

init {
    given("GET: /test/content_list") {

        target = TestController()
        mvc = MockMvcBuilders.standaloneSetup(target).build()

        val response = mvc.perform(MockMvcRequestBuilders.get("/test/content_list"))
                .andExpect(MockMvcResultMatchers.status().isOk()).andReturn().response.contentAsString

        `when`("response is ok") {

            val array = Parser().parse(response.byteInputStream()) as JsonArray<JsonObject>

            then("レスポンスに含まれるPostは `3つ`") {
                val postIds = array.long("postId")
                postIds.size shouldBe 3
            }

            then("categoryIdが30以上のPostは `1つ`でpostIdは `1329709858`") {
                val post = array.filter {
                    it.long("categoryId")!! > 30L
                }
                post.size shouldBe 1
                post.get(0).long("postId") shouldBe 1329709858L
            }

            then("categoryIdが30以下のPostでuserのageが20以上のうち最後のレスポンスのuserのnameは `Amy`") {
                val post = array.filter {
                    it.long("categoryId")!! < 30
                }.findLast{
                    it.obj("user")!!.int("age")!! > 20
                }!!

                post.obj("user")!!.string("name") shouldBe "Amy"
            }
        }
    }
}

APIのレスポンスをパースすることでJsonArrayが取得できます。配列が取れればkotlinのコレクションが使えるのでfilterやmap、findLastのメソッドを利用して値を検証することでテストコードの可読性があがります。
さらにkotlintestのBehaviorSpecでテストコード全体を仕上げたので、それぞれのテストの意図も伝わりやすいです。
JSONパースにコストをかけずJSON構造を上から辿っていく間隔で値の取り出しをできるKlaxonはテストコードで重宝しそうです。

ソースを公開しています

ソースコードを公開しています。Moshiライブラリの検証が入っているプロジェクトに入れました。良ければ参考にしてみてください。

github.com