この前、初めてこのブログでElasticsearchを使ったのですが、次はEmbeddedに使ってみようかなと思いまして。
…正直、いきなりそんなことするもんじゃないなぁと後で思いましたけど。
基本的には、Java APIのドキュメントを見ていけばいいみたいです。
ちょっと古い情報もいくつかあるみたいですし。
Embedded Elasticsearch Server for Tests - Cup of Java
https://orrsella.com/2014/10/28/embedded-elasticsearch-server-for-scala-integration-tests/
今1.x系のAPIで書かれたブログエントリを見ると、だいぶ変わったんだなぁとは思いますが。
まあ、それはそうと試してみましょう。
今回想定する使い方は、こんな感じとします。
準備
今回も、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に対してプラグインを適用するということで、最初に見付けたのが、このエントリ。
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に使えました。また、プラグインを適用する方法も(成功法かどうかは知りませんが)あるみたいなので、満足です。
ちょっとしたツールとかでも、使ってみようかな?