CLOVER🍀

That was when it all began.

Elasticsearch 2.xをEmbeddedableに使う

この前、初めてこのブログでElasticsearchを使ったのですが、次はEmbeddedに使ってみようかなと思いまして。

…正直、いきなりそんなことするもんじゃないなぁと後で思いましたけど。

基本的には、Java APIのドキュメントを見ていけばいいみたいです。

Java API [2.2] | Elastic

ちょっと古い情報もいくつかあるみたいですし。

Embedded Elasticsearch Server for Tests - Cup of Java

https://orrsella.com/2014/10/28/embedded-elasticsearch-server-for-scala-integration-tests/

今1.x系のAPIで書かれたブログエントリを見ると、だいぶ変わったんだなぁとは思いますが。

まあ、それはそうと試してみましょう。

今回想定する使い方は、こんな感じとします。

  • テストコード内の同じJavaプロセス内で、Elasticsearchを起動
  • データ保存ディレクトリは一時ディレクトリを作成し、終了時に削除
  • インデックスの作成や、検索などをやってみる

準備

今回も、Scalaで書きます。ビルド定義はこんな感じ。
build.sbt

name := "embedded-elasticsearch"

version := "1.0"

scalaVersion := "2.11.7"

organization := "org.littlewings"

scalacOptions ++= Seq("-Xlint", "-deprecation", "-unchecked", "-feature")

updateOptions := updateOptions.value.withCachedResolution(true)

libraryDependencies ++= Seq(
  "org.elasticsearch" % "elasticsearch" % "2.2.0",
  "net.java.dev.jna" % "jna" % "4.1.0",
  "commons-io" % "commons-io" % "2.4",
  "org.scalatest" %% "scalatest" % "2.2.6" % "test"
)

JNAは、警告が出るので一応足しておきました。Commons IOはディレクトリの一括削除用、ScalaTestはテストコード用です。

単純に使ってみる

では、このあたりのドキュメントを参考にしつつ、実装してみます。

Node Client | Java API [2.2] | Elastic

Index API | Java API [2.2] | Elastic

Search API | Java API [2.2] | Elastic

できあがったコードは、こんな感じです。
src/test/scala/org/littlewings/elasticsearch/EmbeddedElasticsearchSpec.scala

package org.littlewings.elasticsearch

import java.io.File
import java.nio.file.Files

import org.apache.commons.io.FileUtils
import org.elasticsearch.action.search.SearchType
import org.elasticsearch.client.Requests
import org.elasticsearch.common.settings.Settings
import org.elasticsearch.index.query.QueryBuilders
import org.elasticsearch.node.NodeBuilder
import org.scalatest.{FunSpec, Matchers}

import scala.collection.JavaConverters._

class EmbeddedElasticsearchSpec extends FunSpec with Matchers {
  describe("Embedded Elasticsearch Spec") {
    it("simple embedded server") {
      val dataDirectory = createTemporaryDirectory("elasticsearch-")

      val builder =
        Settings
          .settingsBuilder
          .put("http.enabled", false)
          .put("path.data", dataDirectory)
          .put("path.home", ".")

      val node =
        NodeBuilder
          .nodeBuilder
          .settings(builder)
          .clusterName("test-cluster")
          .local(true)
          .data(true)
          .node()

      try {
        val client = node.client

        val indicesAdminClient = client.admin.indices

        // インデックス存在確認(未作成)
        indicesAdminClient
          .prepareExists("myindex")
          .execute
          .actionGet
          .isExists should be(false)

        // インデックス作成
        indicesAdminClient
          .prepareCreate("myindex")
          .addMapping("mytype",
            """|{
              |  "index.number_of_shards": 1,
              |  "mytype": {
              |    "properties": {
              |      "id": { "type": "integer", "store": "yes", "index": "not_analyzed" },
              |      "value": { "type": "string", "store": "yes", "index": "analyzed" }
              |    }
              |  }
              |}""".stripMargin)
          .get

        // インデックス存在確認(作成済)
        indicesAdminClient
          .prepareExists("myindex")
          .execute
          .actionGet
          .isExists should be(true)

        // ドキュメント登録
        client
          .prepareIndex("myindex", "mytype", "100")
          .setSource(Map("id" -> 100, "value" -> "Embedded Elasticsearch").asJava)
          .get
        client
          .prepareIndex("myindex", "mytype", "200")
          .setSource(Map("id" -> 200, "value" -> "Full Text Search Engine").asJava)
          .get

        // リフレッシュ
        indicesAdminClient.refresh(Requests.refreshRequest("myindex")).actionGet

        // 検索
        val res =
          client
            .prepareSearch("myindex")
            .setTypes("mytype")
            .addField("id")
            .addField("value")
            .setSearchType(SearchType.DFS_QUERY_AND_FETCH)
            .setQuery(QueryBuilders.queryStringQuery("elasticsearch").defaultField("value"))
            .execute
            .actionGet

        val hits = res.getHits
        hits.getTotalHits should be(1L)
        hits.getAt(0).getFields.get("id").getValue[Integer] should be(100)
        hits.getAt(0).getFields.get("value").getValue[String] should be("Embedded Elasticsearch")
      } finally {
        node.close()
        FileUtils.deleteDirectory(new File(dataDirectory))
      }
    }
  }

  protected def createTemporaryDirectory(prefix: String): String =
    Files.createTempDirectory(prefix).toAbsolutePath.toFile.getPath
}

簡単に、説明を。

まずは、Nodeの基本的な設定をします。

      val dataDirectory = createTemporaryDirectory("elasticsearch-")

      val builder =
        Settings
          .settingsBuilder
          .put("http.enabled", false)
          .put("path.data", dataDirectory)
          .put("path.home", ".")

HTTPはオフ、データ保存ディレクトリは「path.data」で指定します。「path.home」は、とりあえずカレントディレクトリにしておきました。

続いて、データをローカルNodeかつデータを持つことを設定して、Nodeを生成。

      val node =
        NodeBuilder
          .nodeBuilder
          .settings(builder)
          .clusterName("test-cluster")
          .local(true)
          .data(true)
          .node()

NodeBuilder#nodeを呼び出すことで、開始済みのNodeのインスタンスが取得できます。NodeBuilder#buildを呼び出した場合は、後で明示的にNode#startする必要があります。

そして、Clientとインデックス操作のためのIndicesAdminClientを取得します。

        val client = node.client

        val indicesAdminClient = client.admin.indices

ここからは、インデックス操作を行います。

インデックスの存在確認。まだ作っていないので、falseです。

        // インデックス存在確認(未作成)
        indicesAdminClient
          .prepareExists("myindex")
          .execute
          .actionGet
          .isExists should be(false)

インデックスを作成します。設定は、JSONをStringでそのまま突っ込みました。

        // インデックス作成
        indicesAdminClient
          .prepareCreate("myindex")
          .addMapping("mytype",
            """|{
              |  "index.number_of_shards": 1,
              |  "mytype": {
              |    "properties": {
              |      "id": { "type": "integer", "store": "yes", "index": "not_analyzed" },
              |      "value": { "type": "string", "store": "yes", "index": "analyzed" }
              |    }
              |  }
              |}""".stripMargin)
          .get

再度インデックスの存在確認。今度は、trueになります。

        // インデックス存在確認(作成済)
        indicesAdminClient
          .prepareExists("myindex")
          .execute
          .actionGet
          .isExists should be(true)

ドキュメントを登録してみます。

        // ドキュメント登録
        client
          .prepareIndex("myindex", "mytype", "100")
          .setSource(Map("id" -> 100, "value" -> "Embedded Elasticsearch").asJava)
          .get
        client
          .prepareIndex("myindex", "mytype", "200")
          .setSource(Map("id" -> 200, "value" -> "Full Text Search Engine").asJava)
          .get

リフレッシュ。

        // リフレッシュ
        indicesAdminClient.refresh(Requests.refreshRequest("myindex")).actionGet

最初、このリフレッシュを飛ばしていたら、今回は載せていませんがidを指定してのprepareGetはできるのに、prepareSearch(検索)ができなくて、とてもハマりました…。

テストコードを見て、解決。
https://github.com/elastic/elasticsearch/blob/v2.2.0/core/src/test/java/org/elasticsearch/document/DocumentActionsIT.java#L124

検索。

        // 検索
        val res =
          client
            .prepareSearch("myindex")
            .setTypes("mytype")
            .addField("id")
            .addField("value")
            .setSearchType(SearchType.DFS_QUERY_AND_FETCH)
            .setQuery(QueryBuilders.queryStringQuery("elasticsearch").defaultField("value"))
            .execute
            .actionGet

        val hits = res.getHits
        hits.getTotalHits should be(1L)
        hits.getAt(0).getFields.get("id").getValue[Integer] should be(100)
        hits.getAt(0).getFields.get("value").getValue[String] should be("Embedded Elasticsearch")

最後にNode#closeして、データディレクトリも削除してお終いです。

        node.close()
        FileUtils.deleteDirectory(new File(dataDirectory))

なんとか使えましたね。

Kuromoji Analysis Pluginを使いたい

と、ここまで使ったのなら、Kuromojiも入れたいところですよね。

…と思ったのが、やっぱり間違いだったのかもしれませんが。

一応、できるにはできました。だいぶハマりましたが…。

準備

まずは、依存関係にKuromoji Analysis Pluginを追加します。

libraryDependencies ++= Seq(
  "org.elasticsearch" % "elasticsearch" % "2.2.0",
  "org.elasticsearch.plugin" % "analysis-kuromoji" % "2.2.0",
  "net.java.dev.jna" % "jna" % "4.1.0",
  "commons-io" % "commons-io" % "2.4",
  "org.scalatest" %% "scalatest" % "2.2.6" % "test"
)

また、import文を含むテストコードの雛形は、以下とします。
src/test/scala/org/littlewings/elasticsearch/EmbeddedElasticsearchWithKuromojiSpec.scala

package org.littlewings.elasticsearch

import java.io.File
import java.nio.file.Files

import org.apache.commons.io.FileUtils
import org.elasticsearch.action.search.SearchType
import org.elasticsearch.client.Requests
import org.elasticsearch.common.settings.Settings
import org.elasticsearch.index.query.QueryBuilders
import org.elasticsearch.node.NodeBuilder
import org.elasticsearch.plugin.analysis.kuromoji.AnalysisKuromojiPlugin
import org.elasticsearch.plugins.Plugin
import org.elasticsearch.search.sort.SortOrder
import org.scalatest.{FunSpec, Matchers}

import scala.collection.JavaConverters._

class EmbeddedElasticsearchWithKuromojiSpec extends FunSpec with Matchers {
  describe("Embedded Elasticsearch with Kuromoji Spec") {
    // ここに、テストコードを書く!
  }

  protected def createTemporaryDirectory(prefix: String): String =
    Files.createTempDirectory(prefix).toAbsolutePath.toFile.getPath
}

テーマは変わらないので、一時ディレクトリ作成用のメソッド付きです。

Nodeを継承する

EmbeddedなElasticsearchに対してプラグインを適用するということで、最初に見付けたのが、このエントリ。

Add plugins from classpath in embedded Elasticsearch client node - Elasticsearch - Discuss the Elastic Stack

Nodeクラスを継承して、なんとかするようです。

事実、このコードはElasticsearchのテストコードにも存在します。
https://github.com/elastic/elasticsearch/blob/v2.2.0/core/src/test/java/org/elasticsearch/node/MockNode.java#L36

というわけで、こんなクラスを作ってみました。
src/test/scala/org/littlewings/elasticsearch/AvailablePluginNode.scala

package org.littlewings.elasticsearch

import org.elasticsearch.Version
import org.elasticsearch.common.settings.Settings
import org.elasticsearch.node.Node
import org.elasticsearch.node.internal.InternalSettingsPreparer
import org.elasticsearch.plugins.Plugin

class AvailablePluginNode(preparedSettings: Settings, classpathPlugins: java.util.Collection[Class[_ <: Plugin]])
  extends Node(InternalSettingsPreparer.prepareEnvironment(preparedSettings, null), Version.CURRENT, classpathPlugins) {
}

こちらを使って、プラグインを利用したいと思います。

設定と、Nodeの作成はこんな感じ。

    it("extends Node") {
      val dataDirectory = createTemporaryDirectory("elasticsearch-")

      val builder =
        Settings
          .settingsBuilder
          .put("http.enabled", false)
          .put("path.data", dataDirectory)
          .put("path.home", ".")
          .put("path.conf", "./src/test/resources/config")
          .put("cluster.name", "test")
          .put("node.local", true)
          .put("node.data", true)
          .build

      val node =
        new AvailablePluginNode(builder, List[Class[_ <: Plugin]](classOf[AnalysisKuromojiPlugin]).asJava)
      node.start()

NodeBuilderが使えなくなったので、ほとんどの設定がSettingsBuilderに寄りました。また、Nodeは明示的にstartしています。

Clientの取得とインデックスの作成。

        val client = node.client

        val indicesAdminClient = client.admin.indices

        // settings
        val settingsJson =
          """|{
            |  "analysis": {
            |    "tokenizer": {
            |      "kuromoji_tokenizer_search": {
            |        "type": "kuromoji_tokenizer",
            |        "mode": "search",
            |        "discard_punctuation" : "true",
            |        "user_dictionary" : "userdict_ja.txt"
            |      }
            |    },
            |    "analyzer": {
            |      "kuromoji_analyzer": {
            |       "type": "custom",
            |       "tokenizer": "kuromoji_tokenizer_search",
            |         "filter": ["kuromoji_baseform",
            |                    "kuromoji_part_of_speech",
            |                    "cjk_width",
            |                    "stop",
            |                    "ja_stop",
            |                    "kuromoji_stemmer",
            |                    "lowercase"]
            |       }
            |    }
            |  }
            |}""".stripMargin

        // インデックス作成
        indicesAdminClient
          .prepareCreate("myindex")
          .setSettings(settingsJson)
          .get

この時、ユーザー定義辞書も含む形で設定していますが、この参照先は先ほどのSettingsBuilderの「path.conf」に依存します。

      val builder =
        Settings
          .settingsBuilder
          .put("http.enabled", false)
          .put("path.data", dataDirectory)
          .put("path.home", ".")
          .put("path.conf", "./src/test/resources/config")

というわけで、ユーザー定義辞書はこちらに配置。

src/test/resources/config/userdict_ja.txt

あとは、ドキュメント登録して検索。

        // ドキュメント登録
        client
          .prepareIndex("myindex", "mytype", "100")
          .setSource(Map("id" -> 100, "value" -> "Elasticsearchは、全文検索エンジンです。").asJava)
          .get
        client
          .prepareIndex("myindex", "mytype", "200")
          .setSource(Map("id" -> 200, "value" -> "Apache Luceneは、全文検索エンジンです。").asJava)
          .get

        // リフレッシュ
        indicesAdminClient.refresh(Requests.refreshRequest("myindex")).actionGet

        // 検索
        val res =
          client
            .prepareSearch("myindex")
            .setTypes("mytype")
            .addField("id")
            .addField("value")
            .setSearchType(SearchType.DFS_QUERY_AND_FETCH)
            .setQuery(QueryBuilders.queryStringQuery("全文 AND 検索").defaultField("value"))
            .addSort("id", SortOrder.ASC)
            .execute
            .actionGet

        val hits = res.getHits
        hits.getTotalHits should be(2L)
        hits.getAt(0).field("value").getValue[String] should be("Elasticsearchは、全文検索エンジンです。")
        hits.getAt(1).field("value").getValue[String] should be("Apache Luceneは、全文検索エンジンです。")

Analyzeも試してみました。

        // analyze
        indicesAdminClient
          .prepareAnalyze
          .setText("Elasticsearchは、全文検索エンジンです。")
          .setIndex("myindex")
          .setAnalyzer("kuromoji_analyzer")
          .get
          .getTokens
          .asScala
          .map(_.getTerm) should contain inOrder("elasticsearch", "全文", "検索", "エンジン")

終了。

      } finally {
        node.close()
        FileUtils.deleteDirectory(new File(dataDirectory))
      }

というわけで、なんとかKuromoji Analysis Pluginを使うことができました。

pluginsディレクトリとplugin-descriptor.propertiesを使う

とまあ、Kuromojiを使うことはできたのですが、先ほどの方法だとNodeクラスを継承する必要があります。

他になんとかする方法はないのかな?といろいろ試していたところ、PluginsServiceクラスにたどり着き
https://github.com/elastic/elasticsearch/blob/v2.2.0/core/src/main/java/org/elasticsearch/plugins/PluginsService.java
https://github.com/elastic/elasticsearch/blob/v2.2.0/core/src/test/java/org/elasticsearch/plugins/PluginsServiceTests.java#L86

最終的に、「path.plugins」を設定すればよいという結論になりました。

もちろん、他に必要なファイルがありますが…。

できあがったコードは、こちら。

    it("using path.plugins") {
      val dataDirectory = createTemporaryDirectory("elasticsearch-")

      val settings =
        Settings
          .settingsBuilder
          .put("http.enabled", false)
          .put("path.data", dataDirectory)
          .put("path.home", ".")
          .put("path.conf", "./src/test/resources/config")
          .put("path.plugins", "./src/test/resources/plugins")
          .build

      val node =
        NodeBuilder
          .nodeBuilder
          .settings(settings)
          .clusterName("test")
          .local(true)
          .data(true)
          .node()

      try {
        val client = node.client

        val indicesAdminClient = client.admin.indices

        // settings
        val settingsJson =
          """|{
            |  "analysis": {
            |    "tokenizer": {
            |      "kuromoji_tokenizer_search": {
            |        "type": "kuromoji_tokenizer",
            |        "mode": "search",
            |        "discard_punctuation" : "true",
            |        "user_dictionary" : "userdict_ja.txt"
            |      }
            |    },
            |    "analyzer": {
            |      "kuromoji_analyzer": {
            |       "type": "custom",
            |       "tokenizer": "kuromoji_tokenizer_search",
            |         "filter": ["kuromoji_baseform",
            |                    "kuromoji_part_of_speech",
            |                    "cjk_width",
            |                    "stop",
            |                    "ja_stop",
            |                    "kuromoji_stemmer",
            |                    "lowercase"]
            |       }
            |    }
            |  }
            |}""".stripMargin

        // インデックス作成
        indicesAdminClient
          .prepareCreate("myindex")
          .setSettings(settingsJson)
          .get

        // ドキュメント登録
        client
          .prepareIndex("myindex", "mytype", "100")
          .setSource(Map("id" -> 100, "value" -> "Elasticsearchは、全文検索エンジンです。").asJava)
          .get
        client
          .prepareIndex("myindex", "mytype", "200")
          .setSource(Map("id" -> 200, "value" -> "Apache Luceneは、全文検索エンジンです。").asJava)
          .get

        // リフレッシュ
        indicesAdminClient.refresh(Requests.refreshRequest("myindex")).actionGet

        // 検索
        val res =
          client
            .prepareSearch("myindex")
            .setTypes("mytype")
            .addField("id")
            .addField("value")
            .setSearchType(SearchType.DFS_QUERY_AND_FETCH)
            .setQuery(QueryBuilders.queryStringQuery("全文 AND 検索").defaultField("value"))
            .addSort("id", SortOrder.ASC)
            .execute
            .actionGet

        val hits = res.getHits
        hits.getTotalHits should be(2L)
        hits.getAt(0).field("value").getValue[String] should be("Elasticsearchは、全文検索エンジンです。")
        hits.getAt(1).field("value").getValue[String] should be("Apache Luceneは、全文検索エンジンです。")

        // analyze
        indicesAdminClient
          .prepareAnalyze
          .setText("Elasticsearchは、全文検索エンジンです。")
          .setIndex("myindex")
          .setAnalyzer("kuromoji_analyzer")
          .get
          .getTokens
          .asScala
          .map(_.getTerm) should contain inOrder("elasticsearch", "全文", "検索", "エンジン")
      } finally {
        node.close()
        FileUtils.deleteDirectory(new File(dataDirectory))
      }
    }

変に自作のクラスも登場せず、普通に使ってるっぽい感じになりました。

で、どこでKuromoji Analysis Pluginをロードしているかですが、まずこちらの「path.plugins」の設定と

      val settings =
        Settings
          .settingsBuilder
          .put("http.enabled", false)
          .put("path.data", dataDirectory)
          .put("path.home", ".")
          .put("path.conf", "./src/test/resources/config")
          .put("path.plugins", "./src/test/resources/plugins")
          .build

「path.plugins」で指定したディレクトリ配下に、「プラグインディレクトリ/plugin-descriptor.properties」を配置すればOKです。

例えば、こんな感じ。Elasticsearchに、普通にKuromoji Analysis Pluginをインストールした場合のディレクトリを参考にしたらいいですよね。

$ find src/test/resources/plugins
src/test/resources/plugins
src/test/resources/plugins
src/test/resources/plugins/analysis-kuromoji
src/test/resources/plugins/analysis-kuromoji/plugin-descriptor.properties

中身は、このくらい書いていればいいみたいです。
src/test/resources/plugins/analysis-kuromoji/plugin-descriptor.properties

description=The Japanese (kuromoji) Analysis plugin integrates Lucene kuromoji analysis module into elasticsearch.
version=2.2.0
name=analysis-kuromoji

jvm=true
classname=org.elasticsearch.plugin.analysis.kuromoji.AnalysisKuromojiPlugin
java.version=1.7
elasticsearch.version=2.2.0

普通にインストールしたKuromoji Analysis Pluginのplugin-descriptor.propertiesファイルを、そのまま持ってきてもよいでしょう。

これで、Embedded ElasticsearchでもKuromojiが使えますよっと。

ユーザー定義辞書も、置いておきましょう。

src/test/resources/config/userdict_ja.txt

まとめ

だいぶてこずりましたが、ElasticsearchをEmbeddedableに使えました。また、プラグインを適用する方法も(成功法かどうかは知りませんが)あるみたいなので、満足です。

ちょっとしたツールとかでも、使ってみようかな?