Live Changes with Jackson: Detecting Changes and Taking Appropriate Actions

Maciej Kasik
Javarevisited
Published in
12 min readSep 10, 2023

A Comprehensive Guide to Developing Live Change Systems with Jackson

Photo by Ferenc Almasi on Unsplash

The system for monitoring and responding to real-time changes is often used in applications that need to track and react to changes in data in real-time. Jackson is a popular library for handling JSON format in the Java language, making it a great tool for analyzing and manipulating JSON data in applications. Below, I present steps you can take to create a system for monitoring changes and taking appropriate actions based on Jackson.

Data loading through ObjectMapper: analysis of the process

The process of deserializing data is a crucial step in converting JSON data into Java objects using the ObjectMapper library in Jackson. Deserialization involves analyzing JSON data and assigning it to the corresponding fields and properties of Java objects. Here are some key points regarding this process

ObjectMapper in Jackson handles JSON data deserialization in several different ways, but we will focus on one method:

Deserialization into a specific type: We can use ObjectMapper to deserialize JSON data directly into a specific type of object, for example

MyObject myObject = objectMapper.readValue(jsonString, MyObject.class);

Jackson’s Mapper will attempt to match the field names in Java objects to the keys in the JSON data. By default, it uses the “camelCase” convention for Java fields. In case of name mismatches, we can use annotations such as @JsonProperty to explicitly specify which field should be assigned to which JSON key.

Jackson defaults to using getters and setters to read and write the values of Java object fields during the deserialization process. This means that for Jackson to correctly load JSON data into a Java object, the relevant fields should have public getters and setters that allow reading and writing of data.

Configuration example

Let’s begin by considering an example configuration in JSON format:

{
"connection": {
"host": "localhost",
"port": 3306,
"credentials": {
"username": "db_user",
"password": "db_password"
},
"databaseName": "db_name"
}
}

In order to deserialize the given config, we will need three POJO classes: ConnectionConfiguration, CredentialsConfiguration, and Configuration, where ConnectionConfiguration contains an object of CredentialsConfiguration, and Configuration contains an object of ConnectionConfiguration.

POJO classes

public class Configuration {

private ConnectionConfiguration connection;

public void setConnection(ConnectionConfiguration connection) {
System.out.println("Configuration.setConnection()");
this.connection = connection;
}

public ConnectionConfiguration getConnection() {
System.out.println("Configuration.getConnection()");
return connection;
}
}
public class ConnectionConfiguration {

private String host;
private Integer port;
private String databaseName;
private CredentialsConfiguration credentials;

public String getHost() {
System.out.println("ConnectionConfiguration.getHost()");
return host;
}

public void setHost(String host) {
System.out.println("ConnectionConfiguration.setHost()");
this.host = host;
}

public Integer getPort() {
System.out.println("ConnectionConfiguration.getPort()");
return port;
}

public void setPort(Integer port) {
System.out.println("ConnectionConfiguration.setPort()");
this.port = port;
}

public String getDatabaseName() {
System.out.println("ConnectionConfiguration.getDatabaseName()");
return databaseName;
}

public void setDatabaseName(String databaseName) {
System.out.println("ConnectionConfiguration.setDatabaseName()");
this.databaseName = databaseName;
}

public CredentialsConfiguration getCredentials() {
System.out.println("ConnectionConfiguration.getCredentials()");
return credentials;
}

public void setCredentials(CredentialsConfiguration credentials) {
System.out.println("ConnectionConfiguration.setCredentials()");
this.credentials = credentials;
}
}
public class CredentialsConfiguration {
private String username;
private String password;

public String getUsername() {
System.out.println("CredentialsConfiguration.getUsername()");
return username;
}

public void setUsername(String username) {
System.out.println("CredentialsConfiguration.setUsername()");
this.username = username;
}

public String getPassword() {
System.out.println("CredentialsConfiguration.getPassword()");
return password;
}

public void setPassword(String password) {
System.out.println("CredentialsConfiguration.setPassword()");
this.password = password;
}
}

Now, when we attempt to deserialize our configuration using Jackson,

we get the following sequence:

  1. Configuration.Configuration() — Jackson creates a Configuration object.
  2. ConnectionConfiguration.ConnectionConfiguration() — Jackson creates a ConnectionConfiguration object.
  3. ConnectionConfiguration.setHost() — Jackson sets the host variable.
  4. ConnectionConfiguration.setPort() — Jackson sets the port variable.
  5. CredentialsConfiguration.CredentialsConfiguration() — Jackson creates a CredentialsConfiguration object.
  6. CredentialsConfiguration.setUsername() — Jackson sets the username variable.
  7. CredentialsConfiguration.setPassword() — Jackson sets the password variable.
  8. ConnectionConfiguration.setCredentials() — Jackson sets the CredentialsConfiguration object within the ConnectionConfiguration object.
  9. ConnectionConfiguration.setDatabaseName() — Jackson sets the databaseName variable.
  10. Configuration.setConnection() — Jackson sets the ConnectionConfiguration object within the Configuration object.

Updating a POJO Object: Analyzing the Process

In the previous subsection, we discussed the process of deserializing data using Jackson. Now, let’s focus on analyzing how an existing POJO object can be updated using ObjectReader in Jackson.

To update a POJO, you can use ObjectReader in Jackson, which allows us to perform partial updates on objects, retaining existing values and updating only those provided in the JSON data.

ObjectReader objectReader = objectMapper.readerForUpdating(configuration);

Now, let’s take a look at our modified configuration that we want to update:

{
"connection": {
"host": "localhost2",
"port": 3307,
"credentials": {
"username": "db_user2",
"password": "db_password"
},
"databaseName": "db_name2"
}
}

Next, using the previously defined ObjectReader, we can update the configuration object as follows:

objectReader.readValue(newJson);

Below are the steps that Jackson performs during this process:

  1. Configuration.getConnection() — Jackson uses the getter in the Configuration object.
  2. ConnectionConfiguration.setHost() — Jackson sets the value of the host field through the ConnectionConfiguration getter.
  3. ConnectionConfiguration.setPort() — Jackson sets the value of the port field through the ConnectionConfiguration getter.
  4. ConnectionConfiguration.getCredentials() — Jackson uses the getter in the ConnectionConfiguration object.
  5. CredentialsConfiguration.setUsername() — Jackson sets the value of the username field through the CredentialsConfiguration getter.
  6. CredentialsConfiguration.setPassword() — Jackson sets the value of the password field through the CredentialsConfiguration getter.
  7. ConnectionConfiguration.setDatabaseName() — Jackson sets the value of the databaseName field through the ConnectionConfiguration getter.

Usage of “@JsonMerge” annotation

However, there is a certain issue. Jackson, instead of updating only selected fields, replaces entire objects if they are not provided in the JSON data. We can change this behavior by using the “@JsonMerge” annotation. The “@JsonMerge” annotation affects how Jackson merges JSON data during the deserialization process. Here’s the difference between using and not using this annotation:

Without using the “@JsonMerge” annotation in nested objects:

If the “@JsonMerge” annotation is not used in the POJO classes ConnectionConfiguration, CredentialsConfiguration, and Configuration, Jackson replaces all nested objects with new objects created from the JSON data. This means that the existing Configuration object is completely replaced by a new object created from the JSON data.

With the use of “@JsonMerge” annotation in nested objects:

If the “@JsonMerge” annotation is applied to the fields of the POJO classes ConnectionConfiguration, CredentialsConfiguration, and Configuration, Jackson behaves differently. If the JSON data does not contain a value for a specific field of a nested object, Jackson does not change its value in the Java object, preserving the existing value.

Below is an example of using the “@JsonMerge” annotation in POJO classes:

public class Configuration {
@JsonMerge
private ConnectionConfiguration connection;

// Other fields and methods
}

public class ConnectionConfiguration {
private String host;
private Integer port;
private String databaseName;

@JsonMerge
private CredentialsConfiguration credentials;

// Other fields and methods
}

Update process using “@JsonMerge” annotation

Now, when we use the “@JsonMerge” annotation, the update process will look as follows:

  1. Configuration.getConnection() — Jackson utilizes the getter in the Configuration object.
  2. ConnectionConfiguration.setHost() — Jackson, through the ConnectionConfiguration getter, sets the value of the host field.
  3. ConnectionConfiguration.setPort() — Jackson, through the ConnectionConfiguration getter, sets the value of the port field.
  4. ConnectionConfiguration.getCredentials() — Jackson utilizes the getter in the ConnectionConfiguration object.
  5. CredentialsConfiguration.setUsername() — Jackson, through the CredentialsConfiguration getter, sets the value of the username field.
  6. CredentialsConfiguration.setPassword() — Jackson, through the CredentialsConfiguration getter, sets the value of the password field.
  7. ConnectionConfiguration.setDatabaseName() — Jackson, through the ConnectionConfiguration getter, sets the value of the databaseName field.

With the “@JsonMerge” annotation, Jackson retains existing values of fields in objects that were not provided in the JSON data, enabling partial updates of POJO objects. You can see the process of updating a POJO object using the “@JsonMerge” annotation in the diagram below:

Update

Now that we understand how the process of deserialization and updating of POJO objects works, we can proceed to determine how we want to perform these updates in our system. In this chapter, we will discuss an update strategy called the “Last Common Ancestor.”

What is the “Last Common Ancestor”?

In the “Last Common Ancestor” strategy, when updating a POJO, we utilize the concept of the “Last Common Ancestor.” This means that instead of directly updating data at the level of the target object, we will perform updates at the level of the lowest common ancestor of all the changed fields.

Benefits of the “Last Common Ancestor” strategy

• Precise Updating: This strategy allows for precise updating of only those parts of an object that have actually changed, without the need to overwrite the entire object.

• Preservation of Unchanged Data: Unchanged data in the POJO object remains untouched, contributing to resource savings and ensuring data security.

• Scalability: This strategy is scalable and can be applied to more complex POJO object structures.

Example of an update

Using the change of the username field as an example, the “Last Common Ancestor” strategy would look as follows:

  1. Let’s find the Last Common Ancestor for the username field. In this case, it will be CredentialsConfiguration because that’s where the username field is located.
  2. Perform the update only at the level of CredentialsConfiguration, leaving the other parts of the object untouched.

In the event of changes to the host, port, or password fields, this strategy will operate in a similar manner, where the Last Common Ancestor will be the appropriate class, in this case, ConnectionConfiguration.

What do we need to achieve our goal?

To achieve our goal, which is to implement the “Last Common Ancestor” strategy in updating our system, we need certain key elements:

Linking Configuration Elements to System Elements:

  • We need to determine which elements in our system correspond to specific parts of the configuration. For example, which POJO objects represent hosts, ports, authentication data, etc.

Storing Changes in the Form of a Graph Structure:

  • We must create a graph structure that will represent our POJO objects and their mutual relationships. Each graph node will correspond to a configuration part, and the edges will represent dependencies between them.

Associating Graph Nodes with Corresponding Configuration Elements:

  • Each graph node must be associated with the relevant configuration element. This will allow us to effectively carry out updates at the graph level, which will be reflected in the configuration. Only after introducing these elements will we be able to successfully implement the “Last Common Ancestor” strategy in our system, enabling precise and efficient configuration updates while maintaining data integrity and structure.

Implementation

Linking configuration elements with system elements

To efficiently perform configuration updates, we need to link the elements of our system with configuration in the form of POJO. To achieve this, I propose using an abstract class called UpdateConfiguration, which will be inherited by all POJO classes.

@Setter
@Getter
public abstract class UpdateConfiguration {

public void onUpdate() {
}
}

The UpdateConfiguration class contains an onUpdate() method, which we will override in inheriting classes to define update scenarios for specific subsystems. With this approach, each subsystem will be able to customize the update process according to its needs.

Saving changes in graph structure

To save changes in the form of a graph structure, we can make use of the JGraphT library.

Let’s begin by creating a graph that will represent our configuration

directedGraph = new DefaultDirectedGraph<>(DefaultEdge.class);

We also need to store the root of our graph.

private static ConfigurationVertex root;

Next, let’s define our custom vertex (ConfigurationVertex) which we will connect to the abstract class UpdateConfiguration. This will enable us to utilize the onUpdate() function from the graph vertex.

@Getter
@Setter
public class ConfigurationVertex {

private String name;
private UpdateConfiguration updateConfiguration;

public ConfigurationVertex(String name, UpdateConfiguration updateConfiguration)
{
this.name = name;
this.updateConfiguration = updateConfiguration;
}

@Override
public String toString() {
return name;
}
}

We should change the ‘name’ field to a randomly generated identifier or sequentially number it with consecutive digits, but for the sake of example and clarity, I used names.

Now let’s take a look at the procedure of how Jackson updates using the “@JsonMerge” annotation. During the update process, we can infer a few important things:

  • Setters are tightly coupled with the leaf nodes of the graph.
  • We can obtain the predecessor of the leaf nodes while executing getters.

To track the predecessor, let’s add a new field called “parent” with the @JsonIgnore annotation to the UpdateConfiguration class. We will use it to store information about the previous graph node.

@Setter
@Getter
public abstract class UpdateConfiguration {

@JsonIgnore
private ConfigurationVertex parent;

public void onUpdate() {
}
}

Graph root:

Let’s illustrate this with an example of the getConnection getter:

public ConnectionConfiguration getConnection() {
if (this.connection != null) {
ConfigurationVertex configurationVertex = new ConfigurationVertex("connection",this.connection);
this.connection.setParent(configurationVertex);
Main.getDirectedGraph().addVertex(configurationVertex);
Main.setRoot(configurationVertex);
}
return connection;
}
  • this.connection != null — we check whether the ‘connection’ field is not empty because we want to execute procedures only when a value is assigned.
  • ConfigurationVertex configurationVertex = new ConfigurationVertex(“connection”, this.connection) — we create our custom graph vertex and pass a reference to the ‘connection’ field.
  • this.connection.setParent(configurationVertex) — we set the previous vertex to our new vertex ( ‘parent’ is in the context of fields within ConnectionConfiguration).

Other vertices:

The procedures for the remaining vertices are similar to the root procedure, but with an additional step. We add one more procedure:

Main.getDirectedGraph().addEdge(getParent(), configurationVertex, new DefaultEdge())

— we add an existing edge to our graph. This procedure establishes a connection between the previous vertex (parent) and the new vertex (configurationVertex), reflecting the relationship between them in our graph.

Procedures for setters

Let’s consider this with the example of the setHost setter:

public void setHost(String host) {
if (this.host != null && !this.host.equals(host)) {
ConfigurationVertex configurationVertex = new ConfigurationVertex("host", this);
Main.getDirectedGraph().addVertex(configurationVertex);
Main.getDirectedGraph().addEdge(getParent(), configurationVertex, new DefaultEdge());
}
this.host = host;
}
  • this.host != null — we check if the host field is not empty. This check is important because we want the procedures to execute only during ObjectReader loading when values are already assigned and can be overwritten.
  • !this.host.equals(host) — we check if the new value is different from the previous one. This check allows us to determine if there is indeed a change that requires writing to the graph.
  • ConfigurationVertex configurationVertex = new ConfigurationVertex(“host”, this) — we create our custom graph vertex representing the change in the host field and pass a reference to this.
  • Main.getDirectedGraph().addVertex(configurationVertex) — we add the created vertex to the graph.
  • Main.getDirectedGraph().addEdge(getParent(), configurationVertex, new DefaultEdge()) — we add an edge from the previous vertex (parent) to our new vertex (configurationVertex). This reflects the dependency between the previous and new values in our graph.

After making these changes and updating the configuration as described in previous chapter, we should obtain the following graph. As you can see, there is no change in the “password” vertex, so everything is working as expected.

Performing the Lowest Common Ancestor (LCA) Algorithm on the Given Graph

In this subsection, we will not discuss the operation of the LCA algorithm itself but will focus on its implementation based on an existing method in the JGraphT library.

Finding the leaves of a graph

We are looking for vertices that have one edge and are not roots.

public static List<ConfigurationVertex> findLeafNodes(DefaultDirectedGraph<ConfigurationVertex, DefaultEdge> graph) {
List<ConfigurationVertex> leafNodes = new ArrayList<>();
for (ConfigurationVertex vertex : graph.vertexSet()) {
if (graph.edgesOf(vertex).size() == 1) { // Vertex has no outgoing edges, so it's a leaf node
if (!vertex.getName().equals("connection") ) { // root node
leafNodes.add(vertex);
}
}
}

return leafNodes;
}

Setting up the LCA algorithm

First, let’s initialize the Tarjan algorithm using the JGraphT library:

The Tarjan’s algorithm is an efficient technique for finding the Lowest Common Ancestor (LCA) in trees and directed graphs. Its fundamental operation relies on traversing the graph using Depth-First Search (DFS) and employing the technique of recursive exploration. The key component of the Tarjan’s algorithm involves the creation of data structures such as stacks and arrays to keep track of information about visited vertices and edges during the DFS process.

In JGraphT we use the tarjan algorithm in this way:

LowestCommonAncestorAlgorithm<ConfigurationVertex> lcaFinder = new TarjanLCAFinder<>(directedGraph, root);

It’s worth noting a few things when searching for algorithms. This algorithm is executed on a graph, not a binary tree, so we need to adapt it to our needs.

We will create our own method that will utilize the LCA algorithm to find the common ancestor in the graph. This method will take a list of vertices representing leaves of the graph.

public static ConfigurationVertex findLowestCommonAncestor(LowestCommonAncestorAlgorithm<ConfigurationVertex> lcaFinder, List<ConfigurationVertex> leafNodes) {
if (leafNodes.isEmpty()) {
return null;
}

ConfigurationVertex lca = leafNodes.get(0);

for (int i = 1; i < leafNodes.size(); i++) {
lca = lcaFinder.getLCA(lca, leafNodes.get(i));
}

return lca;
}
  • If the list of leaves is empty, we return null because there is no common ancestor.
  • We initialize the variable “lca” with the first leaf from the list.
  • Then, we iterate through the remaining leaves and use the LCA algorithm to find the common ancestor “lca” for all the leaves.
  • We return the found common ancestor.

Calling the onUpdate() method

Now that we have found a common ancestor, we can call the onUpdate() function at the appropriate level of our configuration. This procedure will ensure that changes are accurately reflected and applied throughout the configuration system.

ConfigurationVertex lca = findLowestCommonAncestor(lcaFinder, leafNodes);
if (lca != null) {
lca.getUpdateConfiguration().onUpdate();
}

Therefore, before calling onUpdate(), we make sure that LCA is available. Thanks to this approach to configuration management, we can effectively update our system, maintaining consistency and accuracy of configuration at different levels of the hierarchy.

Code: https://github.com/1mkmk/medium-live-changes-system

Bibliography

  • Gabow, H.N., & Tarjan, R.E. (1983). A linear-time algorithm for a special case of disjoint set union.

--

--