Skip to content

Repository files navigation

Buff JSON (Java)

Blazingly fast ⚡️ JSON serialization for Protocol Buffer messages in Java, compliant with the Proto3 JSON spec.

Performance

Up to ~15x faster than JsonFormat.printer().print() from protobuf-java-util.

Up to ~7x faster than jackson.writeValueAsString() from jackson-datatype-protobuf.

Encode throughput, higher is better:

Message typeJsonFormat (ops/s)Jackson (ops/s)BuffJson runtime (ops/s)BuffJson compile (ops/s)Compile vs JsonFormatCompile vs Jackson
SimpleMessage (6 fields)1.81M2.72M16.84M19.51M~10.8x~7.2x
ComplexMessage (nested, maps, repeated)138K277K1.41M2.06M~14.9x~7.4x

Benchmarked on JDK 21 (Corretto) with JMH on Apple Silicon. Run ./run-benchmarks.sh to reproduce on your environment.

How it works

Uses Alibaba fastjson2 as the JSON writing engine. The encoder creates a JSONWriter directly and calls ProtobufMessageWriter.writeMessage() — bypassing fastjson2's module dispatch and provider lookup. All JSON formatting (buffering, number encoding, string escaping) is delegated to fastjson2's optimized infrastructure.

Codegen path (optional protoc plugin):

  • Direct typed accessorsmessage.getId() returns int, no boxing
  • No runtime type dispatch — each field's encoding logic is inlined at compile time
  • No schema cache lookup — field iteration order and names are hardcoded
  • Direct nested encoder calls — nested messages call INSTANCE.writeFields() directly
  • Inline WKT Timestamp/Duration — typed accessor calls (ts.getSeconds(), ts.getNanos()) bypass descriptor lookup and reflection
  • Pre-cached enum names — static String[] array lookup, no forNumber() or descriptor calls
  • Zero-alloc int64/byteswriteString((long) v), writeBase64(v.toByteArray()) — no intermediate String
  • Pre-encoded UTF-8 byte[] field namesif (utf8) writeNameRaw(byte[]); else writeNameRaw(char[]); per field, JIT-specialized

Typed-accessor runtime (default fallback):

  • No getAllFields() / TreeMap allocation per call
  • No getField() reflectionLambdaMetafactory binds typed lambdas once per Descriptor, cached
  • Specialized repeated/map accessorsRepeatedInt/Long/String/Message, TypedMap eliminate per-element type-dispatch switch
  • Same UTF-8 byte[] field name pre-encoding as codegen, via MessageSchema.FieldInfo.nameWithColonUtf8
  • Zero-allocation timestamps — epoch→calendar conversion via integer arithmetic (Howard Hinnant's civil calendar algorithm), exact-size byte buffers
  • fastjson2 striped buffer reuse eliminates per-call buffer allocations

Inspired by fastjson2 and buffa.

Usage

Basic (runtime path, no build changes)

importio.suboptimal.buffjson.BuffJson;
BuffJsonEncoderencoder = BuffJson.encoder();
Stringjson = encoder.encode(myProtoMessage);
byte[] bytes = encoder.encodeToBytes(myProtoMessage);
encoder.encode(myProtoMessage, outputStream);

With Any type support

BuffJsonEncoderencoder = BuffJson.encoder()
.setTypeRegistry(TypeRegistry.newBuilder()
.add(MyMessage.getDescriptor())
.build());
Stringjson = encoder.encode(messageContainingAny);

With protoc plugin (optional, ~2x faster)

Add the plugin to your protobuf-maven-plugin configuration:

<plugin>
<groupId>io.github.ascopes</groupId>
<artifactId>protobuf-maven-plugin</artifactId>
<configuration>
<plugins>
<pluginkind="jvm-maven">
<groupId>io.suboptimal</groupId>
<artifactId>buff-json-protoc-plugin</artifactId>
<version>${buff-json.version}</version>
<mainClass>io.suboptimal.buffjson.protoc.BuffJsonProtocPlugin</mainClass>
</plugin>
</plugins>
</configuration>
</plugin>

No code changes needed — the plugin uses protoc insertion points to inject codec discovery directly into the generated message classes. If the plugin is not configured, the runtime reflection path is used.

The serialization output matches JsonFormat.printer().omittingInsignificantWhitespace().print() exactly.

Deserialization (JSON to protobuf)

BuffJsonDecoderdecoder = BuffJson.decoder();
MyMessagemsg = decoder.decode(json, MyMessage.class);
MyMessagemsg = decoder.decode(bytes, MyMessage.class);
MyMessagemsg = decoder.decode(inputStream, MyMessage.class);
// With Any type supportBuffJsonDecoderdecoder = BuffJson.decoder()
.setTypeRegistry(TypeRegistry.newBuilder()
.add(MyMessage.getDescriptor())
.build());
MyMessagemsg = decoder.decode(json, MyMessage.class);

Mixed pojo + protobuf (fastjson2 registration)

For projects that use JSON.toJSONString() with both POJOs and protobuf messages, register fastjson2 modules from the encoder/decoder:

BuffJsonEncoderencoder = BuffJson.encoder();
BuffJsonDecoderdecoder = BuffJson.decoder();
// Register modules — protobuf messages handled by BuffJson, POJOs by fastjson2JSONFactory.getDefaultObjectWriterProvider().register(encoder.writerModule());
JSONFactory.getDefaultObjectReaderProvider().register(decoder.readerModule());
// Now both work with fastjson2JSON.toJSONString(myProtoMessage); // uses BuffJsonJSON.toJSONString(myPojo); // uses fastjson2 default

Jackson integration

The buff-json-jackson module provides a Jackson Module for projects that use Jackson as their JSON library. It wraps buff-json's encoder/decoder under Jackson's serialization API, so protobuf messages work seamlessly alongside POJOs and records in ObjectMapper:

importio.suboptimal.buffjson.jackson.BuffJsonJacksonModule;
// Register with your ObjectMapperObjectMappermapper = newObjectMapper();
mapper.registerModule(newBuffJsonJacksonModule());
// Protobuf messages serialize/deserialize like any other typeStringjson = mapper.writeValueAsString(myProtoMessage);
MyMessagemsg = mapper.readValue(json, MyMessage.class);
// Works in records and POJOs alongside regular fieldsrecordApiResponse(Stringstatus, MyMessagedata) {}
StringresponseJson = mapper.writeValueAsString(newApiResponse("ok", msg));

For optimal deserialization performance, enable StreamReadFeature.INCLUDE_SOURCE_IN_LOCATION — this lets the deserializer extract raw JSON substrings directly instead of streaming tokens through a buffer:

ObjectMappermapper = JsonMapper.builder()
.enable(StreamReadFeature.INCLUDE_SOURCE_IN_LOCATION)
.addModule(newBuffJsonJacksonModule())
.build();

For google.protobuf.Any support, pass configured encoder and decoder:

varregistry = TypeRegistry.newBuilder().add(MyMessage.getDescriptor()).build();
mapper.registerModule(newBuffJsonJacksonModule(
BuffJson.encoder().setTypeRegistry(registry),
BuffJson.decoder().setTypeRegistry(registry)));

JSON Schema generation

Generate JSON Schema (draft 2020-12) from protobuf message descriptors. Useful for OpenAPI 3.1+, AsyncAPI 3.0+, and MCP tool definitions.

Provided by the buff-json-schema module. When used with the protoc plugin, proto comments are automatically included as description fields in the schema.

importio.suboptimal.buffjson.schema.ProtobufSchema;
// From a DescriptorMap<String, Object> schema = ProtobufSchema.generate(MyMessage.getDescriptor());
// From a Message classMap<String, Object> schema = ProtobufSchema.generate(MyMessage.class);

The schema reflects the Proto3 JSON mapping: int64 types become {"type": "string", "format": "int64"}, Timestamp becomes {"type": "string", "format": "date-time"}, enums become {"type": "string", "enum": [...]}, bytes become {"type": "string", "contentEncoding": "base64"}, etc. Messages include title from the type name and description from proto comments (when the protoc plugin is used). Recursive messages use $defs/$ref. Returns Map<String, Object> for portability — serialize with any JSON library or pass directly to schema-consuming tooling.

buf.validate support

When build.buf:protovalidate is on the classpath, field-level validation constraints are automatically mapped to JSON Schema keywords:

messageCreateUserRequest {
stringname=1 [(buf.validate.field).string.min_len = 1, (buf.validate.field).string.max_len = 100];
stringemail=2 [(buf.validate.field).string.email = true];
int32age=3 [(buf.validate.field).int32 = {gte: 0, lte: 150}];
repeatedstringtags=4 [(buf.validate.field).repeated = {min_items: 1, unique: true}];
}

Generates:

{
"type": "object",
"title": "CreateUserRequest",
"properties": {
"name": {"type": "string", "minLength": 1, "maxLength": 100},
"email": {"type": "string", "format": "email"},
"age": {"type": "integer", "minimum": 0, "maximum": 150},
"tags": {"type": "array", "items": {"type": "string"}, "minItems": 1, "uniqueItems": true}
}
}

Supported mappings include minLength/maxLength, pattern, format (email, uri, uuid, hostname, ipv4, ipv6), minimum/maximum/exclusiveMinimum/exclusiveMaximum, minItems/maxItems/uniqueItems, minProperties/maxProperties, const, enum, and required. Constraints without a JSON Schema equivalent (prefix, suffix, contains, CEL expressions) are included as description text. The dependency is optional — schema generation works without it.

Swagger / OpenAPI integration

The buff-json-swagger module provides a Swagger ModelConverter that automatically resolves protobuf Message types to OpenAPI 3.1 schemas using buff-json-schema:

importio.suboptimal.buffjson.swagger.ProtobufModelConverter;
ModelConverters.getInstance(true).addConverter(newProtobufModelConverter());

Once registered, any protobuf message type used in your API controllers is automatically converted to a proper OpenAPI schema — nested messages, maps, enums, well-known types, recursive types ($ref), and buf.validate constraints are all handled. Schema definitions use full proto names (e.g. my.package.MyMessage) to avoid collisions across packages.

Proto3 JSON Spec Compliance

FeatureStatus
All scalar types (int32, int64, uint32, uint64, sint32, sint64, fixed32, fixed64, sfixed32, sfixed64, float, double, bool, string, bytes)Supported
Unsigned integer formatting (uint32, uint64)Supported
int64/uint64 as quoted stringsSupported
NaN, Infinity, -Infinity as quoted stringsSupported
Nested messagesSupported
Repeated fieldsSupported
Map fields (all key types: string, int, bool, etc.)Supported
Oneof fieldsSupported
Enums as string namesSupported
Proto3 default value omissionSupported
Proto3 explicit presence (optional keyword)Supported
Custom json_nameSupported
google.protobuf.Timestamp (RFC 3339)Supported
google.protobuf.DurationSupported
google.protobuf.FieldMask (camelCase paths)Supported
google.protobuf.Struct / Value / ListValueSupported
All 9 wrapper types (Int32Value, StringValue, etc.)Supported
google.protobuf.Any (with TypeRegistry)Supported
google.protobuf.EmptySupported
Deserialization (JSON to protobuf)Supported

Building

Requires Java 21+ and Maven 3.9+.

mvn clean install

Running Benchmarks

# Full suite — builds, runs JMH, generates markdown report
./run-benchmarks.sh
# Specific benchmark subset
./run-benchmarks.sh "ComplexMessage"# Custom JMH args
./run-benchmarks.sh "SimpleMessage" -wi 3 -i 5 -f 2
# Direct JMH (after mvn package)
java -jar buff-json-benchmarks/target/benchmarks.jar -wi 1 -i 1 -f 1

Reports are written to benchmark-reports/ with raw output, JSON data, and markdown report.

Running Tests

mvn test

The conformance suite parameterizes every test case over all three encoding paths (codegen, typed-accessor, pure reflection) and compares output against JsonFormat.printer().omittingInsignificantWhitespace().print(). Reachability tests in BuffJsonMemoryTest confirm the encoder doesn't retain Message references after a call returns. The Jackson module adds tests covering POJO/record integration, tree model interop, and cross-library roundtrips.

In addition, the buff-json-conformance module is a testee for Google's official protobuf conformance suite (conformance_test_runner), exercising buff-json's proto3 JSON encode/decode against the canonical spec corpus. CI builds the C++ runner from matching protobuf source and runs the proto3 JSON suite once; see buff-json-conformance/CLAUDE.md to run it locally.

For allocation-rate regression detection, run:

./allocation-check.sh # full check (~1 minute)
./allocation-check.sh --quick # faster, less stable iterations

This runs JMH -prof gc on a representative subset and asserts gc.alloc.rate.norm (B/op) stays within per-benchmark budgets defined in the script. Wired into CI as a separate allocation-check job alongside mvn verify.

Project Structure

buff-json/
buff-json/ # Library: BuffJson.encode()/decode() API + internal serialization
buff-json-protoc-plugin/ # Optional protoc plugin for generated encoders/decoders
buff-json-schema/ # JSON Schema generation from protobuf descriptors (no fastjson2 dep)
buff-json-swagger/ # Swagger/OpenAPI ModelConverter for protobuf message schemas
buff-json-jackson/ # Jackson module wrapping BuffJson for ObjectMapper integration
buff-json-tests/ # Conformance tests (both paths) + own .proto definitions
buff-json-benchmarks/ # JMH benchmarks (comparison) + own .proto definitions
buff-json-conformance/ # Testee for the official protobuf proto3 JSON conformance suite

Dependencies

DependencyVersionPurposeModule(s)
com.google.protobuf:protobuf-java4.34.1Protobuf runtime (Message, Descriptor)core, schema, jackson, plugin
com.alibaba.fastjson2:fastjson22.0.63JSON writing enginecore, jackson
com.fasterxml.jackson.core:jackson-databind2.18.1Jackson ObjectMapper integrationjackson
io.swagger.core.v3:swagger-core-jakarta2.2.38Swagger/OpenAPI ModelConverterswagger

License

Apache License 2.0

About

Blazingly fast ⚡️ JSON serialization and deserialization for Protocol Buffer messages in Java — up to ~10x faster than protobuf-java-util's JsonFormat.

Topics

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages