🍃

SpringBootからElasticsearchを使う方法2選 (CREATE/READ編)

8 min read

本記事は ZOZO テクノロジーズ #2 Advent Calendar 2020 の 8 日目の記事です

前回の記事では Spring Data Elasticsearch を用いたクライアント/インデックス生成方法について紹介しました。
本稿では、前回のコードをベースに実際のドキュメントの CREATE/READ 操作を扱ってみたいと思います。

TL;DR

インデックス操作、クラスタの操作含めて全般的に Elasticsearch へのリクエストを手軽に再現したいケースでは Rest High Level Client.
逆に、検索・インデキシング等のドキュメント操作用途に絞れて、かつ中に入るデータの形式が Object で定義できる、定型の場合は Spring Data Elasticsearch.
ただし、後者に関してはほぼドキュメントがないので、頑張りが必要。

CREATE (ドキュメントのインデキシング)

RestHighLevelClient

RestHighLevelClient を用いたインデキシング処理は以下の流れとなります。
イメージとしては Kibana 上でリクエストを構築する様な、宛先、id、ドキュメントの内容を登録する操作を行います。
返却されるレスポンスも、実際に帰ってくる JSON をパースしてオブジェクトに詰めた様な形式となっているため、ある程度 Elasticsearch に慣れた方からするとわかりやすい印象です。

こちらの処理におけるドキュメント定義には、マッピングの登録同様の 3 種 + 独自の 1 種、合計 4 種類から選択可能です。

  • 生 Json 文字列
  • HashMap
  • XContentBuilder
  • IndexRequest に直セット

今回は、4 番目の方法で紹介いたします。

RestHighLevelClientController.java

...
@RequestMapping("/index-document")
public Boolean indexDocument(@RequestParam("id") Integer id, @RequestParam("message") String message) {
  return restHighLevelClientService.indexDocument(id, message);
}
...

ClientRepository.java

public Boolean indexDocument(Integer id, String message) {
  IndexRequest request = new IndexRequest(indexName)
      .id(id.toString())
      .source("message", message);
  return clientRepository.indexDocument(request);
}

RestHighLevelClientRepository.java

public Boolean indexDocument(IndexRequest request) {
  try {
    IndexResponse response = restHighLevelClient.index(request, RequestOptions.DEFAULT);
    return response.getResult() == Result.CREATED;
  } catch (Exception e) {
    return false;
  }
}

ここまで実装を行ってきましたが、いずれの 4 種の方法をとってもあまり可読性が高いとは言い難い実装となってしまます。
そこで幣チームでは、一旦パフォーマンスは置いておき、entity に JsonProperty を付与し mapper で JSON String として出力する方法を取りました。
以下がその例です。

DestIndex.java

package com.pakio.demoelasticsearch.RestHighLevelClient.entity;

import com.fasterxml.jackson.annotation.JsonInclude;
import com.fasterxml.jackson.annotation.JsonInclude.Include;
import com.fasterxml.jackson.annotation.JsonProperty;
import lombok.Data;

@Data
@JsonInclude(Include.NON_NULL)
public class DestIndex {
  @JsonProperty("id")
  Integer id;

  @JsonProperty("message")
  String message;
}

RestHighLevelClientService.java

public Boolean indexDocument(DestIndex destIndex) {
  ObjectMapper mapper = new ObjectMapper();

  try {
    IndexRequest request = new IndexRequest(indexName).id(destIndex.getId().toString())
        .source(mapper.writeValueAsString(destIndex), XContentType.JSON);

    return clientRepository.indexDocument(request);
  } catch (Exception e) {
    return false;
  }
}

SpringDataElasticsearch

次に、SpringDataElasticsearch を用いたドキュメントのインデキシング動作です。
こちらは、前回の記事で作成した Entity を使いまわして実装が可能です。
そのため、マッピングと実際に入るドキュメントが違う、といったトラブルを防ぐことが容易な印象を受けました。

SpringDataElasticsearchController.java

@RequestMapping("/index-document")
public Boolean indexDocument(@RequestParam("id") Integer id, @RequestParam("message") String message) {
  return springDataElasticsearchService.indexDocument(id, message);
}

SpringDataElaticsearchService.java

public Boolean indexDocument(Integer id, String message) {
  DestIndex destIndex = new DestIndex();
  destIndex.setId(id.toString());
  destIndex.setMessage(message);

  try {
    this.elasticsearchOperations.save(destIndex);
    return true;
  } catch (Exception e) {
    return false;
  }
}

注意点が2点ほどあり、まず1点目として、ElasticsearchOperations の save メソッドの返値は入力されたオブジェクトのクラスそのままを返すため、結果の参照が行えません
2点目ですが、作成されたドキュメントに _class というフィールドが追加され、使用されたオブジェクトの情報が保持されるため、インデックスのサイズを気にされる方には向かないかもしれません。_class フィールドのタイプは text です。

...
"hits": [
    {
        "_index": "dest_index2",
        "_type": "_doc",
        "_id": "1",
        "_score": 1,
        "_source": {
            "_class": "com.pakio.demoelasticsearch.SpringDataElasticsearch.entity.DestIndex",
            "id": "1",
            "message": "hoge"
        }
    }
]
...

READ (ドキュメントの取得)

Read 処理は_id 指定の search を条件に比較してみます。

RestHighLevelClient

Rest High Level Client では、こちらも同様に Kibana の UI 上からクエリを構築するのと同じ感覚でクエリ構築を行います。

RestHighLevelClientController.java

@RequestMapping("/get-document")
public ResponseEntity<DestIndex> getDocument(@RequestParam("id") Integer id) {
  DestIndex doc = restHighLevelClientService.getDocumentById(id);
  return new ResponseEntity<DestIndex>(doc, HttpStatus.OK);
}

RestHighLevelClientService.java

public DestIndex getDocumentById(Integer id) {
  SearchSourceBuilder searchSourceBuilder = new SearchSourceBuilder();

  TermQueryBuilder termQueryBuilder = QueryBuilders.termQuery("_id", id);
  searchSourceBuilder.query(termQueryBuilder);

  SearchRequest request = new SearchRequest(indexName);
  request.source(searchSourceBuilder);

  SearchResponse response =  clientRepository.getDocument(request);

  SearchHit[] searchHits = response.getHits().getHits();
  Optional<SearchHit> optionalSearchHit = Arrays.stream(searchHits).findFirst();

  if(optionalSearchHit.isPresent()) {
    return convertSearchHitToEntity(optionalSearchHit.get());
  } else {
    return new DestIndex();
  }
}

public static DestIndex convertSearchHitToEntity(SearchHit searchHit) {
  String jsonString = searchHit.getSourceAsString();

  ObjectMapper mapper = new ObjectMapper();
  try {
    DestIndex destIndex = mapper.readValue(jsonString, DestIndex.class);

    return destIndex;
  } catch (Exception e) {
    return new DestIndex();
  }
}

ClientRepository.java

public SearchResponse getDocument(SearchRequest request) {
  try {
    return restHighLevelClient.search(request, RequestOptions.DEFAULT);
  } catch (Exception e) {
    this.setClient(restHighLevelClientConfig.getRecreateClient());
    return null;
  }
}

だいぶ複雑になってきました。特に SearchResponse から SearchHit[]を取り出すあたりなんかは、クライアント側の大幅な変更がある場合大変そうですね。

SpringDataElasticsearch

次に SpringDataElasticsearch の例です。

SpringDataElasticsearchController.java

@RequestMapping("/get-document")
public ResponseEntity<DestIndex> getDocument(@RequestParam("id") Integer id) {
  DestIndex doc = springDataElasticsearchService.getDocumentById(id);
  return new ResponseEntity<DestIndex>(doc, HttpStatus.OK);
}

SpringDataElasticsearchService.java

public DestIndex getDocumentById(Integer id){
  return this.elasticsearchOperations.get(id.toString(), DestIndex.class);
}

こうなってくると違いは一目瞭然、Spring Data Elasticsearch が全操作の再現でなく、データの扱いにいかに重きを置いているのかがわかります。

上記は id で引きに行く例だったのでこれほど綺麗にかけましたが、Rest High Level Client と同様に term クエリで_id フィールドに対して検索をかけるケースだと、以下の様な形となります。

SpringDataElasticsearchService.java

public DestIndex getDocumentByIdWithQuery(Integer id) {
  StringQuery query  = new StringQuery(QueryBuilders.termQuery("_id", id).toString());

  return this.elasticsearchOperations.searchOne(query, DestIndex.class).getContent();
}

結局のところ、結果の取得に QueryBuilder を構築することに変わりはないようですが、entity への変換はかなりスマートに思えます。

まとめ

本稿では Spring Boot から Elasticsearch に接続する主な 2 方法について、実際のドキュメント操作方法含めて紹介いたしました。
次の記事では、残る 2 操作、UPDATE, DELETE についてまとめたいと思います。

リポジトリ

ここまで登場したコード類は以下のリポジトリで全て公開しています。
もし間違いや改善案等ありましたらこちらまでご報告お願いいたします。
pakio/spring-elasticsearch-client-comparison

参考

Spring Data Elasticsearch - Reference Documentation
Elasticsearch Java API 入門

Discussion

ログインするとコメントできます