Liking cljdoc? Tell your friends :D

clj-protobuf

Clojars Project

The protobuf runtime for code generated by protoc-gen-clojure: records in, protoc's bytes out. Editions supported through 2024.

;; deps.edn
com.github.bpalermo/clj-protobuf {:mvn/version "0.2.0"}

What it is

protoc-gen-clojure emits a defrecord per message plus X->proto / proto->X conversion fns; this library is everything those fns call — the descriptor machinery, the field codec, and byte-level encode/decode:

(require '[clj-protobuf.core :as pb]
         '[acme.greeter.greeter :as g])   ; generated

(-> (g/map->HelloRequest {:name "world" :repeat-count 2})
    g/HelloRequest->proto
    pb/encode)                            ; -> bytes, byte-identical to protoc's

(->> bytes
     (pb/decode g/HelloRequest-prototype)
     g/proto->HelloRequest)               ; -> record; absent fields are nil

Records and plain maps are interchangeable everywhere a message value goes.

Semantics worth knowing

  • nil means absent, both directions. Setting nil sets nothing; reading an unset explicit-presence field returns nil. Fields with IMPLICIT presence (editions) and proto3 no-label scalars have no absence — they read back as their value, default included.
  • The proto field name is the authority. Kebab-cased keys are derived from it (camelCaseField:camel-case-field); STYLE_LEGACY files work because nothing ever converts backwards.
  • Enums are keywords of the exact proto value name (:COLOR_RED), lossless in both directions; numbers, strings and EnumValueDescriptors are accepted on the way in.
  • Editions are the descriptor's problem. The generated file embeds its FileDescriptorProto; protobuf-java resolves presence, DELIMITED encoding and utf8 validation when the descriptor builds. This library carries no edition-specific code, which is why new editions need a protobuf-java bump and nothing else.
  • Unknown fields survive on parsed Messages but are necessarily dropped by a record round trip; pb/unknown-fields inspects them when it matters.

Performance

Generated code carries a Java-class hint per message. When the matching java_proto_library classes are on the classpath the prototypes silently switch to the generated classes — protoc's own serializer. Without them, since 0.2.0, the prototype is this library's own compiled codec: the descriptor compiled once into reader and writer tables over a slot array, with none of DynamicMessage's per-call reflection behind it. Every arm produces byte-for-byte identical output (the byte-identity and equivalence suites prove all of them against protoc's own Java backend), and -Dclj-protobuf.codec=dynamic brings DynamicMessage back if you ever need the reference implementation.

Measured with bazel run //bench:run -- quick (criterium; JDK 21, Linux x86_64; mean latency / allocated bytes per op; full Clojure-data-to-bytes pipelines). java is protoc's generated builders driven directly; hinted is clj-protobuf with the generated classes on the classpath; compiled is clj-protobuf without them; jsonista and data.json carry the same value as JSON:

Encode (Clojure data → bytes)

shapejavahintedcompiledjsonistadata.json
tiny86 ns / 96 B168 ns / 136 B311 ns / 224 B390 ns / 608 B1.03 µs / 624 B
flat682 ns / 312 B1.18 µs / 488 B1.36 µs / 504 B1.57 µs / 1248 B4.01 µs / 2208 B
deep907 ns / 552 B1.27 µs / 656 B1.14 µs / 1080 B2.83 µs / 1392 B
wide-repeated4.26 µs / 3088 B3.77 µs / 2112 B2.63 µs / 1096 B8.65 µs / 4216 B
repeated-messages2.25 µs / 2296 B6.42 µs / 2824 B7.13 µs / 3664 B4.16 µs / 4024 B24.40 µs / 10424 B
map-heavy10.61 µs / 7512 B8.48 µs / 4760 B4.46 µs / 3600 B16.60 µs / 10720 B
enum-heavy1.44 µs / 456 B1.36 µs / 392 B1.61 µs / 1168 B5.44 µs / 2952 B

Decode (bytes → Clojure data)

shapejavahintedcompiledjsonistadata.json
tiny97 ns / 192 B217 ns / 232 B242 ns / 216 B769 ns / 1136 B761 ns / 1584 B
flat407 ns / 432 B910 ns / 600 B638 ns / 520 B2.10 µs / 2168 B4.86 µs / 5776 B
deep918 ns / 1024 B744 ns / 760 B1.32 µs / 2088 B1.19 µs / 3840 B
wide-repeated3.87 µs / 4352 B3.88 µs / 4208 B3.33 µs / 4104 B5.73 µs / 11496 B
repeated-messages2.04 µs / 3312 B6.10 µs / 5344 B6.18 µs / 4704 B11.60 µs / 10080 B10.45 µs / 25096 B
map-heavy13.73 µs / 13048 B11.52 µs / 10032 B10.11 µs / 5736 B15.18 µs / 22248 B
enum-heavy1.99 µs / 1056 B1.87 µs / 856 B2.22 µs / 2840 B2.69 µs / 7200 B

Read it honestly: the hinted arm sits ~2× off protoc's own generated code on small messages and the compiled arm within ~1.3× of the hinted one — and on decode the compiled arm is the faster of the two on most shapes, a slot read beating a typed-accessor call. Both beat JSON both ways on small and nested messages, and lists of messages decode faster than JSON too; jackson still wins encoding the collection-heavy shapes, where protobuf-java's own map and list building dominates. Before 0.2.0 the arm without generated classes was DynamicMessage, two to three times slower than the compiled one on decode and up to 2.5× on encode. Wire compactness and schema are protobuf's argument regardless. The shapes are archetypes precisely because no single number describes 'protobuf vs JSON'.

Building

Bazel (with rules_clj) is the build and test harness: bazel test //.... Plain clj works too: clojure -X:test. The Clojars artifact is bazel build //src:clojars — jar and pom, from the same deps.edn and version.edn everything else here reads. bazel run //src:clojars.publish -- --dry-run prints every upload it would make.

License

Apache-2.0

Can you improve this documentation?Edit on GitHub

cljdoc builds & hosts documentation for Clojure/Script libraries

Keyboard shortcuts
Ctrl+kJump to recent docs
Move to previous article
Move to next article
Ctrl+/Jump to the search field
× close