Elasticsearch Java Clientの SearchTemplateRequestBuilder で Paging を実装する


MacOS el capitan に Elasticsearch を インストールして、java クライアントから検索してみる | Monotalkで、
作成したJavaクライアントをページング検索できるようにしてみました。
実装した結果を以下に記載します。


前提

以下、OS、Java、Elasticsearch の Version について記載します。

  • OS

    sw_vers
    ----------------------------
    ProductName:    Mac OS X
    ProductVersion: 10.12.3
    BuildVersion:   16D32
    ----------------------------
    

  • Java

    java -version
    ----------------------------
    java version "1.8.0_45"
    Java(TM) SE Runtime Environment (build 1.8.0_45-b14)
    Java HotSpot(TM) 64-Bit Server VM (build 25.45-b02, mixed mode)
    ----------------------------
    

  • Elasticsearch

    elasticsearch -version
    ----------------------------
    Version: 5.2.2, Build: f9d9b74/2017-02-24T17:26:45.835Z, JVM: 1.8.0_45
    ----------------------------
    

  • Elasticsearch Java Dependency

        <!-- https://mvnrepository.com/artifact/org.elasticsearch/elasticsearch -->
        <dependency>
            <groupId>org.elasticsearch</groupId>
            <artifactId>elasticsearch</artifactId>
            <version>5.2.2</version>
        </dependency>
        <dependency>
            <groupId>org.elasticsearch.client</groupId>
            <artifactId>transport</artifactId>
            <version>5.2.2</version>
        </dependency>
    


ページングで必要な処理

基本的なページング処理として、以下の処理が必要になるかと思います。

  • Limit、Offset を指定して、ページング表示件数分データを取得する処理

  • Count クエリで、データ全件の件数のみ取得する処理

あとは、個人的な実装都合で、keyword があれば、キーワード指定で検索、
キーワードがなければ、データ全件を取得する処理が必要になります。


ElasticSearch のクエリの記述

1. ElasticSearch での Limit Offset 指定

第4回 Elasticsearch 入門 検索の基本中の基本 | Developers.IO
でページングについて記載がありますが、limit として、size 、 offset として、from が指定できます。
json形式で記載すると、以下のように記述できます。

curl -XPOST 'localhost:9200/festival/_search?pretty' -d '
{
  "query": { "match": { "name" : "ROCK" } },
  "from": 0, 
  "size": 20 
}'
--------------------------
{
  "took" : 215,
  "timed_out" : false,
  "_shards" : {
    "total" : 5,
    "successful" : 5,
    "failed" : 0
  },
  "hits" : {
    "total" : 34,
    "max_score" : 3.1631145,
    "hits" : [ ...... ]
  }
 }

2. ElasticSearch で件数を取得する

elasticsearchとSQL対比しながら理解 - Qiita で、
件数取得について記載がありますが、 "size" : 0 とすると、件数のみの取得ができます。1
[1] size 1以上を指定した場合も、件数は取得できます。0 だと、検索結果なしでとれます。

curl -XPOST 'localhost:9200/festival/_search?pretty' -d '
{
  "query": { "match": { "name" : "ROCK" } },
  "from": 0, 
  "size": 0
}'
-------------------------------------------------
{
  "took" : 34,
  "timed_out" : false,
  "_shards" : {
    "total" : 5,
    "successful" : 5,
    "failed" : 0
  },
  "hits" : {
    "total" : 34,
    "max_score" : 0.0,
    "hits" : [ ]
  }
 }

3. ElasticSearch で条件指定なしでデータを取得する

elasticsearchとSQL対比しながら理解 - Qiita に記載がありますが、 match_all で条件なしでデータが取得できます。

curl -XPOST 'localhost:9200/festival/_search?pretty' -d '
{
  "query": { "match_all": {} },
  "from": 0, 
  "size": 20 
}'
--------------------------------------------------
{
  "took" : 274,
  "timed_out" : false,
  "_shards" : {
    "total" : 5,
    "successful" : 5,
    "failed" : 0
  },
  "hits" : {
    "total" : 547,
    "max_score" : 1.0,
    "hits" : [ ...... ]
  }
 }
}

Java Client の実装

TransportClientを使った検索クラス

実装したクラスは以下になります。

import com.fasterxml.jackson.databind.ObjectMapper;
import com.google.common.io.Resources;
import org.apache.commons.lang3.StringUtils;
import org.elasticsearch.action.search.SearchRequest;
import org.elasticsearch.action.search.SearchResponse;
import org.elasticsearch.client.transport.TransportClient;
import org.elasticsearch.common.settings.Settings;
import org.elasticsearch.common.transport.InetSocketTransportAddress;
import org.elasticsearch.script.ScriptType;
import org.elasticsearch.script.mustache.SearchTemplateRequestBuilder;
import org.elasticsearch.search.SearchHit;
import org.elasticsearch.transport.client.PreBuiltTransportClient;
import xyz.monotalk.festivals4partypeople.models.elasticsearch.dto.Festival;

import java.io.IOException;
import java.net.InetAddress;
import java.net.URL;
import java.net.UnknownHostException;
import java.nio.charset.Charset;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.concurrent.TimeUnit;

/**
 * FestivalIndex
 */
public class FestivalIndex {

    /**
     * findByKeywordWithPaging
     *
     * @param keyword
     * @param from
     * @param size
     * @return
     */
    public List<Festival> findByKeywordWithPaging(String keyword, int from, int size) {
        // on startup
        TransportClient client = getTransportClient();

        String scripts = StringUtils.isEmpty(keyword)
                ? getResourceAsString("scripts/Festival_findAllWithPaging.json")
                : getResourceAsString("scripts/Festival_findByKeywordWithPaging.json");

        SearchRequest searchRequest = new SearchRequest();
        searchRequest.indices("festival");

        Map<String, Object> template_params = new HashMap<>();
        if (!StringUtils.isEmpty(keyword)) {
            template_params.put("keyword", keyword);
        }
        template_params.put("from", from);
        template_params.put("size", size);
        SearchResponse sr = new SearchTemplateRequestBuilder(client)
                .setScript(scripts)
                .setScriptType(ScriptType.INLINE)
                .setScriptParams(template_params)
                .setRequest(searchRequest)
                .get()
                .getResponse();

        SearchHit[] results = sr.getHits().getHits();
        List<Festival> festivals = new ArrayList<>();
        for (SearchHit hit : results) {
            String sourceAsString = hit.getSourceAsString();
            if (sourceAsString != null) {
                ObjectMapper mapper = new ObjectMapper();
                Festival fes = null;
                try {
                    fes = mapper.readValue(sourceAsString, Festival.class);
                } catch (IOException e) {
                    e.printStackTrace();
                }
                festivals.add(fes);
            }
        }
        client.close();
        return festivals;
    }

    /**
     * getRecordCountByKeyword
     *
     * @param keyword
     * @return
     */
    public long getRecordCountByKeyword(String keyword) {
        TransportClient client = getTransportClient();

        String scripts = StringUtils.isEmpty(keyword)
                ? getResourceAsString("scripts/Festival_getAllRecordCount.json")
                : getResourceAsString("scripts/Festival_getRecordCountByKeyword.json");

        SearchRequest searchRequest = new SearchRequest();
        searchRequest.indices("festival");

        Map<String, Object> template_params = new HashMap<>();
        if (!StringUtils.isEmpty(keyword)) {
            template_params.put("keyword", keyword);
        }
        SearchResponse sr = new SearchTemplateRequestBuilder(client)
                .setScript(scripts)
                .setScriptType(ScriptType.INLINE)
                .setScriptParams(template_params)
                .setRequest(searchRequest)
                .get()
                .getResponse();

        long hits = sr.getHits().getTotalHits();
        client.close();
        return hits;
    }

    /**
     * 2度呼び出されるため、メソッド化
     * getTransportClient
     *
     * @return
     */
    private TransportClient getTransportClient() {
        TransportClient client = null;
        try {
            client = new PreBuiltTransportClient(Settings.builder()
                    .put("cluster.name", "elasticsearch_clustername").put("client.transport.sniff", false)
                    .put("node.name", "JlcSImk")
                    .put("client.transport.ping_timeout", 20, TimeUnit.SECONDS).build())
                    .addTransportAddress(new InetSocketTransportAddress(InetAddress.getByName("10.0.1.4"), 9300));
        } catch (UnknownHostException e) {
            e.printStackTrace();
        }
        return client;
    }

    /**
     * guava の Resourcesクラスを使って、src/main/resources配下のファイルをStringで取得
     * getResourceAsString
     * 
     * @param resourceName
     * @return
     */
    private String getResourceAsString(String resourceName) {
        String scripts = null;
        URL url = Resources.getResource(resourceName);
        try {
            scripts = Resources.toString(url, Charset.forName("utf-8"));
        } catch (IOException e) {
            e.printStackTrace();
        }
        return scripts;
    }
}
  • findByKeywordWithPagingの説明

キーワードを条件に、ページング指定検索できるメソッドです。
キーワードが空かどうかで、取得するスクリプトを切り替えるようにしました。2
[2] 何かもっと良い方法があるのかもしれません。

現状、私個人の環境だと、index が1つしかないため結果は変わりませんが、

    SearchRequest searchRequest = new SearchRequest();
    searchRequest.indices("festival");
で index を指定するようにしました。
指定しないと全 index を対象に検索する動作になるのかと思います。

  • getRecordCountByKeywordの説明

キーワードを条件に、検索結果件数のみを返すメソッドです。
キーワードが空かどうかで、取得するスクリプトを切り替えます。

トータル件数は、SearchResponse から、getHits().getTotalHits(); で取得できました。

作成したスクリプトファイル

  • src/main/resources/scripts/Festival_findAllWithPaging.json

    {
      "query": {
        "match_all": {}
      },
      "from": "{{from}}",
      "size": "{{size}}"
    }
    

  • src/main/resources/scripts/Festival_findByKeywordWithPaging.json

    {
      "query": {
        "match": {
          "name": "{{keyword}}"
        }
      },
      "from": "{{from}}",
      "size": "{{size}}"
    }
    

  • src/main/resources/scripts/Festival_getAllRecordCount.json

    {
      "query": {
        "match_all": {}
      },
      "size": 0
    }
    

  • src/main/resources/scripts/Festival_getRecordCountByKeyword.json

    {
      "query": {
        "match": {
          "name": "{{keyword}}"
        }
      },
      "size": 0
    }
    


まとめ

SearchTemplateRequestBuilder に拘ってページングクエリを作成しました。
結構冗長になってしまいましたが、今のところ、SearchTemplateRequestBuilder の使用方法しかわからないので、
一旦これでいいかと思っています。
QueryBuilders を使う形だと実はもっとすっきりかけるのかもしれません。

Count API は 以前は、CountResponse を返すAPI があったようですが、
Java API changes | Elasticsearch Reference [5.2] | Elastic
に削除され、Search API に size 0 を指定して、件数取得してね。という記載がありました。

client.prepareSearch(indices).setSource(new SearchSourceBuilder().size(0).query(query)).get();

一旦、SearchTemplateRequestBuilder ゴリ押しで実装は進めていこうかと思います。 以上です。

コメント