Logging is one of the fundamental components of any application which runs in production. Yet, between performance and logging in critical environments, I'd favor the former. For that reason, modern logging frameworks should implement at least two requirements:
- Async appenders: the write operation shouldn't be blocking the execution of the program
- Lazy computation: the framework doesn't run expensive computations until they are needed - or never if that's the case.
The first logging framework in the Java ecosystem was Log4J. When the main contributor left the project and went on to create SLF4J, Log4J became stale. More than a decade ago, I chose SLF4J over Log4J for that reason.
A couple of years ago, though, I started to become dissatisfied with SLF4J. In particular, even though Java 8 is available since 2014, it didn't offer lazy computations.
LOGGER.debug("Cart total: {}", cart.getTotal())
In the above statement, the cart.getTotal()
is an expensive call but it's executed regardless of the log level. For example, if you set the log level to INFO
, the runtime computes the cart's total but discards it just afterward. A couple of workarounds that I described in this post are available.
I find none of them satisfactory.
The 2.0 version implements lazy computations by allowing to provide Supplier
arguments but:
- It's available in alpha at the time of this writing
- The documentation mentions it in passing
I had a look at Log4J2. It has a lot of interesting features baked in:
- Lazy logging
- Garbage-free logging
- Lookups
- A dedicated JSON layout
- etc.
The time has come for me to reassess my choice about my default choice for a logging framework. I'm considering to use Log4J2 in my next projects.
The problem is that Spring Boot made the same choice as I did: by default, it uses SLF4J. Spring Boot documents how to use Log4J2. It boils down to excluding the spring-boot-starter-logging
in every Spring Boot starter and adding the spring-boot-starter-log4j2
dependency. This is repetitive and fragile: whenever you add a new starter, you must remember to exclude the logging starter.
Let's hack Maven to make it easier. Here's an extract of the result of executing mvn dependency:tree
on one of my projects:
[INFO] +- org.springframework.boot:spring-boot-starter-web:jar:2.4.0:compile
[INFO] | +- org.springframework.boot:spring-boot-starter:jar:2.4.0:compile
[INFO] | | +- org.springframework.boot:spring-boot-starter-logging:jar:2.4.0:compile // 1
[INFO] | | | +- ch.qos.logback:logback-classic:jar:1.2.3:compile
[INFO] | | | | \- ch.qos.logback:logback-core:jar:1.2.3:compile
[INFO] | | | +- org.apache.logging.log4j:log4j-to-slf4j:jar:2.13.3:compile
[INFO] | | | | \- org.apache.logging.log4j:log4j-api:jar:2.13.3:compile
[INFO] | | | \- org.slf4j:jul-to-slf4j:jar:1.7.30:compile
- We want this one to go away because it brings in Logback transitively
The first step is to create an empty JAR:
touch foo
zip empty.zip foo
zip -d empty.zip foo
rm foo
The second step is to add it to our local Maven repository under the same coordinates as the legitimate starter but with a higher version number:
mvn install:install-file -Dfile=empty.zip \
-DgroupId=org.springframework.boot \
-DartifactId=spring-boot-starter-logging \
-Dversion=99 \
-Dpackaging=jar
ls $HOME/.m2/repository/org/springframework/boot/spring-boot-starter-logging/99
The third and final step is to add the newly created dependency to our POM. Because of its closest-version wins strategy, Maven will choose this direct dependency over other transitive dependencies. We shouldn't forget to add the Log4J2 starter as well.
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-logging</artifactId>
<version>99</version>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-log4j2</artifactId>
</dependency>
mvn dependency:tree
now yields the desired result:
[INFO] +- org.springframework.boot:spring-boot-starter-web:jar:2.4.0:compile
[INFO] | +- org.springframework.boot:spring-boot-starter:jar:2.4.0:compile // 1
...
[INFO] +- org.springframework.boot:spring-boot-starter-logging:jar:99:compile // 2
[INFO] \- org.springframework.boot:spring-boot-starter-log4j2:jar:2.4.0:compile // 3
[INFO] +- org.apache.logging.log4j:log4j-slf4j-impl:jar:2.13.3:compile
[INFO] | \- org.apache.logging.log4j:log4j-api:jar:2.13.3:compile
[INFO] +- org.apache.logging.log4j:log4j-core:jar:2.13.3:compile
[INFO] +- org.apache.logging.log4j:log4j-jul:jar:2.13.3:compile
[INFO] \- org.slf4j:jul-to-slf4j:jar:1.7.30:compile
- No more default logging starter
- Our custom JAR that is empty
- Log4J2 dependency
Note that this approach will work on one's machine. If you deploy the empty JAR to your enterprise Maven proxy repository, it will work inside of it as well. But it won't work on machines that don't have access to the empty JAR: thus, it's not an option for publicly available projects. This is a hack after all, albeit an elegant one.
To go further:
Originally published at A Java Geek on December 13th 2020