Monday, December 12, 2022

Efficient JSON serialization with Jackson and Java


When you’re building distributed systems in Java, the problem of serialization naturally arises. Briefly, serialization is the act of creating a representation of an object to store or transmit it and then reconstruct the same object in a different context.

Oracle Java Certification, Oracle Java Career, Java Jobs, Java Prep, Java Tutorial and Materials, Java Learning, Oralce Java JSON

That context could be

◉ Needing the same object in the same JVM but at a different time
◉ Needing the same object in a different JVM, which might be on a different machine
◉ Needing the same object in a non-JVM application

The last of these possibilities deserves a bit more thought. On the one hand, working with a non-JVM application opens the possibility of sharing objects with the whole world of network-connected applications. On the other hand, it can be hard to understand what is meant by “same object” when the object is reconstituted in something that isn’t a JVM.

Java has a built-in serialization mechanism that is likely to have been partially responsible for some of Java’s early success. However, the design of this mechanism is today viewed as seriously deficient, as Brian Goetz wrote in this 2019 post, “Towards better serialization.” While the JDK team has researched ways to rehabilitate (or maybe just remove) the inbuilt platform-level serialization in future versions of Java, developers’ needs to serialize and transport objects have not gone away.

In modern Java applications, serialization is usually performed using an external library as an explicitly application-level concern, with the result being a document encoded in a widely deployed serialization format. The serialization document, of course, can be stored, retrieved, shared, and archived. A preferred format was, once upon a time, XML; in recent years, JavaScript Object Notation (JSON) has become a more popular choice.

Why you should serialize in JSON


JSON is an attractive choice for a serialization format. The following are some of the reasons:

◉ JSON is extremely simple.
◉ JSON is human-readable.
◉ JSON libraries exist for nearly every programming language.

These benefits are counterbalanced by some negatives; the biggest is that a document serialized by JSON can be quite large, which can contribute to poor performance for larger messages. Note, however, that XML can create even larger documents.

Also, JSON and Java evolved from very different programming traditions. JSON provides for a very restricted set of possible value types.

◉ Boolean
◉ Number
◉ String
◉ Array
◉ Object
◉ null

Of these, JSON’s Boolean, String, and null map fairly closely to Java’s conception of boolean, String, and null, respectively. Number is essentially Java’s double with some corner cases. Array can be thought of as essentially a Java List or ArrayList with some differences.

(The inability of JSON and JavaScript to express an integer type that corresponds to int or long turns out to cause its own headaches for JavaScript developers.)

The JSON Object, on the other hand, is problematic for Java developers due to a fundamental difference in the way that JavaScript approaches object-oriented programming (OOP) compared to how Java approaches OOP.

A class comparison. JavaScript does not natively support classes. Instead, it simulates class-like inheritance using functions. The recently added class keyword in JavaScript is effectively syntactic sugar; it offers a convenient declarative form for JavaScript classes, but the JavaScript class does not have the same semantics as Java classes.

Java’s approach to OOP treats class files as metadata to describe the fields and methods present on objects of the corresponding type. This description is completely prescriptive, as all objects of a given class type have exactly the same set of methods and fields.

Therefore, Java does not permit you to dynamically add a field or a method to a single object at runtime. If you want to define a subset of objects that have extra fields or methods, you must declare a subclass. JavaScript has no such restrictions: Methods or fields can be freely added to individual objects at any time.

JavaScript’s dynamic free-form nature is at the heart of the differences between the object models of the two languages: JavaScript’s conception of an object is most similar to that of a Map<String, Object> in Java. It is important to recognize that the type of the JavaScript value here is Object and not ?, because JavaScript objects are heterogeneous, meaning their values can have a substructure and can be of Array or Object types in their own right.

To help you navigate these difficulties, and automatically bridge the gap between Java’s static view and JavaScript’s dynamic view of the world, several libraries and projects have been developed. Their primary purpose is to handle the serialization and deserialization of Java objects to and from documents in a JSON format. In the rest of this article, I’ll focus on one of the most popular choices: Jackson.

Introducing Jackson


Jackson was first formally released in May 2009 and aims to satisfy the three major constraints of being fast, correct, and lightweight. Jackson is a mature and stable library that provides multiple different approaches to working with JSON, including using annotations for some simple use cases.

Jackson provides three core modules.

◉ Streaming (jackson-core) defines a low-level streaming API and includes JSON-specific implementations.
◉ Annotations (jackson-annotations) contains standard Jackson annotations.
◉ Databind (jackson-databind) implements data binding and object serialization.

Adding the databind module to a project also adds the streaming and annotation modules as transitive dependencies.

The examples to follow will focus on these core modules; there are also many extensions and tools for working with Jackson, which won’t be covered here.

Example 1: Simple serialization


The following code fragment from a university’s information system has a very simple class for the people in the system:

public class Person {
    private final String firstName;
    private final String lastName;
    private final int age;

    public Person(String firstName, String lastName, int age) {
        this.firstName = firstName;
        this.lastName = lastName;
        this.age = age;
    }

    public String getFirstName() {
        return firstName;
    }

    public String getLastName() {
        return lastName;
    }

    public int getAge() {
        return age;
    }
}

Jackson can be used to automatically serialize this class to JSON so that it can, for example, be sent over the network to another service that may or may not be implemented in Java and that can receive JSON-formatted data.

You can set up this serialization with a very simple bit of code, as follows:

var grant = new Person("Grant", "Hughes", 19);

var mapper = new ObjectMapper();
try {
    var json = mapper.writeValueAsString(grant);
    System.out.println(json);
} catch (JsonProcessingException e) {
    e.printStackTrace();
}

This code produces the following simple output:

{"firstName":"Grant","lastName":"Hughes","age":19}

The key to this code is the Jackson ObjectMapper class. This class has two minor wrinkles that you should know about.

◉ Jackson 2 supports Java 7 as the baseline version.
◉ ObjectMapper expects getter (and setter, for deserialization) methods for all fields.

The first point is not immediately relevant (it will be in the next example, which is why I’m calling it out now), but the second could represent a design constraint for designing the classes, because you may not want to have getter methods that obey the JavaBeans conventions.

It is possible to control various aspects of the serialization (or deserialization) process by enabling specific features on the ObjectMapper. For example, you could activate the indentation feature, as follows:

var mapper = new ObjectMapper().enable(SerializationFeature.INDENT_OUTPUT);

Then the output will instead look somewhat more human-readable, but without affecting its functionality.

{
  "firstName" : "Grant",
  "lastName" : "Hughes",
  "age" : 19
}

Example 2: Using Java 17 language features


This example introduces some Java 17 language features to help with the data modelling by making Person an abstract base class that prescribes its possible subclasses—in other words, a sealed class. I’ll also change from using an explicit age, and instead I’ll use a LocalDate to represent the person’s date of birth so the student’s age can be programmatically calculated by the application when needed.

public abstract sealed class Person permits Staff, Student {
    private final String firstName;
    private final String lastName;
    private final LocalDate dob;

    public Person(String firstName, String lastName, LocalDate dob) {
        this.firstName = firstName;
        this.lastName = lastName;
        this.dob = dob;
    }

    public String getFirstName() {
        return firstName;
    }

    public String getLastName() {
        return lastName;
    }

    public LocalDate getDob() {
        return dob;
    }

    // ...
}

The Person class has two direct subclasses, Staff and Student.

public final class Student extends Person {
    private final LocalDate graduation;

    private Student(String firstName, String lastName, LocalDate dob, LocalDate graduation) {
        super(firstName, lastName, dob);
        this.graduation = graduation;
    }

    // Simple factory method
    public static Student of(String firstName, String lastName, LocalDate dob, LocalDate graduation) {
        return new Student(firstName, lastName, dob, graduation);
    }

    public LocalDate getGraduation() {
        return graduation;
    }

    // equals, hashcode, and toString elided
}

You can serialize with driver code, which will be slightly more complex.

var dob = LocalDate.of(2002, Month.MARCH, 17);
var graduation = LocalDate.of(2023, Month.JUNE, 5);
var grant = Student.of("Grant", "Hughes", dob, graduation);

var mapper = new ObjectMapper()
                .enable(SerializationFeature.INDENT_OUTPUT)
                .registerModule(new JavaTimeModule());

try {
    var json = mapper.writeValueAsString(grant);
    System.out.println(json);
} catch (JsonProcessingException e) {
    e.printStackTrace();
}

The code above produces the following output:

{
  "firstName" : "Grant",
  "lastName" : "Hughes",
  "dob" : [ 2002, 3, 17 ],
  "graduation" : [ 2023, 6, 5 ]
}

As mentioned earlier, Jackson still requires only Java 7 as a minimum version, and it’s geared around that version. This means that if your objects depend on Java 8 APIs directly (such as classes from java.time), the serialization must use a specific Java 8 module (JavaTimeModule). This class must be registered when the mapper is created—it is not available by default.

To handle that requirement, you will also need to add a couple of extra dependencies to the Jackson libraries’ default. Here they are for a Gradle build script (written in Kotlin).

implementation("com.fasterxml.jackson.core:jackson-databind:2.13.1")
implementation("com.fasterxml.jackson.module:jackson-modules-java8:2.13.1")
implementation("com.fasterxml.jackson.datatype:jackson-datatype-jsr310:2.13.1")

Example 3: Using annotations


The first two examples made it look easy to use Jackson: You created an ObjectMapper object, and the code was automatically able to understand the structure of the Student object and render it into JSON.

However, in practice things are rarely this simple. Here are some real-world situations that can quickly arise when you use Jackson in actual production applications.

In some circumstances, you need to give Jackson a little help. For example, you might want or need to remap the field names from your class into different names in the serialized JSON. Fortunately, this is easy to do with annotations.

public class Person {
    @JsonProperty("first_name")
    private final String firstName;
    @JsonProperty("last_name")
    private final String lastName;
    private final int age;
    private final List<string> degrees;

    public Person(String firstName, String lastName, int age, List<string> degrees) {
        this.firstName = firstName;
        this.lastName = lastName;
        this.age = age;
        this.degrees = degrees;
    }

    // ... getters for all fields
}

Your code will produce some output that looks like the following:

{
  "age" : 19,
  "degrees" : [ "BA Maths", "PhD" ],
  "first_name" : "Grant",
  "last_name" : "Hughes"
}

Note that the field names are now different from the JSON keys and that a List of Java strings is being represented as a JSON array. This is the first usage of annotations in Jackson that you are seeing—but it won’t be the last.

Example 4: Deserialization with JSON


Everything so far has involved serialization of Java objects to JSON. What happens when you want to go the other way? Fortunately, the ObjectMapper provides a reading API as well as a writing API. Here is how the reading API works; this example also uses Java 17 text blocks, by the way.

var json = """
            {
                "firstName" : "Grant",
                "lastName" : "Hughes",
                "age" : 19
            }""";

var mapper = new ObjectMapper();
try {
    var grant = mapper.readValue(json, Person.class);
    System.out.println(grant);
} catch (JsonProcessingException e) {
    e.printStackTrace();
}

When you run this code, you’ll see some output like the following:

com.fasterxml.jackson.databind.exc.InvalidDefinitionException: Cannot construct instance of 'javamag.jackson.ex5.Person' (no Creators, like default constructor, exist): cannot deserialize from Object value (no delegate- or property-based Creator)
 at [Source: (String)"{
  "firstName" : "Grant",
  "lastName" : "Hughes",
  "age" : 19
}"; line: 2, column: 3]
  at com.fasterxml.jackson.databind.exc.InvalidDefinitionException.from(InvalidDefinitionException.java:67)
  at com.fasterxml.jackson.databind.DeserializationContext.reportBadDefinition(DeserializationContext.java:1904)

    // ...

  at com.fasterxml.jackson.databind.ObjectMapper.readValue(ObjectMapper.java:3597)
  at javamag.jackson.ex5.UniversityMain.main(UniversityMain.java:19)

What happened? Recall that ObjectMapper expects getters for serialization—and it wants them to conform to the JavaBeans get/setFoo() convention. ObjectMapper also expects an accessible default constructor, that is, one that takes no parameters.

However, your Person class has none of these things; in fact, all its fields are final. This means setter methods would be totally impossible even if you cheated and added a default constructor to make Jackson happy.

How are you going to resolve this? You certainly aren’t going to warp your application’s object model to comply with the requirements of JavaBeans merely to get serialization to work. Annotations come to the rescue again: You can modify the Person class as follows:

public class Person {
    private final String firstName;
    private final String lastName;
    private final int age;

    @JsonCreator(mode = JsonCreator.Mode.PROPERTIES)
    public Person(@JsonProperty("first_name") String firstName,
                  @JsonProperty("last_name") String lastName,
                  @JsonProperty("age") int age) {
        this.firstName = firstName;
        this.lastName = lastName;
        this.age = age;
    }

    @JsonProperty("first_name")
    public String firstName() {
        return firstName;
    }

    @JsonProperty("last_name")
    public String lastName() {
        return lastName;
    }

    @JsonProperty("age")
    public int age() {
        return age;
    }

    // other methods elided
}

With these hints, this piece of JSON will be correctly deserialized.

{
    "first_name" : "Grant",
    "last_name" : "Hughes",
    "age" : 19
}

The two key annotations here are

◉ @JsonCreator, which labels a constructor or factory method that will be used to create new Java objects from JSON
◉ @JsonProperty, which maps JSON field names to parameter locations for object creation or for serialization

By adding @JsonProperty to your methods, these methods will be used to provide the values for serialization. If the annotation is added to a constructor or method parameter, it marks where the value for deserialization must be applied.

These annotations allow you to write simple code that can round-trip between JSON and Java objects, as follows:

var mapper = new ObjectMapper()
                    .enable(SerializationFeature.INDENT_OUTPUT);
try {
    var grant = mapper.readValue(json, Person.class);
    System.out.println(grant);

    var parsedJson = mapper.writeValueAsString(grant);
    System.out.println(parsedJson);
} catch (JsonProcessingException e) {
    e.printStackTrace();
}

Example 5: Custom serialization


The first four examples explored two different approaches to Jackson serialization. The simplest approaches required no changes to your code but relied upon the existence of a default constructor and JavaBeans conventions. This may not be convenient for modern applications.

The second approach offered much more flexibility, but it relied upon the use of Jackson annotations, which means your code now has an explicit, direct dependency upon the Jackson libraries.

What if neither of these is an acceptable design constraint? The answer is custom serialization.

Consider the following class, which has no default constructor, immutable fields, a static factory, and Java’s record convention for getters:

public class Person {
    private final String firstName;
    private final String lastName;
    private final int age;

    private Person(String firstName, String lastName, int age) {
        this.firstName = firstName;
        this.lastName = lastName;
        this.age = age;
    }

    public static Person of(String firstName, String lastName, int age) {
        return new Person(firstName, lastName, age);
    }

    public String firstName() {
        return firstName;
    }

    public String lastName() {
        return lastName;
    }

    public int age() {
        return age;
    }

}

Suppose you cannot change this code or introduce a direct coupling to Jackson. That’s a real-world constraint: You may be working with a JAR file and might not have access to the source code of this class.

Here is a solution.

public class PersonSerializer extends StdSerializer<person> {
    public PersonSerializer() {
        this(null);
    }

    public PersonSerializer(Class<person> t) {
        super(t);
    }

    @Override
    public void serialize(Person value, JsonGenerator gen, SerializerProvider provider) throws IOException {
        gen.writeStartObject();
        gen.writeStringField("first_name", value.firstName());
        gen.writeStringField("last_name", value.lastName());
        gen.writeNumberField("age", value.age());
        gen.writeEndObject();
    }
}

Here is the driver code, with exception handling omitted to keep this example simple.

var grant = Person.of("Grant", "Hughes", 19);

var mapper = new ObjectMapper()
                    .enable(SerializationFeature.INDENT_OUTPUT);

var module = new SimpleModule();
module.addSerializer(Person.class, new PersonSerializer());
mapper.registerModule(module);

var json = mapper.writeValueAsString(grant);
System.out.println(json);

This example is very simple; in more-complex scenarios the need arises to traverse an entire object tree, rather than just handling simple string or primitive fields. Those requirements can significantly complicate the process of writing a custom serializer for your domain types.

Example 6: Java 17 records


To finish on an upbeat note: Jackson handles Java records seamlessly. The following code shows how it works; again, exception handling is omitted.

Public record Person(String firstName, String lastName, int age) {}

var grant = new Person("Grant", "Hughes", 19);

var mapper = new ObjectMapper()
                .enable(SerializationFeature.INDENT_OUTPUT);

var json = mapper.writeValueAsString(grant);
System.out.println(json);

var obj = mapper.readValue(json, Person.class);
System.out.println(obj);

This code round-trips the grant object without any problems whatsoever. Jackson’s record-handling capability, which is important for many modern applications, provides yet another great reason to upgrade your Java version and start building your domain models using records and sealed types wherever it is appropriate to do so.

Source: oracle.com

Related Posts

0 comments:

Post a Comment