go-json-experiment / json

Experimental implementation of a proposed v2 encoding/json package
BSD 3-Clause "New" or "Revised" License
341 stars 11 forks source link

How to piecemeal stream write/encode a single json object? #23

Open veqryn opened 3 months ago

veqryn commented 3 months ago

I would like to piecemeal construct a single json object or array. Ideally, I would like to be able to clone the Encoder at any point, so that I can have multiple versions of the partially finished json. The main use case right now, is a slog.Handler that will be able to partially write the json as attributes are added, so that it doesn't need to fully marshal all of the attributes every time (similar to the built-in slog handlers, but using this json v2 library so I can take advantage of the new encoder options like SpaceAfterComma).

Example attempt:

package main

import (
    "bytes"
    "fmt"

    "github.com/go-json-experiment/json"
    "github.com/go-json-experiment/json/jsontext"
)

type Property struct {
    Name  string
    Value any
}

func main() {
    properties := []Property{
        {"foo", "bar"},
        {"num", 12},
        {"hi", Hello{Foo: "fooo", Bar: 34.56, Earth: World{Baz: "bazz", Nuu: 78}}},
    }

    opts := []jsontext.Options{
        json.Deterministic(true),
        jsontext.SpaceAfterComma(true),
    }

    buf := &bytes.Buffer{}
    encoder := jsontext.NewEncoder(buf, opts...)

    wToken(encoder, jsontext.ObjectStart)

    for _, p := range properties {
        wValue(encoder, marshal(p.Name))
        wValue(encoder, marshal(p.Value))
    }

    wToken(encoder, jsontext.ObjectEnd)

    fmt.Println(buf.String())
}

func marshal(in any, opts ...json.Options) jsontext.Value {
    b, err := json.Marshal(in, opts...)
    if err != nil {
        panic(err)
    }
    return jsontext.Value(b)
}

func wToken(encoder *jsontext.Encoder, token jsontext.Token) {
    if err := encoder.WriteToken(token); err != nil {
        panic(err)
    }
}

func wValue(encoder *jsontext.Encoder, value jsontext.Value) {
    if err := encoder.WriteValue(value); err != nil {
        panic(err)
    }
}

type Hello struct {
    Foo   string
    Bar   float64
    Earth World
}

type World struct {
    Baz string
    Nuu int
}

Right now, I am encountering a few problems:

  1. The values are being parsed twice. In the example above, I am using the regular json.Marshal(...) to turn an any into a jsontext.Value, then writing that value to the encoder. When writing to the encoder, it automatically re-parses the []byte value to confirm it is valid json. This is unneeded and a performance penalty.

  2. I don't see a way to clone the Encoder. There isn't a method to create a new encoder with an existing buffer or any of the encoder's state set. If I choose to replace the encoder with a simple buffer, I would lose out on all the encoder's guarantees and the jsontext.Options available, or have to re-implement them myself.

Is there an existing better way to do this?

If not, could we discuss what api additions or changes would be needed to allow this use case?

dsnet commented 2 months ago

I don't see a way to clone the Encoder.

If the Encoder backs a bytes.Buffer, then clone might have obvious semantics (as we can clone the underlying bytes.Buffer as well). But if it backs an arbitrary io.Writer, what does that mean?

veqryn commented 2 months ago

Perhaps instead of being able to clone the Encoder, how about being able to create a new Encoder if given a []byte or bytes.Buffer?

Or perhaps there is a better way to accomplish this?

A great example of the problem: stdlib log/slog's JSONHandler, in particular how it piecemeal adds log attributes to a buffer to manually construct the JSON log line

Here is the library I have written, based off of log/slog JSONHandler, but converted to use your JSON v2 library. It also faces the same issue: https://github.com/veqryn/slog-json