LJC Community Talk: "JaVers: Code Audit Logs Easily in Java"
Talk
The London Java Community (LJC) regularly hosts community talks. There, two speakers each have 30 minutes for their talks. On Wednesday, October 21, 2020, I discussed there how JaVers makes audit logs easy in Java.
This was an extended version of the lightning talk I gave nearly three weeks earlier. Head over there for a quick introduction to audit logs with JaVers!
I want to thank Dominique Carlo and Barry Cranford from RecWorks. They make these community talks possible! So if you need to hire Java developers in London, please consider RecWorks.
Video
The talk is on YouTube. It’s 26 minutes and 33 seconds long.
Slides & Code
Grab the PDF slides here. Because I included selected animation changes, it’s 97 pages.
You can also get the slides in their original Keynote format. “Keynote” is Apple’s presentation application. Why would you do that? I animated the slides, so they are more pleasant to watch. Or maybe you want to peek under the hood to see how I achieved specific effects. Follow this link to download these Keynotes slides.
Getting Started
So you want to add an audit log to your application with JaVers? Wonderful! You’ve come to the right place!
Library & Configuration
The JaVers “Getting Started” documentation tells us how to add the library to your application. There are three ways to include JaVers:
- Spring Boot Starter
That is by far the easiest way to get JaVers and what I used in my application. You have to pick a storage option for your versions: a relational database or MongoDB. As of October 21, 2020, the current Spring Boot Starter version 5.13.0 requires Spring Boot 2.3. If you still use Spring Boot 2.2, then you need to use JaVers version 5.9.4. I didn’t change any of the configuration options. - Spring Integration
If you use Spring without Spring Boot, then this is your option. I’ve never used this. - Vanilla JaVers
This works for all non-Spring environments. I haven’t used that, either.
The code below uses the current version of the Spring Boot Starter 5.13.0 with a relational database.
Create a Version
We recall that we version the DTOs that our business layer receives from and sends to the user interface. So let’s assume we have a customer DTO called Customer1
:
Every object that JaVers versions needs a unique id. Fortunately, our DTOs already have that! So the easiest way is to mark the ID field in our DTO is the org.javers.core.metamodel.annotation.Id
annotation in line 3:
Now we’re ready to create a first version of this customer. Besides the DTO, all we need is a user name. We’ll use “joebloggs” in line 11:
- We get the
Javers
instance through dependency injection (line 1). - We create our DTO in line 5.
- The
commit()
call in line 6 converts thecustomer
object into JSON and stores a version in the JaVers tables.
Congratulations — we created our first version with JaVers!
The JaVers Tables
JaVers uses 4 tables. We look at the two most important ones. The first one is JV_COMMIT
. It stores one record per version. Here are the important columns:
COMMIT_PK | AUTHOR | COMMIT_DATE | COMMIT_ID |
---|---|---|---|
100 | joebloggs | 2020-10-01 18:46:27.567 | 1.0 |
- The
COMMIT_PK
is the globally unique ID of our object version: Across all objects and all versions, only one particular version has theCOMMIT_PK
of100
. AUTHOR
is the user name that we passed into the JaVerscommit()
call above.COMMIT_DATE
is when that version was created. JaVers sets this value for us.- The
COMMIT_ID
is the locally unique ID version number of our object: Only one version of our customer with the ID50
has thatCOMMIT_ID
.
JV_SNAPSHOT
stores the JSON data:
SNAPSHOT_PK | TYPE | STATE | CHANGED_PROPERTIES | MANAGED_TYPE |
---|---|---|---|---|
100 | INITIAL | { “firstName”: “Karsten”, “lastName”: “Silz”, “id”: 50 } | changedProperties=’[ “firstName”, “lastName”, “id” ] | com.company.product. dto.Customer1 |
- The
SNAPSHOT_PK
is theCOMMIT_PK
from theJV_COMMIT
table above. - The
TYPE
isINITIAL
because this is the first version. Later versions have the typeUPDATE
. STATE
is ourCustomer1
instance from above as JSON.CHANGED_PROPERTIES
contains which fields of our object have changed compared to the last version. Since this is the first version, all fields have.MANAGED_TYPE
is the full name of our class.
Query for All Versions
Now that we created a version with JaVers. How do query versions? JaVers gives us a query language, using the so-called fluid notation. Let’s retrieve all versions of our customer DTO first. We could show the result in a list or table to our users:
|
|
Here’s the result output with just one version:
Version details: versionNumber=1.00, author=joebloggs, time=2020-10-01T18:46:27.567
So what did we do here?
- We restrict the query to the version of the
Customer1
class with the ID of50
(line 2). - Our objects could be nested and contain other objects. We’re just interested in an overview here, we don’t want to retrieve these nested objects. That’s why we specify the
ShadowScope.SHALLOW
(line 3). - The querying happens in line 5. We’re retrieving what JaVers calls a snapshot. It contains our versioned object as a property map. Since we don’t want to access the versioned object here, that’s fine with us.
- JaVers returns a list of
CdoSnapshot
(line 5). These contain both the version information and the actual objects. - The version number is the so-called commit ID —
COMMIT_ID
from theJV_COMMIT
(line 8). Although it’s a1.0
in the database,getCommitId().value()
returns1.00
. In my experience, you can ignore the decimal places since the next versions would be2.0
,3.0
, and so forth. - We get the author of the version (line 10) and the time it was created (line 11).
Query For One Version
We saw in the previous section how we can retrieve all versions of our DTOs. How do we get a single version then? Well, nearly the same way:
|
|
Here’s the output:
Version details: versionNumber=1.00, author=joebloggs, time=2020-10-01T18:46:27.567, customer=Customer1{id=50, firstName='Karsten', lastName='Silz'}
Here’s what we did differently to get one version:
- We specify the commit ID
1.0
(lines 1 and 4). - We tell JaVers to rebuild nested objects with the
ShadowScope.DEEP_PLUS
(line 5). OurCustomer1
class doesn’t have nested objects, but objects in our real applications probably do. - We now query for so-called Shadows (lines 7 and 9). They do include our versioned object (line 14).
Comparing Versions
The most complex operation is to compare two objects with each other. Why? Because there can be many different changes, all expressed as their own classes in JaVers. The comparison of two objects itself is simple. In the example, below, both the first name and the last name change:
Predictably, JaVers found both changes:
Differences: Diff:
* changes on com.betterprojectsfaster.talks.javers.customer1.Customer1/50 :
- 'firstName' value changed from 'Karsten' to 'Joe'
- 'lastName' value changed from 'Silz' to 'Cool'
Now in our applications, we need the details of the changes. Here’s how we get them:
|
|
This is the output:
Change: ValueChange{ 'firstName' value changed from 'Karsten' to 'Joe' }
Change: ValueChange{ 'lastName' value changed from 'Silz' to 'Cool' }
Now the sample, we just got a single change — ValueChange
instances. So here are some of the changes we will encounter in our work, each expressed as a different JaVers class:
ValueChange
This indicates a single field change. You get the field name withgetPropertyName()
, the old value withgetLeft()
, and the new one withgetRight()
.NewObject
The new version contains a completely new object. We can get the class name and ID of the new object withgetAffectedGlobalId()
.ObjectRemoved
The new version does not contain a certain object anymore. We also can get the class name and ID of the new object withgetAffectedGlobalId()
.ListChange
One or more changes happened in a list of the new version.getChanges()
returns these changes as aList<ContainerElementChange>
. TheseContainerElementChange
can be:ElementValueChange
shows an element has changed.getIndex()
gives us the position in the list,getLeftValue()
the old value,getRightVlue()
the new one.ValueAdded
is a new element in the new version.getIndex()
gives us the position in the list,getAddedValue()
the added value.ValueRemoved
is a deleted element in the new version.getIndex()
gives us the position in the list,getRemovedValue()
the removed value.
Counting Changes
We saw in the previous section that JaVers returns changes when comparing objects. But the number of changes from JaVers could differ from the number our users expect!
An example: Our Customer1
class has both a firstName
and lastName
. If both change, then that’s two changes to JaVers. But if our UI shows both in one “Name” field, then it’s just one change to our users.
The solution to this problem is to customize counting the changes for these objects. It’s probably best to do that in the UI. After all, the display in the UI dictates how many changes the user perceives. For instance, if the UI suddenly displays the name as first name and last name, we’d be back to two changes!
Versioning Nested Objects
Our classes often include other classes. So let’s extend our customer DTO to include a contact information DTO which itself has a list of email address DTOs:
|
|
- Our new customer DTO class is called
Customer2
. It has acontactInfo
field (line 24). - The
ContactInfo
class has a list ofEmailAddress
instances (line 13). EmailAddress
has atype
field (line 5) and theaddress
(line 6).
So let’s create a version with nested objects:
|
|
What does that produce in the JV_SNAPSHOT
table? Since each nested object has an ID field, we expect each one to get a separate entry. And they do:
SNAPSHOT_PK | TYPE | STATE | CHANGED_PROPERTIES | MANAGED_TYPE |
---|---|---|---|---|
100 | INITIAL | { “address”: “work@provider.com”, “id”: 21, “type”: “WORK” } | changedProperties=’[ “address”, “id”, “type” ] | com.company.product. dto.EmailAddress |
200 | INITIAL | { “address”: “private@provider.com”, “id”: 20, “type”: “PRIVATE” } | changedProperties=’[ “address”, “id”, “type” ] | com.company.product. dto.EmailAddress |
300 | INITIAL | { “firstName”: “Karsten”, “lastName”: “Silz”, “contactInfo”: { “entity”: “com.company.product. dto.ContactInfo”, “cdoId”: 63 }, “id”: 50 } | changedProperties=’[ “firstName”, “lastName”, “contactInfo”, “id” ] | com.company.product. dto.Customer2 |
400 | INITIAL | { “emailAddresses”: [ { “entity”: “com.company.product. dto.EmailAddress”, “cdoId”: 20 }, { “entity”: “com.company.product. dto.EmailAddress”, “cdoId”: 21 } ], “id”: 63 } | changedProperties=’[ “emailAddresses”, “id” ] | com.company.product. dto.ContactInfo |
- The two email addresses occupy the first two records (
SNAPSHOT_PK
of100
and200
). - The customer is the third row
SNAPSHOT_PK
of300
). It includes the contact information by reference:
“contactInfo”: { “entity”: “com.company.product. dto.ContactInfo”, “cdoId”: 63 }
- The referenced contact information is record number four (
SNAPSHOT_PK
of400
). It contains the two email addresses as references, too:
{ “emailAddresses”: [
{ “entity”: “com.company.product. dto.EmailAddress”, “cdoId”: 20 },
{ “entity”: “com.company.product. dto.EmailAddress”, “cdoId”: 21 }
]
Now, if we retrieve an instance of Customer2
with JaVers, do we get all the nested objects, too?
|
|
Yes, we do (formatted for display):
Customer: Customer2{id=50, firstName='Karsten', lastName='Silz',
contactInfo=ContactInfo{id=63, emailAddresses=[
EmailAddress{id=20, type=PRIVATE, address='private@provider.com'},
EmailAddress{id=21, type=WORK, address='work@provider.com'}
]}
}
Querying Nested Objects
We just saw in the previous section that we can version objects that contain other objects. There is one problem, though.
Let’s say we update one of the email addresses that now belong to Customer2
. That instance of EmailAddress
receives a new version number. But the Customer2
instance does not! Yes, when a nested object gets a new version number, then the parent object does not.
Now from Javers’ point of view, that’s correct: The customer didn’t change, an email address did. But from an application user’s point of view, it’s wrong: The email belongs to the customer. So the customer has a new version, too!
How do we solve this?
First of all, that’s a feature, not a bug. And JaVers isn’t alone here: Hibernate Envers behaves exactly the same. There are different solutions. Here’s mine: I make sure that the parent object (Customer2
here) always changes when creating a version. This way, the parent object always gets a new version if one of the nested object changes.
More specifically, I add a boolean
field that flip-flops between true
and false
. This takes up less space in the database than, say, a long random number. The downside is that I need to query the current version from JaVers before I can save a new one.
Customizing Versioning
In this last section, we’ll do some meaningful customizations. Let’s recall the first class we versioned:
And it produced this version entry in JV_SNAPSHOT
:
SNAPSHOT_PK | TYPE | STATE | CHANGED_PROPERTIES | MANAGED_TYPE |
---|---|---|---|---|
100 | INITIAL | { “firstName”: “Karsten”, “lastName”: “Silz”, “id”: 50 } | changedProperties=’[ “firstName”, “lastName”, “id” ] | com.company.product. dto.Customer1 |
The JSON data is quite verbose. Since we’ll store a lot of versions in our real applications, shrinking that JSON size will save database space. So, what can we do here?
I actually wrote an article at Baeldung on how to do just that. I wonder what inspired me here? 🤔 The one technique that applies here is to use short field names. So instead of "firstName": "Karsten"
, we’ll get "f": "Karsten"
. In the article, this reduced the JSON data size by more than 25%. Since the field names also appear in the CHANGED_PROPERTIES
column, we can reduce the database space that our versions take up!
So let’s apply short field names in our new DTO class Customer3
. JaVers gives us the org.javers.core.metamodel.annotation.PropertyName
annotation to specify the field name in the database (lines 4, 6 and 8):
This results in the following entry in JV_SNAPSHOT
:
SNAPSHOT_PK | TYPE | STATE | CHANGED_PROPERTIES | MANAGED_TYPE |
---|---|---|---|---|
101 | INITIAL | { “f”: “Karsten”, “l”: “Silz”, “i”: 50 } | changedProperties=’[ “f”, “l”, “i” ] | com.company.product. dto.Customer3 |
As we can see, both STATE
and CHANGED_PROPERTIES
use the one-letter field names. Nice savings!
Beyond the space savings, refactoring of our Java classes down the road is another reason for customized field names. Let’s imagine we rename lastName
to familyName
in our Java class. This would break the retrieval of old versions: The JSON in the database has lastName
for the old versions and familyName
for the new ones. But with the @Field("l")
annotation, all versions store the field name as l
in JSON.
One thing still sticks out in the database entry above: The value in the MANAGED_TYPE
column is the fully qualified class name com.company.product.dto.Customer3
. There’s an annotation for that, too — org.javers.core.metamodel.annotation.TypeName(line 1):
Now we’re talking:
SNAPSHOT_PK | TYPE | STATE | CHANGED_PROPERTIES | MANAGED_TYPE |
---|---|---|---|---|
101 | INITIAL | { “f”: “Karsten”, “l”: “Silz”, “i”: 50 } | changedProperties=’[ “f”, “l”, “i” ] | C3 |
That’s as compact as it’s going to get!
Another customization is to exclude fields from versioning. Let’s say that we add the number of versions to the class itself. We calculate that by querying the versions before we send the object to our front-end. Now we don’t want this field in our version data. Lucky for us, the org.javers.core.metamodel.annotation.DiffIgnore
annotation lets JaVers ignore fields (line 11):
Wrap-Up
This concludes our code introduction. Let’s recap:
- We started with how to add JaVers to our projects.
- We took a class and created a version of it.
- Then we queried JaVers for all versions and for a single version.
- Next, we compared two instances of a class.
- Nested objects and their versioning was the next stop on our tour.
- Finally, we looked at customizing the versioning.
So head over to JaVers and put a cool audit log into your Java application!
Part 6 of 15
in the Talks
series.
« CinJUG: "Pick Technologies & Tools Faster by Coding with JHipster"
|
LJC Lightning Talk: "JaVers: Easy Audit Logs in Java" »
| Start: LJC Lightning Talk: Eclipse OpenJ9: Memory Diet for Your JVM Applications
Developer job ads down 32% year over year, Stack Overflow questions dropped 55% since ChatGPT. I now recommend IntelliJ Community Edition because many AI code assistants don't run in Eclipse. Job ads for Quarkus hit an all-time high.
Read my newsletter