しおしお

IntelliJ IDEAのことなんかを書いてます

Dartの拡張関数を使ってみる

Dartの拡張関数とは

Kotlinと同じような感じで、既存のクラスに対して関数追加できちゃう感じですね。 ドキュメントはこちら→(Extension methods | Dart)

構文

extension onを使って拡張したい型に対して、拡張関数を定義してあげます。 extensiononの間に任意の名前を指定することで、別名をつけることもできるみたいですね。

extension <extension name> on <type> {
  (<member definition>)*
}

拡張関数を試してみる

パターン1

extension onStringを指定することで、Stringに対して拡張関数を追加できますね。 拡張関数は、通常の関数と同じように呼び出すことができますね。

void main() {
  print("hoge".hello());
}

extension on String {
  String hello() {
    return "hello, ${this}";
  }
}

実行結果

$ dart extension-sample.dart
hello, hoge

パターン2

Generics対応したListに対しても拡張関数を追加できるか確認してみます。 この例だと、List<String>に対しては拡張関数を呼び出せますが、List<int>に対しては呼び出すことができません。

void main() {
  var ints = ['1', '2'].toInt();
  print(ints);

}

extension on List<String> {
  List<int> toInt() {
    return this.map((e) => int.parse(e)).toList();
  }
}

実行結果

$ dart list-extension.dart
[1, 2]

拡張関数名が衝突した場合…

スコープ内に同じ名前の拡張関数が存在知る場合は、こんな感じのビルドエラーとなります。 この例だと、Stringに対して追加した拡張関数のhogeが複数存在しているよって感じですね。

※同一スコープ内で名前衝突が起こるような拡張関数を生やすことって無いと思いますが…

Error: The method 'hoge' is defined in multiple extensions for 'String' and neither is more specific.
Try using an explicit extension application of the wanted extension or hiding unwanted extensions from scope.

衝突を回避するために

extension onじに、別名をつけてあげることで回避できるようです。 拡張関数を使う際には、別名の型に事前に変換してから呼び出す感じになるようです。

void main() {
  String2('').hoge();
}

extension String2 on String {
  void hoge() {
    print('hoge');
  }
}

Elasticsearchで条件にマッチした部分を取得する

Elasticsearchで条件にマッチしたワードをハイライト表示したいなんてことありますよね。 これを実現するために、条件にマッチした部分を検索結果から取得する方法になります。

準備

インデックス内容

フィールドをひとつだけ持つインデックスを作成して確認してみます。

{
  "settings": {
    "number_of_shards": 1,
    "analysis": {
      "analyzer": {
        "kuromoji": {
          "type": "custom",
          "tokenizer": "kuromoji_tokenizer"
        }
      }
    }
  },
  "mappings": {
    "_doc": {
      "properties": {
        "name": {
          "type": "text",
          "analyzer": "kuromoji"
        }
      }
    }
  }
}

データ投入

JavaのClientを使って、2つのドキュメントを投入します。

fun main() {
    createClient().use { client ->

        val bulkRequest = BulkRequest().apply {
            add(IndexRequest("test", "_doc").apply {
                source(mapOf("name" to "あいうえお かきくけこ"))
            })
            add(IndexRequest("test", "_doc").apply {
                source(mapOf("name" to "さしすせそ たちつてと"))
            })
        }
        val response = client.bulk(bulkRequest, RequestOptions.DEFAULT)
        println(response.status())
    }

}

検索条件にマッチした部分を取得してみる

検索クエリー

検索クエリーを投げる際の、リクエストボディにhighlightを追加してあげます。 また、highlightにはハイライト表示したい(条件にマッチした部分を知りたい)フィールド名を指定してあげます。

{
  "query": {
    "bool": {
      "filter": {
        "terms": {
          "name": ["あい", "たち"]
        }
      }
    }
  },
  "highlight": {
    "fields": {
      "name": {
      }
    }
  }
}

検索結果

検索結果の各ドキュメントごとに、highlightから検索条件にマッチした部分を取得できます。 デフォルトでは、emタグでマッチした部分が囲まれて返ってきます。

{
  "took": 8,
  "timed_out": false,
  "_shards": {
    "total": 1,
    "successful": 1,
    "skipped": 0,
    "failed": 0
  },
  "hits": {
    "total": 2,
    "max_score": 0.0,
    "hits": [
      {
        "_index": "test",
        "_type": "_doc",
        "_id": "_3tmSnoBBFTrBATTDpDa",
        "_score": 0.0,
        "_source": {
          "name": "あいうえお かきくけこ"
        },
        "highlight": {
          "name": [
            "<em>あい</em>うえお かきくけこ"
          ]
        }
      },
      {
        "_index": "test",
        "_type": "_doc",
        "_id": "AHtmSnoBBFTrBATTDpHa",
        "_score": 0.0,
        "_source": {
          "name": "さしすせそ たちつてと"
        },
        "highlight": {
          "name": [
            "さしすせそ <em>たち</em>つてと"
          ]
        }
      }
    ]
  }
}

JavaのClientで試してみる

検索用のリクエスト(SearchRequest)のhighlighterにハイライト表示したいフィールド名を指定してあげます。 検索結果からは、検索にヒットしたドキュメントごとhighlightFieldsからハイライト表示されたフラグメントが取得できます。

コード

fun main() {
    createClient().use { client ->

        val searchRequest = SearchRequest("test").apply {
            types("_doc")
            source(SearchSourceBuilder().apply {
                query(QueryBuilders.boolQuery().apply {
                    filter(QueryBuilders.termsQuery("name", "あい", "かき", "たち"))
                })
                highlighter(HighlightBuilder().apply {
                    field("name")
                })
            })
        }
        val response = client.search(searchRequest, RequestOptions.DEFAULT)
        println(response.status())
        response.hits.hits.forEach { 
            it.highlightFields.forEach {
                println("it = ${it.value}")
            }
        }
    }
}

実行結果

APIを直接呼び出したときと同じように、ハイライト表示対応されたフラグメントが取得できていることが確認できます。

it = [name], fragments[[<em>あい</em>うえお <em>かき</em>くけこ]]
it = [name], fragments[[さしすせそ <em>たち</em>つてと]]

kotlinx.serializationつかってJSONのシリアライズ・デシリアライズしてみたよ

kotlinx.serialization を使って、JSONシリアライズとデシリアライズを試してみたよ。

シリアライズ&シリアライズ

JSONに対応するdata classを作成する

JSONに対応するdata classには、@Serializableを設定してあげます。ネストオブジェクトに対応するクラスにも同様に@Serializableが必要になります。

@Serializable
data class Test(
    val str: String,
    val int: Int,
    val double: Double,
    val nullable: String?,
    val defaultValue: String = "default",
    val lists: List<Nest>,
)

@Serializable
data class Nest(
    val value: String
)

シリアライズを試してみる

コード

Json.encodeToStringシリアライズが行えます。

val test = Test("str", 100, 9.9, null, "value", listOf(Nest("1"), Nest("2")))
val json = Json.encodeToString(test)
println("json = ${json}")

実行結果

ネストオブジェクト構造やnullもシリアライズできていることが確認できます。

json = {"str":"str","int":100,"double":9.9,"nullable":null,"defaultValue":"value","lists":[{"value":"1"},{"value":"2"}]}

シリアライズを試してみる

Json.decodeFromStringでデシリアライズが行なえます。JSONに対応するdata classは型パラメータとして指定してあげます。

コード

val test = Json.decodeFromString<Test>(
    """
        {
        "str": "str",
        "int": 100,
        "double": 99.99,
        "nullable": null,
        "lists": [
              {"value": "1"},
              {"value": "2"}
            ]
        }
    """.trimIndent()
)
println("test = ${test}")

実行結果

ネストオブジェクト構造もデシリアライズできていることが確認できます。 また、デフォルト値をしているdefaultValueプロパティは、JSON内にキーが存在していないため自動的にdefaultで指定されている値が設定されていることも確認できます。

test = Test(str=str, int=100, double=99.99, nullable=null, defaultValue=default, lists=[Nest(value=1), Nest(value=2)])

設定をカスタマイズしてみる

data classに未定義のキーが存在していた場合の動作を変更する

コード

ignoreUnknownKeystrueに設定することで、data class上に未定義のキーがJSONに存在していても無視して動作するようになります。 なお、この設定値をfalse(デフォルト値がfalse)にした場合は、実行時にkotlinx.serialization.json.internal.JsonDecodingExceptionが送出されます。 (例外メッセージの中で、ignoreUnknownKeystrueにしなよと親切に教えてくれます。)

@Serializable
data class Test(val value: String)

fun main() {
    val json = Json {
        ignoreUnknownKeys = true
    }
    val test = json.decodeFromString<Test>("""{"value": "a", "unknown": null}""")
    println("test = ${test}")
}

実行結果

data class上に存在しているキーのみデシリアライズされていることが確認できます。

test = Test(value=a)

JSONのキー名を指定する

コード

シリアライズ時のキー名は、@SerialNameで指定します。デシリアライズ時のキー名は@JsonNamesで指定します。

@Serializable
data class Test(
    @JsonNames("key") @SerialName("key") val value: String)

fun main() {
    val jsonString = Json.encodeToString(Test("ほげ"))
    println("json = ${jsonString}")
    val test = Json.decodeFromString<Test>(jsonString)
    println("test = ${test}")
}

実行結果

プロパティ名ではなくアノテーションで指定したキー名でやり取りできていることが確認できます。

json = {"key":"ほげ"}
test = Test(value=ほげ)

Date and Time APIのクラスを使ってみる

デフォルトでは、Date and Time APIのクラス群の変換には対応していないので、Serializerを作ることで対応してあげる必要があります。

Serializerの実装

KSerializer を実装して、シリアライズとデシリアライズの実装をしてあげます。 この例では、LocalDateを文字列変換してシリアライズし、デシリアライズではその逆を行っています。

object LocalDateSerializer: KSerializer<LocalDate> {
    override val descriptor: SerialDescriptor = PrimitiveSerialDescriptor("LocalDate", PrimitiveKind.STRING)

    override fun deserialize(decoder: Decoder): LocalDate {
        return LocalDate.parse(decoder.decodeString(), DateTimeFormatter.ISO_DATE)
    }

    override fun serialize(encoder: Encoder, value: LocalDate) {
        encoder.encodeString(value.format(DateTimeFormatter.ISO_DATE))
    }

}

シリアライズ・デシリアライズの実装

Serializerを、serializersModuleに登録してあげます。登録時には、変換対象のKClassとそれに対応するSerializer形式で登録します。

@Serializable
data class DateTime(
    @Contextual
    val date: LocalDate
)

fun main() {

    val json = Json {
        serializersModule = SerializersModule {
            contextual(LocalDate::class, LocalDateSerializer)
        }
    }
    val jsonString = json.encodeToString(DateTime(LocalDate.now()))
    println("jsonString = ${jsonString}")

    val dateTime = json.decodeFromString<DateTime>(jsonString)
    println("dateTime = ${dateTime}")
}

実行結果

自作したSerializerを使ったシリアライズとデシリアライズが動いていることが確認できますね。

jsonString = {"date":"2021-06-16"}
dateTime = DateTime(date=2021-06-16)

IntelliJ IDEA2021.1からGitのコミットメッセージテンプレートに対応したのが便利

現時点ではBeta版のIntelliJ IDEAの2021.1からGitのコミットメッセージテンプレートに対応してくれたのが便利ですね。 これを使うことでサードパーティ製のプラグインなど入れなくても、テンプレートを元にメッセージのプレフィックス的なもの入れたりするのも簡単にできますね。

使い方

使い方はgit config commit.templateを使ってテンプレートのメッセージを登録しておくだけですね。 私は、こんな感じに絵文字のリストをコメントとして登録しておいて、コミット時に必要な絵文字をコメントからコピって入力するようにしています。

#{id}

# :tada:        Initial commit
# :bookmark:    Version tag
# :sparkles:    New feature
# :bug:    Bugfix
# :card_index:    Metadata
# :books:    Documentation
# :bulb:    Documenting source code
# :racehorse:    Performance
# :lipstick:    Cosmetic
# :rotating_light:    Tests
# :white_check_mark:    Adding a test
# :heavy_check_mark:    Make a test pass
# :zap:    General update
# :art:    Improve format/structure
# :hammer:    Refactor code
# :fire:    Removing code/files
# :green_heart:    Continuous Integration
# :lock:    Security
# :arrow_up:    Upgrading dependencies
# :arrow_down:    Downgrading dependencies
# :shirt:    Lint
# :alien:    Translation
# :pencil:    Text
# :ambulance:    Critical hotfix
# :rocket:    Deploying stuff
# :apple:    Fixing on MacOS
# :penguin:    Fixing on Linux
# :checkered_flag:    Fixing on Windows
# :construction:    Work in progress
# :construction_worker:    Adding CI build system
# :chart_with_upwards_trend:    Analytics or tracking code
# :heavy_minus_sign:    Removing a dependency
# :heavy_plus_sign:    Adding a dependency
# :whale:    Docker
# :wrench:    Configuration files
# :package:    Package.json in JS
# :twisted_rightwards_arrows:    Merging branches
# :hankey:    Bad code / need improv.
# :rewind:    Reverting changes
# :boom:    Breaking changes
# :ok_hand:    Code review changes
# :wheelchair:    Accessibility
# :truck:    Move/rename repository
# :wastebasket: remove unnecessary files
# :memo: memo memo

テンプレートを設定後、IntelliJさん側でコミットをしようとするとこんな感じでテンプレートのメッセージが適用されるようになります。

f:id:sioiri:20210310094235p:plain

会社用のみテンプレートを分けたいケース

私の場合はghqを使っているので、会社用のリポジトリが特定のディレクトリ配下に集まっています。 なので、includeIfを使って特定ディレクトリ配下のみ特定のテンプレートを適用する設定を入れています。

.gitconfigの設定はこんな感じにしています。(xxxxは組織名が入る感じですね)

[includeIf "gitdir:~/src/github.com/xxxx/"]
  path = ~/.xxxx-gitconfig

~/.xxxx-gitconfigに、コミットメッセージのテンプレートを指定する感じになります。

[commit]
  template = ~/xxxxCommitTemplate.txt

これで、会社用リポジトリのみテンプレートが適用できるようになりますね。

Testcontainersを使ったテストの高速化

Testcontainersを使ったテストは、コンテナの起動が毎回行われるのでどうしてもslow testになってしまいます。 そこで、一度あげたコンテナを使い回すことで2回目以降のテスト実行を高速化してみようと思います。

コンテナを使い回す設定を追加

コンテナを使い回す設定は、テストコードとTestcontainersの設定ファイルの両方に対して行う必要があります。 片方だけに設定を行っても有効にならないので注意です。

テストコード

テストコードで、コンテナを起動する際にreuse(org.testcontainers.containers.GenericContainer#withReuse)trueを設定してあげます。

    private val elasticsearchContainer: ElasticsearchContainer
    init {
        val time = measureNanoTime {
            elasticsearchContainer = ElasticsearchContainer("docker.elastic.co/elasticsearch/elasticsearch:7.11.1")
                .withCreateContainerCmdModifier {
                    it.withEntrypoint("/bin/bash", "-c", "./bin/elasticsearch-plugin install analysis-kuromoji && docker-entrypoint.sh")
                }
                .withLabel("filter-label", "plugin-install-test")
                .withReuse(true)
                .apply {
                    start()
                }
        }
        println("time >>>>> ${TimeUnit.NANOSECONDS.toMillis(time)}")
    }

Testcontainersの設定

上記のテストコードの設定に加えて$HOMEディレクトリ直下にある.testcontainers.propertiesに以下の設定を加えてあげます。 ファイルがない場合には、新規で作成して設定を追加する感じになります。

testcontainers.reuse.enable=true

実行結果

1回目と2回目の時間を比較してみると、コンテナ起動が省略でき、20秒ほど早くなっていることが確認できますね。

1回目

08:57:57.666 [Test worker] INFO 🐳 [docker.elastic.co/elasticsearch/elasticsearch:7.11.1] - Container docker.elastic.co/elasticsearch/elasticsearch:7.11.1 started in PT19.649672S
time >>>>> 21072

2回目

08:58:54.287 [Test worker] INFO 🐳 [docker.elastic.co/elasticsearch/elasticsearch:7.11.1] - Container docker.elastic.co/elasticsearch/elasticsearch:7.11.1 started in PT0.049485S
time >>>>> 1676

起動しっぱなしのコンテナの終了方法

Testcontainersで上げたコンテナンにはラベルが設定されているので、そのラベルでフィルターすることで簡単に終了できます。

コマンド的には、こんな感じになります。

docker ps --filter label=org.testcontainers -q | xargs docker stop

注意点

コンテナを使い回すことになるので、テスト開始時にクリーンな状態ではない可能性があります。 テストコードでは必ず前回のテストの状態を削除するなどして、きれいな状態にする必要があります。

TestcontainersのElasticsearch containerでコンテナ起動時にpluginをインストールする方法

TestcontainersのElasticsearchコンテナ起動時にテストで必要となるプラグインをインストールする方法を調べてみました。

サンプルコード

ElasticsearchContainerの親クラスのGenericContainerが、docker-java APICreateContainerCmdに対してなにか処理を追加できるwithCreateContainerCmdModifierメソッドを提供してくれています。 このメソッドを使って、entrypointを設定することでプラグインのインストールが実現できます。

サンプルコードでは、analysis-kuromojiインストール後にdocker-entrypoint.shを実行してElasticsearchを起動してみました。

import org.junit.jupiter.api.Test
import org.testcontainers.elasticsearch.ElasticsearchContainer

class ElasticSearchTest {

    val elasticsearchContainer = ElasticsearchContainer("docker.elastic.co/elasticsearch/elasticsearch:7.11.1")
        .withCreateContainerCmdModifier {
            it.withEntrypoint("/bin/bash", "-c", "./bin/elasticsearch-plugin install analysis-kuromoji && docker-entrypoint.sh")
        }
        .withLabel("filter-label", "plugin-install-test")
        .withReuse(true)
        .apply {
            start()
        }

    @Test
    internal fun test() {
        val containerId = elasticsearchContainer.containerId
        println("containerId = ${containerId}")
    }
}

pluginのインストール確認

withReusetrueにしているので、テスト終了後もコンテナが起動しっぱなしの状態となります。 そのコンテナ内に、入り込んでプラグインがインストールされていることを確認してみます。

docker ps --filter label=filter-label=plugin-install-test
CONTAINER ID   IMAGE                                                  COMMAND                  CREATED          STATUS          PORTS                                              NAMES
e705ae24a8a6   docker.elastic.co/elasticsearch/elasticsearch:7.11.1   "/bin/bash -c './bin…"   13 minutes ago   Up 13 minutes   0.0.0.0:49259->9200/tcp, 0.0.0.0:49258->9300/tcp   keen_gould
~ ❯ docker exec -it e705 bash
[root@e705ae24a8a6 elasticsearch]# ./bin/elasticsearch-plugin list
analysis-kuromoji

./bin/elasticsearch-plugin listの結果、 analysis-kuromojiが表示されたので想定通りプラグインがインストールされていますね!

Spring Cloud Contractのstubをもとにサーバを起動してみる

Spring Cloud Contractを試してみた - しおしおの続きで、Producer側で生成したstubsをサーバとして起動してエンドポイントを叩いて結果が取得できるか試してみました。

Spring Cloud Contractを適用したプロジェクトの作成

新規でプロジェクトを作るのは大変なので、Spring Cloud Contractを試してみた - しおしおで作ったプロジェクトを使っていきたいと思います。

プロジェクトは、GitHubにいるので↓でcloneしてきます。

git clone git@github.com:siosio/spring-cloud-contract-example.git

Producer側でstubを生成する

generateClientStubsタスクを実行することで、stubを生成できます。 生成されたファイルは、build/stubs:/contracts/stubs配下にあります。

サンプルプロジェクトの場合、プロジェクト直下で↓を実行することで生成できます。

./gradlew server-service:generateClientStubs

生成したstubをもとにサーバを起動する

springcloud/spring-cloud-contract-stub-runnerイメージを使って、コンテナを起動できます。

環境変数に渡す値は、基本はAutoConfigureStubRunnerと同じ値になります。 大きく異なるのは、生成したstubをもとにサーバを起動したいので、STUBRUNNER_REPOSITORY_ROOTにはローカルのディレクトリを指定します。 指定した、ディレクトリには生成したstubが格納されているディレクトリの内容をコピーしておきます。

docker run  --rm \
  -e "STUBRUNNER_IDS=siosio:server-service:1.0.0:8080" \
  -e "STUBRUNNER_REPOSITORY_ROOT=stubs://file:///contracts/stubs/" \
  -e "STUBRUNNER_STUBS_MODE=LOCAL" \
  -p "8080:8080" \
  -v `pwd`/server-service/build/stubs:/contracts/stubs \
  springcloud/spring-cloud-contract-stub-runner:2.2.5.RELEASE

stubベースのサーバにアクセスしてみる

CDCの内容

CDCの内容的に、/sample/{数値}のエンドポイントを叩けば、stubが結果を返してくれるはずです。

import org.springframework.cloud.contract.spec.Contract

Contract.make {
  priority 2
  request {
    method('GET')
    url "/sample/${regex('\\d+')}"
  }

  response {
    status(OK())

    headers {
      contentType(applicationJson())
    }
    
    body(
        id: fromRequest().path(1),
        name: "name_${fromRequest().path(1)}"
    )
  }
}

実行結果

CDCの契約に従って、サーバが結果を返してくれていることが確認できますね。

$ curl http://localhost:8080/sample/1
{"id":"1","name":"name_1"}
$ curl http://localhost:8080/sample/123
{"id":"123","name":"name_123"}

これで、Producer側の開発が終わっていないタイミングでも、早いタイミングでインタフェースを確定してしまえば、stubベースのサーバを使ったConsumer側の開発ができそうな感じがありますね。