Scruse is an annotation-processor that generates databind classes to map JSON (and similar formats) to Java classes and vice versa. It is intended for two contexts:
- Reflection is not possible or is discouraged, e.g. when working with GraalVM native images or when static code analysis is important.
- A tiny footprint is required, i.e. jars like jackson-databind are too big.
Scruse does not include any parsers or formatters and requires external ones. Various JSON libraries are supported out-of-the-box, including:
- Jackson streaming (
JsonParser
andJsonGenerator
) - note that this gives you support of many additional input and output formats through existing extensions of these classes like YAML, CBOR, Smile, and more. - Jackson objects (
JsonNode
) - Gson (
JsonParser
andJsonWriter
) - JSON-P
- Fastjson2 (
JSONReader
andJSONWriter
) - Nanojson
Note that Scruse is not a beginner-friendly library. It optimizes for constraints that are not common in most applications. If you just want to serialize and deserialize JSON and do not have any of the constraints above, you are probably better off with Jackson.
If you have ever used Mapstruct, you will feel right at home with Scruse.
Include the following in your POM:
<dependency>
<groupId>org.tillerino.scruse</groupId>
<artifactId>scruse-core</artifactId>
<version>${scruse.version}</version>
</dependency>
<dependency>
<groupId>org.tillerino.scruse</groupId>
<artifactId>scruse-processor</artifactId>
<version>${scruse.version}</version>
<scope>provided</scope>
</dependency>
(Alternatively, you can use scruse-processor
as an annotation processor, both work just fine.)
To generate readers and writers, create an interface and annotate a method with @JsonInput
or @JsonOutput
:
interface MyObjectSerde {
@JsonInput
MyObject read(JsonParser parser, DeserializationContext context) throws IOException;
@JsonOutput
void write(MyObject object, JsonGenerator generator, SerializationContext context) throws IOException;
}
The example above is based on Jackson streaming, which provides JsonParser
for parsing and JsonGenerator
for writing JSON.
The Scruse annotation processor will generate MyJsonMapperImpl
, which implements the interface.
The context parameters can be omitted if they are not explicitly needed.
Scruse can delegate to other suitable @JsonInput
and @JsonOutput
methods whenever possible.
This is very important for keeping the generated code small.
Take the following example:
interface MyObjectSerde {
@JsonInput
List<MyObject> read(JsonParser parser) throws IOException;
@JsonInput
MyObject read(JsonParser parser) throws IOException;
}
Here, the implementation of the first method will call the second method for each element in the list. It is recommended to view the generated code and declare further methods to break down large generated methods. This will work at any level, and you can even declare methods for primitive types.
To organize your methods, you can use the uses
attribute of the @JsonConfig
annotation:
@JsonConfig(uses = {PrimitivesSerde.class})
interface MyObjectSerde {
...
}
interface PrimitivesSerde {
@JsonInput
int readInt(JsonParser parser) throws IOException;
...
}
It is impractical to write actual (de-)serialisers for data types which have a simpler representation like a string.
@JsonValue
and @JsonCreator
are supported, but if you cannot (or do not want to) modify the actual types, you can use converters.
Converters are static methods annotated with @JsonOutputConverter
or @JsonInputConverter
.
See this example for OffsetDateTime
:
public class OffsetDateTimeConverters {
@JsonOutputConverter
public static String offsetDateTimeToString(OffsetDateTime offsetDateTime) {
return offsetDateTime.toString();
}
@JsonInputConverter
public static OffsetDateTime stringToOffsetDateTime(String string) {
return OffsetDateTime.parse(string);
}
}
Converter methods can be either located in the same class as the @JsonInput
or @JsonOutput
method
or in a separate class and referenced with the @JsonConfig
uses
value.
Generics are supported for converters, @JsonValue
, and @JsonCreator
methods.
For example, you can write a converter for Optional<T>
:
public class OptionalConverters {
@JsonOutputConverter
public static <T> T optionalToNullable(Optional<T> optional) {
return optional.orElse(null);
}
@JsonInputConverter
public static <T> Optional<T> nullableToOptional(T value) {
return Optional.ofNullable(value);
}
}
This specific case has already been implemented for reuse in OptionalInputConverters and OptionalOutputConverters. See the converters package for more premade converters.
There is some support for generics. Suppose you have a record with a generic component:
record MyRecord<T>(T genericComponent /*, and a bunch of non-generic components */) { }
Suppose you need several @JsonInput
and @JsonOutput
methods for MyRecord
with different types of T
,
e.g. MyRecord<String>
, MyRecord<Integer>
, and so on.
Firstly, the generics are correctly instantiated, i.e. for MyRecord<String>
, genericComponent
is serialized as a String.
However, simply writing several methods with different types of T
will produce a lot of repeated code.
To avoid this, you can pass sub-(de-)serializers to a generic method.
For a sub-(de-)serializer, you need to define an interface, for example the following:
interface GenericOutput<T> {
@JsonOutput
void write(T whatever, JsonGenerator generator) throws IOException;
}
Then, you can use this interface in the main (de-)serializer:
interface MyRecordSerde {
@JsonOutput
<T> void write(MyRecord<T> record, JsonGenerator generator, GenericOutput<T> subSerializer) throws IOException;
}
The generated code will invoke the passed serializer for the generic component.
Once MyRecordSerde
is used by any other (de-)serializer, any matching sub-(de-)serializer from the classes in @JsonConfig(uses = { ... })
will be passed as the subSerializer
parameter,
or a suitable lambda expression will be passed to instantiate the sub-(de-)serializer from any method in all available (de-)serializers.
This is a fairly involved feature. Please see the examples in GenericsTest and compare the generated code.
Scruse supports multiple backends for reading and writing JSON. You can choose the backend that best fits your requirements and dependencies.
You can get a clearer idea of each backend in practice by looking at the modules in the scruse-tests
directory.
We run the entire test suite for each backend with some exceptions.
The tests are written for the jackson-core
backend and copied/adapted to the other backends in the generate-sources
phase.
The jar of each test module is shaded with the minimizeJar
flag for each test module to estimate the overhead of each backend.
In many cases, this can be optimized further, but we provide this number as a baseline.
If you have trouble picking a backend, here are some ideas:
- Jackson Core if you do not want to worry about anything.
- Fastjson2 if you want speed.
- Nanojson if you want a small footprint.
jackson-core
provides JsonParser
and JsonGenerator
for reading and writing JSON.
We consider this the default backend and use it for most examples.
The required dependency is:
<dependency>
<groupId>com.fasterxml.jackson.core</groupId>
<artifactId>jackson-core</artifactId>
<version>${jackson.version}</version>
</dependency>
Overhead: 740kiB
jackson-databind
provides JsonNode
for reading and writing JSON.
You would only use this instead of jackson-core
if you have some special requirements, e.g.
you cannot guarantee that the order of fields in JSON is stable and require polymorphism.
The required dependency is:
<dependency>
<groupId>com.fasterxml.jackson.core</groupId>
<artifactId>jackson-databind</artifactId>
<version>${jackson.version}</version>
</dependency>
Overhead: 2100kiB
gson
provides JsonParser
and JsonWriter
for reading and writing JSON.
The required dependency is:
<dependency>
<groupId>com.google.code.gson</groupId>
<artifactId>gson</artifactId>
<version>${gson.version}</version>
</dependency>
Overhead: 280kiB
fastjson2
provides JSONReader
and JSONWriter
for reading and writing JSON.
At the time of writing it is the fastest JSON library for Java
according to some benchmarks.
The required dependency is:
<dependency>
<groupId>com.alibaba.fastjson2</groupId>
<artifactId>fastjson2</artifactId>
<version>${fastjson2.version}</version>
</dependency>
Overhead: 1920kiB
jakarta.json-api
is an API definition which provides JsonParser
and JsonGenerator
for reading and writing JSON.
JsonParser#currentToken
is fairly new and not supported by all implementations.
Additionally, JsonParser
does not allow us to properly save the state of the input has ended.
This is why we wrap JsonParserWrapper
around it.
The required dependency is:
<dependency>
<groupId>jakarta.json</groupId>
<artifactId>jakarta.json-api</artifactId>
<version>${jakarta.json.version}</version>
</dependency>
In addition to the API, you need to include an implementation. There are several available.
<dependency>
<groupId>org.apache.johnzon</groupId>
<artifactId>johnzon-core</artifactId>
<version>${johnzon.version}</version>
</dependency>
Johnzon and the API have an overhead of 180kiB.
<dependency>
<groupId>org.glassfish</groupId>
<artifactId>jakarta.json</artifactId>
<version>${glassfish.json.version}</version>
</dependency>
The Glassfish implementation has an overhead of 137kiB.
nanojson
is a small JSON parser and writer.
Its JsonParser
class just misses what we need. To read JSON, you need to create a TokenerWrapper
instance
from an InputStream
or Reader
.
We use JsonAppendableWriter
for writing JSON, which can be obtained from the JsonWriter
factory.
The required dependency is:
<dependency>
<groupId>com.grack</groupId>
<artifactId>nanojson</artifactId>
<version>${nanojson.version}</version>
</dependency>
Overhead: 30kiB
Obviously, Scruse is not complete in any sense, and you will soon reach the limits of the core functionality. We have included some escape hatches, a.k.a. ways to hack your way around missing functionalities.
You can always simply serializers yourself:
interface CustomizedSerialization {
@JsonOutput
void writeMyObj(MyObj o, JsonGenerator generator) throws IOException;
@JsonOutput
default void writeOffsetDate(OffsetDateTime timestamp, JsonGenerator generator) throws IOException {
generator.writeString(timestamp.toString());
}
record MyObj(OffsetDateTime t) { }
}
This works for output and input.
(TODO; something like @JsonSerializer, but this annotation is in databind, so off limits)
(TODO; allow extending context classes, allow abstract classes as mappers, handle constructors of those)
- jackson-databind: The definitive standard for Java JSON serialization ❤️. Jackson is the anti-Scruse: It is entirely based on reflection, and even includes a mechanism to write Java bytecode at runtime to boost performance. Jackson is so large that there is a smaller version called jackson-jr.
- https://github.com/ngs-doo/dsl-json
In general, Scruse tries to be compatible with Jackson's default behaviour. Some of Jackson's annotations are supported, but not all and not each supported annotation is supported fully.
- With polymorphism, Jackson will always write and require a discriminator, even when explicitly limiting the type to a specific subtype. Scruse will not write or require a discriminator when the subtype is known.
- Jackson requires
ParameterNamesModule
and compilation with the-parameters
flag to support creator-based deserialization without @JsonProperty annotations. Scruse does not require this since this information is always present during annotation processing. - Scruse will assign the default value of the property type to absent properties even when converters are used.
Jackson will always use the converter and invoke it with its default argument - I think.
An example of this is that Scruse will initialize an absent
Optional<Optional<T>>
property withOptional.empty()
whereas Jackson will instead initialize it withOptional.of(Optional.empty())
. I asked here: FasterXML/jackson-modules-java8#310
- Polymorphism
- Reflection bridge
Escape hatches: fucky mechanisms to work around missing features, e.g. ghetto injection.
- Allow extending Contexts
- Custom converters per property
- Abstract converter classes with constructors.
The following is a rough indication of compatibility with Jackson's annotations. A checkmark indicates basic compatibility, although there can be edge cases where we are not compatible.
- JacksonInject
- JsonAlias
- JsonAnyGetter
- JsonAnySetter
- JsonAutoDetect
- JsonBackReference
- JsonClassDescription
- JsonCreator - with default
mode
- JsonEnumDefaultValue
- JsonFilter
- JsonFormat
- JsonGetter
- JsonIdentityInfo
- JsonIdentityReference
- JsonIgnore
- JsonIgnoreProperties
- JsonIgnoreType
- JsonInclude
- JsonIncludeProperties
- JsonKey
- JsonManagedReference
- JsonMerge
- JsonProperty (only
value
) - JsonPropertyDescription
- JsonPropertyOrder
- JsonRawValue
- JsonRootName
- JsonSetter
- JsonSubTypes (
failOnRepeatedNames
unsupported) - JsonTypeId
- JsonTypeInfo (not
use
CUSTOM
orDEDUCE
, alwaysinclude
PROPERTY
,defaultImpl
ignored,visible
alwaysfalse
) - JsonTypeName
- JsonUnwrapped
- JsonValue
- JsonView
- Sort out unknown properties (discriminators are repeated for
JsonNode
). - Sort out missing properties.
- Slowly add support for more Jackson annotations, but on a need-to-have basis. There are so many annotations that we cannot support them all.