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のフィールド名になりました!
Spring Boot + Kotlinでmockitoを使ってモック化したテストコードを書く
今回はkotlinでmockito使って処理をモック化したテストコードを書いてみた。
セットアップ
dependencies { ... testCompile "org.springframework.boot:spring-boot-starter-test" testCompile "org.mockito:mockito-core:$mockito_core_version" testCompile 'junit:junit:$junit_version' }
※ mockitoは1.10.19を使いました。
モック化するクラス
- Elasticsearchからデータを取得するサービスクラスを想定してモック化します。
- 今回はモック化メインなのでElasticsearch側の処理は適当です。
interface ElasticsearchClient { fun foo() : String } @Service class ElasticsearchClientImpl : ElasticsearchClient { override fun foo() : String { return "foo" } }
- foo()メソッドは"foo"を返すように実装しています。
- このfoo()メソッドをコントローラーから呼び出し、コントローラーのテストでfoo()メソッドをモック化します。
テスト対象のコントローラー
@RestController @EnableAutoConfiguration @ComponentScan @Api(description = "ヘルスチェック") open class HealthCheckController constructor(val elasticsearchClient: ElasticsearchClient) { @ApiModel data class HealthCheck( @ApiModelProperty(required = true, value = "結果", example = "true") val result: Boolean, @ApiModelProperty(required = true, value = "ステータス", example = "OK") val status: String ) @ApiOperation(value = "ヘルスチェック") @RequestMapping(value = "/health-check", method = arrayOf(RequestMethod.GET)) fun index(): Response<HealthCheck> { return Response(HealthCheck(true, "OK")) } @ApiOperation(value = "ヘルスチェック") @RequestMapping(value = "/health-check/elasticsearch", method = arrayOf(RequestMethod.GET)) fun elasticsearch(): Response<HealthCheck> { return Response(HealthCheck(true, elasticsearchClient.foo())) } }
- elasticsearch()ではfoo()メソッドを利用しています。
- 運用時ではelasticsearch()はElasticsearchへリクエストを送信し正常に稼働しているかのヘルスチェックに利用します。
- では、テストコードでfoo()メソッドをモック化します。
テストコード
@RunWith(SpringJUnit4ClassRunner::class) @ContextConfiguration(classes = arrayOf(Application::class)) @WebAppConfiguration class HealthCheckControllerTest { lateinit var mvc: MockMvc lateinit var elasticsearchClient: ElasticsearchClient lateinit var target: HealthCheckController @Before fun setup() { elasticsearchClient = Mockito.mock(ElasticsearchClient::class.java) target = HealthCheckController(elasticsearchClient) MockitoAnnotations.initMocks(this) mvc = MockMvcBuilders.standaloneSetup(target).build() } @Test fun testElasticsearch() { val type = Types.newParameterizedType(Response::class.java, HealthCheckController.HealthCheck::class.java) val adapter: JsonAdapter<Response<HealthCheckController.HealthCheck>> = Moshi.Builder().build().adapter(type) Mockito.`when`(elasticsearchClient.foo()).thenReturn("NG") val result: MvcResult = mvc.perform(get("/health-check/elasticsearch")) .andExpect(status().isOk()).andReturn() val response = adapter.fromJson(result.response.contentAsString) assertThat(response.value.result, Is.`is`(true)) assertThat(response.value.status, Is.`is`("NG")) } }
setup()メソッド
@Before fun setup() { elasticsearchClient = Mockito.mock(ElasticsearchClient::class.java) target = HealthCheckController(elasticsearchClient) MockitoAnnotations.initMocks(this) mvc = MockMvcBuilders.standaloneSetup(target).build() }
- 上記のコードでHealthCheckControllerのElasticsearchClientクラスインスタンスをモック化しています。
モック化はどこで?
Mockito.`when`(elasticsearchClient.foo()).thenReturn("NG")
- 上記のコードでfoo()メソッドの返り値が"NG"になるようにしています。
assertThat(response.value.result, Is.`is`(true)) assertThat(response.value.status, Is.`is`("NG"))
- アサーションでは正しく"NG"になるかチェックを行っています。