ElasticsearchのPluginを作る 連載(2) 検索

この連載では[Elasticsearch](https://www.elastic.co/)のpluginの基本的な書き方について解説していきます。[前回](https://qiita.com/y_taka8/items/14dfee9c5b3c7bd63020)は、Hello Worldを表示する簡単なPluginを作成する事で、Pluginの大まかな作り方を説明しました。第二回は、簡単な検索要求を受け付けるPluginを作成します。
作成したソースは[github](https://github.com/taka8-0304/elasticsearch-sample)にあります(前回のものを修正しています)。

#目的
ElasticsearchはJSON形式のリクエストでほとんどの検索要求に対応可能です。ですが、標準だとインデックスの削除等の保守系の機能も見えてしまいますし、WEBから直接Elasticsearchにアクセスするのは望ましくありません。そのため通常は、WEB→APサーバ→Elasticsearchのような形でElasticsearchをデータベースのようなイメージで使用します。またElasticsearchはクラスタを使う事で可用性の担保が容易に出来ますが、APサーバは何らかの独自方式で可用性を担保する必要があります。これは運用負荷が大きく監視対象の数も増えます。もしElasticsearchにAPサーバの機能を持たせる事が出来ればElasticsearchの管理のみである程度の業務要件を満たすアプリケーションが作れるはずです。
今回からは、ElasticsearchにAPサーバを持たせる事を目標に、どの程度の事がプラグインで出来るかを検証していきます。まずは、クエリパラメータ(text)で、文書の「text」フィールドに対して全文検索を行うREST APIを作成してみます。

#実装概要
プラグインの枠組みについては前回と同様になります。
違いはRestHandlerから、インデックスノードに対して「検索リクエスト」を投げる所です。
RESTのリクエストからインデックスノードに対しての「検索リクエスト」を生成するのですが、ElasticsearchのJSON形式のRESTのリクエストからは「検索リクエスト」を簡単に生成できます。今回はそれを使います。

#動作環境
前回とほぼ同様ですが、Gradleの6.1版が出ていたのでそちらを使用しました。
- Elasticsearch 7.4.2
- Gradle 6.1
- OracleJDK 13(OpenJDKでも問題ない)
- Windows(コマンドの実行例のみ。プラグインの作成、実行はElasticsearchの実行環境に準じます)

#記事中の記法
- [～]は環境に応じた変数です。ご自分の環境に合わせて読みかえて下さい。

#事前準備
- Elasticsearchのインストール [OSS版](https://www.elastic.co/jp/downloads/elasticsearch-oss) [ELASTICSEARCH_DIR]に展開
- Gradleのインストール[リリース版](https://gradle.org/releases/)　[GRADLE_DIR]に展開
- JDKのインストール [OracleJDK] (https://www.oracle.com/technetwork/java/javase/downloads/index.html) [JAVA_DIR]に配置

#プロジェクトの準備
[前回](https://qiita.com/y_taka8/items/14dfee9c5b3c7bd63020)作成したプロジェクトを使用します。

#プラグインソースの準備

##JSON形式のリクエスト生成するためのユーティリティを作成します。
ファイルからテンプレートをロードする方が見やすいのですが、Elasticsearchはプラグインからjavaの標準APIへのアクセスがかなり制限されています。設定でアクセス可能に出来ますが、今回はソースに埋め込みます。APIへのアクセス制限の緩和については次回以降に書きます。

``` java:QueryTemplate.java
package taka8.elasticsearch.rest;

public class QueryTemplate {

//	{
//	  "query" : {
//	    "match" : {
//	      "text" : {
//	        "query" : "[[text]]",
//	        "operator" : "and"
//	      }
//	    }
//	  }
//	}
	private static final String TEXT_ONLY = "{\"query\":{\"match\":{\"text\":{\"query\":\"[[text]]\",\"operator\":\"and\"}}}}";

	public static String getTextOnly(String text) {
		return QueryTemplate.TEXT_ONLY.replace("[[text]]", text);
	}
}
```

##RestHandlerを作成します

``` java:TextOnlySearchAction.java
package taka8.elasticsearch.rest;

import static org.elasticsearch.rest.RestRequest.Method.GET;

import java.io.IOException;

import org.elasticsearch.action.search.SearchRequest;
import org.elasticsearch.action.search.SearchResponse;
import org.elasticsearch.client.node.NodeClient;
import org.elasticsearch.common.Strings;
import org.elasticsearch.common.xcontent.DeprecationHandler;
import org.elasticsearch.common.xcontent.XContentParser;
import org.elasticsearch.common.xcontent.XContentType;
import org.elasticsearch.rest.BaseRestHandler;
import org.elasticsearch.rest.RestController;
import org.elasticsearch.rest.RestRequest;
import org.elasticsearch.rest.action.RestStatusToXContentListener;
import org.elasticsearch.search.builder.SearchSourceBuilder;

// REST APIを受け付けるRestHandlerはBaseRestHandlerを継承すると作りやすい
public class TextOnlySearchAction extends BaseRestHandler {

	public TextOnlySearchAction(final RestController controller) {
		assert controller != null;
		// 要求を受け付けるパスを定義する。
		controller.registerHandler(GET, "/{index}/search_text", this);
	}

	// RestHandlerの名前を定義する。
	// 人が見て分かりやすい名前にする。
	// 使用方法を返すAPIで使用される
	@Override
	public String getName() {
		return "search_text_action";
	}

	@Override
	protected RestChannelConsumer prepareRequest(RestRequest request, NodeClient client) throws IOException {
		SearchRequest searchRequest = new SearchRequest();
		request.withContentOrSourceParamParserOrNull(parser -> _parseSearchRequest(searchRequest, request, parser));
		return channel -> {
			// 検索結果からREST APIの戻り値を作成するListenerを生成する。
			// ここでは、検索結果をそのまま返す標準のListenerにする。
			RestStatusToXContentListener<SearchResponse> listener = new RestStatusToXContentListener<>(channel);
			// インデックスノードに対して要求を投げる。Elasticsearchはインデックスを分割して複数のノードに配置する事で、検索性能と可用性を担保している。
			// このため、REST要求を受け付けるノードから実際の検索を行うノードに対して要求を投げる必要がある。
			client.search(searchRequest, listener);
		};
	}

	// RESTの入力からインデックスノードに対しての検索要求を初期化する。
	private void _parseSearchRequest(SearchRequest searchRequest, RestRequest request,
			XContentParser requestContentParser) throws IOException {
		if (searchRequest.source() == null) {
			searchRequest.source(new SearchSourceBuilder());
		}
		// クエリのtextパラメータから、ElasticsearchのJSON形式の検索要求を生成する。
		String source = QueryTemplate.getTextOnly(request.param("text"));
		// JSON形式の検索要求用のパーサ。
		XContentParser parser = XContentType.JSON.xContent().createParser(request.getXContentRegistry(),
				DeprecationHandler.THROW_UNSUPPORTED_OPERATION, source);
		searchRequest.source().parseXContent(parser);
		searchRequest.indices(Strings.splitStringByCommaToArray(request.param("index")));
	}

}

```

#動作確認
##デプロイ
[前回](https://qiita.com/y_taka8/items/14dfee9c5b3c7bd63020)の動作確認の手順でElasticsearchのインストールまで行います。

##検索対象の文書の登録
POSTリクエストが発行可能なツールを用いて以下のURLにjson形式の文書をPOSTします。
http\://localhost:9200/test/_bulk

```
{"create": {"_id": "1"}}
{"text": "自動車に乗る"}
{"create": {"_id": "2"}}
{"text": "自転車を降りる"}
```


##画面確認
ブラウザで「http\://localhost:9200/test/search_text?text=自動車」にアクセスし、以下のように「自動車」を含む文書が検索される事を確認します(下記は、JSONをフォーマットしています)。

```
{
  "took": 43,
  "timed_out": false,
  "_shards": {
    "total": 1,
    "successful": 1,
    "skipped": 0,
    "failed": 0
  },
  "hits": {
    "total": {
      "value": 1,
      "relation": "eq"
    },
    "max_score": 1.0921589,
    "hits": [
      {
        "_index": "test",
        "_type": "_doc",
        "_id": "1",
        "_score": 1.0921589,
        "_source": {
          "text": "自動車に乗る"
        }
      }
    ]
  }
}
```

#あとがき
プラグインでクエリからJSON形式のリクエストを作成し、それを用いて正しく検索が出来る事を確認しました。これで標準的な業務要件での検索は満たせそうです。次は、業務要件を満たすような戻り値の生成を試します。