I've recently started on a new project where I need to write nontrivial amounts of Java, which is great! Except that I've never written any Java before, outside some data structure classes. So I'm going to try help myself retain and deepen my knowledge of various Java topics (beyond just syntax & semantics) by keeping short notes of a few paragraphs here on my blog to help me feel like I'm making progress.

Simple Logging Façade for Java (slf4j)

Basically, the idea is to decouple the logging implementation from the log statements in your application until deploy time. At deploy time, you just add a Jar with the logging implementation to your $CLASSPATH, and the logging works. Or, if the Ops team wants to use a different logging framework, then they just swap in a new Jar in production and you the developer don't have to change anything. It just works. That's cool!

Rust has standardized on a similar logging framework (probably inspired by slf4j) which delegates the logging implementation to compile time. However, switching loggin implementations in Rust would require recompiling the entire application and would require at least one line of code change in the Cargo.toml. Win for slf4j on the deployment side!

Adding slf4j to your Java Code

There's a few different patterns here, the log format string (like Python):

// Java
logger.info("Something went wrong: {}", badThing);
# Python
logger.info("Something went wrong: {}", bad_thing);

These format strings are kinda the same as Rust too:

// Rust
info!("Something went wrong {}", bad_displayable_thing);

Another style is the fluent logging that basically uses the "builder" pattern to progressively construct a log message piece by piece:

logger.atDebug().addArgument(myPojo).addArgument(dbConnection).addArgument("Hi Mom!").log("Args: 1={} 2={} 3={}");

You can pass virtually anything to addArgument() and it will be coerced to a printable representation. You must use the at<LEVEL>() methods of Logger to use the fluent API, and .log() to flush it.

Copy-pasting from the docs, you can also label an argument with a key instead of only a position. By default, these two log statements will output the same string:

// using classical API
logger.debug("oldT={} newT={} Temperature changed.", newT, oldT);

// using fluent API
logger.atDebug().addKeyValue("oldT", oldT).addKeyValue("newT", newT).log("Temperature changed.");

Creating the slf4j logger instance

Since we don't know during development what the logging implementation will be, we need to make this a runtime decision by the slf4j framework. That means using a factory. Let's see it in action:

import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

public class HelloWorld {
  public static void main(String[] args) {
    Logger logger = LoggerFactory.getLogger(HelloWorld.class);
    logger.info("Hello World");
  }
}

Notes:

  • Logger - this is the parent class that all logging implementation classes will subclass. It's the actual logging object you call .log() on
  • LoggerFactory - this is how slf4j figures out at runtime which implementation to give you

It's often handy to just have an instance of Logger ready to use, so in at least one codebase at work, I've seen instances of setting up the logger as a static member of the class, something like this:

import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

public class HelloWorld {
  private static final Logger logger = LoggerFactory.getLogger(HelloWorld.class);

  public static void main(String[] args) {
    logger.info("Hello World");
  }
}

Now, we don't have to pass loggers around or make sure each method creates a logger. Instead, every method on this class will now have access to a logger at any time.