Snap is an experimental Java library for serialization/deserialization, focused on clarity and cohesion.
The central idea is very simple. First, you make your objects create snapshots of their own state, and these snapshot objects. Next, you implement serializers and deserializers that work with snapshots. This approach requires some extra work but can result in more flexible and robust code.
Snap is pretty much just a basic API, so you also need an implementation for your serializers and deserializers. You can roll out your own, or use an existing backend:
- snap-json-jakarta. Currently a work in progress. Uses the Jakarta JSON API.
- snap-json-jackson. Currently a work in progress. Uses Jackson Core + Databind.
This minimal example shows how to give a class the ability to create snapshots:
public class Message implements Resource {
private final String content;
public Message(final String content) {
this.content = content;
}
@Override
public Snapshot snapshot() {
return () -> Map.ofEntries(
Map.entry("content", new StringNode(this.content))
);
}
}Important takeaways from this snippet:
- The
Resourceinterface exposes asnapshot()method, which creates a snapshot of the object's state. The returned object implementsSnapshot. - The
Snapshotinterface, in turn, exposes anodes()method that provides access to its nodes. The returned object is aMap<String, Node>, and you can process those nodes as you see fit. Noderepresents all types of values your snapshot can have.StringNodeis an implementation of this interface, and represents a single string value. It's worth mentioning thatNodeis a sealed interface, so you cannot create new implementations for it.
Note that we haven't written a single line of serialization logic yet. This is only for modeling a generic snapshot
(i.e. not tied to any particular format). In order to serialize snapshots to a given format, you create a class
implementing Serializer that converts snapshots into your preferred format. This only needs to be done once for each
target format, not for each type of resource. For example, consider
this JSON serializer using the Jakarta JSON API:
public final class JakartaJsonSerializer implements Serializer<JsonValue> {
private final JsonBuilderFactory jsonBuilderFactory;
public JakartaJsonSerializer(final JsonBuilderFactory jsonBuilderFactory) {
this.jsonBuilderFactory = jsonBuilderFactory;
}
@Override
public JsonValue serializeNumber(final Number number) {
final String representation = new DecimalFormat(
"#.##################",
new DecimalFormatSymbols(Locale.ENGLISH)
).format(number);
return Json.createValue(new BigDecimal(representation));
}
@Override
public JsonValue serializeBoolean(final boolean bool) {
return (bool) ? JsonValue.TRUE : JsonValue.FALSE;
}
@Override
public JsonValue serializeString(final String string) {
return Json.createValue(string);
}
@Override
public JsonValue serializeList(final List<Node> list) {
final JsonArrayBuilder arrayBuilder = this.jsonBuilderFactory.createArrayBuilder();
list.stream()
.map(node -> node.serialize(this))
.forEach(arrayBuilder::add);
return arrayBuilder.build();
}
@Override
public JsonValue serializeMap(final Map<String, Node> map) {
final JsonObjectBuilder objectBuilder = this.jsonBuilderFactory.createObjectBuilder();
map.entrySet().stream()
.filter(entry -> entry.getValue() != null)
.forEach(entry -> objectBuilder.add(entry.getKey(), entry.getValue().serialize(this)));
return objectBuilder.build();
}
@Override
public JsonValue serializeNil() {
return JsonValue.NULL;
}
}A snapshot is an object that represents the state of a domain object at a given point. The concept is heavily based on the Memento design pattern. Working with snapshots instead of polluting your domain classes with serialization logic has two advantages: higher control over how objects are serialized and stronger encapsulation.
Snapshots are essentially mappings of string keys to node values. There's only six types of nodes: number, boolean, string, list, map and nil.
These types can be grouped into:
- Scalars (number, string and boolean). They represent a single element.
- Collections (list and map). They represent a group of elements, which may or may not belong to the same type.
- Nil. Represents the absence of value. It's the equivalent of Java's
null.
Serializing a collection node implies recursively serializing each node until a scalar or nil node is found.
A serializer is an object that transforms individual nodes to a specific type of payload. For example, a JSON serializer
might transform nodes to JsonValue objects (Jakarta JSON API) or JsonNode (Jackson). Serializers are based on the
Visitor design pattern.
Conversely, a deserializer is an object that transforms a source payload to a snapshot. Because all snapshots share a common interface, converting a payload to a snapshot shouldn't require casts, reflection or any other weird tricks.