Scala + PlayFramework + Elasticsearch ことはじめ

この記事は[ただの集団 Advent Calendar 2019](https://adventar.org/calendars/4054)の7日目の記事です。

# 背景
Elasticsearch(以下、ESと呼びます)は[REST API](https://www.elastic.co/guide/en/elasticsearch/reference/current/rest-apis.html)を提供しており、私自身もちょっとしたことならばシェルスクリプトやpython等の軽量言語で記述することが多いです。
しかし、複雑な処理を扱う場合、例として[Search](https://www.elastic.co/guide/en/elasticsearch/reference/current/search-search.html)を用いる場合はscoreも含めて構成要素が多くなるため、もう少し楽に書きたいというのがあります。

# Scalaを使うメリット
仕事ではScalaをメインに開発していますが、私自身がScalaに対して良いと思うのは以下のような点です。

- OptionやCollection操作が扱いやすい
- 型安全・型推論が便利
- 強力なパターンマッチ

一言でいうと、「パワフルな言語」という位置づけです。

# 今回紹介するもの
[elastic4s](https://github.com/sksamuel/elastic4s)はESへの操作をScalaのDSLで記述できるライブラリです。TCPも提供していますが、今回はHTTPについて触れることにします。

# サンプルアプリケーション
ESへの検索をWebアプリケーション上で実行するケースを想定して書いていきます。
elastic4sは、執筆時点での最新版である7.3.1を例とします。まず、build.sbtに以下のように記述します。

```scala
scalaVersion := "2.13.1"
val elastic4sVersion = "7.3.1"

libraryDependencies ++= Seq(
  "com.sksamuel.elastic4s" %% "elastic4s-core" % elastic4sVersion,
  "com.sksamuel.elastic4s" %% "elastic4s-client-esjava" % elastic4sVersion,
  "com.sksamuel.elastic4s" %% "elastic4s-http-streams" % elastic4sVersion,
  "com.sksamuel.elastic4s" %% "elastic4s-json-play" % elastic4sVersion
)
```

`elastic4s-json-play`は必須ではないですが(後述)、playframeworkとplay-jsonを扱うならば選択肢として入れておくと良いと思います。

## ローカル環境のES構築

### Docker
[公式ドキュメントのversion7.3](https://www.elastic.co/guide/en/elasticsearch/reference/7.3/docker.html)を例にdocker-compose.ymlを記述し、起動すればよいです。

### Mapping
データの題材は迷ったのですが、駅を検索できるようなものを想定します。

```json
{
  "mappings": {
    "properties": {
      "stationCode": {
        "type": "keyword"
      },
      "stationName": {
        "type": "keyword"
      },
      "prefectureCode": {
        "type": "keyword"
      },
      "address": {
        "type": "text"
      },
      "point": {
        "type": "geo_point"
      }
    }
  }
}
```

↑の内容を用いてindexを作成します。

```sh
$ curl -XPUT http://localhost:9200/station/ -H 'Content-Type:application/json' -d @mapping.json
```

Mappingの詳しい内容は、[公式ドキュメント](https://www.elastic.co/guide/en/elasticsearch/reference/7.x/mapping.html)を参照あれ。

### データ投入
今回はSearchをメインとするため、割愛します。私の手元では[駅データJP](http://www.ekidata.jp/doc/station.php)のものを用いて行いました。

## Scalaのサンプルコード
全体はこんな感じになります。

```scala
import com.sksamuel.elastic4s.ElasticDsl._
import com.sksamuel.elastic4s.http._
import com.sksamuel.elastic4s.playjson._
import com.sksamuel.elastic4s.requests.searches.SearchBodyBuilderFn
import com.sksamuel.elastic4s.{ElasticClient, ElasticProperties, HitReader}
import javax.inject.Inject
import play.api.Logging
import play.api.libs.json.{Json, Reads}

import scala.concurrent.{ExecutionContext, Future}

case class Station(
  stationCode: String,
  stationName: String,
  point: Point,
  prefectureCode: String,
  address: String
)

case class Point(
  lat: Double,
  lon: Double
)

class Sample @Inject() ()(implicit ec: ExecutionContext) extends Logging {
  val client: ElasticClient = ElasticClient(JavaClient(ElasticProperties("http://localhost:9200")))

  def list(stationName: String, offset: Int, limit: Int): Future[(Long, List[Station])] = {
    implicit val reader: Reads[Point] = Json.reads[Point]
    implicit val hitReader: HitReader[Station] = playJsonHitReader(Json.reads[Station])

    val searchRequest =
      search("station")
        .query(
          termQuery("stationName", stationName)
        )
        .from(offset)
        .size(limit)
    logger.debug(s"search body: ${SearchBodyBuilderFn(searchRequest).string}")

    client.execute(searchRequest).map { response: Response[SearchResponse] =>
      response.body.foreach(body => logger.debug(s"response body: $body"))
      (response.result.totalHits, response.result.to[Station].toList)
    }
  }
}
```

### client生成
いくつか方法があるようですが、今回はシンプルな`JavaClient`を用いて生成します。

```scala
val client: ElasticClient = ElasticClient(JavaClient(ElasticProperties("http://localhost:9200")))
```

### 実行
`client.execute()`で実行します。

```scala
client.execute(searchRequest)
```

### SearchRequest
今回はSearchを扱うので、`ElasticDsl`にある`search`を用います。引数にはインデックス名を指定します。

```scala
val searchRequest =
  search("station")
    .query(
      termQuery("stationName", condition.stationName)
    )
    .from(condition.offset)
    .size(condition.limit)
```

以下、生成したンスタンスに対してメソッドチェーンで追加していくイメージです。上記の例では、[Term Query](https://www.elastic.co/guide/en/elasticsearch/reference/7.3/query-dsl-term-query.html)と[From・Size](https://www.elastic.co/guide/en/elasticsearch/reference/7.3/search-request-from-size.html)を指定しています。
Term Query以外のQuery、その他の機能も同じような感覚で使っていけます。

## ESからのResponse
`SearchResponse`は、`.result`から取得できます。また、レスポンスのJSON全体は`.body`で取得可能です。

```scala
client.execute(searchRequest).map { response: Response[SearchResponse] =>
  response.body.foreach(body => logger.debug(s"response body: $body"))
  (response.result.totalHits, response.result.to[Station].toList)
}
```

### playjsonを使って変換する
結果のリストの部分の取得は`.result.hits.hits`の内容を変換することも可能ですが、`.result.to[T]`を使って変換するほうが楽です。当然のことながら、型クラスに指定するclassのフィールドや型は、ESのmappingのに対応していなければなりません。
この機能を利用するには、`HitReader[T]`を定義する必要があります。playjsonを使うとこのようになります。

```scala
implicit val reader: Reads[Point] = Json.reads[Point]
implicit val hitReader: HitReader[Station] = playJsonHitReader(Json.reads[Station])
```

### デバッグ・テストのやり方
以下のようにすることで、リクエスト時に送られるJSONが確認できます。

```scala
logger.debug(s"search body: ${SearchBodyBuilderFn(searchRequest).string}")
```

全体のbodyに限らず、queryの部分の生成に当たる`QueryBuilderFn`等も提供されており、これらメソッド群はテストコードにも応用できます。

少し注意することとしては、複数の形式の記述をサポートしている場合、生成されるJSONのパターンは1種類になるということです。
Term Queryの例でいうと、以下のクエリはどちらも同じ意味ですが、後者が生成されます。

```json
{
  "term": {
    "stationName": "東京"
  }
}
```

```json
{
  "term": {
    "stationName": {
      "value": "東京"
    }
  }
}
```

この他にも、default値が明示的に指定されるなど少し冗長的なJSONになることがあるようです。
どのクエリが生成されるかは、ソースコードを実際に読んでいみるとわかりますので、興味のある方はどうぞ。

# まとめ
elastic4sを用いると、Scalaの恩恵を受けながらElasticsearchを扱うことができます。
REST APIで実現できることは大半のことが可能ですので、ぜひ使ってみてださい。