log.pocka.io

Zig v0.11 での JSON の取り扱い

Created at
Updated at

Zig で JSON ドキュメントをパース・シリアライズしようとしてハマったのでメモ。 std.json はソースコードドキュメントもテストもろくにないため Issue なんかを調べる羽目になった。

プリミティブのパース・シリアライズは問題ないが、オブジェクト周りに関してははっきり言ってデザインがよろしくない。 現実的な JSON ドキュメント、特に既存のスキーマを扱う場合は以下のような追加実装が必ず必要になってくるだろう。

Tagged Union

const T = union(enum) {
    string: []const u8,
    number: i64,
};

v0.10 では上記のコードの T は文字列もしくは数値の JSON ドキュメントに対応する。

"Foo" // OK
100 // OK
true // NG

しかし v0.11 で union のパース・シリアライズが大きく変更され、 { "<タグ名>": データ } という JSON ドキュメントにしなくてはならない。

{ "string": "Foo" } // OK
{ "number": 100 } // OK
"Foo" // NG
100 // NG

v0.10 で扱えていた一般的なユニオン型表現に対応するためには pub fn jsonParsepub fn jsonParseFromValuepub fn jsonStringify を対象の union (上記の例だと T) に実装しなくてはならない。

const T = union(enum) {
    string: []const u8,
    number: i64,

    pub fn jsonStringify(self: *const @This(), jw: anytype) !void {
        try switch (self.*) {
            .string => |str| jw.write(str),
            .number => |num| jw.write(num),
        };
    }

    pub fn jsonParse(
        _: mem.Allocator,
        source: anytype,
        _: json.ParseOptions,
    ) json.ParseError(@TypeOf(source.*))!@This() {
        const value = try json.innerParse(json.Value, allocator, source, options);

        return @This().jsonParseFromValue(allocator, value, options);
    }

    // 間接的に呼ばれない場合は jsonParse に全部書いちゃってもいい
    pub fn jsonParseFromValue(
        allocator: mem.Allocator,
        value: json.Value,
        options: json.ParseOptions,
    ) json.ParseFromValueError!@This() {
        return switch (value) {
            .string => |v| @This(){ .string = v },
            .integer => |v| @This(){ .number = v },
            else => error.UnexpectedToken,
        };
    }
};

イメージとしては AST のノードを受け取って許容できれば実体を返し、できなければエラーを返す、といった感じ。

任意フィールド

値がない場合に null ではなくフィールド自体がない場合、構造体フィールドに初期値を指定すればパースはされる。

const User = struct {
    name: []const u8,
    email: ?[]const u8 = null,
};
{ "name": "Alice", "email": "alice@example.com" } // OK
{ "name": "Bob" } // OK

しかしこのままシリアライズするとフィールドに null がセットされてしまう。

{ "name": "Bob", "email": null }

構造体フィールドの値が null の場合に JSON のフィールドを省略したい場合は pub fn jsonStringify を構造体に実装する必要がある。

const User = struct {
    name: []const u8,
    email: ?[]const u8 = null,

    pub fn jsonStringify(self: *const @This(), jw: anytype) !void {
        try jw.beginObject();

        try jw.objectField("name");
        try jw.write(self.name);

        if (self.email) |email| {
            try jw.objectField("email");
            try jw.write(email);
        }

        try jw.endObject();
    }
};

かなりプリミティブな内容を書くことになるが、出力される JSON を細かくチューニングできるとも言えるだろう。 AST を扱うパース処理と近いが、こちらはどちらかというとトークンを手続き的に積み上げていくイメージだろう。

任意フィールドや Tagged Union だけでなく、例えば boolean を 1 | 0 で表現するような 💩 JSON ドキュメントを扱う際にもこれらは役に立つ。

参考リンク