はじめに
オークファン新卒2年目エンジニアの @tmot です。
オークファンでは、大量のオークションデータが保存されたElasticsearchクラスタを持ち、そのデータを活用して様々なサービスを提供しています。私は主にバックエンドの開発に携わっているので、Elasticsearchと対話しなければならない業務も多いです。
そんなElasticsearchとの格闘戯れの日々の中で、日付に関する検索・集計で苦戦したことがあったので、少し書いていきたいと思います。
環境
使用OS: Windows10
Elasticsearch: 6.2.4
Kibana: 6.2.4
昔作成されたElasticsearchクラスタを扱う業務が直近であったため、Elastic Stackは少し古い6.2.4
となっています。
データ
業務で実際に扱っているデータはお見せできないので、今回は、item_sample_index
というインデックスに簡素化したサンプルデータを作成し、使用します。
シンプルなオークション出品データです。
- フィールド
フィールド名 | データ型 | 説明 |
---|---|---|
id |
keyword |
出品ID |
title |
text |
商品名 |
price |
scaled_float |
価格 |
bids |
integer |
入札数 |
startDateTime |
date |
出品開始日時 |
endDateTime |
date |
出品終了日時 |
{
"id": "item-tuesday-01",
"title": "火曜日までだよ",
"price": 100,
"quantity": 1,
"bids": 1,
"startDateTime": "2020-11-24T19:00:00+09:00",
"endDateTime": "2020-12-01T19:00:00+09:00"
}
このような出品データを、出品終了日時が2020-12-01
(火) ~ 2020-12-18
(金)の期間で1日につき1レコード作成します。時刻はhh:00:00
とし、hour部分を0~23でランダムに設定しておきます。
検索
日付で検索する
出品終了日時が特定の日付であるデータが欲しいときは、range
で範囲指定してやればよいです。
GET item_sample_index/_search
{
"query": {
"range": {
"endDateTime": {
"gte": "2020-12-01T00:00:00+09:00",
"lt": "2020-12-02T00:00:00+09:00"
}
}
}
}
{
...
"hits": {
"total": 1,
"max_score": 1,
"hits": [
{
"_index": "item_sample_index",
"_type": "item",
"_id": "Rfz8YHYBYbdd_Ev6-ddK",
"_score": 1,
"_source": {
"id": "item-tuesday-01",
"title": "火曜日までだよ",
"price": 100,
"quantity": 1,
"bids": 1,
"startDateTime": "2020-11-24T19:00:00+09:00",
"endDateTime": "2020-12-01T19:00:00+09:00"
}
}
]
}
}
曜日で検索する
今度は出品終了日時の曜日で検索します。日付そのままではできなさそうなので、scriptクエリでendDateTime
の曜日を取って検索しようと思います。
まずは小手調べ
scriptクエリに使用するpainless scriptではJavaのように書くことができるようなので、試しに書いてみます。
java.time
ライブラリが使えるとのことなので、日付をjava.time.DateTime
クラスとかに変換してくれたりしたら助かるなと若干期待しながら書いてみます。
GET item_sample_index/_search
{
"query": {
"script": {
"script": """
// 勝手にキャストしてくれないかな~
OffsetDateTime dateTime = doc['endDateTime'].value;
dateTime.dayOfWeek == DayOfWeek.TUESDAY
"""
}
}
}
{
"error": {
...
"caused_by": {
"type": "class_cast_exception",
"reason": "Cannot cast org.joda.time.MutableDateTime to java.time.OffsetDateTime"
}
}
}
]
},
"status": 500
}
一度でできるとは思っていなかったのでまあよし。
doc['endDateTime'].value
はorg.joda.time.MutableDateTime
として読まれるのですね。
MutableDateTimeクラスを活用する
org.joda.time.MutableDateTime
で取れるようなので、このクラスのdayOfWeekを使えばできそうです。
以下のようにスクリプトを書き換えました。
"script": """
DayOfWeek dayOfWeek = DayOfWeek.of(doc['endDateTime'].value.dayOfWeek);
dayOfWeek == DayOfWeek.TUESDAY
"""
{
...
"hits": {
"total": 4,
"max_score": 1,
"hits": [
{
"_index": "item_sample_index",
"_type": "item",
"_id": "Rfz8YHYBYbdd_Ev6-ddK",
"_score": 1,
"_source": {
"id": "item-tuesday-01",
"title": "火曜日までだよ",
"price": 100,
"quantity": 1,
"bids": 1,
"startDateTime": "2020-11-24T19:00:00+09:00",
"endDateTime": "2020-12-01T19:00:00+09:00"
}
},
{
"_index": "item_sample_index",
"_type": "item",
"_id": "R_z9YHYBYbdd_Ev649cg",
"_score": 1,
"_source": {
"id": "item-tuesday-03",
"title": "火曜日までだよ",
"price": 100,
"quantity": 1,
"bids": 1,
"startDateTime": "2020-12-08T18:00:00+09:00",
"endDateTime": "2020-12-15T18:00:00+09:00"
}
},
{
"_index": "item_sample_index",
"_type": "item",
"_id": "Svz_YHYBYbdd_Ev6UNck",
"_score": 1,
"_source": {
"id": "item-wednesday-03",
"title": "水曜日までだよ",
"price": 100,
"quantity": 1,
"bids": 1,
"startDateTime": "2020-12-09T00:00:00+09:00",
"endDateTime": "2020-12-16T00:00:00+09:00"
}
},
{
"_index": "item_sample_index",
"_type": "item",
"_id": "Rvz9YHYBYbdd_Ev6hNfA",
"_score": 1,
"_source": {
"id": "item-tuesday-02",
"title": "火曜日までだよ",
"price": 100,
"quantity": 1,
"bids": 1,
"startDateTime": "2020-12-01T21:00:00+09:00",
"endDateTime": "2020-12-08T21:00:00+09:00"
}
}
]
}
}
できた!!
と思いきや…
{ "_index": "item_sample_index", "_type": "item", "_id": "Svz_YHYBYbdd_Ev6UNck", "_score": 1, "_source": { "id": "item-wednesday-03", "title": "水曜日までだよ", "price": 100, "quantity": 1, "bids": 1, "startDateTime": "2020-12-09T00:00:00+09:00", "endDateTime": "2020-12-16T00:00:00+09:00" } }
水曜日のものも混ざってしまっていますね…。
タイムゾーンを合わせる
ElasticsearchはUTCで時刻を保持しているので、UTC基準で曜日集計してしまいます。
例えば先ほどの2020-12-16T00:00:00+09:00
は、2020-12-15T15:00:00Z
として保存されているため、火曜日として集計されてしまったのです。
MutableDateTime.setZone(DateTimeZone newZone)
でタイムゾーンをJSTに変換してみます。
"script": """
DateTimeZone dateTimeZone = new DateTimeZone('Asia/Tokyo');
DayOfWeek dayOfWeek = DayOfWeek.of(doc['endDateTime'].value.setZone(dateTimeZone).dayOfWeek);
dayOfWeek == DayOfWeek.TUESDAY
"""
実行。
{
"error": {
...
"caused_by": {
"type": "illegal_argument_exception",
"reason": "unexpected token ['dateTimeZone'] was expecting one of [{<EOF>, ';'}]."
}
}
}
}
]
},
"status": 400
}
?
OffsetDateTimeに変換する
Elasticsearchのissueを見たところ、org.joda.time.MutableDateTime
はpainlessには存在せず(!?)、org.joda.time.ReadableDateTime
をJavaのTimeクラスに変換する必要があるとのこと。
最初に出てきた"reason": "Cannot cast org.joda.time.MutableDateTime to java.time.OffsetDateTime"
というエラーメッセージは誤りだったのですね…。
java.time
クラスに変換して扱うという方針で、以下のようにスクリプトを修正しました。
"script": """
OffsetDateTime time = OffsetDateTime.parse(doc['endDateTime'].value.toString()).withOffsetSameInstant(ZoneOffset.ofHours(9));
time.dayOfWeek == DayOfWeek.TUESDAY
"""
{
...
"hits": {
"total": 3,
"max_score": 1,
"hits": [
{
"_index": "item_sample_index",
"_type": "item",
"_id": "Rfz8YHYBYbdd_Ev6-ddK",
"_score": 1,
"_source": {
"id": "item-tuesday-01",
"title": "火曜日までだよ",
"price": 100,
"quantity": 1,
"bids": 1,
"startDateTime": "2020-11-24T19:00:00+09:00",
"endDateTime": "2020-12-01T19:00:00+09:00"
}
},
{
"_index": "item_sample_index",
"_type": "item",
"_id": "R_z9YHYBYbdd_Ev649cg",
"_score": 1,
"_source": {
"id": "item-tuesday-03",
"title": "火曜日までだよ",
"price": 100,
"quantity": 1,
"bids": 1,
"startDateTime": "2020-12-08T18:00:00+09:00",
"endDateTime": "2020-12-15T18:00:00+09:00"
}
},
{
"_index": "item_sample_index",
"_type": "item",
"_id": "Rvz9YHYBYbdd_Ev6hNfA",
"_score": 1,
"_source": {
"id": "item-tuesday-02",
"title": "火曜日までだよ",
"price": 100,
"quantity": 1,
"bids": 1,
"startDateTime": "2020-12-01T21:00:00+09:00",
"endDateTime": "2020-12-08T21:00:00+09:00"
}
}
]
}
}
今度こそできましたね。
集計
先ほどの曜日検索に用いたスクリプトを少し変えてやれば曜日ごとの集計にも使えます。
GET item_sample_index/_search
{
"aggs": {
"dayOfWeekAggregation": {
"terms": {
"script": {
"source": """
OffsetDateTime time = OffsetDateTime.parse(doc['endDateTime'].value.toString()).withOffsetSameInstant(ZoneOffset.ofHours(9));
time.dayOfWeek
"""
}
}
}
},
"size": 0
}
{
"took": 24,
"timed_out": false,
"_shards": {
"total": 5,
"successful": 5,
"skipped": 0,
"failed": 0
},
"hits": {
"total": 18,
"max_score": 0,
"hits": []
},
"aggregations": {
"dayOfWeekAggregation": {
"doc_count_error_upper_bound": 0,
"sum_other_doc_count": 0,
"buckets": [
{
"key": "FRIDAY",
"doc_count": 3
},
{
"key": "THURSDAY",
"doc_count": 3
},
{
"key": "TUESDAY",
"doc_count": 3
},
{
"key": "WEDNESDAY",
"doc_count": 3
},
{
"key": "MONDAY",
"doc_count": 2
},
{
"key": "SATURDAY",
"doc_count": 2
},
{
"key": "SUNDAY",
"doc_count": 2
}
]
}
}
}
おまけ: Elasticsearch 7.6.1で試す
エラーメッセージに誤りがあったと思われる最初のクエリを、バージョン7.6.1
(別プロジェクトで少し前に使用していた)でも試してみます。
GET item_sample_index/_search
{
"query": {
"script": {
"script": """
OffsetDateTime dateTime = doc['endDateTime'].value;
dateTime.dayOfWeek == DayOfWeek.TUESDAY
"""
}
}
}
{
"error" : {
...
"caused_by" : {
"type" : "class_cast_exception",
"reason" : "Cannot cast org.elasticsearch.script.JodaCompatibleZonedDateTime to java.time.OffsetDateTime"
}
}
}
]
},
"status" : 400
}
Elasticsearchのライブラリ内のクラスにキャストされるように変更されていますね!
以下のようなスクリプトで曜日検索ができました。かなり書きやすくなった印象。
"script": "doc['endDateTime'].value.withZoneSameInstant(ZoneId.of('Asia/Tokyo')).getDayOfWeek() == DayOfWeek.TUESDAY"
最後に
試行錯誤しているうちにElasticsearchのスクリプトの扱いに少し慣れてきた気がします。
スクリプトを使うと検索速度が落ちるという問題はあるので、できるだけスクリプトを使わずに済むのが一番ですが、今後必要になったときに今回の知見を生かせる場面があればと思います。