Documentation
¶
Overview ¶
Package jsoncompress is lossless JSON compression that is still JSON.
Three ideas, applied together:
- Every object key is replaced by a short token and the real names are listed once, in a catalogue. A key that occurs ten thousand times is spelled out once.
- A slice of like-shaped objects becomes a table - one column list and a matrix of rows - so the keys disappear from the rows entirely. Columns whose value never changes are hoisted out of the rows altogether, and columns drawn from a small set of strings become integer indices.
- String values that repeat are moved into a second catalogue and referenced by token.
The output is an ordinary JSON value with no binary framing, so it survives anything that carries JSON: an HTTP body, a WebSocket frame, a jsonb column, a queue message, a log line.
envelope, err := jsoncompress.Compress([]any{
map[string]any{"id": 1, "role": "admin"},
map[string]any{"id": 2, "role": "admin"},
})
back, err := jsoncompress.Decompress(envelope)
The wire format is specified in SPEC.md at the root of the repository, and this implementation writes the same bytes as the JavaScript, .NET, Python and PHP ones.
The value model ¶
A restored document is built from the types JSON has and no others: nil, bool, float64, string, []any and *Object. Objects are *Object rather than map[string]any because a Go map has no order and this format promises to give key order back exactly as it arrived.
Input is more forgiving. Anything encoding/json can marshal can be compressed - structs with their tags, maps, slices, json.Marshaler, encoding.TextMarshaler, time.Time - and is read the way encoding/json reads it, with map keys sorted because a Go map cannot say what order it wants.
Example ¶
The shape the format is built for: a record set, where every row repeats every key name and most rows repeat the values too.
package main
import (
"fmt"
jsoncompress "github.com/eharain/JSON-Compress/go"
)
type user struct {
ID int `json:"id"`
Name string `json:"name"`
Role string `json:"role"`
Country string `json:"country"`
}
func main() {
users := []user{
{1, "Ada", "admin", "United Kingdom"},
{2, "Bob", "admin", "United Kingdom"},
{3, "Cy", "user", "United Kingdom"},
}
small, err := jsoncompress.Marshal(users)
if err != nil {
panic(err)
}
fmt.Println(string(small))
back, err := jsoncompress.Unmarshal(small)
if err != nil {
panic(err)
}
restored, err := jsoncompress.FormatJSON(back)
if err != nil {
panic(err)
}
fmt.Println(string(restored))
}
Output: {"jc":1,"k":["id","name","role","country"],"d":{"~t":[0,1,2,3],"~e":[0,0,0,2],"~cv":["United Kingdom"],"~r":[[1,"Ada","admin"],[2,"Bob","admin"],[3,"Cy","user"]]}} [{"id":1,"name":"Ada","role":"admin","country":"United Kingdom"},{"id":2,"name":"Bob","role":"admin","country":"United Kingdom"},{"id":3,"name":"Cy","role":"user","country":"United Kingdom"}]
Index ¶
- Constants
- func Beautify(text string, indent int) (string, error)
- func ByteLength(text string) int
- func Decompress(envelope any) (any, error)
- func DecompressIfNeeded(value any) (any, error)
- func FormatJSON(value any) ([]byte, error)
- func FormatJSONIndent(value any, indent int) ([]byte, error)
- func Gunzip(data []byte) ([]byte, error)
- func GzipBytes(data []byte) ([]byte, error)
- func GzipSize(data []byte) int
- func IsCompressed(value any) bool
- func IsPacked(value any) bool
- func Marshal(value any, options ...Options) ([]byte, error)
- func MarshalIndent(value any, indent int, options ...Options) ([]byte, error)
- func Minify(text string) (string, error)
- func Normalize(value any) (any, error)
- func PackAll(resultSets any) ([]any, bool)
- func ParseJSON(data []byte) (any, error)
- func ParseJSONString(text string) (any, error)
- func Unmarshal(data []byte) (any, error)
- func Unpack(packed any) ([]any, bool)
- func UnpackAll(resultSets any) ([]any, bool)
- type Codec
- type DecodeError
- type Location
- type Object
- func (o *Object) At(i int) (string, any)
- func (o *Object) Clone() *Object
- func (o *Object) Delete(key string) *Object
- func (o *Object) Get(key string) (any, bool)
- func (o *Object) Has(key string) bool
- func (o *Object) Keys() []string
- func (o *Object) Len() int
- func (o *Object) MarshalJSON() ([]byte, error)
- func (o *Object) Set(key string, value any) *Object
- func (o *Object) UnmarshalJSON(data []byte) error
- func (o *Object) Value(key string) any
- type Options
- type PackedTable
- type Report
- type TextShape
Examples ¶
Constants ¶
const FormatVersion = 1
FormatVersion is the version of the wire format this build reads and writes. It is bumped only on a breaking format change.
const Version = "1.0.0"
Version is the version of this implementation, which is not the same thing as the version of the wire format (FormatVersion).
Variables ¶
This section is empty.
Functions ¶
func Beautify ¶
Beautify re-indents a JSON document. Line feeds are written whatever the platform, so a document beautified on Windows matches the same document beautified anywhere else.
func ByteLength ¶
ByteLength is the length of a string in UTF-8 bytes - what goes over the wire, as opposed to the number of characters in it.
func Decompress ¶
Decompress restores the original value from a json-compress envelope.
The envelope may be an *Object - from Compress, or from ParseJSON - or a map[string]any, as encoding/json would have decoded it. Note that a map has no key order to give back, so an envelope that has been through encoding/json restores its ordinary object nodes in catalogue order rather than the order they were written in; packed tables carry their own order and are unaffected. Unmarshal avoids the question by parsing the text itself.
func DecompressIfNeeded ¶
DecompressIfNeeded restores a value whether or not it was compressed.
Handy at a boundary you do not fully control - an endpoint being migrated, a cache holding entries written by two versions of the same service.
func FormatJSON ¶
FormatJSON serialises a value as JSON text, character for character as JSON.stringify would write it.
That is not quite what encoding/json writes. Go escapes <, > and & for the benefit of HTML documents, and switches to exponent form at different magnitudes than ECMAScript does - so 0.00001 comes out as 1e-05 there and 0.00001 here. The wire format is defined against the ECMAScript spelling, and every implementation of it agrees byte for byte, which is what this preserves.
Values are normalised on the way in, so anything encoding/json can marshal can be written here.
func FormatJSONIndent ¶
FormatJSONIndent is FormatJSON with each level indented by the given number of spaces, capped at ten as JSON.stringify caps it. An indent of zero or less writes the compact form.
func GzipBytes ¶
GzipBytes gzips a document.
The header carries no timestamp, so the same input always gives the same bytes and a size can be compared across runs. The level is zlib's own default, which is what browsers use for CompressionStream, so a figure measured here is comparable with one measured in the JavaScript implementation.
func GzipSize ¶
GzipSize is how many bytes a document takes once gzipped. Compression cannot fail on a byte slice, so this reports the size and no error; it is the figure Report carries.
func IsCompressed ¶
IsCompressed reports whether a value looks like a json-compress envelope this build can read.
func Marshal ¶
Marshal compresses a value and serialises it in one step. It is Compress followed by FormatJSON.
func MarshalIndent ¶
MarshalIndent is Marshal with the envelope indented by the given number of spaces.
func Normalize ¶
Normalize brings an arbitrary Go value down to the JSON value model: nil, bool, string, the three number types the model keeps, []any and *Object.
The rules are the ones encoding/json follows, so that a value compressed here and a value marshalled there describe the same document:
- a json.Marshaler is asked for its JSON, so a time.Time becomes the string it always writes, and a json.RawMessage is read as the document it holds;
- an encoding.TextMarshaler becomes its text;
- struct fields follow their json tags, including omitempty, and embedded structs are flattened where encoding/json flattens them;
- a []byte becomes base64 text;
- a nil slice, map, pointer or interface becomes null;
- map keys are sorted, because a Go map has no order of its own.
Two rules come from the wire format rather than from encoding/json, and are the ones every implementation shares: a non-finite number becomes null rather than an error, and negative zero becomes zero. A cycle is an error, not a hang.
Integers keep their exact 64-bit value rather than being folded through a double, so a number too large for a float64 survives here where it would not in JavaScript.
func PackAll ¶
PackAll packs every result set in a slice of result sets.
Database drivers that return several result sets from one call hand back a slice of slices; this packs each inner one and leaves the outer one alone. A set that cannot be packed is carried through as it was, so the result holds a *PackedTable where packing worked and the original set where it did not.
func ParseJSON ¶
ParseJSON reads JSON text into the value model: nil, bool, float64, string, []any and *Object.
It is JSON.parse, with objects that remember their key order. Numbers become float64, as they do in every other implementation of this format and in encoding/json.
func ParseJSONString ¶
ParseJSONString is ParseJSON for a string.
func Unmarshal ¶
Unmarshal parses a compressed document and restores the original value. It is ParseJSON followed by Decompress, and because it does the parsing itself the restored document keeps the key order it was written with.
Types ¶
type Codec ¶
type Codec struct {
// contains filtered or unexported fields
}
Codec is a set of options, bound once and reused.
A codec is the natural unit when the same options apply everywhere - a transport layer, a cache adapter, a storage helper - and it keeps the option struct off every call site. It holds nothing but its options, which it does not change, so one instance built at start-up is safe to share across goroutines.
Example ¶
A codec binds a set of options once, for a transport or storage layer that uses the same ones everywhere.
package main
import (
"fmt"
jsoncompress "github.com/eharain/JSON-Compress/go"
)
func main() {
options := jsoncompress.DefaultOptions()
options.MinRows = 10
codec, err := jsoncompress.NewCodec(options)
if err != nil {
panic(err)
}
rows := []any{
jsoncompress.NewObject().Set("a", 1.0),
jsoncompress.NewObject().Set("a", 2.0),
}
// Two rows is under the threshold, so they stay as objects.
data, err := codec.Marshal(rows)
if err != nil {
panic(err)
}
fmt.Println(string(data))
}
Output: {"jc":1,"k":["a"],"d":[{"A":1},{"A":2}]}
func (*Codec) Decompress ¶
Decompress restores a compressed value.
type DecodeError ¶
type DecodeError struct {
// contains filtered or unexported fields
}
DecodeError says that a document is not a readable json-compress envelope.
func (*DecodeError) Error ¶
func (e *DecodeError) Error() string
type Location ¶
type Location struct {
// Valid is true when the document is well-formed JSON, in which case every
// other field is zero.
Valid bool
// Error says what is wrong, in plain words.
Error string
// Position is the byte offset of the problem, so text[Position:] starts at
// it.
Position int
// Line is the line number, counted from one.
Line int
// Column is the column number, counted from one in characters - so a column
// means what it means in an editor even when the line above holds an emoji.
Column int
// Excerpt is the line the problem is on.
Excerpt string
}
Location is what a scan of a JSON document found.
func Locate ¶
Locate checks a JSON document and, if it is broken, says exactly where.
It is only worth running after a parse has already failed, so the cost of a second pass falls on documents that were not going to work anyway.
Example ¶
Locate says exactly where a document stops being JSON.
package main
import (
"fmt"
jsoncompress "github.com/eharain/JSON-Compress/go"
)
func main() {
found := jsoncompress.Locate("{\n \"a\": 1,\n \"b\": oops\n}")
fmt.Println(found.Valid)
fmt.Println(found.Error)
fmt.Println(found.Line, found.Column)
fmt.Println(found.Excerpt)
fmt.Println(found)
}
Output: false expected a value, found "o" 3 8 "b": oops invalid JSON at line 3, column 8: expected a value, found "o"
func Validate ¶
Validate checks that a string is valid JSON, and says exactly where it stops being so.
The verdict comes from the parser, so it is the same verdict ParseJSON would give. The location comes from a scan of the document, because parser messages are written for parser authors and often name the wrong culprit.
type Object ¶
type Object struct {
// contains filtered or unexported fields
}
Object is a JSON object that remembers the order its keys arrived in.
A Go map does not, and encoding/json writes one out in sorted order, so a document decoded into map[string]any comes back with its members shuffled. This format promises the opposite - the same values and the same key order - so objects are represented by this type instead.
The zero value is an empty object and is ready to use. An Object is not safe for concurrent modification.
Example ¶
An Object keeps the order its keys arrived in, and can say whether a key is present at all - which a nil value cannot.
package main
import (
"fmt"
jsoncompress "github.com/eharain/JSON-Compress/go"
)
func main() {
row := jsoncompress.NewObject().Set("id", 1.0).Set("name", "Ada").Set("note", nil)
fmt.Println(row.Len(), row.Keys())
note, present := row.Get("note")
fmt.Println(note, present)
missing, present := row.Get("nickname")
fmt.Println(missing, present)
text, _ := jsoncompress.FormatJSON(row)
fmt.Println(string(text))
}
Output: 3 [id name note] <nil> true <nil> false {"id":1,"name":"Ada","note":null}
func Compress ¶
Compress packs a value into a json-compress envelope.
The result is an ordinary JSON-shaped value, so it can be stored, posted or logged anywhere plain JSON goes; Marshal does the same thing and hands back the text. The input is left untouched, and anything encoding/json can marshal can be given (Normalize describes how each kind of value is read).
At most one set of options may be given; with none, DefaultOptions apply.
func TextToArray ¶
TextToArray reads a delimited-integer column back into a slice of numbers.
Some databases return a set-valued column as a bracketed, comma-separated string. This converts one such member in place, leaving an empty slice where the column was null, and returns the record so calls can be chained.
func (*Object) At ¶
At returns the member at position i, counted from zero in key order. It panics if i is out of range, like any other index.
This is the allocation-free way to walk an object:
for i := 0; i < obj.Len(); i++ {
key, value := obj.At(i)
...
}
func (*Object) Clone ¶
Clone returns a shallow copy: the same values under the same keys, in the same order, in an object that can be changed without touching this one.
func (*Object) Get ¶
Get returns the value stored under key, and whether the key is present at all. A key that is present with a nil value and a key that is absent are different things, here as in the format itself.
func (*Object) Keys ¶
Keys returns the member names, in order. The result is a copy; changing it does not change the object.
func (*Object) MarshalJSON ¶
MarshalJSON writes the object with its members in order.
Note that encoding/json re-escapes what a json.Marshaler returns, so json.Marshal(obj) escapes <, > and & as Go always does. FormatJSON writes the bytes this format is defined in terms of.
func (*Object) Set ¶
Set stores a value, appending the key when it is new and leaving it where it is when it is not - the same rule JSON.parse follows for a repeated key. It returns the object, so calls can be chained.
func (*Object) UnmarshalJSON ¶
UnmarshalJSON reads a JSON object, keeping the order its members arrived in.
type Options ¶
type Options struct {
// Strings de-duplicates repeated string values into a catalogue.
Strings bool
// Tables packs slices of like-shaped objects into columnar tables.
Tables bool
// Constants hoists columns whose value never changes out of the rows.
Constants bool
// DictColumns encodes low-cardinality string columns as integer indices.
DictColumns bool
// MinRows is the smallest slice that may become a table.
MinRows int
// MaxHoleRatio is the largest share of missing cells a table may carry.
MaxHoleRatio float64
// MinStringCount is how many times a string must occur before it can be
// catalogued.
MinStringCount int
// MinStringLength is the shortest string that may be catalogued.
MinStringLength int
}
Options is what the encoder is allowed to do.
The zero value is not a usable set of options - every switch would be off and MinRows would be nonsense - so build one from DefaultOptions and change what you need:
options := jsoncompress.DefaultOptions() options.MinRows = 10 envelope, err := jsoncompress.Compress(value, options)
Every default is safe on arbitrary input, and the defaults are tuned for API responses and log batches, which is where the format earns most of its keep.
func DefaultOptions ¶
func DefaultOptions() Options
DefaultOptions returns the options used when none are given.
type PackedTable ¶
type PackedTable struct {
// Fields are the column names, in the order the row slices use.
Fields []string `json:"fields"`
// Data is one slice of values per record.
Data [][]any `json:"data"`
// Count is the number of records, so consumers need not measure.
Count int `json:"count"`
}
PackedTable is a record set with its keys lifted out of the rows.
func Pack ¶
func Pack(records any) (*PackedTable, bool)
Pack flattens a slice of records into a field list and a rows matrix, and reports whether it could.
Fields are taken from the union of every record, in first-seen order, so a record that is missing one contributes nil in that column. Anything that is not a slice of objects is refused rather than mangled, which is what the second return value is for:
if table, ok := jsoncompress.Pack(rows); ok {
payload = table
}
Records may be anything Normalize reads as an object - an *Object, a map, a struct - so what a database driver hands back goes straight in.
Example ¶
Pack is the columnar idea on its own, with no envelope and no catalogue.
package main
import (
"fmt"
jsoncompress "github.com/eharain/JSON-Compress/go"
)
type user struct {
ID int `json:"id"`
Name string `json:"name"`
Role string `json:"role"`
Country string `json:"country"`
}
func main() {
table, ok := jsoncompress.Pack([]user{{1, "Ada", "admin", "UK"}, {2, "Bob", "user", "UK"}})
if !ok {
panic("not a record set")
}
text, _ := jsoncompress.FormatJSON(table)
fmt.Println(string(text))
}
Output: {"fields":["id","name","role","country"],"data":[[1,"Ada","admin","UK"],[2,"Bob","user","UK"]],"count":2}
type Report ¶
type Report struct {
// Original is the bytes of the input as given.
Original int
// Minified is the bytes once whitespace is removed.
Minified int
// Compressed is the bytes of the compressed envelope.
Compressed int
// Saved is the bytes saved against the minified form.
Saved int
// Ratio is Compressed divided by Minified, 0 to 1.
Ratio float64
// Percent is the percentage saved against the minified form.
Percent float64
// GzipMinified is the minified form after gzip.
GzipMinified int
// GzipCompressed is the compressed form after gzip.
GzipCompressed int
}
Report is what compression bought on one value.
func Measure ¶
Measure reports what compression buys on a given value.
The honest comparison is against minified JSON, not against whatever whitespace the input happened to arrive with, so Ratio and Percent both use the minified size as their baseline. Gzip figures are included because that is what most payloads meet in production, and the interesting question is whether the format still helps once gzip has had its turn.
Example ¶
Measure says what compression would buy before anything is committed to.
package main
import (
"fmt"
jsoncompress "github.com/eharain/JSON-Compress/go"
)
func main() {
rows := make([]any, 100)
for i := range rows {
rows[i] = jsoncompress.NewObject().
Set("id", float64(i)).
Set("status", "delivered").
Set("currency", "GBP")
}
report, err := jsoncompress.Measure(rows)
if err != nil {
panic(err)
}
fmt.Println(report.Minified, report.Compressed, report.Saved)
}
Output: 4791 594 4197
type TextShape ¶
TextShape describes how a delimited column is written: how many characters to trim from each end, and what separates the values.
func DefaultTextShape ¶
func DefaultTextShape() TextShape
DefaultTextShape is the shape TextToArray assumes when none is given, which reads a column written as "[[[1,2,3]]]".
Source Files
¶
Directories
¶
| Path | Synopsis |
|---|---|
|
cmd
|
|
|
json-compress
command
Command json-compress compresses, restores and measures JSON documents.
|
Command json-compress compresses, restores and measures JSON documents. |
|
examples
|
|
|
benchmark
command
Command benchmark reports what the format is worth, on shapes that occur in real systems.
|
Command benchmark reports what the format is worth, on shapes that occur in real systems. |