3011.io

Dev Log #001 - LibCBOR and LibJSON

Hi! Welcome to my dev logs, a somewhat informal place where I gather my thoughts after doing some programming. This first one is dedicated to two small libraries I put together after dealing with various other ones. Note that I mostly write my code in C++, primarily because of personal preference. I find the language fits my style quite well, even though it's not great in many aspects.

The formats we'll be dealing with in this post are CBOR (RFC 8949 or website), and JSON (RFC 8259 or website). I chose CBOR as my binary format of choice for simple storage of various things. It's versatile, easy to both encode and decode, and it maps nicely onto most data. That's one of the nicer properties it inherited from JSON. The second is JSON, but mostly as a grudging admission that it's unfortunately unavoidable in anything web related these days1.

My motivation for writing these libraries was two-fold. First, I wanted a nicer library API than what many established libraries provide. Second, I wanted to understand both formats properly. I don't like staring into blackboxes. JSON might seem trivial at a glance, but parsing it has some intricacies, even if it's woefully underspecified.

On the API side, I think libraries usually pan out one of two ways. Either the API gives the user a lot of tools and customization points. That is a great property, but it often makes the library difficult to handle for beginners. Wrong use often blows up in unexpected places, and it's easy to introduce various vulnerabilities just by incorrectly prodding into it. The other extreme is something like nlohmann/json. It's a great library, and it serves its stated purpose of treating JSON as close to a native type as possible. However, due to that decision, it introduces nontrivial overhead by materializing the DOM for even the simplest of tasks.

I wanted a middle ground. I want an API that is simple to use, hard to mess up, and reasonably fast without resorting to SIMD tricks like what simdjson uses. Don't get me wrong, that library is a marvel of engineering, and it's genuinely impressive, but it imposes requirements on its input buffers, and I don't find it particularly easy to use.

The CBOR situation is somewhat more complicated. It's a newer, and much less popular format. Some libraries exist (and nlohmann/json supports it to an extent), but the ecosystem definitely isn't as crystallized as for JSON, where the aforementioned libraries are the default choices.

CBOR

After looking at what libraries were available at the time for CBOR around a year ago, I wasn't satisfied (a list is here). I wanted an API where I could easily pull out (almost) arbitrary data directly out of an input buffer, ideally without introducing allocations in the Encoder/Decoder, and leaving the allocation choice to the user as much as possible. That led me down to what the API actually looks like now, you can see a small (untested) example below.

#include <CBOR/Encoder.hpp>
#include <CBOR/Decoder.hpp>
#include <CBOR/DecoderHooks.hpp>

#include <array>
#include <compare>
#include <cstdlib>

struct MyData
{
    std::uint32_t Id;
    std::string   Name;
    double        Speed;

    constexpr auto operator<=>(const MyData &) const = default;
};

void EncodeHook(CBOR::Encoder &enc, const MyData &value)
{
    enc.EncodeMap(
        "id",    value.Id,
        "name",  value.Name,
        "speed", value.Speed
    );
}

void DecodeHook(CBOR::Decoder &dec, MyData &value)
{
    constexpr CBOR::StructHelper helper {
        CBOR::StructMember { "id",    &MyData::Id    },
        CBOR::StructMember { "name",  &MyData::Name  },
        CBOR::StructMember { "speed", &MyData::Speed },
    };
    helper.Decode(dec.AsItem(), value);
}

int main()
{
    std::array<std::uint8_t, 1024> buffer {};

    MyData data {
        .Id    = 10,
        .Name  = "John Doe",
        .Speed = 5.0,
    };

    CBOR::Encoder enc(buffer);
    enc.Encode(data);

    CBOR::ConstBuffer encoded(buffer.data(), enc.Size());
    CBOR::Decoder dec(encoded);

    MyData decoded = dec.Decode<MyData>();

    return data == decoded ? EXIT_SUCCESS : EXIT_FAILURE;
}

This is all that's needed to round trip MyData to and from any CBOR buffer. All that's needed is for the Encoder/Decoder to have access to the necessary hooks at the callsite via ADL. There are several nice properties that fall out of this design:

The underlying Encoder is split into two layers. The BasicEncoder exposes an interface that basically emits headers and data based on how it's driven. It requires discipline, because it's easy to emit incorrect data by mistake. That is wrapped by the actual Encoder, which includes support for most standard library data structures out of the box (std::string_view, std::string, std::vector, and more), and supports extensions via hooks. This is mostly a convenience layer where it's difficult to make mistakes, but it's still possible to provide a hook that uses the BasicEncoder directly when needed.

The Decoder is a little different. It's mostly stateless by itself, and it uses auxiliary types to drive the decoding process in a pull-based fashion. For example, arrays are decoded by an ArrayDecoder that exposes a Key() and Value() call, where each returns a generic ItemDecoder wrapper. The hooks serve as thin wrappers over that API, and the StructHelper hides most of the unordered handling and skipping detail behind a nicer interface (but it's worth noting that in general it's O(n²) where n is the # of specified data members). I learned afterwards that this is a fairly popular approach in Rust, where it's called the typestate pattern. Unfortunately, C++ can't produce the same level of soundness as Rust, but that's mostly a non-issue with some basic caution.

A nice consequence of this is that the user always has access to the underlying primitives. The easy option works well enough, but the Decoder can be driven directly with lookahead to produce a DOM. I used this functionality to create a special Printer that serves to produce a JSON-esque output for debugging.

I'm not claiming that this design is the best possible solution, but I find it achieves a nice balance. The user has explicit control over what happens, while being shielded from the format details. It requires some boilerplate, but I think that is acceptable. In the future, I might look into augmenting this with C++26 reflection, but I'm not that tempted by it so far, since I think this isn't obnoxious enough to engineer more "convenience" layers on top.

JSON

Once I had the API, and needed to work with JSON, I had a hammer and saw a nail. Funnily enough, I needed it as part of test tooling for a different format library, but that's a topic for a future dev log. Anyway, the point is that I think this API shape is nice, and even if I end up needing two hook functions per format per data structure, it's worth it. Of course, there are some considerations, but I don't want to get bogged down in the details right now, since this post is already dragging on.

Overall, the simple API for the JSON Parser and Printer pair can be very similar. Internally, the Printer is even simpler, we don't need a BasicEncoder alternative for JSON, since we're not dealing with raw bytes, and we can leverage standard formatting capabilities. The harder part is writing a proper Parser with the same style as the Decoder. Interestingly, it turned out to be less of a problem than I initially thought it would be. I found that it was really helpful to write a small pull-based lexer that produces small non-owning tokens. It was really easy to just check if the tokens are valid inside the helper types (almost identical naming convention to LibCBOR).

One compromise I made (partly out of laziness) was to support only doubles in the initial versions. This is partly because the primary Number type in JavaScript is a double under the hood, and JSON is invariably a very JS-native format. The implementation is internally ready for other types as well, but those will be added on an as-needed basis, if ever.

Conclusion

I think these two formats are a really nice exercise. They are fairly small, and it's easy enough to get a reasonably conformant implementation with decent performance in a few kLoC (I think both libraries are under 5k lines so far). I'm planning on using this convention in future projects as well, even when I'm building DOMs, because APIs like this are nice both to implement and use.

If you're interested in checking these libraries out, you can find them on my Forgejo instance (LibCBOR, LibJSON). The READMEs have some quickstart information, but I'm planning on introducing some more detailed documentation on this website as well (LibCBOR and LibJSON).

Thanks for reading, and see you next time!


1: This post articulates what's wrong with JSON in much more detail than I would be able to manage. Personally, I consider the lack of trailing commas and comments as a serious defect as well, even if it was explicitly designed not to support either.