エムスリーテックブログ

エムスリー(m3)のエンジニア・開発メンバーによる技術ブログです

Goによる動的なJSON生成パターンの比較検討~Elasticsearch Query生成機構を求めて~

こちらの記事はGo アドベントカレンダー2021の8日目の記事です。

qiita.com

エムスリーエンジニアリンググループ AI・機械学習チームでソフトウェアエンジニアをしている中村(@po3rin) です。好きな言語はGo。情報検索系の話が好物です。

弊社の検索基盤ではElasticsearchをGoから叩いています。ElasticsearchのクエリはJSONになるので、ユーザーからのHTTPリクエストから巨大JSONを動的に生成する処理が発生します。これをどのように実装するかはさまざまなパターンがあります。今回はElasticsearchのクエリ生成を例に、JSON生成パターンをまとめて検討していきます。

Elasticsearch Query生成問題

なぜ、Elasticsearchのクエリを生成するのが問題なのでしょうか?例えば下記のような基本的なクエリを生成する例を考えます。

{
    "query": {
        "bool": {
            "should": [
                {
                    "multi_match": {
                        "fields": [
                            "title"
                        ],
                        "query": "アレルギー"
                    }
                },
                {
                    "multi_match": {
                        "fields": [
                            "title"
                        ],
                        "query": "ばなな"
                    }
                }
            ]
        }
    },
    "sort": [
        {
            "post_date": {
                "format": "strict_date_optional_time_nanos"
            }
        }
    ],
    "size": 8
}

こちらの例はユーザーのAPIを叩くときのクエリから動的に生成する必要があります。sizeや、shouldの内容を生成したりする必要はもちろん、sortフィールドをつけるかつけないかなどの判断をコード上で行う必要があります。つまり、今回はリストの繰り返しや条件分岐などの処理でJSONを生成する必要がある場合を考えます。

実際にエムスリーでは300行超えのJSONクエリを生成する必要があります。ここからはこのようなJSONを動的に生成する方法を検討していきます。

構造体からのJSON生成

まず最初に思い浮かぶのは構造体からのJSONでしょうか。構造体を介してJSONを生成する場合、コード内に巨大JSON分の構造体を定義することになります。

type ESQuery struct {
    Qyery Query `json:"query"`
    Sort  Sort  `json:"sort"`
    Size  int   `json:"size"`
}

type Query struct {
    Should []Should `json:"should"`
}

type Should struct {
    MultiMatch MultiMatch `json:"multi_match"`
}

type MultiMatch struct {
    Query  string `json:"query"`
}

// このような構造体を大量に定義...

func buildQuery(keywords []string, size int, sort bool) string {
    esQuery := genShouldQueries(keywords, size, sort) // EsQueryに値を詰める
    q, err := json.Marshal(esQuery) // 構造体からJSONに
    if err != nil {
        log.Fatal(err)
    }
    fmt.Println(string(q))
}

この方法のメリットは型をしっかり定義できるので、安心感があります。そして、Goの基本的なJSON生成方法に乗っ取れるので学習コストが少ないです。

一方でデメリットは、生成するJSONを大きく変更したいときに、構造体ベースで欲しいJSONと比較しながら構造体を定義、変更していく必要があることです。小さなJSONの場合は良いですが、Elasticsearchnのクエリなどの巨大JSONではフィールド数が多く定義する構造体が非常に多くなります。また、構造体はぱっと見でどんなJSONが生成されるのかわかりづらいという欠点があります。

ちなみに構造体定義は巨大JSONの場合は時間がかかるので、json-to-goなど、完成系のJSONから構造体を生成してくれるツールを運用すると便利です。

golang.hateblo.jp

map[string]interface

エムスリーでは現在mapを利用しています。下記のようにmap[string]intrface{}でJSONの構造体を定義していきます。

func buildQuery(keywords []string, size int, sort bool) string {
    return map[string]interface{}{
        "query": map[string]interface{}{
            "should": []map[string]interface{}{
                {
                    "malti_match": map[string]interface{
                        "fields": []string["title"],
                        "query": query,
                    },
                },   
            },
        },
    }
}

この方法のメリットとしては、こちらの方が構造体と比べてフィールドの階層がぱっと見で確認できるので、どんな最終結果になるかがコードからある程度わかりやすい点が挙げられます。 デメリットとしては、map[string]interface{}が少し冗長です。書くのが非常に面倒なので、開発コストが上がります。クエリを大きく変更するときはmap[string]interface{}を書くのが結構面倒です。

http/templateパッケージ

html/templateパッケージでJSONを定義するのはどうでしょうか?tmp.jsonというファイルを定義してGoで読み込んで利用します。tmp.jsonという名前で下記のようなGo template構文を使ったJSONを書きます。

{
    "query": {
        "bool": {
            "should": [
                {{range .}} {
                    "fileds": ["title"],
                    "name": "{{.Query}}"
                    }{{.Comma}}
                {{end}}
            ]
        }
    },
    {{if .UseDataSort}}
    "sort": [
        {
            "post_date": {
                "format": "strict_date_optional_time_nanos"
            }
        }
    ],
    {{ end }}
    "size": {{.Size}}
}

このテンプレートファイルに値をGoから埋めていきます。

tynpe Query struct {
    ShouldQueries []ShouldQuery
    Size int
    UseDateSort bool
}

type ShouldQuery struct {
    Query string
    Comma string // 配列の最後のitemの場合は空文字にする必要がある
}

func buildQuery(keywords []string, size int, sort bool) string {
    b, err := ioutil.ReadFile("./tmp.json")
    if err != nil {
        log.Fatal(err)
    }

    tpl, err := template.New("").Parse(string(b))
    if err != nil {
        log.Fatal(err)
    }

     query := genShouldQueries(keywords, size, sort) // Queryに値を詰める

    buf := &bytes.Buffer{}
    if err := tpl.Execute(buf, query); err != nil {
        log.Fatal(err)
    }

    fmt.Println(buf.String())
}

ここでのポイントは、JSON内に配列がある場合はCommaフィールドで最後の要素にカンマを入れることをこちらから指示しないといけません。

templateのメリットとして、構造体を全て定義せずに必要な箇所の構造体だけを定義できるのでコードは少しシンプルになります。

一方デメリットはでGoの構文が書けないといけないので、少し学習障壁があります。また、templateファイルのformatが効かないことが非常に苦しいです。JSONのフォーマットを正しく行おうとすると非常に苦しいので、もしこの方法を採用するなら完成系のJSONファイルをベースにtemplateを作っていくのをお勧めします。

CUEパッケージ

最後に検討するのはCUEというパッケージを使ったJSON生成です。CUEは、オープンソースのデータ検証言語および推論エンジンです。 データ検証、データのテンプレート化、設定、クエリ、コード生成、さらにはスクリプト化など、多くの機能を備えています。CUE の 更なる良さとして Go とのスムーズな連携が挙げられます。

過去にも私がCUEについて紹介した記事があるので興味のある方はぜひこちらをご覧ください。今回は過去の記事で紹介しなかった繰り返し処理などもこのあと紹介します。

po3rin.com

まずはCUEファイルを作成します。

queries: [...]
useDataSort: bool
size: int

{
    "query": {
        "bool": {
            "should": [
                for q in (queries) {
                    {
                        "fields": [
                            "title",
                        ]
                        "query": (q)
                    }
                }]
        }
    }
    if useDataSort {
        "sort": [
            {
                "post_date": {
                    "format": "strict_date_optional_time_nanos"
                }
            },
        ]
    }
    "size": (size)
}

CUEのポイントとして、型が定義できます。今回はリスト、真偽値、intを利用してます。http/templateに比べて、カンマの管理が不要であったり、構文がシンプルだったりします。YAMLやJSONを生成するならCUEは完全にhttp/templateの上位互換です。

このCUEファイルを読み込んで値を埋めていきます。実装としては下記のようになります。

package main

import (
    // ...
    "cuelang.org/go/cue"
)

func main() {
    b, _ := ioutil.ReadFile("./query.cue")
    config := string(b)

    var r cue.Runtime

    ins, _ := r.Compile("test", config)
    ins, _ = ins.Fill([]string{"バナナ", "アレルギー"}, "queries")
    ins, _ = ins.Fill(true, "useDataSort")
    ins, _ = ins.Fill(3, "size")

    json, _ := ins.Value().MarshalJSON()
    fmt.Println(string(json))
}

CUEのメリットとして、JSONなどのデータ構造をサポートしているので、http/templateパッケージに比べてformatが効きます。

$ cue fmt query.cue

また、最終系もcueファイルを見ればほとんどJSONの形になっているので最終系が理解がしやすいです。

デメリットは新しいツールを導入するときの学習コストです。CUEの構文を学ぶ必要があるので、新しいチームメンバーがガンガン入ってくる状況だとキャッチアップに時間がかかるかもしれません。

比較

ここまで紹介した方法を超主観で5段階でまとめます。

JSON生成方法 完成形のわかりやすさ 運用しやすさ とっつきやすさ
構造体 1 2 (json-to-goなどで改善可能) 4
map[string]interface 2 3 4
http/template 4 1 (formatできないのが苦しい) 3
CUE 4 5 (formatも可能) 2 (CUEというツールを新しくキャッチアップする必要あり)

完成形のわかりやすさは出来上がるJSONがコードやテンプレートから理解しやすいかです。 個人的にはhttp/templateのJSONの書きっぷりがかなり辛かったので、巨大JSONの場合はhttp/template以外の採用をお勧めします。個人的にはElasticsearchレベルの大きなJSONならCUEの採用もありだと思います。弊社ではCUEの導入コストが高いと判断して、map[string]interface{}によるクエリ生成を行なっています。

JSON生成のテスト

巨大JSONを扱うときはこれまでに紹介したどのパターンでもJSONの完成形を必ずテストしておきましょう。 完成形jsonファイルを定義してgolden files testを行うことで最終生成結果を確認できるようにしておく必要があります。diffだけでなく、コード更新時のgolden fileのアップデートをサポートしておくと非常に開発がしやすくなります。

golden files testに関しては下記が詳しいです。

medium.com

おまけ: olivere/elastic

実はElasticsearchのクエリを作るだけならolivere/elasticという便利なパッケージがあります。こちらはGoのElasticsearchクライアントパッケージであり、クエリの組み立てメソッドも提供しています。

github.com

下記は公式からの引用コードですが、メソッド呼び出しで簡単にクエリのJSONを作れることがわかります。

// Search with a bool query
query := elastic.NewBoolQuery()
query = query.Must(elastic.NewTermQuery("user", "olivere"))
query = query.Filter(elastic.NewTermQuery("account", 1))
src, err := query.Source()
if err != nil {
  panic(err)
}
data, err := json.MarshalIndent(src, "", "  ")
if err != nil {
  panic(err)
}
fmt.Println(string(data))
// Output:
// {
//   "bool": {
//     "filter": {
//       "term": {
//         "account": 1
//       }
//     },
//     "must": {
//       "term": {
//         "user": "olivere"
//       }
//     }
//   }
// }

メリットはElasticsearch特化な点であり、簡単なJSONクエリなら数行で作成できます。

一方デメリットとして、コードを見るだけだと階層構造がすぐに理解できないので、大きなJSONだと少し把握に時間がかかります。また、ネストが深いクエリだとメソッド呼び出しが非常に多くなります。完成形が把握しやすいテンプレート系手法が好みの方は合わないかもしれません。

まとめ

今回は巨大JSONを動的に生成する方法を検討していきました。Elasticsearch Queryのような複雑なものになると、繰り返しや、条件分岐などの処理でJSONを生成する必要があるので、今回紹介したような工夫が必要です。みなさんのJSON生成の仕方でもっと良い方法があればぜひ教えてください!

We're hiring !!!

エムスリーではGoで検索&推薦基盤の開発&改善を通して医療を前進させるエンジニアを募集しています! 社内では日々検索や推薦についての議論が活発に行われています。

「ちょっと話を聞いてみたいかも」という人はこちらから! jobs.m3.com