Elasticsearch.net を使う


# はじめに

ElasticsearchのC#クライアントであるElasticsearch.netを使ってElasticsearchを操作するやり方の基本操作についてまとめました。  
web上にElasticsearch.netについての情報が少なく、いろいろ調査・試行錯誤してわかったことです。

# 前提

- Elasticsearch: 7.17.3
- Elasticsearch.net: 7.17.2
- .Net Framework 4.7
- visual studio pro 2019 / win10
- データモデルは不定形
- 内容を全部完全に理解したわけでなく、予想してる内容もたくさん入ってます。

# 前置き

## C#クライアントには2種類ある

[公式ページ](https://www.elastic.co/guide/en/elasticsearch/client/net-api/7.17/introduction.html)にも書かれているとおり、
Elasticsearchのver7時点では、C#クライアントには以下の2種類あります。

- Elasticsearch.net : low level client ; データ型不定形
- NEST : high level client ; データ型固定形

上記違いがあるものの、NESTでもobject型を使ってデータ型不定形を扱えるようです。  
Elasticsearchのver8からはC#クライアントが更新?されているようなので、ver8はこの記事の対象外です。

## Elasticsearch.net

[公式ページ](https://www.elastic.co/guide/en/elasticsearch/client/net-api/7.17/elasticsearch-net-getting-started.html)にほぼすべて書いてあります。  
私の場合、Elasticsearch.netを使うことになりましたが、可能ならNESTを使った方がいいと思います。クエリの記述の場合など、NESTの方が簡単に書けると思います。

## 返り値

データ型不定形のレスポンスは、`StringResponse`型を使いました。  
応答のbodyが文字列型ということのようです。他にも`ByteResponse`型も用意されているようです。  
応答結果はエラーの場合を含めてすべて`response.Body`(string型)で得られ、辞書型データ(json)をシリアライズした形になっています。  
応答結果詳細の取り出しは、`JObject`などでパースして辞書型に変換してからキーを使って取り出しました。

```csharp
using Newtonsoft.Json.Linq;

var response = client.Search<StringResponse>("indexName", ...);
JObject body = JObject.Parse(response.Body);
int count = (int) body["hits"]["total"]["value"];
var jobj = body["hits"]["hits"][0]["_source"];
var dic = jobj.ToObject<Dictionary<string, string>>();
Console.WriteLine(dic["Name"]);
```

## 送信データの書式が2種類

クライアントから送信データを渡すときの書き方として主に2種類あり、string型とobject型です。  
好きな方を使えます。  
データを渡すときに文字列型の場合はそのまま渡し、object型の時は`PostData.Serializable`を使います。

### string型

```csharp
string data = @"
    ""Tokyo"": ""Tokyo"",
    ""Kanagawa"": ""Yokohama"",
    ""Miyagi"": ""Sendai"",
    ""Fukui"": ""Fukui""
";
var response = client.Index<StringResponse>("indexName", "documentId", data);
```

### object型

```csharp
var data = new {
    Tokyo = "Tokyo",
    Kanagawa = "Yokohama",
    Miyagi = "Sendai",
    Fukui = "Fukui"
};
var response = client.Index<StringResponse>("indexName", "documentId", PostData.Serializable(data));
```

# 表記例

## コネクション

公式サイトに書かれていますが、以下で作成します。  
[細かいオプション設定](https://www.elastic.co/guide/en/elasticsearch/client/net-api/7.17/configuration-options.html)ができるようですが、今は使いません。

```csharp
string url = "http://localhost:9200";
var settings = new ConnectionConfiguration(new Uri(url));
var client = new ElasticLowLevelCient(settings);
```

## using宣言

```csharp
using Elasticsearch.Net;
```


## インデックスの状態確認

### インデックス一覧取得 : Cat/Indices

```csharp
var response = client.Cat.Indices<StringResponse>();
```

結果は`response.Body`に入ってきますが、Elasticsearchに存在するインデックスのみを取得するには一工夫要りそうです。  
上記コマンドは単に`http://localhost:9200/_cats/indices`を実行しているだけなので。

### インデックス名を指定してインデックスの存在確認 : Indices.Exists

```csharp
var response = client.Indices.Exists<StringResponse>("indexName");
```

結果は`response.Success`や`response.HttpStatusCode`で判定できます。  
- インデックスが存在するとき: `response.Success` = true
- インデックスが存在しないとき: `response.Success` = false

Elasticsearchが起動していないときは、`response.Body == ""`となるので、これを使って判定しました。

## データの登録

まず、用語の話です。  
indexという言葉には2種類の意味があって、区別して使う必要があります。

1. RDBのデータベースに相当するもの。名詞。
2. データをデータベースに登録すること。動詞。

### インデックス作成 : Indices.Create

データ無しでインデックスだけ先に作りたいケースです。  
通常はデータ登録すれば自動でインデックスも作成されますので、この作業は不要と思います。  
ここではインデックス作成時に、インデックスの設定も同時に行うこととします。  
`index.mapping.total_fields.limit`と`index.max_window_result`を設定することにします。  
`index.mapping.total_fields.limit`はfield数の上限で、デフォルトは1,000に設定されています。  
`index.max_result_window`はクエリ時のデータ数の上限で、デフォルトは10,000です。

```csharp
int fieldQty = 10_000;
int maxResult = 50_000;
string createIndexSettings = $@"{{
    ""settings"": {{
        ""index"": {{
            ""max_result_window"": {maxResult},
            ""mapping"": {{
                ""total_fields"": {{
                    ""limit"": {filedQty}
                }}
            }}
        }}
    }}
}}";
var response = client.Indices.Create<StringResponse>("indexName", createIndexSettings);
```

### 1個データ登録 : Index

個々のデータを登録する際にはindex名とdocument番号を指定します。  
documentIdは省略可能で、省略するとElasticsearchが適当に付けてくれます。

```csharp
var response = client.Index<StringResponse>("indexName", "documentId",
    @"{ 
        ""hamada"": ""my friend"",
        ""satamoto"": ""kaiten"",
        ""yamashita"": ""run fast"",
        ""matsuda"": ""never call me""
    }"
);
```

送信成功可否は、`response.Success`(bool型)や`response.HttpStatusCode`(int?型)で判定可能です。  
成功時の返り値はそれぞれ`true`, `201`です。  

### bulk

大量データをまとめて送るケースです。  
bulkの書式として、1行目にindex, idを指定、2行目に登録データを書き、それが1個のドキュメントとして認識されます。  
公式ページでは送信データがobject型のケースしか示されていませんが、listやdictionaryを使って動的にデータを組み立てることができます。  
以下はListに入ったDictionary型のデータをBulk送信する場合に、Bulkデータフォーマット用の新規リストを作成して、そこにBulk送信データを組み立ててから一気にBulk送信する例です。  
Bulkを送信するときには、`PostData.MultiJson`を使うようです。

```csharp
var memlist = new List<Dictionary<string, string>>() {
    new Dictionary<string, string> {
        {"name", "Takashi"},
        {"residential area", "tomi town"},
        {"grade", "elem4"},
        {"sports", "soccer"},
        {"likes", "TV game"}
    },
    new Dictionary<string, string> {
        {"name", "Tomohisa"},
        {"residential area", "wake city"},
        {"grade", "elem2"},
        {"sports", "swimming"},
        {"likes", "train"}
    },
    new Dictionary<string, string> {
        {"name", "Shohei"},
        {"residential area", "mago city"},
        {"grade", "elem1"},
        {"sports", "judo"},
        {"likes", "insects"},
    }
};
var oneBulkShot = new List<object>();
for ( int i = 0; i < 3; i++) {
    var info = new { index = new { _index = "people", _id = i.ToString() }};
    oneBulkShot.Add(info);
    oneBulkShot.Add(memlist[i]);
}
var response = client.Bulk<StringResponse>(PostData.MultiJson(oneBulkShot));
```

## クエリ

### matchを使う

とりあえず以下でクエリ可能です。  
条件にマッチするデータ検索には、基本的にはSearchを使います。  
全インデックスを対象とする場合は、"*"と書きます。

```csharp
// object型
var response = client.Search<StringResponse>("*", PostData.Serializable(new
    {   query = new { match = new {
        Kanagawa = "Yokohama"
    }}}
));

// string型
var response = client.Search<StringResponse>("*",
    @"{
        ""query"": {
            ""match"": {
                ""Kanagawa"": ""Yokohama""
            }
        }
    }"
);
```

結果は`response.Body`に入ります。  

```csharp
using Newtonsoft.Json.Linq;

JObject body = JObject.Parse(response.Body);
```

検索結果が無い場合は `body["hits"]["hits"][0]["_source"]`は取り出せないので、例外となります。  
マッチ件数は、パースしてから`(int)body["hits"]["total"]["value"]`で取得できます。  
ただし検索結果が10,001件以上ある場合は、`hits.total.value`は正確なマッチ件数を返してくれません。後述の`Count`項を参照してください。
<br/>
matchは要素解析されるようで全文一致ではありませんので、意図しないデータを取得するケースがあります。  
その場合はmatch_phraseやtermを使うといいようです。

match_phrase の場合
```csharp
var response = client.Search<StringResponse>("*",
    @"{
        ""query"": {
            ""match_phrase"": {
                ""Kanagawa"": ""Yokohama""
            }
        }
    }"
);
```

### termを使う

完全一致で検索するケースです。  
文字列を扱う場合は`text`,`keyword`の2種類の型があり、データ登録時にマッピングで指定できます。  
両者の違いについては[こちら](https://baubaubau.hatenablog.com/entry/2020/07/02/203000)などを参照ください。  
データ登録時に自分でマッピングを指定しない場合(Elasticsearchが自動マッピングしてくれる)、通常文字列は`text`型と`keyword`型の2タイプでクエリができます。  
`keyword`型を使う場合はkeyとなる語の後に`.keyword`を付けますが、`text`型を使う場合はkeyをそのまま使います。

```csharp
// text型 ... matchを使う
var response = client.Search<StringResponse>("*",
    @"{
        ""query"": {
            ""match"": {
                ""Kanagawa"": ""Yokohama""
            }
        }
    }"
);

// keyword型 ... termを使う
var response = client.Search<StringResponse>("*",
    @"{
        ""query"": {
            ""term"": {
                ""Kanagawa.keyword"": ""Yokohama""
            }
        }
    }"
);
```

### boolクエリを使う

これらの識別子の直下はobject{}ではなく、配列[]となります。

- `must`: and
- `should`: or
- `must not`: not


```csharp
var response = client.Search<StringResponse>("*",
    @"{ 
        ""query"": {
            ""bool"": {
                ""must"": [
                    { ""term"": {
                        ""grade.keyword"": ""elem2""
                    }},
                    { ""term"": {
                        ""likes.keyword"": ""train""
                    }}
                ]
            }
        }
    }"
);
```

boolクエリは、string型では書けましたが、NEST無しでobject型で書く方法はわかりませんでした。  
NESTを使う方法は[公式サイト](https://www.elastic.co/guide/en/elasticsearch/client/net-api/8.2/bool-queries.html)を参照願います。  
object型は `object initializer`というようなので、このフレーズで検索すれば情報が見つけやすいです。


### インデックスのヒット件数を得る : Count

通常`Search`などでクエリをかけると、`hits.total.value`にヒット件数が入りますが、ヒット件数が10,000件を超える場合には`hits.total.value`には10,000としか表示されずに困ることがあります。  
この場合、countAPIを使って、以下のように取り出せました。  
このCount関数はクエリを書かないと値を返してくれません。

```csharp
var response = client.Count<StringResponse>("indexName", @"{
    ""query"": { ""match_all"": {} }
}");
```

データ数は、`response.Body`から"count"キーで取得できます。

```csharp
using Newtonsoft.Json.Linq;

JObject jobj = JObject.Parse(response.Body);
int count = (int) jobj["count"];
```

### refreshAPI : Indices.Refresh

データの格納直後にそのデータを読み出す処理をしてましたが、格納直後の読み出しそのままではうまく格納結果を得られないことがあります。 
その場合データ登録時?に`refresh`の設定（デフォルトではfalse）が使えそうでしたが、結局refreshAPIを使いました。登録(Bulk)直後にrefreshAPIを呼ぶ、という具合です。インデックスごとにrefreshを実行できます。[^1] 


```csharp
var response = client.Indices.Refresh<StringResponse>("indexName");
```

### 万能関数 : DoRequest

すべてのケースに対応できる関数のようです。  
公式ページの説明通りの書式が書けます。  
[別記事](https://qiita.com/dimanche/items/9fe2de5aeca203119244)に書きました。


# 終わりに

間違い等ございましたらご指摘願いたいです。
次はNESTを使ってまとめたいです。

[^1]: [Elasticsearchに実際に書き込むテストなんかで「indexへの反映に時間がかかって困る〜〜」って時](https://moznion.hatenadiary.com/entry/2018/10/11/190809)