🚀

Goの自作パーサーライブラリでJSON・TOMLパーサーを作った話

2023/02/21に公開

はじめに

Goの自作パーサーコンビネーターライブラリ「takenoco」を使って、トイ言語を作ったり、まだ公開していないプロジェクトでパーサーを作ったりしているのですが、自分も活用できる実用的なサンプルを作ろうと思い、JSON・TOMLパーサーの作成を思いつきました。
完成したのがこちら「go-loose-json-parser」です。

https://github.com/shellyln/go-loose-json-parser

※作り始めたとき、JSONパーサーだけのサンプルにしようかと思っていたので、リポジトリ名にはTOMLが入っていません😖
Live demoはこちら ※wasmで動いています
https://shellyln.github.io/jsonlp/

Goには既に有名なTOMLパーサー(1),(2)もあるので、作成する必然性は無いのですが、車輪の再発明は楽しいですね。また、プロジェクトの依存関係が自家製なのは精神衛生上、大変良いです。

コンセプト

まず、最初に作り始めたJSONパーサーについて、パーサーライブラリの特性も考え、ゴリゴリにチューンして速度を狙うよりも、コメント付きや、キーがダブルクォーテーションで囲まれていないものを読めるようにしたいと思いました。JSONCJSON5を概ね包含するイメージですね。

設定ファイルにコメントを書きたい、というのもありますし、モックアップに直書きしてあるようなダミーデータをモックAPIサーバーに移そうと思ったときに楽に使えるものがあると良いと思った次第です。

さらに、他のスクリプト言語のリスト・マップリテラルをなるべく読み込めるようにしようと思い、キーワード(nullNaNInfinity 等)やキー・バリューの区切り文字を複数種類許すことを考えました。

TOMLに関しては、プリミティブやインラインのリスト・マップのパーサーをJSONと共通化しつつ、TOML 1.0 仕様のサンプルをパース出来ることを目標としました。
文字列についてはTOML準拠の新しいパーサーを作りつつ、JavaScriptのバックスラッシュ複数行文字列も輸入することにしました。
TOMLのドットで繋がったキー(foo.bar 等)は、JSONパーサーに輸入しました。

製作

パーサーライブラリへのフィードバック

以下のの汎用的なパースについて「takenoco」の機能としてフィードバックすることができました。

  • Unicodeの識別子、Unicode単語境界(ゼロ幅)
  • Date、DateTime、Time
    ※DateTimeについては、TOML仕様で厳密なISO8601拡張形式以外もパースする必要が生じたため、結局「go-loose-json-parser」ではカスタムなパーサーを採用しました。

まずは、Unicode識別子、単語境界について説明したいと思います。
以下のコードに登場する ID_Start, ID_Continue とは Unicode character properties の名前です。識別子の最初の文字と2文字目以降は汎用的にこうすると良いですよ、という仕様(多分)で、JavaScript等が参照しています。Goでは、ID_Start, ID_Continue は直接定義されていないので、構成するプロパティのOrを取っています。

// Parse the Unicode identifier
func UnicodeIdentifierStr() ParserFn {
    return Trans(
        FlatGroup(
            Once(First(
                // ID_Start + '_' + '$'
                CharClass("_", "$"),
                CharClassFn(func(c rune) bool {
                    // ID_Start: Alpha(), and ...
                    return (unicode.Is(unicode.L, c) ||
                        unicode.Is(unicode.Nl, c) ||
                        unicode.Is(unicode.Other_ID_Start, c)) &&
                        !unicode.Is(unicode.Pattern_Syntax, c) &&
                        !unicode.Is(unicode.Pattern_White_Space, c)
                }),
            )),
            ZeroOrMoreTimes(First(
                // ID_Continue + '$' + U+200C + U+200D
                CharClass("$"),
                CharClassFn(func(c rune) bool {
                    // Alnum(), '_', and ...
                    return (unicode.Is(unicode.L, c) ||
                        unicode.Is(unicode.Nl, c) ||
                        unicode.Is(unicode.Other_ID_Start, c) ||
                        unicode.Is(unicode.Mn, c) ||
                        unicode.Is(unicode.Mc, c) ||
                        unicode.Is(unicode.Nd, c) ||
                        unicode.Is(unicode.Pc, c) ||
                        unicode.Is(unicode.Other_ID_Continue, c) ||
                        c == 0x0200c || c == 0x0200d) &&
                        !unicode.Is(unicode.Pattern_Syntax, c) &&
                        !unicode.Is(unicode.Pattern_White_Space, c)
                }),
            )),
        ),
        Concat,
        ChangeClassName(clsz.IdentifierStr),
    )
}

次に、Date、DateTime、Timeですが、ここでは代表してTOML用にカスタマイズしたDateTimeで説明したいと思います。これは苦労が見て取れますね。
Goの time.Parse(layout, value) において、日付書式を示す layout はすべて固定です。
ISO8601において、秒や小数点以下はオプショナルかつ可変長、また、タイムゾーンもZとオフセット指定が許されますが、time.Parse が期待するのは常に固定の書式です (json.Unmarshal する裏技もありますが)。パース結果を固定フォーマットに合致するように変換しています。
※パーサーは5桁以上、またはマイナスの年を切り出していますが、残念ながら time.Parse が対応していません。

// Parse the ISO 8601 datetime string.
// (yyyy-MM-ddThh:mmZ , ... , yyyy-MM-ddThh:mm:ss.fffffffffZ)
// (yyyy-MM-ddThh:mm+00:00 , ... , yyyy-MM-ddThh:mm:ss.fffffffff+00:00)
func dateTimeStr() ParserFn {
    return Trans(
        FlatGroup(
            ZeroOrOnce(Seq("-")),
            Repeat(Times{Min: 4, Max: -1}, Number()),
            Seq("-"),
            CharRange(RuneRange{Start: '0', End: '1'}),
            CharRange(RuneRange{Start: '0', End: '9'}),
            Seq("-"),
            CharRange(RuneRange{Start: '0', End: '3'}),
            CharRange(RuneRange{Start: '0', End: '9'}),
            First(
                Seq("T"),
                // TOML allows Datetime format with date and time delimited by space
                // (RFC 3339 section 5.6)
                FlatGroup(
                    erase(Seq(" ")),
                    Zero(Ast{
                        Type:  AstType_String,
                        Value: "T",
                    }),
                ),
            ),
            CharRange(RuneRange{Start: '0', End: '2'}),
            CharRange(RuneRange{Start: '0', End: '9'}),
            Seq(":"),
            CharRange(RuneRange{Start: '0', End: '5'}),
            CharRange(RuneRange{Start: '0', End: '9'}),
            First(
                FlatGroup(
                    Seq(":"),
                    CharRange(RuneRange{Start: '0', End: '6'}),
                    CharRange(RuneRange{Start: '0', End: '9'}),
                    First(
                        FlatGroup(
                            Seq("."),
                            Trans(
                                Repeat(Times{Min: 1, Max: 9}, // 3: milli, 6: micro, 9: nano
                                    CharRange(RuneRange{Start: '0', End: '9'}),
                                ),
                                Concat,
                                func(ctx ParserContext, asts AstSlice) (AstSlice, error) {
                                    return AstSlice{{
                                        Type:  AstType_String,
                                        Value: (asts[len(asts)-1].Value.(string) + "000000000")[0:9],
                                    }}, nil
                                },
                            ),
                        ),
                        Zero(Ast{
                            Type:  AstType_String,
                            Value: ".000000000",
                        }),
                    ),
                ),
                Zero(Ast{
                    Type:  AstType_String,
                    Value: ":00.000000000",
                }),
            ),
            First(
                FlatGroup(
                    erase(Seq("Z")),
                    Zero(Ast{
                        Type:  AstType_String,
                        Value: "+00:00",
                    }),
                ),
                FlatGroup(
                    CharClass("+", "-"),
                    Repeat(Times{Min: 2, Max: 2},
                        CharRange(RuneRange{Start: '0', End: '9'}),
                    ),
                    Seq(":"),
                    CharRange(RuneRange{Start: '0', End: '5'}),
                    CharRange(RuneRange{Start: '0', End: '9'}),
                ),
                FlatGroup(
                    // TOML allows Datetime format without timezone
                    Zero(Ast{
                        Type:  AstType_String,
                        Value: "+00:00",
                    }),
                ),
            ),
        ),
        Concat,
        ChangeClassName(class.DateTimeStr),
    )
}

time.Parse にISO8601のプリセットが欲しいですね。
以下はGo 1.20の time パッケージのConstantsです。DateTime, DateOnly, TimeOnly が追加になったそうですが、「違う、そうじゃない」!

const (
	Layout      = "01/02 03:04:05PM '06 -0700" // The reference time, in numerical order.
	ANSIC       = "Mon Jan _2 15:04:05 2006"
	UnixDate    = "Mon Jan _2 15:04:05 MST 2006"
	RubyDate    = "Mon Jan 02 15:04:05 -0700 2006"
	RFC822      = "02 Jan 06 15:04 MST"
	RFC822Z     = "02 Jan 06 15:04 -0700" // RFC822 with numeric zone
	RFC850      = "Monday, 02-Jan-06 15:04:05 MST"
	RFC1123     = "Mon, 02 Jan 2006 15:04:05 MST"
	RFC1123Z    = "Mon, 02 Jan 2006 15:04:05 -0700" // RFC1123 with numeric zone
	RFC3339     = "2006-01-02T15:04:05Z07:00"
	RFC3339Nano = "2006-01-02T15:04:05.999999999Z07:00"
	Kitchen     = "3:04PM"
	// Handy time stamps.
	Stamp      = "Jan _2 15:04:05"
	StampMilli = "Jan _2 15:04:05.000"
	StampMicro = "Jan _2 15:04:05.000000"
	StampNano  = "Jan _2 15:04:05.000000000"
	DateTime   = "2006-01-02 15:04:05"
	DateOnly   = "2006-01-02"
	TimeOnly   = "15:04:05"
)

TOMLにおけるテーブルのパース

TOMLには、「テーブル」と「テーブルの配列」という構造があります。
テーブルはJSONにおけるオブジェクト、テーブルの配列はオブジェクトの配列に相当します。それぞれを自由にネストすることができます。

[table-A]
item-a = 1
item-b = 2

[table-A.sub-table-P]
item-m = 11
item-n = 12
sub-table-X.sub-table-Y = 111

[[array-of-table-U]]
foo = 1
[array-of-table-U.sub-table-V]
bar = 11

[[array-of-table-U]]
foo = 2
[array-of-table-U.sub-table-V]
bar = 22

一見難しそうですが、それぞれの識別子の階層について、最後に登場したインスタンスを保持しておけばよい (以下のソースでは lastRefs) という、パーサーに優しい仕様となっています。

func tableTransformer(ctx ParserContext, asts AstSlice) (AstSlice, error) {
    length := len(asts)
    v := make(map[string]interface{})

    lastRefs := make(map[string]*map[string]interface{})
    lastRefs[""] = &v

    for i := 0; i < length; i += 2 {
        switch w := asts[i].Value.(type) {
        case string:
            // Simple identifier
            merged := false

            if asts[i+1].ClassName == class.TomlArrayOfTable {
                var a2 []map[string]interface{}
                if tmp, ok := v[w].([]map[string]interface{}); ok {
                    a2 = tmp
                } else {
                    // Register (case of v[w] == nil) or overwrite
                    // NOTE: If overwrite, it is invalid TOML
                    a2 = make([]map[string]interface{}, 0, 8)
                    v[w] = a2
                }
                if m1, ok := asts[i+1].Value.(map[string]interface{}); ok {
                    dottedKey := makeDottedKeyForSimpleName(w)
                    lastRefs[dottedKey] = &m1
                    a2 = append(a2, m1)
                    v[w] = a2
                }
            } else {
                if m1, ok := asts[i+1].Value.(map[string]interface{}); ok {
                    dottedKey := makeDottedKeyForSimpleName(w)
                    lastRefs[dottedKey] = &m1
                    if m2, ok := v[w].(map[string]interface{}); ok {
                        // Merge redefined table
                        // NOTE: Possibly an invalid TOML (except in cases such as `[x.y.z] ... [x]`)
                        for xKey, xVal := range m1 {
                            m2[xKey] = xVal
                        }
                        merged = true
                    }
                }
                if !merged {
                    v[w] = asts[i+1].Value
                }
            }

        case []string:
            // Dotted identifier
            for j, key := range w {
                if j == len(w)-1 {
                    merged := false
                    table := lastRefs[makeDottedKey(w, j)]

                    if asts[i+1].ClassName == class.TomlArrayOfTable {
                        var a2 []map[string]interface{}
                        if tmp, ok := (*table)[key].([]map[string]interface{}); ok {
                            a2 = tmp
                        } else {
                            // Register (case of (*table)[key] == nil) or overwrite
                            // NOTE: If overwrite, it is invalid TOML
                            a2 = make([]map[string]interface{}, 0, 8)
                            (*table)[key] = a2
                        }
                        if m1, ok := asts[i+1].Value.(map[string]interface{}); ok {
                            dottedKey := makeDottedKey(w, j+1)
                            lastRefs[dottedKey] = &m1
                            a2 = append(a2, m1)
                            (*table)[key] = a2
                        }
                    } else {
                        if m1, ok := asts[i+1].Value.(map[string]interface{}); ok {
                            dottedKey := makeDottedKey(w, j+1)
                            lastRefs[dottedKey] = &m1
                            if m2, ok := (*table)[key].(map[string]interface{}); ok {
                                // Merge redefined table
                                // NOTE: Possibly an invalid TOML
                                //       (except in cases such as `[x.y.z] ... [x.y]`)
                                for xKey, xVal := range m1 {
                                    m2[xKey] = xVal
                                }
                                merged = true
                            }
                        }
                        if !merged {
                            (*table)[key] = asts[i+1].Value
                        }
                    }
                } else {
                    prev := lastRefs[makeDottedKey(w, j)]
                    dottedKey := makeDottedKey(w, j+1)
                    if _, ok := lastRefs[dottedKey]; ok {
                        // Already registerd to lastRefs
                    } else {
                        // Not registerd to lastRefs
                        if cur, ok := (*prev)[key]; ok {
                            switch next := cur.(type) {
                            case map[string]interface{}:
                                // Register
                                lastRefs[dottedKey] = &next
                            default:
                                // Overwrite
                                // NOTE: it is invalid TOML
                                table := make(map[string]interface{})
                                (*prev)[key] = table
                                lastRefs[dottedKey] = &table
                            }
                        } else {
                            // Append
                            table := make(map[string]interface{})
                            (*prev)[key] = table
                            lastRefs[dottedKey] = &table
                        }
                    }
                }
            }
        }
    }
    return AstSlice{{
        ClassName: class.Object,
        Type:      AstType_Any,
        Value:     v,
    }}, nil
}

Unmarshalとリフレクション

リフレクションってビジネスロジックを組み立てるような場面では中々登場しないですよね。標準ライブラリの database/sql 等もリフレクションを使用しているので、日常的にお世話になっているのですけど。
ということで、普段リフレクションの知識はあまり積み上がらないのですが、今回はリフレクションが主役です。
Unmarshaller は大雑把に言えば、シリアル化または共通の表現形式に変換されたデータからオブジェクトを復元する機能です。
標準の encoding/jsonUnmarshal のシグネチャは以下の通りです。data にソースとするJSONのUTF-8バイト列、v にデコード先の struct, []any, map[string]any 等のポインタを渡します。

func Unmarshal(data []byte, v any) error

今回、私はパーサーとUnmarshallerを分離しました。パーサーは常に []any, map[string]any またはプリミティブにデコードします。
そして Unmarshal のシグネチャは次のようにしました。from が変換元の []any, map[string]any またはプリミティブ、to がマッピング先の struct 等のポインタです。
パーサーとUnmarshallerを分離した理由は、[]any, map[string]any から struct への変換関数が以前から欲しいと思っていたため、また、パーサーを単純化するためです。

func Unmarshal(from interface{}, to interface{}, opts *MarshalOptions) error

Unmarshal の中では、from/to それぞれの型とデータを調べて、配下のフィールドを取得したり、値をキャストしたりしているのですが、それらを行う前にしなければならないことがあります。

from

reflect.Value

  • IsValid() である
    また、値を取る前には
  • CanInterface() である
    ことが必要です。

何を言っているのか分からないと思いますのでそれぞれ説明していきます。

IsValid reflect.Value 自身がゼロ値ではないことを示します。 reflect.Value の指す先がゼロ値の意味ではないことに注意が必要です。ゼロ値の reflect.Value を得るのは例えば、マップの要素を取得する MapIndex(key) で存在しないキーを指定した場合などです。
IsValidfalse の場合、文字列表現を得る String() 以外のすべてのメソッドが失敗します。

CanInterface は名前の通り reflect.Value の指す値を any として取れるかどうかをテストする関数ですが、これが false を返すのは、struct 等のフィールドがエクスポートされていない (小文字から始まるフィールド) 場合などです。 CanInterfacefalse の場合、unsafe を介さずに値を取得することができません。 CanInterface というのは実は フィールドの可視性をテストしてるのです。内部的には flagRO というフラグをテストしてます。
CanInterfacefalse でも配下の型をリフレクトすることはできます。

to

reflect.Value

  • IsValid() である
    また、値を取る前には
  • CanSet() である
    ことが必要です。

CanSetreflect.Value の値が変更可能かどうかをテストします。CanSetCanAddr() かつ CanInterface() と等価です。つまり、フィールドがエクスポートされており、アドレスを持っている (つまり、lvalue (左辺値) である) ということです。内部的には flagAddrflagRO というフラグ両方をテストしてます。

reflect パッケージのコメントには次のように記載されています。

// The next set of bits are flag bits:
//	- flagStickyRO: obtained via unexported not embedded field, so read-only
//	- flagEmbedRO: obtained via unexported embedded field, so read-only
//	- flagIndir: val holds a pointer to the data
//	- flagAddr: v.CanAddr is true (implies flagIndir and ptr is non-nil)
//	- flagMethod: v is a method value.

なお、テンポラリとして CanSetreflect.Value を作るには、関数内で定義した変数のポインタをreflect.ValueOf() に渡しても上手くいきません。次のようなトリックが必要です。(理解が足りず、なぜ前者では駄目なのか説明できません。ValueOfの内部でヒープへのエスケープをしているんですが……なぜでしょう?)

s := make([]float64, 1)
rvDest := reflect.ValueOf(s).Index(0)

CanInterfaceCanSetfalse でフィールドにアクセスできない場合でも、より上の階層で struct 全体を Set すれば直接は見れなくても値を取得/更新することはできます。

case reflect.Struct:
        switch rvFrom.Kind() {
        case reflect.Struct:
            length := rvTo.NumField()
            if rtTo == rvFrom.Type() {
                // shallow copy all the fields
                if !ctx.opts.NoCopyUnexportedFields {
                    rvTo.Set(rvFrom)
                }
            }
            // deep copy
            for i := 0; i < length; i++ {
                rtDestField := rtTo.Field(i)
                destFieldName := rtDestField.Name
                if err := unmarshalCore(rvFrom.FieldByName(destFieldName), rvTo.Field(i), ctx, false); err != nil {
                    return err
                }
            }

デモの作成

Live demo は生の html、JavaScript でビルド無しで作っています。wasm にビルドした関数を呼んで結果を表示するだけですからね。
エディタ部分には CodeMirror 5 を使用しています。CDNから読み込むだけで使えます。このような簡易なツールを作る際のテキストエリアに大変おすすめです!

<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/codemirror/5.65.11/codemirror.min.css"
    integrity="sha512-uf06llspW44/LZpHzHT6qBOIVODjWtv4MxCricRxkzvopAlSWnTf6hpZTFxuuZcuNE9CBQhqE0Seu1CoRk84nQ=="
    crossorigin="anonymous" referrerpolicy="no-referrer" />
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/codemirror/5.65.11/theme/dracula.min.css"
    integrity="sha512-gFMl3u9d0xt3WR8ZeW05MWm3yZ+ZfgsBVXLSOiFz2xeVrZ8Neg0+V1kkRIo9LikyA/T9HuS91kDfc2XWse0K0A=="
    crossorigin="anonymous" referrerpolicy="no-referrer" />
<script src="https://cdnjs.cloudflare.com/ajax/libs/codemirror/5.65.11/codemirror.min.js"
    integrity="sha512-rdFIN28+neM8H8zNsjRClhJb1fIYby2YCNmoqwnqBDEvZgpcp7MJiX8Wd+Oi6KcJOMOuvGztjrsI59rly9BsVQ=="
    crossorigin="anonymous" referrerpolicy="no-referrer"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/codemirror/5.65.11/mode/javascript/javascript.min.js"
    integrity="sha512-Cbz+kvn+l5pi5HfXsEB/FYgZVKjGIhOgYNBwj4W2IHP2y8r3AdyDCQRnEUqIQ+6aJjygKPTyaNT2eIihaykJlw=="
    crossorigin="anonymous" referrerpolicy="no-referrer"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/codemirror/5.65.11/mode/toml/toml.min.js"
    integrity="sha512-O6QYGRZVa6FLNVTWPdLXXiVC4pNCVA6NNVW29i7qHFprs/Wat4QrjSKjINcoCT+2IySTFmfIoIp2yZmWoafQsg=="
    crossorigin="anonymous" referrerpolicy="no-referrer"></script>

さいごに

パーサーを自分で作れるようになると、プログラミングで出来ることの幅が広がります。
皆さんもぜひ、自分に合ったパーサーライブラリを見つけて (または自作して)、プログラミングを楽しんでくださいね。

追記 (2023/02/23)

📢本ライブラリが使用している自作パーサーコンビネーター「takenoco」について🔰入門記事🔰を書きました。よろしければご覧ください

Discussion