FlatBuffers: Generic Reader and Builder
| (require flatbuffers) | package: flatbuffers |
This package is a pure-Racket implementation of the FlatBuffers wire format — Google’s zero-copy serialization format. It provides:
a generic reader that decodes any FlatBuffers buffer by numeric field id, with no generated bindings and no schema at hand, and
a low-level builder that follows the reference builder’s write-back-to-front model, with vtable deduplication and correct per-primitive alignment.
There is no schema compiler and no code generation in this version; schema codegen bindings are future work. There is no FFI. The only dependency is base.
The format implemented here is the core FlatBuffers format as produced and consumed by current flatc releases: scalars are always little-endian, buffers use 32-bit offsets, and buffers are standard (not size-prefixed). The 64-bit-offset variant of the format is not supported. (Little-endian is a property of the format itself, not of this implementation: “Each scalar is also always represented in little-endian format.”)
1 A tour of the format
A FlatBuffers buffer starts with a uoffset (a uint32) pointing forward to the root table. A table starts with an soffset (a signed int32) such that vtable position = table position - soffset; because the value is signed, the vtable may lie before or after the table. A vtable holds a uint16 vtable size, a uint16 table inline size, and one uint16 per field: the field’s offset from the table start, or 0 if the field is absent. Strings are uint32-length-prefixed, null-terminated UTF-8 byte vectors; vectors are uint32-length-prefixed sequences of aligned elements. Strings, vectors, sub-tables and structs are reached through unsigned offsets stored at the field’s position, each relative to its own location.
Two consequences drive this API:
Default elision. A field whose value equals its default is simply not stored. A reader must return the default for absent fields — which is why every accessor in this package takes an optional default.
Vtable deduplication. Tables with identical layouts share one vtable; the shared vtable may sit after the table (the soffset is then negative). The builder performs this dedup automatically, and the reader handles both directions.
2 Quick start
Take this schema (the one used by the package’s own tests):
// test/schema.fbs (excerpt) |
table Inner { |
x: int32; // id 0 |
y: int32; // id 1 |
name: string; // id 2 |
} |
|
table Outer { |
label: string; // id 0 |
inner: Inner; // id 1 |
inners: [Inner]; // id 2 |
tags: [string]; // id 3 |
scalars: Scalars; // id 4 |
} |
Building the equivalent buffer with the low-level builder. Strings and vectors must be created before the object that references them; vector elements are prepended in reverse order; field ids are the vtable slot numbers:
> (define b (make-builder)) > (define label-off (builder-create-string b "hello")) > (define name-off (builder-create-string b "in")) > (define a-off (builder-create-string b "a")) > (define b-off (builder-create-string b "b"))
> (define inner-off (let () (builder-start-object b 3) (builder-add-i32-slot b 0 1) (builder-add-i32-slot b 1 -2) (builder-add-slot b 2 name-off) (builder-end-object b)))
> (define inner-a-off (let () (builder-start-object b 3) (builder-add-i32-slot b 0 10) (builder-add-slot b 2 a-off) (builder-end-object b))) > (builder-start-vector b 2 4) > (builder-prepend-uoffset b inner-a-off) > (builder-prepend-uoffset b inner-off) > (define inners-off (builder-end-vector b 2))
> (define tags-off (let () (builder-start-vector b 2 4) (builder-prepend-uoffset b b-off) (builder-prepend-uoffset b a-off) (builder-end-vector b 2)))
> (define root-off (let () (builder-start-object b 5) (builder-add-slot b 0 label-off) (builder-add-slot b 1 inner-off) (builder-add-slot b 2 inners-off) (builder-add-slot b 3 tags-off) (builder-add-slot b 4 0) (builder-end-object b))) > (define buf (builder-finish b root-off)) > (bytes-length buf) 152
Reading it back with the generic reader. Missing fields return the caller’s default (or the type default 0 / 0.0 / #f):
> (define t (fb-root (open-flatbuffer buf))) > (table-field-string t 0) "hello"
> (table-field-i32 (table-field-table t 1) 1) -2
> (define inners (table-field-vector t 2)) > (fb-vector-length inners) 2
> (table-field-i32 (fb-vector-ref-table inners 1) 0) 10
> (table-field-table t 4) #f
> (table-field-i32 t 4 99) 99
> (table-field-present? t 4) #f
Buffers built by this builder are ordinary FlatBuffers buffers: they can be verified with flatc -t, read by the official C++/Java/Python runtimes, and so on. The package’s test suite does exactly that (see Interoperability with flatc).
3 Reading buffers
procedure
(open-flatbuffer bs) → fb?
bs : bytes?
procedure
(fb-root f) → table?
f : fb?
A table pairs a buffer with the table’s absolute byte position; table-pos exposes it. Fields are accessed by numeric field id (the vtable slot, normally the field’s declaration order in the schema):
procedure
(table-field-present? t id) → boolean?
t : table? id : exact-nonnegative-integer?
procedure
(table-field-bool t id [default]) → boolean?
t : table? id : exact-nonnegative-integer? default : any/c = #f (table-field-u8 t id [default]) → any/c t : table? id : exact-nonnegative-integer? default : any/c = 0 (table-field-u16 t id [default]) → any/c t : table? id : exact-nonnegative-integer? default : any/c = 0 (table-field-u32 t id [default]) → any/c t : table? id : exact-nonnegative-integer? default : any/c = 0 (table-field-u64 t id [default]) → any/c t : table? id : exact-nonnegative-integer? default : any/c = 0 (table-field-i8 t id [default]) → any/c t : table? id : exact-nonnegative-integer? default : any/c = 0 (table-field-i16 t id [default]) → any/c t : table? id : exact-nonnegative-integer? default : any/c = 0 (table-field-i32 t id [default]) → any/c t : table? id : exact-nonnegative-integer? default : any/c = 0 (table-field-i64 t id [default]) → any/c t : table? id : exact-nonnegative-integer? default : any/c = 0 (table-field-f32 t id [default]) → any/c t : table? id : exact-nonnegative-integer? default : any/c = 0.0 (table-field-f64 t id [default]) → any/c t : table? id : exact-nonnegative-integer? default : any/c = 0.0 (table-field-string t id [default]) → any/c t : table? id : exact-nonnegative-integer? default : any/c = #f (table-field-table t id [default]) → any/c t : table? id : exact-nonnegative-integer? default : any/c = #f
procedure
(table-field-vector t id [ default #:elem-size elem-size]) → any/c t : table? id : exact-nonnegative-integer? default : any/c = #f elem-size : (or/c #f exact-positive-integer?) = #f
procedure
(table-field-struct t id size [default]) → any/c
t : table? id : exact-nonnegative-integer? size : exact-positive-integer? default : any/c = #f
struct
fb : fb? pos : exact-nonnegative-integer? len : exact-nonnegative-integer?
procedure
v : fb-vector?
4 Building buffers
procedure
(make-builder [capacity]) → builder?
capacity : exact-positive-integer? = 1024
procedure
b : builder?
procedure
b : builder? str : string?
procedure
(builder-create-u8-vector b data) → exact-nonnegative-integer?
b : builder? data : bytes?
procedure
(builder-start-vector b num-elems elem-size) → void?
b : builder? num-elems : exact-nonnegative-integer? elem-size : exact-positive-integer?
procedure
(builder-end-vector b num-elems) → exact-nonnegative-integer?
b : builder? num-elems : exact-nonnegative-integer?
procedure
(builder-start-object b num-fields) → void?
b : builder? num-fields : exact-nonnegative-integer?
procedure
(builder-add-slot b field-id offset-value) → void?
b : builder? field-id : exact-nonnegative-integer? offset-value : exact-nonnegative-integer?
procedure
(builder-add-bool-slot b id v [default]) → void?
b : builder? id : exact-nonnegative-integer? v : any/c default : any/c = #f (builder-add-u8-slot b id v [default]) → void? b : builder? id : exact-nonnegative-integer? v : (integer-in 0 255) default : (integer-in 0 255) = 0 (builder-add-u16-slot b id v [default]) → void? b : builder? id : exact-nonnegative-integer? v : (integer-in 0 65535) default : (integer-in 0 65535) = 0 (builder-add-u32-slot b id v [default]) → void? b : builder? id : exact-nonnegative-integer? v : exact-nonnegative-integer? default : exact-nonnegative-integer? = 0 (builder-add-u64-slot b id v [default]) → void? b : builder? id : exact-nonnegative-integer? v : exact-nonnegative-integer? default : exact-nonnegative-integer? = 0 (builder-add-i8-slot b id v [default]) → void? b : builder? id : exact-nonnegative-integer? v : (integer-in -128 127) default : (integer-in -128 127) = 0 (builder-add-i16-slot b id v [default]) → void? b : builder? id : exact-nonnegative-integer? v : (integer-in -32768 32767) default : (integer-in -32768 32767) = 0 (builder-add-i32-slot b id v [default]) → void? b : builder? id : exact-nonnegative-integer? v : exact-integer? default : exact-integer? = 0 (builder-add-i64-slot b id v [default]) → void? b : builder? id : exact-nonnegative-integer? v : exact-integer? default : exact-integer? = 0 (builder-add-f32-slot b id v [default]) → void? b : builder? id : exact-nonnegative-integer? v : real? default : real? = 0.0 (builder-add-f64-slot b id v [default]) → void? b : builder? id : exact-nonnegative-integer? v : real? default : real? = 0.0
procedure
b : builder?
procedure
(builder-finish b root) → bytes?
b : builder? root : exact-nonnegative-integer?
4.1 Alignment primitives
The builder-prepend-* primitives write scalars back-to-front, each aligned to its own size relative to the start of the finished buffer. The two padding primitives are exposed for completeness:
procedure
(builder-prep b alignment) → void?
b : builder? alignment : exact-positive-integer?
procedure
(builder-prealign b future-bytes alignment) → void?
b : builder? future-bytes : exact-nonnegative-integer? alignment : exact-positive-integer?
5 Interoperability with flatc
The test suite ("flatbuffers/test/flatbuffers-test.rkt") checks:
binaries produced by flatc -b from JSON test data (including a default-elision case and an empty-vector/empty-string edge case) are decoded correctly by the reader;
buffers built by the builder are converted back to JSON with flatc -t and compared field-by-field semantically — byte-for-byte equality is not required, because the format deliberately leaves field order, object placement and vtable sharing unspecified;
an alignment fuzz loop (strings of length 1..40 interleaved with uint64 fields) round-trips through both our reader and, for one case, flatc;
vtable deduplication produces buffers flatc accepts (soffset pointing forward to a shared vtable).
The interop tests are skipped gracefully at runtime when flatc is not on PATH (e.g. on Windows CI), so the pure-Racket round-trip suite always runs.
6 Limitations
Little-endian, 32-bit-offset, non-size-prefixed buffers only.
No schema verification: a generic reader trusts the schema knowledge of its caller (a vector’s element type, a struct’s size, ...). Malformed or hostile buffers raise exn:fail on out-of-range access rather than guaranteeing memory safety properties beyond Racket’s own byte-string bounds. A formal verifier is future work.
The builder deduplicates vtables byte-exactly (like the reference implementation); tables built with different slot counts produce distinct vtables even if some slots are unused.