📝

ElasticsearchのmappingからGoのTypeを作る(1/2)

2023/08/01に公開

Overview

    1. Elasticsearchの概要について説明する
    1. Elasticsearchについて、JSONを格納したり引き出したりする場合に必要な知識を調査し、明示する
    1. 以下を達成する型を作るcode generatorを作成する
    • mapping.jsonからindexに格納されたJSONを容易に生成/消費できる
    • Plain / Rawと二つに分け、アプリの決定事項を反映した型、Elasticsearchが受け入れるすべての値からUnmarshalできる型とそれぞれする
      • 相互を適切に変換するメソッドを設ける
    • "dynamic"値が"strict"以外の時にmapping.jsonに載っていない数値を格納できる
    1. 作成中に見つけたjenniferによるcode generationのポイントを述べる

この記事は1.2.を述べ、3.4.part2で述べます。

成果物

  • Helper Types: Elasticsearchにドキュメントとして格納するJSONのフィールドをmarshal / unmarshalする型
  • Code Generator: mappingからGoのstructを作るcode generator

を作りました。

成果物はこちらです。

https://github.com/ngicks/estype

以下でインストールし、

# go install github.com/ngicks/estype/cmd/genestype@latest

以下のようなオプションを受け付けます。

# genestype --help
Usage of genestype:
  -c string
        path to config file.
        see definition of github.com/ngicks/estype/generator.GeneratorOption.
  -m string
        path to mapping.json.
        You can use one that can be fetched from '<index_name>/_mapping',
        or one that you've sent when creating index.
  -o string
        [optional] path to output generated code. (default "--")
  -p string
        package name of generated code.

サンプルで用意してあるmapping.jsonとオプションは以下に格納され

https://github.com/ngicks/estype/blob/cbfaf3aa60e2fb2eaf9a3c25aca2716966d521b1/generator/test/testdata

それをgenestypeに食わせて以下のコードを生成してあります。

https://github.com/ngicks/estype/blob/cbfaf3aa60e2fb2eaf9a3c25aca2716966d521b1/generator/test

前提知識

以下を有する

  • Go programming languageを使って開発が行える程度の理解度。
  • Elasticsearchとやり取りするアプリケーションを開発できる程度の理解度。
    • indexの作成
    • documentの格納/取得

Elasticsearchついて、基本的な概要からJSONを生成、消費することにかかる説明はこの記事で行いますが十分足りてるかは不明です。Goに関しては一切説明を行いません。

  • 本投稿のごく一部で何の説明もなしにTypeScriptの型表記がでてきます。知っている人か、でなければなんとなくで読んでください。

対象読者

  • Elasticsearchとやり取りするアプリを書いてJSON構造がよくわからなくて困った人
  • 格納できるJSONの生成に関するという観点のみだが、Elasticsearchのmappingに関する細かい話が知りたい人

環境

作り出した時期が大分前なので、elasticsearchは8.4.3を対象に作られています。
ドキュメントもすべて8.4のものを参照しています。

# go version
go version go1.20.6 linux/amd64
# curl ${ELASTICSEARCH_URL}
{
  "name" : "cdf7a5d86cb7",
  "cluster_name" : "docker-cluster",
  "cluster_uuid" : "raebKhvuRay4SB_eC704NQ",
  "version" : {
    "number" : "8.4.3",
    "build_flavor" : "default",
    "build_type" : "docker",
    "build_hash" : "42f05b9372a9a4a470db3b52817899b99a76ee73",
    "build_date" : "2022-10-04T07:17:24.662462378Z",
    "build_snapshot" : false,
    "lucene_version" : "9.3.0",
    "minimum_wire_compatibility_version" : "7.17.0",
    "minimum_index_compatibility_version" : "7.0.0"
  },
  "tagline" : "You Know, for Search"
}

背景

筆者は業務でElasticsearchとやり取りするアプリをNode.jsで開発しています。その中で、いろいろなことで困りました。例えば:

  • あるフィールドがundefined(JSONにキーが存在しない), null, T(keywordの場合stringのような), T[]のどれでも取りうることがあるため、typescriptの型定義が非常に煩雑であること
  • JSON.parseしただけではDate型がjavascriptのDate型に変換できず、アプリケーションの中で変換コードが散らばってしまう。
  • Boolean型がtrue / falseだけでなく"true" / "false"を受け付けるため、アプリ内で非常に煩雑なコードで判別することになってしまう。

後ろの二つは明らかに筆者が未熟でした。JSON.parseした後に、アプリに都合のいいフォーマットに変換する変換部を設けるべきでした。後悔は先に立たないものですね。(最近はzodを使えばいい感じにできるかもしれませんん。)

一方で、Goではある型がjson.Marshaler,json.Unmarshalerを実装している場合、json.Marshal(),(*json.Encoder).Encode(), json.Unmarshal(),(*json.Decoder).Decode()などの対応する関数の中で、デフォルトの挙動の代わりにそれらが呼び出されるという挙動があります。

GoはEncode / Decodeの界面での変換、validationに関して意識しやすい設計になっているため、ここにElasticsearchのindexに格納できるJSON documentとGo structと変換するいいブリッジをかけば同じ轍を踏まずに済みそうです。

目的

  • mapping.jsonを解析してそこからJSONを生成/消費できる型を生成するcode generatorを作成する

目標

この記事では、そのために以下を調べます

  • Elasticsearchがmappingに対してどのようなJSONを受け付けるのか
    • 逆にどのようなJSONを受け付けないのか
  • その中でstringintmap[string]anyなどで表現できない特定のJSONのサブフィールドを必要とするfield data type(s)

さらに、

  • そのfield data typeを表現する型を作成する

Elasticsearch

コンテキストを共有するためにElasticsearchの概要について説明します。筆者はElasticsearchに明るくないので基本的には引用元をあたってください。

概要

引用:
https://www.elastic.co/guide/en/elasticsearch/reference/8.4/elasticsearch-intro.html

Elasticsearch is the distributed search and analytics engine at the heart of
the Elastic Stack. Logstash and Beats facilitate collecting, aggregating, and
enriching your data and storing it in Elasticsearch.

(中略)

Elasticsearch provides near real-time search and analytics for all types of
data. Whether you have structured or unstructured text, numerical data, or
geospatial data, Elasticsearch can efficiently store and index it in a way
that supports fast searches.

Elasticsearchは検索と分析を行う分散型ドキュメントストアであり、
REST APIを通じてJSONをDocumentとして格納することができます。

格納されたドキュメントはデフォルトでは1秒程度で検索可能状態となり、謳われる通りnear real-timeです。

引用:
https://www.elastic.co/guide/en/elasticsearch/reference/8.4/documents-indices.html

Elasticsearch uses a data structure called an inverted index that supports
very fast full-text searches.

格納されたドキュメントは、設定に基づいて解析され、
Inverted Indexに変換されます。これにより高速な検索を実現してるとのことです。

引用:
https://www.elastic.co/guide/en/elasticsearch/reference/8.4/search-analyze.html

While you can use Elasticsearch as a document store and retrieve documents and
their metadata, the real power comes from being able to easily access the full
suite of search capabilities built on the Apache Lucene search engine library.

Apache Luceneを包むことでこれらの挙動を実現していると述べており、種々の事情がここから透けてきています。ElasticsearchがShardという単位で管理を行う部分がありますが、これはLucene Indexのことのようです(参考)。

ElasticsearchのIndexというのはRDBで言うところのTableに近く、
同じschemaを共有するドキュメントを格納することができ、indexに対して検索をかけることができます。
このschemaを決めるのがmappingであり、mappingによってIndexに収めるJSON Documentの形を完全に、あるいは部分的に決めることができます。

dynamic mapping / explicit mapping

引用:
https://www.elastic.co/guide/en/elasticsearch/reference/8.4/documents-indices.html

Elasticsearch also has the ability to be schema-less, which means that
documents can be indexed without explicitly specifying how to handle each of
the different fields that might occur in a document.

(中略)

You can define rules to control dynamic mapping and explicitly define mappings
to take full control of how fields are stored and indexed.

Elasticsearchはschema-lessで稼働して検索されることも可能です。この場合はDynamic field mappingで述べられるように、格納されたJSONの内容から自動的にmappingを推定します。データの形が推定されるために手元のデータをとりあえず検索可能にするなどのユースケースには便利なのかもしれません。

一方で、推定されるために

  • date detectionに検知されないフォーマットの時間が時間としてindexされない
  • "true" / "false"がbooleanとしてindexされない

などのドローバックがあると述べられています。

mappingはindex作成時などに明確に指定することができます。

mappingの指定/更新

IndexはCreate index APIで作成します。このAPIで同時にmappingを指定することができます。このmappingがこの記事で呼ぶmapping.jsonのことです。

一度作られたindexのフィールド(mapping)は減らすはできません。
追加はUpdate mapping APIにより可能です。

"dynamic": "strict"によるmappingの固定

mappingはしばしば完全に固定にされる("dynamic":"strict")ことがあります。

引用: https://www.elastic.co/guide/en/elasticsearch/reference/8.4/dynamic.html#dynamic-parameters

strict If new fields are detected, an exception is thrown and the document is rejected. New fields must be explicitly added to the mapping.

推定を防ぐことで後々のmapping拡張を許したり、ill-formedなJSONを検知するなどの目的でこの設定が行われると考えられます。また、自由なフィールド追加を許すとmapping explosionという現象が起きる可能性があります。これはindexに任意の入力を許すことによりmappingが増殖してパフォーマンスが劣化することです。

ユーザーの任意な値を収める場合はflattenedフィールドを使うか、objectのmapping設定で"index":falseにするなどをしたほうが良いです。

この記事の目的は主に、"dynamic":"strict"なmappingからGoの型を生成することで知識をコードにすることです。

お品書き

mapping.jsonから型を作るcode generatorを作るにあたり以下の作業が必要でした。

  1. undefined | null | T | (null | T)[]をunmarshalできる型を作る。
  2. Elasticsearchに格納できるJSONの形を調査する。
  3. Helper typeを実装する: ElasticsearchのField data typesのうち、単なるstringintなどでない型に対するhelperを作成する。
  4. date formatの変換: Elasticsearchの理解するtime formatをGoが理解するそれに変換する部分を実装する。
  5. code generatorの作成: github.com/dave/jenniferを使ったcode generatorを作る。

5.part2で述べます。

undefined | null | T | (null | T)[]をunmarshalできる型を作る。

すべてのフィールドはundefinedであることが許され、ほとんどのfield data type(s)において、nullを受け付けます。Arraysの項に説明される通り、ほとんどのfield data typesにおいてT[] | T[][]も同じく受け付けられます。T[][]T[]にflattenされると述べられています。

上記のすべてはオペレーション上混在しうることが考えられるため、これらすべてからunmarshalできる型があるとGoのコードからの扱いがよくなります。(T[][]はサポートしないことにしました)

混在する状況として考えらるのは

  • 全くデータを指定しない({})で初期化されたドキュメント
    • すべてのフィールドがundefined
  • mappingが追加されてフィールドが拡張された場合の、それ以前にindexされたドキュメントのそのフィールドはundefined
  • フィールドをクリアするためにupdate APIのpartial update[]、もしくはnullで上書きされた場合
  • サービスで運用されていくうちに、もとはTだったフィールドがT[]にしたい場合、TT[]は混在

undefinednullTT[]の混在は、reindexとpipelineなどででデータをいじって、aliasをreindex先に切り替えれば無停止でそれぞれ片方に統一できます。しかし、ドキュメント数が多いとメモリやCPU使用率が高い状態が長時間続くため、あえてやりたい理由はないでしょう。

上記の型を実装するには前回の記事でも述べた、github.com/ngicks/undを拡張し、Elastic[T]として実装しました。

https://github.com/ngicks/und/blob/fd0b45653fa93b1bb1ec1928253b563bd1d33eca/elastic/elastic.go#L12-L15

前回の記事で提示したUndefinedable[T]Nullable[T]の組み合わせで上記のすべての型を表現することを実現しました。

https://github.com/ngicks/und/blob/fd0b45653fa93b1bb1ec1928253b563bd1d33eca/elastic/elastic.go#L184-L218

UnmarshalJSONの実装でnull, T and (null | T)[]のいずれも受け付けられるようにしてあります。

https://github.com/ngicks/und/blob/fd0b45653fa93b1bb1ec1928253b563bd1d33eca/elastic/elastic.go#L175-L182

とある通り、値がある場合は必ずT[]に向けてエンコードされるように設計されています。
値が一つだけの場合にTにmarshalすべきか、T[]にすべきかは型レベルでは判別のつきづらい要素であったためです。

Elasticsearchに格納できるJSONの形を調査する

Elasticsearchのmappingとフィールドの形

前述のとおり、ElasticsearchではIndexごとに格納するJSON documentの形をmapping.jsonによって決めることができます。specによればトップは必ずJSON Objectであり、各フィールドはfield data type(s)を指定することで、格納するデータの型とそれの意味が決められます。

前述のとおり、mappingはindex生成時などに事前に定義することができ、設定によって部分的に、あるいは完全にJSONの形を固定することができます。

mappingはJSONとしてPUT /<index_name>にsettingとともに渡すことができます。

例えば、以下のようなmappingの場合

{
  "mappings": {
    "dynamic": "strict",
    "properties": {
      "name": {
        "type": "text"
      },
      "blob": {
        "type": "binary"
      },
      "range": {
        "type": "integer_range"
      }
    }
  }
}

以下のJSONをそのIndexに格納することできます

{
  "name": "alice",
  "blob": "Zm9vYmFyYmF6",
  "range": {
    "gte": 3,
    "lt": 5
  }
}

このようにmappingによってJSONの形と意味が決められます。

ドキュメントによると

  • textはその言葉の通り、analyzerにって解析されるfull-textコンテンツ
  • binaryはbase64 encodeされたstring
  • rangeはサブフィールドにgt,gte,lt,lteを持つことでrangeを表現できる型

textkeywordstringintegerdoubleint32float64に単にすればよいですが、それ以外は大なり小なりフォーマットに意味があります。

Elasticsearchの各field data typeのふるまい

8.4のfield data typesの項をすべて読み、以下を一通りチェックしました。tableの以下の各項目と対応しています。

  • built-in / std
    • => Goのbuilt-in typeとstdの範疇で表現できるか
  • 要mapping解析
    • => mappingの値によって型が変わったりcode generationで生成される内容が変わるか。
  • multi-value
    • => 複数値を受け付けるか
  • null
    • => nullをそのフィールドに格納できるか
  • single valueのarrayを受け付けるか
    • dense_vector以外は許されました。おそらくArraysの項に説明がある通り、T[][]T[]にflattenされるからでしょう。
field data type built-in / std 要mapping解析 multi-value null 備考
aggregate_metric_double ⭕️
alias N/A ⭕️ いかなる値も受け付けない
binary []byte
boolean
completion string
date/date_nanos ⭕️
dense_vector ⭕️ [[1,2,3]]は受け付けない
flattened map[string]any
geo_point
geo_shape
histogram
ip netip.Addr
join ⭕️
keyword string
constant_keyword string ⭕️ mapping.jsonで入れたワードしか受け付けない
wildcard string
nested ⭕️ code generatorからするとobjectと同じ
byte int8
double float64
float float32
half_float float32 goはnativeでfloat16をサポートしない
integer int32
long int64
scaled_float float64
short int16
object ⭕️
percolator map[string]any QueryDSLを格納する用途
point
date_range ⭕️
double_range
float_range
integer_range
ip_range
long_range
rank_feature float64
rank_features map[string]float64 同じキーが複数のobjectにあるとエラー
search_as_you_type string
shape
text string
match_only_text string
token_count おそらくfieldプロパティ―以外では使えない?
unsigned_long uint64
version string semverパッケージ使うほうがいいかもしれない

Helper Typeを実装する

必要性

上記のように、ものによっては単なるbuilt-in typeでElasticsearchのfield data typeを表現できません。

前記のtext, binary, rangeのうち、textは単なるstring,binary[]byteとすればよいでしょう。

引用: https://pkg.go.dev/encoding/json#Marshal

Array and slice values encode as JSON arrays, except that []byte encodes as a
base64-encoded string, and a nil slice encodes as the null JSON value.

とあるように、json.Marshal()[]byteをbase64にエンコードするように設計されているためです。
もし仮にぎりぎりまでデコードを遅延したいならばそれ用の特別な型が必要ですが、ひとまずはそのことは考えないようにしましょう。

他方、rangeなど、決められた形のJSONを収める型に関しては、例えば以下のような型が定義されてあると、勘違いが少なく、実装の手間も少ないわけです。

range.go
type Range[T comparable] struct {
	Gt  *T `json:"gt,omitempty"`
	Gte *T `json:"gte,omitempty"`
	Lt  *T `json:"lt,omitempty"`
	Lte *T `json:"lte,omitempty"`
}

実装

前述のテーブルのbuilt-in / stdが空白であった型に関してhelper typeを定義します。

力尽きてしまったのでjoinとpointは未実装です。自分で使う機会があれば実装するかもしれません。

aggregate metric double

aggregate_metric_doubleは、mappingの"metrics"で定義した値のみ値を格納できるようです。

// 引用: https://www.elastic.co/guide/en/elasticsearch/reference/8.4/aggregate-metric-double.html
{
  "mappings": {
    "properties": {
      "my-agg-metric-field": {
        "type": "aggregate_metric_double",
        "metrics": ["min", "max", "sum", "value_count"],
        "default_metric": "max"
      }
    }
  }
}

4種類のサブフィールドの組み合わせですから、15種類の型を定義しておけばよいです。そこで:

https://github.com/ngicks/estype/blob/cbfaf3aa60e2fb2eaf9a3c25aca2716966d521b1/generator/gen_aggregate_metric_double/gen.go#L24-L70

以上のように、フラグのon/offの全パターン網羅はfor文で容易に実装できます。これによって事前にすべてのパターンを事前に生成しておけばよいのです。

https://github.com/ngicks/estype/blob/cbfaf3aa60e2fb2eaf9a3c25aca2716966d521b1/fielddatatype/aggregate_metric_double.go

この型のうちmappingに対し適切なものをcode generatorによって選択してもらえばいいわけですね。

boolean

booleanは以下の値を受けれるとドキュメントされています。

  • trueして: true, "true"
  • falseとして: false, "false", ""

boolをbase typeと持つ型とし、MarshalJSON / UnmarshalJSONを実装すればよいでしょう。

https://github.com/ngicks/estype/blob/cbfaf3aa60e2fb2eaf9a3c25aca2716966d521b1/fielddatatype/boolean.go#L70-L83

困ったことに、stringの"true" / "false"を好むプロジェクトが存在する(筆者が実際に参加していました)ため、MarshalJSONで出すのがboolean literalになる型とstring literalになる型をそれぞれ作ってcode generatorの設定値でどちらを使うか決める決断を下しました。

https://github.com/ngicks/estype/blob/cbfaf3aa60e2fb2eaf9a3c25aca2716966d521b1/fielddatatype/boolean.go#L10-L17

https://github.com/ngicks/estype/blob/cbfaf3aa60e2fb2eaf9a3c25aca2716966d521b1/fielddatatype/boolean.go#L39-L47

geo_point

geo_pointは以下の6つのフォーマットを受け付けます。

多いですね。

https://github.com/ngicks/estype/blob/cbfaf3aa60e2fb2eaf9a3c25aca2716966d521b1/fielddatatype/geopoint.go#L15-L147

頑張って実装しました。これで少なくとも公式のサンプルを全部パーズできます。

https://github.com/ngicks/estype/blob/cbfaf3aa60e2fb2eaf9a3c25aca2716966d521b1/fielddatatype/geopoint.go#L149-L159

この型はシンプルな{"lat":123,"lon":456}フォーマットにMarshalします。
boolと同じく、どのフォーマットに対してmarshalするかを設定で決められるようにすればよかったと思いますが、力尽きてしまいました・・・。

geo_shape

geo_shapeGeoJSONWell-Known Textを受け付けます。

https://www.elastic.co/guide/en/elasticsearch/reference/8.4/geo-shape.html#input-structure

にある通り、GeoJSON Typeのうち
"Feature""FeatureCollection"以外を受け付けるとあります。

そこで実装は、

https://github.com/ngicks/estype/blob/cbfaf3aa60e2fb2eaf9a3c25aca2716966d521b1/fielddatatype/geoshape.go#L18-L44

特にデータフォーマットを制限したりせず、github.com/go-spatial/geomに委譲してしまう実装にしました。内部の実装を読む限り、wktはbboxをサポートしていないのでそれを使われるとデコードできないですが、それ以外は網羅できています。

histogram

histogramはドキュメントによるとalgorithm agnosticな値のセットであり、あらかじめaggregateされている値を入れておくものらしいです。

これはシンプルに

https://github.com/ngicks/estype/blob/cbfaf3aa60e2fb2eaf9a3c25aca2716966d521b1/fielddatatype/histogram.go

というだけです。

point / shape

見たところgeo_point, geo_shapeとほぼ同じなのですが、ドキュメントを読んでる時間がなく、shapeはgeo_shapeをそっくり使いまわします決断をしました。pointがgeo_pointとどう違うか確認が取れなかったため、実装を先送りしています。

range

rangeはその名の通り数値のrangeを表現するものです。8.4の時点では

  • integer_range
  • float_range
  • long_range
  • double_range
  • date_range
  • ip_range

があります。

version_rangeが存在しないのがちょっと気になるところですが、semverを数値に変換すれば実現可能なので優先度が低いんでしょうか。

https://github.com/ngicks/estype/blob/cbfaf3aa60e2fb2eaf9a3c25aca2716966d521b1/fielddatatype/range.go

各フィールドはnullを許容しないため、,omitemptyが必要です。試してないですがGtGteLtLteは同時に存在してはいけないはずです。これに関しては特に型やメソッドによってvalidationをかけられるようにはしていません。

型制限をこれ以上きつくできなかった話

実際には以下のようにしたほうが型レベルできついのですが

type Range[T interface {
	constraints.Integer | constraints.Float | netip.Addr | time.Time
}] struct{}

後述するElasticsearchのdateフォーマットをすべて理解できる型を作る際に以下のように、エラーになるため、できませんでした。

type builtinDate time.Time

func init() {
	var n Range[builtinDate]
  // builtinDate does not satisfy interface{constraints.Integer | constraints.Float | netip.Addr | time.Time}
  // (builtinDate missing in ~int | ~int8 | ~int16 | ~int32 | ~int64 | ~uint | ~uint8 | ~uint16 | ~uint32 | ~uint64 | ~uintptr | ~float32 | ~float64 | net/netip.Addr | time.Time)
}

筆者の理解が正しければ、 structをunderlying typeとする型を、 base
typeとする型をconstraintにすることは現状できません。

つまり

type Range[T interface {
	constraints.Integer | constraints.Float | ~netip.Addr | ~time.Time
}] struct{}

// invalid use of ~ (underlying type of netip.Addr is struct{addr netip.uint128; z *intern.Value})
// invalid use of ~ (underlying type of time.Time is struct{wall uint64; ext int64; loc *time.Location})

というエラーです。実際この型にtype paramを入れるのはcode
generatorなのでこの制限は特に問題ないとみなし、とりあえずでcomparableにしてあります。

date helper typeの実装

dateおよびdate_nanos field data typeは"format"フィールドで指定されたフォーマットに従うstringもしくはnumberを収めることができ、フォーマット通りに解釈されて時間としてインデックスされます。

// 引用: https://www.elastic.co/guide/en/elasticsearch/reference/8.4/date.html#multiple-date-formats
{
  "mappings": {
    "properties": {
      "date": {
        "type": "date",
        "format": "yyyy-MM-dd HH:mm:ss||yyyy-MM-dd||epoch_millis"
      }
    }
  }
}
  • フォーマットはDateTimeFormatterのフォーマットに従う(Cunstom Date Formats)
  • ||区切りの複数のフォーマットを指定できる
  • 特定の文字列(e.g. strict_date_optional_time)はbuilt in formatとして認識される
  • epoch_millisepoch_secondを指定すると、Epochからのミリ秒、秒をJSONのnumberで指定できる。
  • "format"を指定しない場合デフォルトは"strict_date_optional_time||epoch_millis", "strict_date_optional_time_nanos||epoch_millis"にそれぞれなる
    • "strict_date_optional_time"yyyy-MM-dd'T'HH:mm:ss.SSSSSSZ or yyyy-MM-ddです。
    • と、ドキュメントには載っていますが実際には9桁までデータを格納できます。おそらくIndex時にデータがドロップしています。

したがって以下が必要となります。

  • "format"フィールドを読んでフォーマットの解析
  • built in formatの事前展開展開
  • DateTimeFormatterが理解するフォーマットをGoのtime.Parseが理解するフォーマットに変換
    • optional section ( [, ])の展開
    • トークンごとの変換
  • 複数のフォーマットでパーズができる型を定義
    • Marshal / Unmarshal時、フォーマットにepoch_*が含まれている場合numberも解釈する必要がある。

"format"フィールドの展開や、built in formatの内容からレイアウト変換などはcode generatorの行いますのでこのセクションでは述べません。

optional sectionの展開

この機能はoptionalstringという名前でパッケージにまとめてあります。

https://github.com/ngicks/estype/blob/cbfaf3aa60e2fb2eaf9a3c25aca2716966d521b1/fielddatatype/estime/optionalstring

パパっと調べた限り、特定のトークンで囲まれたstringをoptionalとみなして展開し列挙する、というほしい機能を備えたパッケージは見つかりませんでした。
探し方が悪いだけな可能性が高いですが、いいんですこれは趣味プロジェクトなんだから作ってしまえば。

このパッケージはgithub.com/prataprc/goparsecというパーザコンビネータを利用して文字列を木構造に変更、木構造を展開してoptional sectionなしの文章に列挙します。

このパッケージは事前処理のために使われるため、パフォーマンスは重視されていません。あまり賢い実装をしているとは思えませんし、実際頭がこんがらがりながら木構造を展開する処理をかいていました。実際パーザコンビネータの吐くトークン列から文字列を列挙をすればいいのに木構造に落としなおしている時点で非効率なはずです。

実装の不備やバグは探せばいくらでもあると思いますがdate formatを展開するという用途には現状問題なく動作しています。

time tokenの変換

こちらは以下のファイルで実装されています。

https://github.com/ngicks/estype/blob/cbfaf3aa60e2fb2eaf9a3c25aca2716966d521b1/fielddatatype/estime/convert.go

中身はtime.Parseを簡易化したような実装をしており、愚直にswitch-caseを書いてパフォーマンスを求めるより、トークンをテーブル化して実装の負担を減らす方針でいきました。

すなわち

func convert(input string) string {
  var prefix, token, suffix, out string
  for len(input) > 0 {
    prefix, token, suffix = nextToken(input)
    input = suffix
    out += prefix + fromJavaLikeToGoToken(token)
  }
  return out
}

のような感じです。time.Parseでは上記のnextTokenに当たる関数の中で膨大なswitch-caseを走らせてトークンを抽出しています。

https://github.com/ngicks/estype/blob/cbfaf3aa60e2fb2eaf9a3c25aca2716966d521b1/fielddatatype/estime/convert.go#L236-L258

こういったtableを作ることで、switch-caseの量を大分減らせます。

doc commentでも述べていますが、Goが同じ機能を持つトークンを持たない以下はサポートされません

https://github.com/ngicks/estype/blob/cbfaf3aa60e2fb2eaf9a3c25aca2716966d521b1/fielddatatype/estime/convert.go#L28-L41

とくにweekyear系トークンがないのでbuilt in date formatの中にいくつか使えないものが出てきます。

複数フォーマットでパーズできる型を定義

複数のstringフォーマットを持つパーザ

これ以下のファイルで実装しました。

https://github.com/ngicks/estype/blob/cbfaf3aa60e2fb2eaf9a3c25aca2716966d521b1/fielddatatype/estime/multi_layout.go

これはとっても簡単ですね。

複数のレイアウトを保持

https://github.com/ngicks/estype/blob/cbfaf3aa60e2fb2eaf9a3c25aca2716966d521b1/fielddatatype/estime/multi_layout.go#L9-L13

イニシャライズ時にlengthでdescending, 文字コードでdescendingでソート、dedupe,
validateし、

https://github.com/ngicks/estype/blob/cbfaf3aa60e2fb2eaf9a3c25aca2716966d521b1/fielddatatype/estime/multi_layout.go#L15-L55

順番にパーズを試みて成功したらそのまま値を返します。

https://github.com/ngicks/estype/blob/cbfaf3aa60e2fb2eaf9a3c25aca2716966d521b1/fielddatatype/estime/multi_layout.go#L79-L89

順番にレイアウトを試していくのはElasticsearch自身のソースを参考にしました(すみません、出展なしです)。どうやっているんだろう、と思って見に行くと単にパーザーをイテレートしながらパーズを繰り返していたので、なるほど、と思いに似たような処理にしています。

numberもパーズ/フォーマットできるパーザ

これは前述のMultiLayoutとnumberを変換できるパーザを組み合わせます。

https://github.com/ngicks/estype/blob/cbfaf3aa60e2fb2eaf9a3c25aca2716966d521b1/fielddatatype/estime/estime.go#L51-L54

numberのパーザ/フォーマッタはElasticsearchのそれと一致したstring typeであると非常に楽です。つまり

https://github.com/ngicks/estype/blob/cbfaf3aa60e2fb2eaf9a3c25aca2716966d521b1/fielddatatype/estime/estime.go#L11-L13

https://github.com/ngicks/estype/blob/cbfaf3aa60e2fb2eaf9a3c25aca2716966d521b1/fielddatatype/estime/estime.go#L35-L39

switch-caseによってtime.UnixMillitime.Unixを呼び出せば所望の動作を実現できます。

https://github.com/ngicks/estype/blob/cbfaf3aa60e2fb2eaf9a3c25aca2716966d521b1/fielddatatype/estime/estime.go#L15-L24

まとめ

    1. Elasticsearchの概要について説明した
    1. Elasticsearchについて、JSONを格納したり引き出したりする場合に必要な知識を調査し、明示した
    • 単にstring, intなどにできない型について型を定義し、必要であればjson.Marshaler, json.Unmarshalerを実装した。
    • date / date_nanosのために時間フォーマットの変換部と複数レイアウトを保持してパーズができる型を実装した

part2mapping.jsonから型を生成するcode generatorを実装します。

GitHubで編集を提案

Discussion