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はテストコードで重宝しそうです。
KotlinでtoJsonとfromJsonのJSONパース。MoshiのCustom Type Adaptersを使ってオブジェクトのテストを快適に。
最近はkotlinで開発しています。これまで他の言語で出来ていたこともkotlinではどうやって出来るのか?、調査したり試したりすることは楽しいですね。新しい言語に触れる醍醐味とも言えます。
kotlinをサーバサイドのメイン言語に使っています。数ある処理の中でもJSONの扱いは必須です。
今のプロジェクトではMoshiライブラリをメインで使っています。今回はkotlinでMoshiライブラリを使った話です。
Moshi
ライブラリの特徴は公式のほうを参照いただくとして、メインの話をしたいのが「Custom Type Adapters」です。
Moshiは自作のアダプタクラスに@ToJson と @FromJsonのアノテーションがついたメソッドを実装すればプリミティブな型以外でも追加した型をオブジェクトからjson文字列へ(toJson)、json文字列からオブジェクトへ(fromJson)の変換処理を自作することができます。
テストを書くときにCustom Type Adaptersを活用する
今のプロジェクトではelasticsearchを使っていて検索パラメータを定義するビルダークラスの整合性の検証にMoshiのCustom Type Adaptersを活用しました。
カスタムアダプタを使いelasticsearchのAggregationBuildersクラスをkotlinのdata classへ変換し検索パラメータの検証を行いました。
ビルダークラスはgetAggregationsQuery()をコールすることで取得できます。
object AggregationModel { fun getAggregationsQuery() = AggregationBuilders.terms("aggs_post_id").field("post_id").size(100) .subAggregation(AggregationBuilders.terms("aggs_category_id").field("category_id").size(200) .subAggregation(AggregationBuilders.terms("aggs_user_id").field("user_id").size(300)))!! }
このビルダークラスをjson文字列で表すと次のような構造になります。
{ "aggs_post_id": { "terms": { "field": "post_id", "size": 100 }, "aggregations": { "aggs_category_id": { "terms": { "field": "category_id", "size": 200 }, "aggregations": { "aggs_user_id": { "terms": { "field": "user_id", "size": 300 } } } } } }
- ネストが深い(key1.aggregations.key2.aggregations.key3.aggregations.... と幾重にも定義できる)
- keyの文字列はこちらで決めれる( aggs_post_id, aggs_category_id、aggs_user_id)
- key.aggregationsの指定は任意
kotlinのdata classで表すと次のように定義できます。
data class Aggregations( val name: String, val terms: Terms, val aggregations: Aggregations? ) data class Terms( val field: String, val size: Long )
今回のやりこと
今回、やりたいことを明確にします。
AggregationBuildersクラスをjson文字列変換し、MoshiのCustom Type Adaptersを使い、kotlinのdata classに変換してテストを快適にしたい。
です。
AggregationBuildersクラスはjson文字列に変換できる
AggregationBuildersはXContentFactoryでパラメータ構造を文字列に変換できます。
val aggregations = AggregationModel.getAggregationsQuery() val builder = XContentFactory.jsonBuilder() builder.startObject() aggregations.toXContent(builder, org.elasticsearch.common.xcontent.ToXContent.EMPTY_PARAMS) builder.endObject() val json = builder.string()
val json = builder.string()ではビルドしたパラメータがjson文字列として格納されています。こちらのjson文字列を実装したカスタムアダプタでkotlinのdata classに変換します。
AggregationAdapterクラスを実装する
次のように定義することでMoshiがサポートしている型ではないAggregations型を追加できます。
class AggregationAdapter : JsonAdapter<Aggregations>() { companion object { val FACTORY: Factory = object : Factory { override fun create(type: Type, annotations: Set<out Annotation>, moshi: Moshi): JsonAdapter<*>? { if (type === Aggregations::class.java) { return AggregationAdapter() } return null } } }
次のページを参考にさせていただきました。
Retrofit2.0に備えてKotlinで始めるMoshi(JSONパーサ) - Qiita
今回はJsonWriterとJsonReaderを使って@ToJson、@FromJsonを実装しました。
@ToJson override fun toJson(writer: JsonWriter?, value: Aggregations?) @FromJson override fun fromJson(reader: JsonReader?): Aggregations?
keyの値に"aggs_post_id"など独自定義ができたり、ネストが深かったりとJsonWriterとJsonReaderを使うことで複雑な構造であっても変換処理を実装することができます。
実際のコードは省略してしまっていますが公開していますので、よろしければ参照してください。役立つと嬉しいです。
github.com
実装したアダプタクラスを使いdata class化することでアサーションも見通しが良くなりました。
val adapter = Moshi.Builder().add(AggregationAdapter()).build().adapter(Aggregations::class.java) val actual = adapter.fromJson(json) actual.name shouldBe "aggs_post_id" actual.terms.field shouldBe "post_id" actual.terms.size shouldBe 0L actual.aggregations?.name shouldBe "aggs_category_id" actual.aggregations?.terms?.field shouldBe "category_id" actual.aggregations?.terms?.size shouldBe 0L actual.aggregations?.aggregations?.name shouldBe "aggs_user_id" actual.aggregations?.aggregations?.terms?.field shouldBe "user_id" actual.aggregations?.aggregations?.terms?.size shouldBe 0L
fluentd + logstash_formatの難点をrecord_reformerで解決した話
「アプリケーションで出力したログをelasticsearchにインデックスするときにログ時間のフィールド名を@timestampにしたい。」
こんなときにfluentdのlogstash_formatを使うと少々はまります。
今回はそのハマりポイントと解決についてのお話です。
なぜログ時間のフィールド名を@timestampにしたいか
先々にkibanaでもログを取り込むことを考えていました。(kibanaではログ時間のTime Fieldの扱いが@timestampとなっています。)
アプリケーションから出力されるフィールドはtimeのままで@timestampにしたくありません。
logstash_formatを使えばtimeフィールドを@timestampに変換してくれますが難点があります。
logstash_formatを使わない理由
logstash_formatはfluentdでsource化したログをlogstashのログ形式にフォーマットしてくれるプラグインです。
例えば以下のログをfluentdでsource化してelasticsearchにインデックスするとインデックスは以下のようになります。
ログ
{"time":"2016-12-06T10:43:10Z","post_id":1000,"category_id":200,"user_id":123}
elasticsearchでindex確認
$ curl -XGET "http://localhost:9200/localhost.api-2016.12.06/logstash_action_log/AVjnaIabuvtAHVSVViec?pretty" { "_index" : "localhost.api-2016.12.06", "_type" : "logstash_action_log", "_id" : "AVjnaIabuvtAHVSVViec", "_version" : 1, "found" : true, "_source" : { "post_id" : 1000, "category_id" : 200, "user_id" : 123, "@timestamp" : "2016-12-06T10:43:10+00:00" } }
ここに1つの難点があります。logstash_formatを使うとアプリケーションログのtimeは@timestampに変換してくれます。(便利)
だけど、インデックス名がlocalhost.api-yyyyMMddとなってしまいます。
fluentdの設定からはインデックス名のprefixは指定できますが、時刻部分はコントロールできません。(type名は指定できます)
fluentdの設定でいうと以下の部分です。
<match api.logstash_action_log> type copy <store> 〜〜省略〜〜 type_name logstash_action_log ← indexのtype名 logstash_format true ←ココでフォーマットを有効化 logstash_prefix "localhost.api" ←ココでindex名のprefixを指定 </store> </match>
インデックス名はコントロールしたいけど、timeフィールドは@timestampに変換してほしい。
logstash_formatだとインデックス名はコントロールできない。
そこでrecord_reformerの出番です。
record_reformer
record_reformerはfluentdでsource化したログのフィールド名を新しいフィールド名に書き換えることができます。
ログ値も同様に書き換えられます。
以下、利用例です。
fluentdの設定
<match foo.**> type record_reformer remove_keys remove_me renew_record false enable_ruby false tag reformed.${tag_prefix[-2]} <record> hostname ${hostname} input_tag ${tag} last_tag ${tag_parts[-1]} message ${message}, yay! </record> </match>
書き換え前のログ
foo.bar { "remove_me":"bar", "not_remove_me":"bar", "message":"Hello world!" }
record_reformerが書き換え後のログ
reformed.foo { "not_remove_me":"bar", "hostname":"YOUR_HOSTNAME", "input_tag":"foo.bar", "last_tag":"bar", "message":"Hello world!, yay!", }
record_reformerが解決してくれる
見事に今回の課題をrecord_reformerが解決してくれました。
record_reformerで書き換えたmatch節からelaseticsearchにcopyするmatch節に繋げるのがコツです。
最終的な設定は以下のようになりました。
<source> # 省略 </source> <match api.reformer_action_log> type record_reformer enable_ruby true tag api.action_log.reformer <record> @timestamp ${time.strftime('%Y-%m-%dT%H:%M:%S%z')} </record> </match> <match api.action_log.reformer> type copy <store> @type elasticsearch host "#{ENV['ELASTICSEARCH_HOST']}" port "#{ENV['ELASTICSEARCH_PORT']}" index_name api type_name reformer_action_log include_time_key true flush_interval 1s </store> </match>
- match api.reformer_action_logでtimeフィールドを@timestampのフィールド名に再フォーマット
- match api.reformer_action_logのtag api.action_log.reformerでelasticsearchへ転送するmatch定義へフォワード
- match api.action_log.reformerではlogstash_formatは使わずにindex名が指定できています
elasticsearchでindex確認
$ curl -XGET "http://localhost:9200/api/reformer_action_log/AVjnaJoIuvtAHVSVVied?pretty" { "_index" : "api", "_type" : "reformer_action_log", "_id" : "AVjnaJoIuvtAHVSVVied", "_version" : 1, "found" : true, "_source" : { "post_id" : 1000, "category_id" : 200, "user_id" : 123, "@timestamp" : "2016-12-06T10:43:10+0000" } }
インデックスが定義で指定したreformer_action_logに、timeフィールドは@timestampのフィールド名になりました!