JDK 14 record classes. A retrospective.
From JDK 14 onwards, we have the possibility of creating records. We'll have a look in this post at my experiences using it on my Bridges project
Records are in many forums debated and sometimes the discussions go off-topic and take strange turns. In this article I will indeed discuss how records can be used with Lombok. At least I will discuss my humble experience with trying to use records with Lombok It may sound a bit odd that I'm doing this at the same time that I'm saying that records are meant to be used within immutability paradigms. However, somethings in Lombok do make sense to use, even with records in some odd occasions and that is part of what sharing my experience is about. Although boiler plate is part of the goal of records, the actual main goal is quite different.
From JEP 359 we can read this:
"Records provide a compact syntax for declaring classes which are transparent holders for shallowly immutable data. This is a preview language feature in JDK 14."
in JEP 359
This essentially means that we want to create immutable classes, with minimum data functionality. Pretty much the @Data annotation from Lombok in a final
way. We won't be getting setters. We are in the end using immutable classes, so that's already implied. Instead, we will only have getters. The idea of shallowly comes really from the idea that records area really a simplified way of creating immutable data, without thinking about boiler plate.
And this is why we find on the same JEP 359 this comment:
"While it is superficially tempting to treat records as primarily being about boilerplate reduction, we instead choose a more semantic goal: modeling data as data. (If the semantics are right, the boilerplate will take care of itself.) It should be easy, clear, and concise to declare shallowly-immutable, well-behaved nominal data aggregates."
To emphasise this, they even provide an explanation on what this JEP 359 is not about:
"It is not a goal to declare "war on boilerplate"; in particular, it is not a goal to address the problems of mutable classes using the JavaBean naming conventions. It is not a goal to add features such as properties, metaprogramming, and annotation-driven code generation, even though they are frequently proposed as "solutions" to this problem."
So now we know that we are probably not even dealing with boiler plate code. This could just be an effect of that. The point is really immutability. We want to create data objects once, use it like a data object, and we don't want to change it until the end of its lifecycle.
When making my article The streaming bridges — A Kafka, RabbitMQ, MQTT and CoAP example, I made a mission to use as much as possible, the new features from Java 14. However, I ended up focusing my attention on JEP 359: Records. This JEP, offers the possibility to avoid boiler plate code and allow developers and engineers to focus on other parts of Software design and development. This is in many ways, what Projet Lombok offered and delivered. However, in record classes, this differs slightly in the sense that we are only concerned about immutable data. In fact, that is the goal of this new feature The subject of this post is my findings and what I have learned.
1. The other JEP's
JEPs in JDK 14 |
305: Pattern Matching for instanceof (Preview) |
343: Packaging Tool (Incubator) |
345: NUMA-Aware Memory Allocation for G1 |
349: JFR Event Streaming |
352: Non-Volatile Mapped Byte Buffers |
358: Helpful NullPointerExceptions |
359: Records (Preview) |
361: Switch Expressions (Standard) |
362: Deprecate the Solaris and SPARC Ports |
363: Remove the Concurrent Mark Sweep (CMS) Garbage Collector |
364: ZGC on macOS |
365: ZGC on Windows |
366: Deprecate the ParallelScavenge + SerialOld GC Combination |
367: Remove the Pack200 Tools and API |
368: Text Blocks (Second Preview) |
370: Foreign-Memory Access API (Incubator) |
2. Environment Setup
The project we will be looking at is developed in Java 14.
Java 14 is currently available in most installation packages. If you have questions about setting up Java 14, then please lookup more information on my Hints&Tricks document.
Alternatively, please take a look at my post: How to make the best of a slow machine running on limited resources with a Windows environment as a Java Engineer. There are a few suggestions there which work for Mac-OS, Windows and Linux.
It's also important that you have Docker desktop installed. We'll be using it to have a look into the process of getting serialized data and bringing it to the database using JPA repositories.
3. JEP in detail
Please note that it's important to read the JEP in order to understand the examples I'm presenting further on.
JEP 359: Records
3.1. Trial and Errors
On the repo in path bl-central-server/bl-web-app, we'll find the module we'll use for this example.
In this solution we'll look at the entrails and alternative ways we can use records and Lombok combined. This isn't a standard way of using records.
Before we get into the details of this, let's first start a docker container with a PostgreSQL databsase:
In the root folder, we first run:
./startPostgreSQL.sh
Then we can run the application, but before we do that let's have a look at the code. In particular, let's look at the FreightDto.java:
I - Annotated non canonical constructor
package org.jesperancinha.logistics.web.data;
import lombok.Builder;
import java.util.List;
public record FreightDto(Long id,
String name,
String type,
Long supplierId,
Long vendorId,
List<ContainerFullDto>composition) {
@Builder
public FreightDto {
}
}
This class is used to send information about Freight Trucks and their cargo. We are using Lombok with it and this is because we want to make use of the builder pattern. This is the record we are going to look at. We are using Lombok in this final example, but let's go a bit further down in the past to other examples first. This is the version before it:
II - Annotated Class
package org.jesperancinha.logistics.web.data;
import lombok.Builder;
import java.util.List;
@Builder
public record FreightDto(Long id,
String name,
String type,
Long supplierId,
Long vendorId,
List<ContainerFullDto>composition) {
public FreightDto() {
this(null, null, null, null, null, null);
}
}
And this is the first version without the use of Lombok:
III - No annotations and canonical constructor
package org.jesperancinha.logistics.web.data;
import java.util.List;
public record FreightDto(Long id,
String name,
String type,
Long supplierId,
Long vendorId,
List<ContainerFullDto>composition) {
public FreightDto() {
this(null, null, null, null, null, null);
}
}
And this is the version as described in the JEP:
IV - Plain record
package org.jesperancinha.logistics.web.data;
import java.util.List;
public record FreightDto(Long id,
String name,
String type,
Long supplierId,
Long vendorId,
List<ContainerFullDto>composition) {
}
In this last primitive version of this class, I ran into a few issues while trying to serialize/deserialize data. Before we run our application lets go from this point up to from where we started reading by following the steps I made: First let's comment out this piece of code on class BridgeLogisticsInitializer.java.
JAI - JacksonAnnotationIntrospector
JacksonAnnotationIntrospector implicitRecordAI = new JacksonAnnotationIntrospector() {
@Override
public String findImplicitPropertyName(AnnotatedMember m) {
if (m.getDeclaringClass()
.isRecord()) {
if (m instanceof AnnotatedParameter parameter) {
return m.getDeclaringClass()
.getRecordComponents()[parameter.getIndex()].getName();
}
if (m instanceof AnnotatedMember member) {
for (RecordComponent recordComponent : m.getDeclaringClass()
.getRecordComponents()) {
if (recordComponent.getName()
.equals(member.getName())) {
return member.getName();
}
}
}
}
return super.findImplicitPropertyName(m);
}
};
objectMapper.setAnnotationIntrospector(implicitRecordAI);
I'll explain later on what this actually does. Now let's copy the contents of IV, into FreightDto.java. If we run the application, we will now get this exception:
Error starting ApplicationContext. To display the conditions report re-run your application with 'debug' enabled.
2020-05-24 08:26:40.518 ERROR 37020 --- [
main] o.s.boot.SpringApplication
: Application run failed
java.lang.IllegalStateException: Failed to execute CommandLineRunner
at o.s.b.SpringApplication.callRunner
(SpringApplication.java:787) ~[spring-boot-2.2.6.RELEASE.jar:2.2.6.RELEASE]
at o.s.b.SpringApplication.callRunners
(SpringApplication.java:768) ~[spring-boot-2.2.6.RELEASE.jar:2.2.6.RELEASE]
at o.s.b.SpringApplication.run
(SpringApplication.java:322) ~[spring-boot-2.2.6.RELEASE.jar:2.2.6.RELEASE]
at o.s.b.SpringApplication.run
(SpringApplication.java:1226) ~[spring-boot-2.2.6.RELEASE.jar:2.2.6.RELEASE]
at o.s.b.SpringApplication.run
(SpringApplication.java:1215) ~[spring-boot-2.2.6.RELEASE.jar:2.2.6.RELEASE]
at o.j.l.w.BridgeLogisticsApplication.main(BridgeLogisticsApplication.java:13) ~[classes/:na]
Caused by: com.fasterxml.jackson.databind.exc.InvalidDefinitionException:
Cannot construct instance of `o.j.l.w.data.FreightDto`
(no Creators, like default construct, exist):
cannot deserialize from Object value (no delegate- or property-based Creator)
at [Source: (BufferedInputStream); line: 3, column: 5]
(through reference chain: java.lang.Object[][0])
at c.f.j.d.e.InvalidDefinitionException.from
(InvalidDefinitionException.java:67)
~[jackson-databind-2.10.3.jar:2.10.3]
at c.f.j.d.DeserializationContext.reportBadDefinition
(DeserializationContext.java:1592)
~[jackson-databind-2.10.3.jar:2.10.3]
at c.f.j.d.DeserializationContext.handleMissingInstantiator
(DeserializationContext.java:1058) ~[jackson-databind-2.10.3.jar:2.10.3]
at c.f.j.d.deser.BeanDeserializerBase.deserializeFromObjectUsingNonDefault
(BeanDeserializerBase.java:1297) ~[jackson-databind-2.10.3.jar:2.10.3]
at c.f.j.d.deser.BeanDeserializer.deserializeFromObject
(BeanDeserializer.java:326)
~[jackson-databind-2.10.3.jar:2.10.3]
at c.f.j.d.deser.BeanDeserializer.deserializer
(BeanDeserializer.java:159)
~[jackson-databind-2.10.3.jar:2.10.3]
at c.f.j.d.deser.std.ObjectArrayDeserializer.deserialize
(ObjectArrayDeserializer.java:195)
~[jackson-databind-2.10.3.jar:2.10.3]
at c.f.j.d.deser.std.ObjectArrayDeserializer.deserialize
(ObjectArrayDeserializer.java:21)
~[jackson-databind-2.10.3.jar:2.10.3]
at c.f.j.d.ObjectMapper._readMapAndClose(ObjectMapper.java:4218)
~[jackson-databind-2.10.3.jar:2.10.3]
at c.f.j.d.ObjectMapper.readValue(ObjectMapper.java:3251)
~[jackson-databind-2.10.3.jar:2.10.3]
at o.j.l.w.BridgeLogisticsInitializer.run(BridgeLogisticsInitializer.java:122)
~[classes/:na]
at o.s.b.SpringApplication.callRunner(SpringApplication.java:784)
~[spring-boot-2.2.6.RELEASE.jar:2.2.6.RELEASE]
... 5 common frames omitted
This means that the runtime cannot find any default canonical constructor. In records, this is true. If no constructors are specified, the only canonical constructor we'll find is just the all arguments constructor. In order to mitigate this, I then tried option III. Let's overwrite our class with that that and see what happends:
Error starting ApplicationContext. To display the conditions report re-run your application with 'debug' enabled.
2020-05-24 08:39:34.340 ERROR 37121 --- [
main] o.s.boot.SpringApplication : Application run failed
java.lang.IllegalStateException: Failed to execute CommandLineRunner
at o.s.b.SpringApplication.callRunner
(SpringApplication.java:787) ~[spring-boot-2.2.6.RELEASE.jar:2.2.6.RELEASE]
at o.s.b.SpringApplication.callRunners
(SpringApplication.java:768) ~[spring-boot-2.2.6.RELEASE.jar:2.2.6.RELEASE]
at o.s.b.SpringApplication.run
(SpringApplication.java:322) ~[spring-boot-2.2.6.RELEASE.jar:2.2.6.RELEASE]
at o.s.b.SpringApplication.run
(SpringApplication.java:1226) ~[spring-boot-2.2.6.RELEASE.jar:2.2.6.RELEASE]
at o.s.b.SpringApplication.run
(SpringApplication.java:1215) ~[spring-boot-2.2.6.RELEASE.jar:2.2.6.RELEASE]
at o.j.l.w.BridgeLogisticsApplication.main(BridgeLogisticsApplication.java:13) ~[classes/:na]
Caused by: c.f.j.d.e.UnrecognizedPropertyException: Unrecognized field "id"
(class o.j.l.w.data.FreightDto),
not marked as ignorable (0 known properties: ])
at [Source: (BufferedInputStream); line: 3, column: 12]
(through reference chain:
java.lang.Object[][0]->o.j.l.w.data.FreightDto["id"])
at c.f.j.d.e.UnrecognizedPropertyException.from
(UnrecognizedPropertyException.java:61)
~[jackson-databind-2.10.3.jar:2.10.3]
at c.f.j.d.DeserializationContext.handleUnknownProperty
(DeserializationContext.java:843)
~[jackson-databind-2.10.3.jar:2.10.3]
at c.f.j.d.deser.std.StdDeserializer.handleUnknownProperty
(StdDeserializer.java:1206)
~[jackson-databind-2.10.3.jar:2.10.3]
at c.f.j.d.deser.BeanDeserializerBase.handleUnknownProperty
(BeanDeserializerBase.java:1592)
~[jackson-databind-2.10.3.jar:2.10.3]
at c.f.j.d.deser.BeanDeserializerBase.handleUnknownVanilla
(BeanDeserializerBase.java:1570)
~[jackson-databind-2.10.3.jar:2.10.3]
at c.f.j.d.deser.BeanDeserializer.vanillaDeserialize
(BeanDeserializer.java:294)
~[jackson-databind-2.10.3.jar:2.10.3]
at c.f.j.d.deser.BeanDeserializer.deserialize(BeanDeserializer.java:151)
~[jackson-databind-2.10.3.jar:2.10.3]
at c.f.j.d.deser.std.ObjectArrayDeserializer.deserialize
(ObjectArrayDeserializer.java:195)
~[jackson-databind-2.10.3.jar:2.10.3]
at c.f.j.d.deser.std.ObjectArrayDeserializer.deserialize
(ObjectArrayDeserializer.java:21)
~[jackson-databind-2.10.3.jar:2.10.3]
at c.f.j.d.ObjectMapper._readMapAndClose(ObjectMapper.java:4218)
~[jackson-databind-2.10.3.jar:2.10.3]
at c.f.j.d.ObjectMapper.readValue(ObjectMapper.java:3251)
~[jackson-databind-2.10.3.jar:2.10.3]
at o.j.l.w.BridgeLogisticsInitializer.run(BridgeLogisticsInitializer.java:122) ~[classes/:na]
at o.s.b.SpringApplication.callRunner(SpringApplication.java:784)
~[spring-boot-2.2.6.RELEASE.jar:2.2.6.RELEASE]
... 5 common frames omitted
Now it says that our field 'id' isn't marked as ignorable. What if we try to just make it ignorable? Let's try it!:
package org.jesperancinha.logistics.web.data;
import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
import java.util.List;
@JsonIgnoreProperties(ignoreUnknown = true)
public record FreightDto(Long id,
String name,
String type,
Long supplierId,
Long vendorId,
List<ContainerFullDto>composition) {
public FreightDto() {
this(null, null, null, null, null, null);
}
}
And now another problem:
Error starting ApplicationContext. To display the conditions report re-run your application with 'debug' enabled.
2020-05-24 08:41:55.389 ERROR 37129 --- [
main] o.s.boot.SpringApplication
: Application run failed
java.lang.IllegalStateException: Failed to execute CommandLineRunner
at o.s.b.SpringApplication.callRunner
(SpringApplication.java:787)
~[spring-boot-2.2.6.RELEASE.jar:2.2.6.RELEASE]
at o.s.b.SpringApplication.callRunners
(SpringApplication.java:768)
~[spring-boot-2.2.6.RELEASE.jar:2.2.6.RELEASE]
at o.s.b.SpringApplication.run(
SpringApplication.java:322)
~[spring-boot-2.2.6.RELEASE.jar:2.2.6.RELEASE]
at o.s.b.SpringApplication.run(SpringApplication.java:1226)
~[spring-boot-2.2.6.RELEASE.jar:2.2.6.RELEASE]
at o.s.b.SpringApplication.run(SpringApplication.java:1215)
~[spring-boot-2.2.6.RELEASE.jar:2.2.6.RELEASE]
at o.j.l.w.BridgeLogisticsApplication.main(BridgeLogisticsApplication.java:13)
~[classes/:na]
Caused by: java.lang.NullPointerException: null
at o.j.l.w.BridgeLogisticsInitializer.lambda$run$1
(BridgeLogisticsInitializer.java:128)
~[classes/:na]
at j.b/j.u.s.ReferencePipeline$3$1.accept
(ReferencePipeline.java:195) ~[na:na]
at j.b/j.u.Spliterators$ArraySpliterator.forEachRemaining
(Spliterators.java:948) ~[na:na]
at j.b/j.u.s.AbstractPipeline.copyInto(AbstractPipeline.java:484) ~[na:na]
at j.b/j.u.s.AbstractPipeline.wrapAndCopyInto(AbstractPipeline.java:474) ~[na:na]
at j.b/j.u.s.ForEachOps$ForEachOp.evaluateSequential(ForEachOps.java:150) ~[na:na]
at j.b/j.u.s.ForEachOps$ForEachOp$OfRef.evaluateSequential(ForEachOps.java:173)
~[na:na]
at j.b/j.u.s.AbstractPipeline.evaluate(AbstractPipeline.java:234) ~[na:na]
at j.b/j.u.s.ReferencePipeline.forEach(ReferencePipeline.java:497) ~[na:na]
at o.j.l.w.BridgeLogisticsInitializer.run(BridgeLogisticsInitializer.java:138)
~[classes/:na]
at o.s.b.SpringApplication.callRunner(SpringApplication.java:784)
~[spring-boot-2.2.6.RELEASE.jar:2.2.6.RELEASE]
... 5 common frames omitted
Well, now it did ignore our properties, but now they are all null! It didn't override the data! Let's now go for example II. To be honest, at this point, everything will still fail with the corresponding exception being thrown. This apparently has to do with the code we commented a while a go. Although we could be lead to the idea that records can somehow replace lombok, in reality what they are mean't to do is to help developers skip the use of some boiler plate code. Namely, the implementation of accessors, equals
, toString
and hashCode
.
Looking back at solution I, which is from where we started, it is also not complete. It basically just allows the usage of @Builder by annotating the non canonical constructor with it.
The goal here is to serialize and deserialize data. For that we can make us of the ObjectMapper. The only thing though is that we need to tell this mapper that we are using records. It just doesn't know that. Luckily we can program it by giving it an JacksonAnnotationIntrospector This is what the code in JAI is meant to do.
First we tell the object JAI to get the annotated parameters from record classes:
if (m instanceof AnnotatedParameter parameter) {
return m.getDeclaringClass()
.getRecordComponents()[parameter.getIndex()].getName();
}
Also note that we are already making use of JEP 305: Pattern Matching for instanceof (Preview). It just makes the code much simpler.
On other calls, we tell JAI to get the annotated members from record classes:
if (m instanceof AnnotatedMember member) {
for (RecordComponent recordComponent : m.getDeclaringClass()
.getRecordComponents()) {
if (recordComponent.getName()
.equals(member.getName())) {
return member.getName();
}
}
}
Without these settings, both the @Builder and the canonical constructors, won't be recognized.
3.2 The actual way to use records
On the repo in path bl-central-server/bl-sensor-data-collector, we'll find the module we'll use for this example.
Specifically let's look at VehicleLogDto.java:
package org.jesperancinha.logistics.sensor.collector.data;
import java.math.BigDecimal;
public record VehicleLogDto(Long id,
String source,
String type,
Long timestamp,
BigDecimal lat,
BigDecimal lon) {
}
This record is being used in this VehicleSensorReceiver:
@Slf4j
@Component
@ConditionalOnProperty(name = "bridge.logistics.vehicle.sensor.active",
matchIfMissing = true)
public class VehicleSensorReceiver {
private final Gson gson;
private final VehicleLogRepository vehicleLogRepository;
private final VehicleRepository vehicleRepository;
private final CountDownLatch latch = new CountDownLatch(1);
public VehicleSensorReceiver(Gson gson, VehicleLogRepository vehicleLogRepository, VehicleRepository vehicleRepository) {
this.gson = gson;
this.vehicleLogRepository = vehicleLogRepository;
this.vehicleRepository = vehicleRepository;
}
public void receiveMessage(byte[] message) {
String messageString = new String(message, Charset.defaultCharset());
VehicleLogDto vehicleLogDto = gson.fromJson(messageString, VehicleLogDto.class);
if (Objects.nonNull(vehicleLogDto.id())) {
vehicleLogRepository.save(VehicleConverter.toModel(vehicleLogDto, vehicleRepository.findById(vehicleLogDto.id())
.orElse(null)));
System.out.println("Received <" + messageString + ">");
} else {
System.out.println("Received <" + messageString + ">");
}
}
public CountDownLatch getLatch() {
return latch;
}
}
This is a RabbitMQ message receiver, but a discussion about RabbitMQ itself is off-topic. The point here is that in this example we are deserializing an object into a record class. It is immutable and we only use it to receive and transfer data. This is one example of how records can actually be used.
4. Wrapping up
After finishing your tests in the bridges project, you can then stop the container and remove it by running the following sequence of commands from the root:
cd bl-central-server/bl-central-psql
docker-compose down
cd ../..
5. Conclusion
In spite of all the workarounds to get records to work with my project, I still find it extremely useful that at least the @Getter, @Data, @ToString and @EqualsAndHashCode , at least, are not strictly necessary anymore with records. I don't mention @Setter in the list because we don't need it and Iin any case, records are immutable and therefore we can't make that parallel with it. This is why it may just be that records don't suit your needs.
The reason we went through trial and error, is just that I think that the combination of using Lombok and records added value to my application. It's not the sandard way of using records, but sharing this may help others facing the same situation.
If your main focus is avoiding boiler plate, then I can't say that it cannot be done with records, but please check if you really don't need to change your data object during runtime. In any case, if you go forward with the use of records, you will still need the @Builder annotation anyways, if you want to stick with that builder syntax.
Apart from this, I was faced with another dilemma in this project. Records just don't seem to work well with JPA repositories. The only way I found to make them work with persistence is by using the Entity Manager, and thus not make any use of the Spring data JPA features. This is the reason why I did not use any record classes to implement the persistence layer. I really wanted to try something new, but contrary to the use of records in serializing, it is still, as of this date, not very clear to me how can we use records with Spring data JPA, if we will ever use them, if there is a better way to do this, if we are going to now be using the EntityManager, etc etc. In other words, when it comes to persistence, it's not very clear to me in which way records can improve performance, readability or anything else. On the other hand it kind of makes sense that it wouldn't work with Spring data JPA. Remember, the point is immutability anyway.
Resources
Thank you!
I hope you enjoyed this article as much as I did making it!
Please leave a review, comments or any feedback you want to give on any of the socials in the links bellow.
I’m very grateful if you want to help me make this article better.
I have placed all the source code of this application on GitHub.
Thank you for reading!