ElasticsearchのmappingからGoのTypeを作る(2/2)
Overview
-
- Elasticsearchの概要について説明する
-
- Elasticsearchについて、JSONを格納したり引き出したりする場合に必要な知識を調査し、明示する
-
- 以下を達成する型を作るcode generatorを作成する
- mapping.jsonからindexに格納されたJSONを容易に生成/消費できる
- Plain / Rawと二つに分け、アプリの決定事項を反映した型、Elasticsearchが受け入れるすべての値からUnmarshalできる型とそれぞれする
- 相互を適切に変換するメソッドを設ける
-
"dynamic"
値が"strict"
以外の時にmapping.jsonに載っていない数値を格納できる
-
- 作成中に見つけたjenniferによるcode generationのポイントを述べる
この記事はpart1の続きで3.
、4
について述べます
成果物
- Helper Types: Elasticsearchにドキュメントとして格納するJSONのフィールドをmarshal / unmarshalする型
- Code Generator: mappingからGoのstructを作るcode generator
を作りました。
成果物はこちらです。
以下でインストールし、
# go install github.com/ngicks/estype/cmd/genestype@latest
以下のようなオプションを受け付けます。
root@16cb5614efe3:/mnt/git/github.com/ngicks/estype# 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とオプションは以下に格納され
それをgenestype
に食わせて以下のコードを生成してあります。
前提知識
以下を有する
- Go programming languageを使って開発が行える程度の理解度。
-
Elasticsearchとやり取りするアプリケーションを開発できる程度の理解度。
- indexの作成
- documentの格納/取得
part1でElasticsearchの基本述べているので、それ以上を前提知識としてあります。Goについては一切説明を行いません。
- 本投稿のごく一部で何の説明もなしにTypeScriptの型表記がでてきます。知っている人か、でなければなんとなくで読んでください。
対象読者
- Elasticsearchとやり取りするアプリを書いてJSON構造がよくわからなくて困った人
- Goのcode generationで躓きがちなところを知りたい人
環境
作り出した時期が大分前なので、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"
}
code generatorの作成
part1では、Elasticsearchに収めるJSONの注意点や、特定の形のJSONを要求するfield data typesについて調べました。この記事ではcode generatorを作ることで、mapping.json
からElasticsearchのあるindexに格納するJSONを容易に生成/消費できる型を生成します。
そのためには:
- mapping.jsonの解析して型情報を取得し
- ユーザーから設定値を受けとり
- 設定と型情報に基づいてcode generateを行う
以降ではmapping.json
の解析に関する情報と、code generateにおける注意点について述べます。
part1でのべた通り、
-
T
とT[]
-
undefined
とnull
などが混在することを許しながら、Goのほかのコードで円滑に消費できるplainでidiomaticな型を生成するのがこのcode generatorの目的です。これらの目的は互いに矛盾するため、それぞれの目的を達成する型をそれぞれ作り、ブリッジとなる相互変換メソッドを設けることとします。
そのため、Plain
とRaw
の2つのタイプと、相互に変換を行うメソッドを実装する方針になっています。
Plain
は以下のサンプル生成コードのように「普通の」Go structのようなものです
Raw
はundefined | (null | T) | (null | T)[]
を許容するstructです
part1で説明したelastic.Elastic[T]
などを使うことでこれを実現します。前回の記事で説明した通りserde
パッケージでMarshal
した場合のみフィールドのスキップが起きます。
Plain
はユーザーから設定値を受けとって、T
、[]T
、*T
、*[]T
のいずれであるかなどを決めます。
mapping.jsonの解析: specの実装
mapping.jsonを解析するには、mapping.jsonの内容を定義したGo structを定義してjson.Unmarshal()
するか、jsonをパーズせずに手続き的にキーを探索するかなどを行うことになります。
(jsonをパーズしないまま探索を行う場合は、例えば、github.com/tidwall/gjsonを使います。)
今回はのちのことを考えて静的なstructを定義してそれを使うこととします。
github.com/elastic/go-elasticsearchのtypedapi/types
以下にはIndexState
(index生成時に/<index_name>
にPUT
するJSON)や、TypeMapping
が定義されています。当初はこれを使えばよいと思いましたが、"type"
フィールドが存在しないときobject
として扱われるというルールが正しく実装されていないことと、それを正しく修正する方法が不明であることからハンドポートを行いました。
specification
今回のこれを実装するためにあれこれ調べるまで全然知らなかったのですが、github.com/elastic/elasticsearch-specificationで、Elasticsearchの種々のJSON Documentの型をtypescriptの型定義として実装してあり、これをcompilerでJSONの何かしらのschemaに変換できます。これによって別言語のクライアントを作成するようです。
goの公式clientライブラリであるgithub.com/elastic/go-elasticsearchも、typedapi以下にここから生成されたコードがあります。
ここでmappingの型が定義されています。
ではこれを使えば目的が達せられるのかと言えばそうでもないんです。
go-elasticsearchのspecificationの変換に関する問題
propertiesのデコード部分
ここで、"type"
フィールドが不在の場合がハンドルされていません。
引用: https://www.elastic.co/guide/en/elasticsearch/reference/8.4/object.html
You are not required to set the field type to object explicitly, as this is the default value.
ドキュメント曰く、"type"
がないと"type":"object"
とみなされます。
さらに悪いことに、PropertyにUnmarshalJSONが実装されているのではなく、Propertyをフィールドに持つ各種のstructにデコードのコードが分散しているため、1か所直したフォーク版をメンテすればいいというものではないようです。
go-elasticsearchのMakefileを見る限り、makeの範疇でこの型の生成を行っているわけではないようです。どう直していいやらわからないためPRも書けません。困りましたね。
ハンドポート版の実装
幸いなことに生成元のtypescript定義は前述のとおりわかっていますし、go-elasticsearchの各ソースファイルに生成元の定義が載っています。
それさえわかれば後は単純なテキスト置換で実装しなおすことは自体は簡単そうです。
specというモジュールとして再実装しました。
こちらでは、ハンドポートであるのでPropertyにUnmarshalJSONが実装される形に変わっています。もちろん"type"
フィールドの不在もハンドルされています。
(ちなみにtypedapiの中にははhelper typeで実装したような(rangeのような)型の定義は含まれておらず、無駄な努力をしたわけではなさそうでした。よかったよかった。)
code generatorの実装
このセクションではcode generator考慮すべきことを述べます。
実装の詳細については述べません。今までのセクションと違い、詳細に説明しても、別段ElasticsearchやGoへの理解が深まるわけでもありませんので。GoやElasticsearchや周辺ライブラリの知識が深まりそうなところだけポイントとして説明します。
dynamic inheritance
mappingの"dynamic"
の値によって、そのfield dataがmapping.jsonに載っていない値を持てるかが決まります。
引用: https://www.elastic.co/guide/en/elasticsearch/reference/8.4/dynamic.html
Inner objects inherit the dynamic setting from their parent object.
とある通り、上位のオブジェクトから値を継承するため、再帰的な型の生成にはcontext情報が必要となります。
nestedも同じく"dynamic"
の値を継承します。これはElasticsearch 8.4.3相手に確認してあります。ドキュメントに明確に書かれてはいないですが「nestedは特殊版objectである」という記述はあります。
"dynamic":"strict"
以外はあればmapping.jsonに載っていないフィールドも受け付けるので、生成されるコードはこれをうまく格納できるフィールドとMarshalJSON
/ UnmarshalJSON
を実装する必要があります。
そこで、strict
以外の場合、AdditionalProps_ map[string]any
フィールドを追加し、MarshalJSONはこんな感じ、
UnmarshalJSONはこんな感じで生成されます
ポイントは
- MarshalJSONの結果であるJSONは
encoding/json
の挙動を模倣する-
<
,>
,&
のようなhtmlに使われるキーワードを\u003c
のようにunicode escapeする -
Plain
ならば、,omitempty
タグされているとき、reflect.Value#IsZero
判定を行ったうえでゼロならフィールドをスキップする -
Raw
ならば、IsUndefined
のときフィールドをスキップする -
AdditionalProps_
以外のフィールドは定義順に出力する-
mapping.json
解析時にsort.Strings
でソートされるのでフィールド名をascendingの順です。
-
-
AdditionalProps_
はキー名をsort.Strings
でソートした順序で出力。- 有名な話ですが
map[K]V
をrange
オペレータでイテレートするとき、ランダムな順序になるように仕様が定義されています。 -
encoding/json
はsort.Strings
でソートすることで結果をstableにします。
- 有名な話ですが
-
- 生成されるGo codeはGoのFieldDeclに従い、exportされている必要がある
- identifierになるように、letter以外をunicode escapeする
-
_
がprefixされているとき、exportフィールドにするために_
-suffixに変換する - Operators and punctuationを除くようにunicode escapeする
json.Marshal
の特定文字のunicode escapeとmap
のstable化の挙動はは以下のようになります。
// https://go.dev/play/p/qQdZ_FhJEUp
package main
import (
"encoding/json"
"fmt"
)
type Sample struct {
Foo string `json:"<foo>"`
}
func main() {
bin, _ := json.Marshal(Sample{Foo: "<bar>&&"})
fmt.Printf("%s\n", bin)
bin, _ = json.Marshal(map[string]string{"<foo>": "<bar>&&"})
fmt.Printf("%s\n", bin)
bin, _ = json.Marshal(map[string]string{"a": "", "A": "", "b": "", "B": "", "c": "", "C": ""})
fmt.Printf("%s\n", bin)
}
/*
{"\u003cfoo\u003e":"\u003cbar\u003e\u0026\u0026"}
{"\u003cfoo\u003e":"\u003cbar\u003e\u0026\u0026"}
{"A":"","B":"","C":"","a":"","b":"","c":""}
*/
見てのとおり、map
のキーはunicodeでascending
順です(asciiコード表で分かる通り'A' < 'a'
ですね)
unicode escapeされてても普通はdecode時にunescapeされるっぽいのであんまりこの辺は心配しなくても大丈夫です。すくなくともjavascriptは以下のようにunescape
してくれます。
# deno
Deno 1.32.4
exit using ctrl+d, ctrl+c, or close()
REPL is running with all permissions allowed.
To specify permissions, run `deno repl` with allow flags.
> JSON.parse(`{"\u003cfoo\u003e":"\u003cbar\u003e\u0026\u0026"}`)
{ "<foo>": "<bar>&&" }
mapping.json
に記載できる値は有効なjsonならば何でもよいのか、✨のようなemojiでも許されました(前記のとおり8.4のみで確認)。
しかし、GoにおけるFieldDeclはIdentifierListであり、identifierは
identifier = letter { letter | unicode_digit } .
です。つまり、unicodeのletter categoryと"_"
とnumber category以外はすべてescapeする必要があります。emojiはLetter categoryではないようです。
Goにはこの辺のことをする処理がぱっと調べた限りstrconv
にいろいろ実装されているのですが、strings.Builder
と組み合わせて使うには微妙に不都合なので適当に実装しなおしてあります。
エスケープ処理はstrconv
の中身を見て実装しなおしています。
utf8は4byteまであり得るので、2byte以下の場合は\u1234
、それ以上の場合は\u12345678
になるようにする以外はMSB
から順にhex encodeするといういつもの奴ですね。
激しい例ですと以下のようにコードを生成します
null/multi-valueを許容しない型を考慮する
Elasticsearchの各field data typeのふるまいで述べた通り、一部のfield data typeはnullやmulti-valueをがあるとパーズ時にエラーとなります。これらはユーザーが渡す設定値よりも優先されますので、そのような挙動を作ります。
そこで、内部的なfield data typeに対応する型を表すための型を作り、そこに上記の各性質を反映するようにフィールドを定義します。
code generatorはこれらに基づいてコードを生成します
特別なgeneratorを必要としないfield data typeについてはこのようにテーブルを定義し、そこでまとめて管理するように実装しました。
github.com/dave/jenniferによるcode generation
code generationはgithub.com/dave/jenniferを使用します。
text/templateを使わない理由
code generationを行うとき真っ先に思いつくのはtext/templateを使う方法です。実際一度は検討しました。
というか1度text/template
で同じようなコードを書いたことがあるんですよ
これ、見ただけで何してるかわかりますか?
これは、mapping.jsonのformat解析済みのデータからdate型の生成を行っています。
ほとんどは平叙なテキストなので、入力された値を取り扱う部分がなければを書いているかはわかりやすいです。
if
やrange
が二つネストするともうお手上げです。メンテする自信はありません。
text/template
を使う方法の問題点は
-
range
,if
などがネストするとよくわからなくなる - 改行や空白の制御がしにくい
- パラメータを渡すときにstructを定義する必要がある。
- 当然goのsyntax highlightがかからないので間違いに気づきにくい。
もちろんこれは、text/template
が複雑なgo codeの生成に使われる際、使う側のテクニックを要するというだけの話です。
text/template
の明確な良い点は
- 外部からtemplate textの入力を受け付けるような使い方ができる
- dockerや互換cliの
--format
オプションはtext/template
が認識する文字列です
- dockerや互換cliの
- go code以外にも使える
- templateから登録した任意の関数を呼び出せる。
などがあります。
ドキュメントが明確に述べる通り、data-drivenな使い道が主な用途でしょう。
github.com/dave/jennifer
awesome-goを見てみるとリストされているもので任意のgo codeの生成を行えるのはjenniferだけですね。
golang code generation
と検索して出てくるのもjenniferくらいのものです。
とりあえず使ってみましたが、使い心地がよくてAPIも一貫性があります。これ以上のものは探してもないかもしれません。
jenniferを使ったcode generation
上記のdate生成の部分をjenniferで書きなおすと以下のようになります。
うーんネストが深いですね。
実際に生成されるコードと記述順序を一致させようとするとネストが深くなりがちです。ただ、jenniferを利用するとGo codeのトークンと対応づいた名前の関数を順番に呼ぶだけなので、書きにくいと感じることはなかったです。分量が多くなるので書くのは大変です。コードなのでリファクタは簡単でした。
jenniferのcode generationレシピ
プルダウンの下でjenniferの使い方にいくらか触れます。書くだけ書いて、このセクション過剰に詳細かなと思えてきましたが、記事をいじくっていられる時間が無くなってきたのでとりあえずdetailsに隠します。
jenniferのcode generationレシピ
公式のREADME.mdが丁寧なので、読めばわかると思います。
最初に触ってすぐにはわからなかったことを書いていきます。これ別の記事に分けたほうがいいかな・・・
基本
メソッドチェーンで書いていきます。
// https://go.dev/play/p/8KuGlxMIjX3
package main
import (
"io"
"os"
"github.com/dave/jennifer/jen"
)
func main() {
f := jen.NewFile("main")
f.Func().Id("double").Params(jen.Id("v").Int()).Int().Block(
jen.Return(jen.Id("v").Op("*").Lit(int(2))),
).
Line()
f.Func().Id("main").Params().Block(
jen.Qual("fmt", "Println").Call(jen.Id("double").Call(jen.Lit(5))),
)
var out io.Writer = os.Stdout
if err := f.Render(out); err != nil {
panic(err)
}
}
出力されるコードは
package main
import "fmt"
func double(v int) int {
return v * 2
}
func main() {
fmt.Println(double(5))
}
*Tと書くとき
jen.Op("*").Id("T")
operatorはすべてOp()
です。何ならId("[]string")
や、Id("*time.Time")
でも問題ありません。
forを回しながらコードを生成する
forでsliceやmapをイテレートしながら値に基づいてコードを生成するには、jen.Do
もしくはjen.*Func
を呼び出します。
// https://go.dev/play/p/q-zgkBQwTQC
package main
import (
"bytes"
"crypto/rand"
"encoding/hex"
"io"
"os"
"github.com/dave/jennifer/jen"
)
func main() {
f := jen.NewFile("main")
f.Func().Id("main").Params().Block(
jen.Qual("fmt", "Println").Call(jen.Do(func(s *jen.Statement) {
for i := 0; i < 3; i++ {
buf := new(bytes.Buffer)
_, err := io.CopyN(buf, rand.Reader, 16)
if err != nil {
panic(err)
}
s.Id(`"` + hex.EncodeToString(buf.Bytes()) + `"`).Op(",")
}
})),
)
if err := f.Render(os.Stdout); err != nil {
panic(err)
}
}
/*
package main
import "fmt"
func main() {
fmt.Println("99efaa4504a933201846d83dce09967d", "6eec9aca8fb4c381e3ea5a96e5b4d75c", "575fa8f3d669d204071afb06575f0504")
}
*/
これはListFuncで書きなおしても同じコードが得られます。
- jen.Qual("fmt", "Println").Call(jen.Do(func(s *jen.Statement) {
+ jen.Qual("fmt", "Println").Call(jen.ListFunc(func(g *jen.Group) {
for i := 0; i < 3; i++ {
buf := new(bytes.Buffer)
_, err := io.CopyN(buf, rand.Reader, 16)
if err != nil {
panic(err)
}
- s.Id(`"` + hex.EncodeToString(buf.Bytes()) + `"`).Op(",")
+ g.Id(`"` + hex.EncodeToString(buf.Bytes()) + `"`)
}
})),
こんな感じで、Doの特化版がCustom/CustomFunc
, さらにそれぞれへの特化版がBlockFunc
, StructFunc
, ValuesFunc
...といった感じのようです。
以下mapping.json
を解析して収集したtypeId
からtype FooBar struct {...}
を生成するコードです。
Custom/CustomFuncをつかう
- Dictを使うと
map[Code]Code
であることからキー順序がソートされる - Valuesは改行を自動で入れない
ことからCustom
/CustomFunc
を使って以下のようにすると手動で.Line()
を呼ばなくていいぶん楽です。
多分こうするしかないかな?
// https://go.dev/play/p/wkkwsrH6H-p
package main
import (
"os"
"github.com/dave/jennifer/jen"
)
func main() {
f := jen.NewFile("main")
f.Type().Id("SampleTy").Struct(
jen.Id("Foo").String(),
jen.Id("Bar").Int(),
)
f.Func().Id("main").Params().Block(
jen.Qual("fmt", "Println").Call(
jen.Id("SampleTy").Values(jen.Dict{
jen.Id("Foo"): jen.Lit("foo"),
jen.Id("Bar"): jen.Lit(123),
}),
),
jen.Qual("fmt", "Println").Call(
jen.Id("SampleTy").Values(
jen.Id("Foo").Op(":").Lit("foo"),
jen.Id("Bar").Op(":").Lit(123),
),
),
jen.Qual("fmt", "Println").Call(
jen.Id("SampleTy").Custom(
jen.Options{Open: "{", Close: "}", Separator: ",", Multi: true},
jen.Id("Foo").Op(":").Lit("foo"),
jen.Id("Bar").Op(":").Lit(123),
),
),
)
if err := f.Render(os.Stdout); err != nil {
panic(err)
}
}
/*
package main
import "fmt"
type SampleTy struct {
Foo string
Bar int
}
func main() {
fmt.Println(SampleTy{
Bar: 123,
Foo: "foo",
})
fmt.Println(SampleTy{Foo: "foo", Bar: 123})
fmt.Println(SampleTy{
Foo: "foo",
Bar: 123,
})
}
*/
if err != nil ...を生成する
以下のよく書くやつを生成するには
if err != nil {
return nil, err
}
// https://go.dev/play/p/ms2qGw7Zn27
package main
import (
"os"
"github.com/dave/jennifer/jen"
)
func main() {
f := jen.NewFile("main")
f.Func().Id("foo").Params().Params(jen.Error(), jen.Error()).Block(
jen.Var().Defs(
jen.Err().Error(),
),
jen.If(jen.Err().Op("!=").Nil()).Block(
jen.Return(jen.Nil(), jen.Err()),
),
)
if err := f.Render(os.Stdout); err != nil {
panic(err)
}
}
/*
package main
func foo() (error, error) {
var (
err error
)
if err != nil {
return nil, err
}
}
*/
禁じ手: go codeを直接書く
禁じ手ですが、決まり切ったgo codeなのでjenniferのメソッドチェーン外で生成したい場合は
jen.
Line().
Line().
Id(`
func foo() {
fmt.Prinln("bar")
}
`,
).
Line().
Line()
とするとよいでしょう。
調べた限り生のstringをそのまま入力させてくれるAPIはないです。
Id()
は入力をそのまま出力するのでLine()
で改行を挟んでおけば任意のコードを書き込めます。
生成されるコード
以下に置かれたデータを入力に
以下のような方が出力されます。
テスト
以上のcomposeを使って、elasticsearch 8.4.3相手に
- mapping.jsonでindexを作れるか
- 作られたindexに生成された型のサンプル入力を格納できるか
- plain, raw両方に対して
-
null
やmulti-valueを許容しない型に対して、許容されない値を出力しないか
などをテストしてパスするのを確認しました。長かった・・・。
cli
cliからも呼び出せるように実行ファイルも作ってあります。
# go install github.com/ngicks/estype/cmd/genestype@latest
root@16cb5614efe3:/mnt/git/github.com/ngicks/estype# 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.
それぞれのフィールドの型名を決定する関数を渡すオプションがありませんが、ほかのオプションはわらせるようになりました。
サンプルの型もこれによって生成されています。
まとめ
part1で:
-
- Elasticsearchの概要について説明した
-
- Elasticsearchについて、JSONを格納したり引き出したりする場合に必要な知識を調査し、明示した
この記事で:
-
- 以下を達成する型を作るcode generatorを作成した
- mapping.jsonからindexに格納されたJSONを容易に生成/消費できる
- Plain / Rawと二つに分け、アプリの決定事項を反映した型、Elasticsearchが受け入れるすべての値からUnmarshalできる型とそれぞれする
- 相互を適切に変換するメソッドを設ける
-
"dynamic"
値が"strict"
以外の時にmapping.jsonに載っていない数値を格納できる
-
- 作成中に見つけたjenniferによるcode generationのポイントを述べた
おわりに
- これについて調べなかったら一生github.com/elastic/elasticsearch-specificationの存在を知らなかったかもしれないので、やってよかった
- github.com/dave/jenniferによるcode generationがすごく快適で、code generationいつでも任せてくださいって感じになれてうれしい。
今後の課題は
- 実際に使ってみて、使い勝手が悪いかなどを確かめる。
- 似たようなことをしてる人がいないことを祈る
- いた場合、そちらに貢献する
- いくつかオプションを追加する
- SkipRaw
- Omit
- これらによって
_source
でElasticsearch
が返すフィールドの量を減らしとき、それ用の型をそれぞれに生成できる
- QueryDSLのヘルパーも同様に生成する
- 今回の型生成に比べて見るべきmappingのパラメータが増えるので絶対に時間がかかる
-
Plain
にDiff(v Plain) Raw
を実装し、update APIのpartial updateで利用しやすくする
最近Elasticsearchをいじくる業務から離れてしまって使う機会が確保できるか微妙です。
まとめきれなくて取り留めのない感じになってしまったのが悔やまれます。誰かの役に立つ文章であることを祈ります。
Discussion