Logo

dev-resources.site

for different kinds of informations.

Practical Java 16 - Using Jackson to serialize Records

Published at
4/24/2021
Categories
java
jackson
serialization
records
Author
brunooliveira
Author
13 person written this
brunooliveira
open
Practical Java 16 - Using Jackson to serialize Records

Introduction

With JDK 16 being in General Availability and JDK 17 already in early access, I think it's finally time to explore what is arguably one of the coolest features offered by the "bleeding edge" Java: records, and how to work with them from a practical perspective.

One of the most common workflows for modern applications, relies on serializing what is commonly referred to as a "Data Transfer Object", shortened to DTO, that is an object used to represent a domain concept that we want to serialize to a common format that can be consumed by clients, usually JSON.

A common library to do this in the Java stack, is called Jackson that offers core functionalities and databinding capabilities to serialize Java classes to JSON and to deserialize from JSON into Java classes.

We will see how Jackson works and how records are so much more compact and easier to use.

Current status and usage before Java 16 and Jackson 2.12.x

Currently, using Java < 16 and Jackson, since records are not supported, the only way we have to serialize a Java object into JSON, is to use the Jackson object mapper, as follows:

Let's create a simple PersonDTO class which has the following information:

public class PersonDTO {
    private final String name;
    private final int age;
    private final double weight;
    @JsonSerialize(using = LocalDateSerializer.class)
    private final LocalDate collegeApplicationDate;

    public PersonDTO(String name, int age, double weight,
        @JsonSerialize(using = LocalDateSerializer.class) LocalDate collegeApplicationDate) {
        this.name = name;
        this.age = age;
        this.weight = weight;
        this.collegeApplicationDate = collegeApplicationDate;
    }

    public String getName() {
        return name;
    }

    public int getAge() {
        return age;
    }

    public double getWeight() {
        return weight;
    }

    @JsonSerialize(using = LocalDateSerializer.class)
    public LocalDate getCollegeApplicationDate() {
        return collegeApplicationDate;
    }
Enter fullscreen mode Exit fullscreen mode

As we can see, this is quite verbose, and arguably, most of this boilerplate could be avoided, and the information conveyed by the class would be exactly the same: it's a person with some personal information and an application to a college date, nothing else.

The constructor, the getters and the attributes, add a lot of clutter, but, it's the only way to have a DTO to be serialized by Jackson, as the getters are required so that the library can write them to JSON internally, if we are below Java 16.

Let's use the below service as a kind of placeholder to exercise Jackson:

public class PersonListingService {

    public PersonListingService() {
    }

    public List<PersonDTO> getPersonList() {
        return Arrays.asList(
            new PersonDTO("John",30,60.7, LocalDate.now()),
            new PersonDTO("Shaw", 33, 70.5, LocalDate.now().minusDays(20L)),
            new PersonDTO("Harold Finch", 50, 70, null));
    }
}
Enter fullscreen mode Exit fullscreen mode

We can write a quick test and verify that this works as expected, using Jackson 2.11.4, that comes bundled by default with Spring Initializr in IntelliJ:

 @Test
    void dto_list_can_be_serialized_via_jackson() throws JsonProcessingException {
        ObjectMapper mapper = new ObjectMapper();
        mapper.setDateFormat(new SimpleDateFormat("yyyy-MM-dd"));

        String serializedList = mapper.writeValueAsString(personListingService.getPersonList());

        Assert.assertEquals("[{\"name\":\"John\",\"age\":30,\"weight\":60.7,\"collegeApplicationDate\":\"2021-04-24\"}"
                + ",{\"name\":\"Shaw\",\"age\":33,\"weight\":70.5,\"collegeApplicationDate\":\"2021-04-04\"},{\"name\":\"Harold "
                + "Finch\",\"age\":50,\"weight\":70.0,\"collegeApplicationDate\":null}]", serializedList);
}
Enter fullscreen mode Exit fullscreen mode

This works in Java < 16 and with Jackson 2.11.4, because, the getters in our DTO are used internally by Jackson to map the fields that need to be translated to the JSON: it knows to write an age field because our DTO has a getAge method, and this correspondence is done based on the getters and attributes names, by default.

Let's see now how to make records work with the Jackson version that comes bundled with the Spring Initializr.

Serializing a record using Jackson 2.11.4

We can now "convert" our PersonDTO into a Record, very easily:

public record PersonDTO(String name, int age, double weight,
                        LocalDate collegeApplicationDate) {
}
Enter fullscreen mode Exit fullscreen mode

A Java Record can be thought of as a data class that has specific fields, and can be created as a normal class, using the standard constructor based on the attributes defined above. This means that our original service works as it is declared above, no changes needed.

However, if we try to serialize this as it is above, our test will fail:

com.fasterxml.jackson.databind.exc.InvalidDefinitionException: No serializer found for class com.example.demo.dto.PersonDTO and no properties discovered to create BeanSerializer (to avoid exception, disable SerializationFeature.FAIL_ON_EMPTY_BEANS) (through reference chain: java.util.Arrays$ArrayList[0])
Enter fullscreen mode Exit fullscreen mode

Let's try to add a @JsonSerialize annotation above the record:

@JsonSerialize
public record PersonDTO(String name, int age, double weight,
                        LocalDate collegeApplicationDate) {
}
Enter fullscreen mode Exit fullscreen mode

and we get:

org.junit.ComparisonFailure: expected:<[{["name":"John","age":30,"weight":60.7,"collegeApplicationDate":"2021-04-24"},{"name":"Shaw","age":33,"weight":70.5,"collegeApplicationDate":"2021-04-04"},{"name":"Harold Finch","age":50,"weight":70.0,"collegeApplicationDate":null]}]> but was:<[{[},{},{]}]>
Expected :[{"name":"John","age":30,"weight":60.7,"collegeApplicationDate":"2021-04-24"},{"name":"Shaw","age":33,"weight":70.5,"collegeApplicationDate":"2021-04-04"},{"name":"Harold Finch","age":50,"weight":70.0,"collegeApplicationDate":null}]
Actual   :[{},{},{}]
Enter fullscreen mode Exit fullscreen mode

So now, we did manage to "serialize" something, but, we get an empty array, meaning that each individual record couldn't be serialized correctly.

Without any real "insight" on how records truly work under the hood, it can be hard to debug this, but, it's worth having a deeper look.

"Debugging" records serialization with "old" Jackson

IntelliJ offers us the possibility to convert a record to a class, so, let's do it and see exactly how the JVM "sees" a record as a true data class under the hood:

@JsonSerialize
public final class PersonDTO {
    private final String name;
    private final int age;
    private final double weight;
    @JsonSerialize(using = LocalDateSerializer.class)
    private final LocalDate collegeApplicationDate;

    public PersonDTO(String name, int age, double weight,
        @JsonSerialize(using = LocalDateSerializer.class) LocalDate collegeApplicationDate) {
        this.name = name;
        this.age = age;
        this.weight = weight;
        this.collegeApplicationDate = collegeApplicationDate;
    }

    public String name() {
        return name;
    }

    public int age() {
        return age;
    }

    public double weight() {
        return weight;
    }

    @JsonSerialize(using = LocalDateSerializer.class)
    public LocalDate collegeApplicationDate() {
        return collegeApplicationDate;
    }

    @Override
    public boolean equals(Object obj) {
        if (obj == this)
            return true;
        if (obj == null || obj.getClass() != this.getClass())
            return false;
        var that = (PersonDTO)obj;
        return Objects.equals(this.name, that.name) &&
            this.age == that.age &&
            Double.doubleToLongBits(this.weight) == Double.doubleToLongBits(that.weight) &&
            Objects.equals(this.collegeApplicationDate, that.collegeApplicationDate);
    }

    @Override
    public int hashCode() {
        return Objects.hash(name, age, weight, collegeApplicationDate);
    }

    @Override
    public String toString() {
        return "PersonDTO[" +
            "name=" + name + ", " +
            "age=" + age + ", " +
            "weight=" + weight + ", " +
            "collegeApplicationDate=" + collegeApplicationDate + ']';
    }
}
Enter fullscreen mode Exit fullscreen mode

So a record is nothing more than a "final class" with final fields, and an auto-generated hashcode and equals methods. Interesting.

It's especially interesting from a debugging perspective, because, as we noted earlier, Jackson 2.11.4 requires the getters to be defined explicitely in order to be able to serialize the class to JSON, and, from this representation, it seems like the field names are being used directly to generate the getters, which opens up the road to the next logical step.....

.....

@JsonSerialize
public record PersonDTO(String getName, int getAge, double getWeight,
                        @JsonSerialize(using = LocalDateSerializer.class) LocalDate collegeApplicationDate) {
}
Enter fullscreen mode Exit fullscreen mode

Don't do this at home!! :D

We can actually trick Jackson's reflection mechanism as well as the JVM record conversion into generating "getters" in the underlying data class, which CAN be picked up by Jackson...

Expected :[{"name":"John","age":30,"weight":60.7,"collegeApplicationDate":"2021-04-24"},{"name":"Shaw","age":33,"weight":70.5,"collegeApplicationDate":"2021-04-04"},{"name":"Harold Finch","age":50,"weight":70.0,"collegeApplicationDate":null}]
Actual   :[{"collegeApplicationDate":"2021-04-24","name":"John","age":30,"weight":60.7},{"collegeApplicationDate":"2021-04-04","name":"Shaw","age":33,"weight":70.5},{"collegeApplicationDate":null,"name":"Harold Finch","age":50,"weight":70.0}]
Enter fullscreen mode Exit fullscreen mode

While the fields of the objects are out of order, we see that we do have the data there, and actually it works because, the values in the fields are exactly the same. We left the custom serializer to handle the date, but, using the "artificial getter" actually was a funny way to leverage record's internal construction structure.

The correct way is to use Jackson's annotation AutoDetect:

@JsonAutoDetect(fieldVisibility = JsonAutoDetect.Visibility.ANY)
@JsonSerialize
public record PersonDTO(String name, int age, double weight,
                        @JsonSerialize(using = LocalDateSerializer.class) LocalDate collegeApplicationDate) {
}
Enter fullscreen mode Exit fullscreen mode

this will let Jackson autodetect the fields based on the record's attributes and the test will pass, since the restriction of the getter field will be lifted.

Since records generate the getters directly from the attributes of the record, this is a good way to bypass it, that is perfectly valid and allows us to use records today with Jackson 2.11.4.

How to do it the right way

By upgrading Jackson to it's latest release 2.12.3:

  <!-- https://mvnrepository.com/artifact/com.fasterxml.jackson.core/jackson-core -->
        <dependency>
            <groupId>com.fasterxml.jackson.core</groupId>
            <artifactId>jackson-core</artifactId>
            <version>2.12.3</version>
        </dependency>
        <!-- https://mvnrepository.com/artifact/com.fasterxml.jackson.core/jackson-databind -->
        <dependency>
            <groupId>com.fasterxml.jackson.core</groupId>
            <artifactId>jackson-databind</artifactId>
            <version>2.12.3</version>
        </dependency>
        <!-- https://mvnrepository.com/artifact/com.fasterxml.jackson.core/jackson-annotations -->
        <dependency>
            <groupId>com.fasterxml.jackson.core</groupId>
            <artifactId>jackson-annotations</artifactId>
            <version>2.12.3</version>
        </dependency>
Enter fullscreen mode Exit fullscreen mode

We can make use of the great interoperability between Jackson and records, thanks to the added support for record serialization (more info here ) which means that simply declaring a record in its simplest form:

public record PersonDTO(String name, int age, double weight,
                        @JsonSerialize(using = LocalDateSerializer.class) LocalDate collegeApplicationDate) {
}
Enter fullscreen mode Exit fullscreen mode

will work, and the record will be able to be serialized correctly, leveraging the local date serializer as well.

Conclusion

As Java keeps improving and maturing, some of its features are extremely useful and versatile, and it's always nice to explore them in some detail to understand its nuances and how it works under the hood. Our example of leveraging the knowledge of Jackson together with the way JVM generates a record is a good example of that.

With the right configuration, records can work almost hassle free with Jackson 2.11.4 and it's as seamlessly integrated as normal workflows of today in Jackson's latest releases. Are you using records already? :)

serialization Article's
30 articles in total
Favicon
Java interacting with Apache Avro
Favicon
Apache Avro
Favicon
Protocol Buffers as a Serialization Format
Favicon
WSON (Web Simple Object Notation)
Favicon
Serializing Python Object Using the pickle Module
Favicon
Converting a Unicorn project to Sitecore Content Serialization (SCS)
Favicon
Converting a TDS project to Sitecore Content Serialization (SCS) 
Favicon
Deserializing Polymorphic Lists with Kotlin, Jackson, and Spring Boot
Favicon
Mapify-ts
Favicon
ProtoBuf message serialization in Akka.NET using protobuf-net
Favicon
Pandas Dataframe to AVRO
Favicon
Mapping > Json Converters // true
Favicon
Nested Encoding Efficency
Favicon
Java serialization with Avro
Favicon
Java Serialization with Flatbuffers
Favicon
Java Serialization with Protocol Buffers
Favicon
Serialização de Objectos
Favicon
Effective Java: Consider Serialization Proxies Instead of Serialized Instances
Favicon
Effective Java: For Instance Control, Prefer Enum types to readResolve
Favicon
Effective Java: Write readObject Methods Defensively
Favicon
Effective Java: Consider Using a Custom Serialized Form
Favicon
Effective Java: Prefer Alternatives To Java Serialization
Favicon
ASP.NET XML serialisation issues: Observations on DataContractSerializer
Favicon
ReScript JSON Typed Strongly
Favicon
使用序列化在兩個 Rails 站台間傳遞物件
Favicon
Serialização no Kotlin
Favicon
Working with Firebase Cloud Firestore made easier with "withConverter()"
Favicon
Meet Model Object Mapper, a Database Serialization Utility for Django!
Favicon
How to Speak Binary in TypeScript
Favicon
Practical Java 16 - Using Jackson to serialize Records

Featured ones: