Skip to content

Instantly share code, notes, and snippets.

Show Gist options
  • Star 1 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save michael-simons/72c17cb1cbb0ad9eb673ab2b18bf4272 to your computer and use it in GitHub Desktop.
Save michael-simons/72c17cb1cbb0ad9eb673ab2b18bf4272 to your computer and use it in GitHub Desktop.
///usr/bin/env jbang "$0" "$@" ; exit $?
//JAVA 16
//JAVAC_OPTIONS -source 16
//DEPS info.picocli:picocli:4.2.0
//DEPS org.asciidoctor:asciidoctorj:2.4.2
//DEPS org.fusesource.jansi:jansi:2.1.1
import picocli.CommandLine;
import picocli.CommandLine.ExitCode;
import picocli.CommandLine.Option;
import picocli.CommandLine.Parameters;
import java.io.BufferedReader;
import java.io.File;
import java.io.IOException;
import java.io.InputStreamReader;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.StandardOpenOption;
import java.util.HashMap;
import java.util.Map;
import java.util.Optional;
import java.util.concurrent.Callable;
import java.util.stream.Collectors;
import org.asciidoctor.Asciidoctor;
import org.asciidoctor.OptionsBuilder;
import org.asciidoctor.SafeMode;
import org.asciidoctor.ast.Block;
import org.asciidoctor.ast.Document;
import org.asciidoctor.ast.Section;
import org.asciidoctor.ast.StructuralNode;
import org.asciidoctor.extension.Treeprocessor;
import org.fusesource.jansi.Ansi;
import org.fusesource.jansi.AnsiConsole;
@CommandLine.Command(name = "read", mixinStandardHelpOptions = true,
description = "Renders a blog about the usage of Jackson annotations and mixin")
public class HowtoHandleCyclomaticObjectReferencesInJackson implements Callable<Integer> {
public static final String CONTENT = """
= Howto serialize and deserialize cyclomatic references in Java objects with Jacksons to and from JSON
Michael Simons <michael.simons@neo4j.com>
:doctype: article
:lang: en
:listing-caption: Listing
:source-highlighter: coderay
:icons: font
:sectlink: true
:sectanchors: true
:numbered: true
:xrefstyle: short
:java: 15+
:deps: \
com.fasterxml.jackson.core:jackson-databind:2.11.3 \
com.fasterxml.jackson.module:jackson-module-parameter-names:2.11.3 \
com.fasterxml.jackson.datatype:jackson-datatype-jsr310:2.11.3 \
com.fasterxml.jackson.datatype:jackson-datatype-jdk8:2.11.3 \
org.assertj:assertj-core:3.18.1
== Introduction
I work at Neo4j on our Java based object mappers, Neo4j-OGM and Spring Data Neo4j.
In this role and many times before, when working with JPA / Hibernate, I realized that many people like to map
one-to-many or one-to-one relationships in both directions: From parent to child and back.
This may often look like in the following two classes, <<Child.java>> and <<Parent.java>>.
=== The depending class
[[Child.java]]
[source,java,tabsize=4]
----
public final class Child {
public final Integer id;
public final String name;
private Parent owner;
public Child(Integer id, String name) {
this.id = id;
this.name = name;
}
public Parent getOwner() {
return owner;
}
void setOwner(Parent owner) {
this.owner = owner;
}
}
----
=== The owning class
[[Parent.java]]
[source,java,tabsize=4]
----
import java.util.Collection;
import java.util.Collections;
import java.util.HashSet;
import java.util.Set;
public final class Parent {
public final Integer id;
public final String name;
private final Set<Child> children = new HashSet<>();
public Parent(Integer id, String name) {
this.id = id;
this.name = name;
}
public Child addChild(Child child) {
if (child.getOwner() == null) {
this.children.add(child);
child.setOwner(this);
}
return child;
}
public Collection<Child> getChildren() {
return Collections.unmodifiableCollection(children);
}
}
----
== Using Mixins
I am a big fan of Jackson mixins.
In object-oriented programming languages, a mixin is a class that contains methods for use by other classes
without having to be the parent class of those other classes. Mixins are sometimes described as being "included"
rather than "inherited".
Through a mixin I can keep the above classes free of Jackson specific code. All the annotations go in the mixin.
== The base application
The following class provides us with an instance of Jacksons `ObjectMapper` configured with a couple of modules.
The parameter names module is especially handy, as it allows for immutable objects like `Parent` and `Child`
presented above. It also takes care of executing our tests:
[[ApplicationBase.java]]
[source,java,tabsize=4]
----
import static org.assertj.core.api.Assertions.assertThat;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.datatype.jdk8.Jdk8Module;
import com.fasterxml.jackson.module.paramnames.ParameterNamesModule;
abstract class ApplicationBase {
private final ObjectMapper objectMapper;
ApplicationBase() {
this.objectMapper = new ObjectMapper();
this.objectMapper.registerModule(new Jdk8Module());
this.objectMapper.registerModule(new ParameterNamesModule()); // <.>
addMixins(this.objectMapper);
}
abstract void addMixins(ObjectMapper objectMapper);
final void run() throws Exception {
try {
var parent = new Parent(4711, "A parent"); // <.>
var child = parent.addChild(new Child(23, "A child"));
var jsonString = objectMapper.writeValueAsString(parent); // <.>
System.out.println("Serialized parent as " + jsonString);
System.out.println("Serialized child as " + objectMapper.writeValueAsString(child));
assertThat(objectMapper.readValue(jsonString, Parent.class) // <.>
.getChildren()).hasSize(1).first()
.satisfies(c -> assertThat(c.getOwner()).isNotNull());
} catch (Throwable e) {
System.out.println(e.getMessage());
System.exit(1);
}
}
}
----
<.> Add some helpful modules
<.> Here we create a parent and add a child, thus forming a circle
<.> Serialize to JSON
<.> Assert everything is as expected
== The failing example
The following application creates a parent / child relationship and tries to serialize it without further hints:
[[FailingApplication.java]]
[source,java,runnable=true,tabsize=4]
----
import com.fasterxml.jackson.databind.ObjectMapper;
class FailingApplication extends ApplicationBase {
public static void main(String...args) throws Exception {
new FailingApplication().run();
}
void addMixins(ObjectMapper objectMapper) {}
}
----
The application crashes:
$ java FailingApplication.java
== The simple and often wrong solution: `@JsonIgnore`
How to fix this? One solution comes in quite often: `@JsonIgnore`. While this works fine during serialization,
it will fail on deserialization:
[[IgnorantApplication.java]]
[source,java,runnable=true,tabsize=4]
----
import com.fasterxml.jackson.annotation.JsonIgnore;
import com.fasterxml.jackson.databind.ObjectMapper;
public class IgnorantApplication extends ApplicationBase {
public static void main(String...args) throws Exception {
new IgnorantApplication().run();
}
abstract static class JsonIgnoreMixin {
@JsonIgnore // <.>
abstract Parent getOwner();
}
void addMixins(ObjectMapper objectMapper) {
objectMapper.addMixIn(Child.class, JsonIgnoreMixin.class);
}
}
----
<.> Ignore the parent property
Running the application is only a partial success. It serializes fine, but the assertion fails:
$ java IgnorantApplication.java
Why is this? `@JsonIgnore` marks a property is completely ignore. It is not written during serialization and it
is not read during deserialization.
We add the property with a mixin and run the ignorant application
== Using managed references
This problem can be solved by the combination of `@JsonManagedReference` and `@JsonBackReference`.
`@JsonManagedReference` indicates a bean or a collection of beans managed by another bean.
In our case it looks like this:
[[ManagedReferenceApplication.java]]
[source,java,runnable=true,tabsize=4]
----
import java.util.Collection;
import com.fasterxml.jackson.annotation.JsonBackReference;
import com.fasterxml.jackson.annotation.JsonManagedReference;
import com.fasterxml.jackson.databind.ObjectMapper;
public class ManagedReferenceApplication extends ApplicationBase {
public static void main(String...args) throws Exception {
new ManagedReferenceApplication().run();
}
abstract static class ParentMixin {
@JsonManagedReference // <.>
abstract Collection<Child> getChildren();
}
abstract static class ChildMixin {
@JsonBackReference // <.>
abstract Parent getOwner();
}
void addMixins(ObjectMapper objectMapper) {
objectMapper.addMixIn(Parent.class, ParentMixin.class);
objectMapper.addMixIn(Child.class, ChildMixin.class);
}
}
----
<.> `@JsonManagedReference` goes into the parent class onto the attribute that holds the managed beans.
<.> Whereas the @JsonBackReference goes into the child class onto, well, the back reference of the owning object.
Output is as follows:
$ java ManagedReferenceApplication.java
== Using `@JsonIdentityInfo`
The third option changes the output. It basically tells Jackson how a bean can be identified.
When working with a database, we often have such an identifier: The generated database id (or if we are really
lucky, a unique business identifier).
What does Jackson do with that identifier? Jackson serializes the object normally when it sees it for the first time.
If it comes across it a second time, it doesn't do the whole dance a second (and a tripple ( and so on)) time,
but writes down the id only.
It looks like this:
[[IdentifyInfoApplication.java]]
[source,java,runnable=true,tabsize=4]
----
import static org.assertj.core.api.Assertions.assertThat;
import com.fasterxml.jackson.annotation.JsonIdentityInfo;
import com.fasterxml.jackson.annotation.ObjectIdGenerators;
import com.fasterxml.jackson.databind.ObjectMapper;
public class IdentifyInfoApplication extends ApplicationBase {
public static void main(String...args) throws Exception {
new IdentifyInfoApplication().run();
}
@JsonIdentityInfo(generator = ObjectIdGenerators.PropertyGenerator.class, property = "id")
abstract static class IdentityInfoMixin {
}
void addMixins(ObjectMapper objectMapper) {
objectMapper.addMixIn(Parent.class, IdentityInfoMixin.class);
objectMapper.addMixIn(Child.class, IdentityInfoMixin.class);
}
}
----
Output is as follows:
$ java IdentifyInfoApplication.java
== Personal preferences
It's of course up to the use case, which of the solutions is chosen. In a scenario with Spring Data REST,
I would go with the 2nd one. It fits nicely with the resources exposed by REST.
The first solution can be a fit as well.
If I would offer only a HTTP JSON Api and not deal with resources, I would maybe pick the 3rd one.
However, I would definitely ask the stake holders multiple times if cyclomatic back references are actually
necessary in the domain itself. As you see with the addChild method, the are hard to maintain and I would
avoid going that way.
""";
@Option(names = "-i")
private boolean interactive;
@Parameters(index = "0", defaultValue = "how-to-handle-cyclomatic-object-references-in-jackson.html")
private File output;
public static void main(String... a) {
int exitCode = new CommandLine(new HowtoHandleCyclomaticObjectReferencesInJackson()).execute(a);
System.exit(exitCode);
}
@Override
public Integer call() {
var asciidoctor = Asciidoctor.Factory.create();
asciidoctor.javaExtensionRegistry().treeprocessor(ExtractAndRunJavaClasses.class);
if (!interactive) {
asciidoctor.convert(CONTENT, OptionsBuilder.options()
.toFile(output)
.safe(SafeMode.UNSAFE));
System.out.println("Output has been rendered to " + output.getAbsolutePath());
} else {
var sectionSelector = Map.<Object, Object>of("context", ":section");
var sections = asciidoctor.load(CONTENT, Map.of()).findBy(sectionSelector);
sections.stream().map(Section.class::cast).filter(s -> s.getLevel() == 1).forEach(section -> {
var subsections = section.findBy(sectionSelector).stream()
.map(Section.class::cast)
.filter(s -> s.getLevel() == section.getLevel() + 1).collect(Collectors.toList());
handleSection(section);
subsections.forEach(s -> handleSection(s));
});
}
return ExitCode.OK;
}
record Result(int exitCode, String message) {
String truncatedMessage() {
if (message.length() > 500) {
return message.substring(0, 500) + "…";
}
return message;
}
}
static void handleSection(Section section) {
var ansi = Ansi.ansi(80)
.eraseScreen()
.cursor(0, 0)
.bold()
.a(section.getTitle())
.boldOff()
.newline().newline();
section.getBlocks().stream()
.filter(Block.class::isInstance)
.map(Block.class::cast)
.forEach(n -> {
if (n.hasRole("terminal")) {
ansi.fg(n.getAttribute("exitCode").equals(0L) ? Ansi.Color.GREEN : Ansi.Color.RED);
}
ansi.a(n.getSource()).fgDefault().newline().newline();
});
ansi.append("Press any key to continue…").saveCursorPosition();
AnsiConsole.out().println(ansi);
try {
System.in.read();
} catch (IOException e) {
e.printStackTrace();
}
}
static boolean isRunnable(Block block) {
return Boolean.parseBoolean(block.getAttribute("runnable", "false").toString());
}
public static class ExtractAndRunJavaClasses extends Treeprocessor {
private final Path tmpDir;
public ExtractAndRunJavaClasses(Map<String, Object> config) throws IOException {
super(config);
tmpDir = Files.createTempDirectory("foo");
}
@Override
public Document process(Document document) {
try {
extractJavaClasses(document);
replaceRunnableCalls(document);
} catch (Exception e) {
throw new RuntimeException(e);
}
return document;
}
private void extractJavaClasses(Document document) {
var applicationHeader = new StringBuilder(
"//JAVA " + document.getAttribute("java").toString() + System.lineSeparator() +
"//DEPS " + document.getAttribute("deps").toString() + System.lineSeparator() +
"//JAVAC_OPTIONS -parameters " + System.lineSeparator() +
"//SOURCES ");
var classDefinitions = document
.findBy(Map.of("context", ":listing", "style", "source", "language", "java"))
.stream().map(Block.class::cast).collect(Collectors.toList());
classDefinitions.forEach(n -> {
var p = tmpDir.resolve(Path.of(n.getId()));
try {
if (isRunnable(n)) {
Files.writeString(p, applicationHeader.toString() + System.lineSeparator(),
StandardOpenOption.CREATE, StandardOpenOption.APPEND);
} else {
applicationHeader.append(" ").append(n.getId());
}
Files.writeString(p, n.getSource(), StandardOpenOption.CREATE, StandardOpenOption.APPEND);
} catch (IOException e) {
e.printStackTrace();
}
});
}
private void replaceRunnableCalls(StructuralNode block) {
var blocks = block.getBlocks().listIterator();
while (blocks.hasNext()) {
var childBlock = blocks.next();
if ("paragraph".equals(childBlock.getContext())) {
Optional.ofNullable(((Block) childBlock).getLines())
.filter(l -> !l.isEmpty())
.map(l -> l.get(0))
.filter(l -> l.startsWith("$"))
.map(id -> runRunnable(id.substring(id.lastIndexOf(" ")).trim(), (Block) childBlock))
.ifPresent(blocks::set);
} else {
// It's not a paragraph, so recursively descend into the child node
replaceRunnableCalls(childBlock);
}
}
}
private Block runRunnable(String id, Block originalBlock) {
Result optionalResult;
try {
var process = new ProcessBuilder().directory(tmpDir.toFile())
.command("jbang", id)
.start();
var code = process.waitFor();
var message = new BufferedReader(new InputStreamReader(process.getInputStream()))
.lines().collect(Collectors.joining(System.lineSeparator()));
optionalResult = new Result(code, message);
} catch (Exception e) {
throw new RuntimeException(e);
}
var newAttributes = new HashMap<>(originalBlock.getAttributes());
newAttributes.put("exitCode", optionalResult.exitCode);
var block = createBlock(
(StructuralNode) originalBlock.getParent(),
"listing",
optionalResult.truncatedMessage(),
newAttributes,
new HashMap<>(Map.of("subs", ":specialcharacters")));
block.addRole("terminal");
return block;
}
}
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment