Kotlinで快適なJSONパース。Klaxon: JSON for Kotlinを使ってみた。
前回の記事ではMoshiライブラリを使ったJSON文字列からのオブジェクト変換、オブジェクトからのJSON文字列変換の話でした。
JSONが複雑な構造でもあってもMoshiのCustom Type Adaptersを使って@ToJsonと@FromJsonを実装すればJSON←→オブジェクトの変換が難なく行えます。
難なく行えますと書きましたが、Moshiのカスタムアダプタを利用する難点もあります。
次のような難点が考えられます。
- 複数のカスタムアダプタの実装が必要になると骨が折れる作業となる
- 複雑ではないJSON構造でもカスタムアダプタが必要な場合には実装コストがかかる
- 実装したカスタムアダプタの整合性テストが必要になる
カスタムアダプタを実装して整合性テストまでのコストを考えると多くのカスタムアダプタの実装は避けたいところです。
開発の過程でJSONパースの処理は多く登場します。JSON文字列をオブジェクトに変換して値を取り出すような良くあるケースでカスタムアダプタの実装は避けたいなぁ・・・と考え調べていたところ「Klaxon: JSON for Kotlin」を見つけました。
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はテストコードで重宝しそうです。