KotlinでtoJsonとfromJsonのJSONパース。MoshiのCustom Type Adaptersを使ってオブジェクトのテストを快適に。

最近はkotlinで開発しています。これまで他の言語で出来ていたこともkotlinではどうやって出来るのか?、調査したり試したりすることは楽しいですね。新しい言語に触れる醍醐味とも言えます。
kotlinをサーバサイドのメイン言語に使っています。数ある処理の中でもJSONの扱いは必須です。
今のプロジェクトではMoshiライブラリをメインで使っています。今回はkotlinでMoshiライブラリを使った話です。

Moshi

MoshiはJavaで開発されたJSONライブラリです。

github.com

ライブラリの特徴は公式のほうを参照いただくとして、メインの話をしたいのが「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

まとめ

開発においてjsonの扱いは必須です。
kotlinでもMoshiのCustom Type Adaptersを使うことで独自の型の変換を学べました。
JsonWriterとJsonReaderのを使い変換処理をコード化することは慣れが必要で扱いにくいと感じてしまうかもしれません。

APIのResponse Bodyからオブジェクトへの変換をする、よくあるパターンです。
今回の学びでkotlinでも臆することなく向き合えるようになりました!

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の出番です。

github.com

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_logtag 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のフィールド名になりました!

ソースを公開しています

今回のソースはgithubに公開しています。logstashの設定も入れているので合わせて確認できます。

github.com


同じ課題にぶち当たっている人の手助けになれば嬉しいです。

Spring Boot + Kotlinでmockitoを使ってモック化したテストコードを書く

今回はkotlinでmockito使って処理をモック化したテストコードを書いてみた。

site.mockito.org

セットアップ

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"))