Crafting Log4j Configuration DSL

Crafting Log4j Configuration DSL

Eugene Petrenko

In this post I show how to implement The DSL Way to manage Log4j configuration and extend an IDE without writing a plugin for it

The Problem

Log4j configuration can be either in .xml file or in .properties files. Both formats are not supported well in IDEs.

I'll show how to create a decent IDE support for Log4j configuration files without writing an IDE plugin at all. We illustrate how The DSL Way is applied here.

The Basic Assumptions

I decided to use IntelliJ IDEA as an IDE and Kotlin as \(Target Language \).

Kotlin is a static typed opensource language by JetBrains. It's easy to learn and use. For us it's vital that is has a static typed DSLs.

The Original Language

A configuration of a Log4j loggers looks like this:

 log4j.rootLogger=ERROR,stdout
 log4j.logger.corp.mega=INFO
 # meaningful comment goes here
 log4j.logger.corp.mega.itl.web.metrics=INFO
 log4j.appender.stdout=org.apache.log4j.ConsoleAppender
 log4j.appender.stdout.layout=org.apache.log4j.PatternLayout
 log4j.appender.stdout.layout.ConversionPattern=%p\t%d{ISO8601}\t%r\t%c\t[%t]\t%m%n

A Transformation

Let's implement the following scheme for Log4j configurations in .properties file format.

See The DSL Way post for more details on the approach

The implementation of \(generate\) and \(execute\) transitions is an engineering task of average complexity. Below I focus mostly on a creativity part -- on a build of a DSL API that provides good readability, refactoring and find-usages in an IDE

Building a DSLs

Creating a DSL is a repeating process. You create a first version of it, check how it looks and how one can use it. Next some improvements are done. Next you repeat. At some point you have a nice solution.

Building a DSL requires detailed knowledge of \(Target Language\), you should understand how to translate any strings into some valid expression in your language. I would recommend checking the following articles on Kotlin to learn more about how DSLs are created:

Step 0. A Straightforward DSL

We start with simplistic thing.
As a starting point we need an entry function log4j, a builder interface Log4J with two methods comment and param. Log4JBase is added here for compatibility with future code samples.

```kotlin` interface Log4JBase { fun comment(text: String) fun param(name: String, value: String) }

interface Log4J: Log4JBase

fun log4j(builder: Log4J.() -> Unit)


Please follow to [Kotlin](https://kotlinlang.org) documentation for better understanting 
of the code above. 

This allows us to \\(generate\\) the following Kotlin code for a Logger configurations

```kotlin
 log4j {
  param("log4j.rootLogger", "ERROR,stdout")
  param("log4j.logger.corp.mega", "INFO")
  comment("meaningful comment goes here")
  param("log4j.logger.corp.mega.itl.web.metrics", "INFO")
  param("log4j.appender.stdout", "org.apache.log4j.ConsoleAppender")
  param("log4j.appender.stdout.layout", "org.apache.log4j.PatternLayout")
  param("log4j.appender.stdout.layout.ConversionPattern", "%p\t%d{ISO8601}\t%r\t%c\t[%t]\t%m%n")
 }

At that point we have a trivial DSL. Next we will be improving it. There is still no support for semantic checks or model. We now have all Kotlin language features opened for crafting a .properties file. The DSL Way handles .properties escaping allowing us to write strings as is.

Using Kotlin here creates a way to meta-extend the original format. We are able now to use functions, conditions, string manipulation, libraries and everything we have in Kotlin. All such tools are projected into the \(Original Language\), a .properties file. A \(generator\) can be smart to generate a compact code with use of Kotlin features. It may, for example, fold duplicates into loops or function calls.

Let's make the DSL for Log4j configuration more expressive and readable

Step 1. Improving the DSL

There is a wellknown parameter log4j.rootLogger. IDE code completion is unaware about a fancy property one should use. A user also may not know which is the right property. Finally, one may misprint the name of it. Let's replace it with an explicit call. For an extension property in Kotlin is used

var Log4J.rootLogger : String
  set(value: String) = param("log4j.rootLogger", value)
  get() = throw Error("Read API is not implemented")

Now the improved part is

log4j {
  rootLogger = "stdout"

  //instead of
  param("log4j.rootLogger", "stdout")
}

Step 2. Builders for Appenders

Let's take a look on the code

  param("log4j.appender.stdout", "org.apache.log4j.ConsoleAppender")
  param("log4j.appender.stdout.layout", "org.apache.log4j.PatternLayout")
  param("log4j.appender.stdout.layout.ConversionPattern", "%p\t%d{ISO8601}\t%r\t%c\t[%t]\t%m%n")

Log4j uses a key name encoding to achieve the goal. This requires one to re-type similar strings on and one. This may be a source of typos. From the other hand, this can be hard to read. Let's avoid constant repeating strings and make those lines more expressive. For that we define the following extension methods in the \(DSL Library\).

fun Log4J.appender(name : String, type : String, builder : Log4JAppender.() -> Unit)
           
interface Log4JAppender : Log4JBase {
  fun layout(type: String, builder : Log4JLayout.() -> Unit)
}

interface Log4JLayout : Log4JBase 

And this allows us to tune the \(generator\) to have the following Kotlin code


//use this
appender("stdout", "org.apache.log4j.ConsoleAppender") {
  layout("org.apache.log4j.PatternLayout") {
    param("ConversionPattern", "%p\t%d{ISO8601}\t%r\t%c\t[%t]\t%m%n")
  }
}

//instead of
param("log4j.appender.stdout", "org.apache.log4j.ConsoleAppender")
param("log4j.appender.stdout.layout", "org.apache.log4j.PatternLayout")
param("log4j.appender.stdout.layout.ConversionPattern",             
   

Step 3. Builder for Loggers

Let's simplify the rest of Log4j configuration code. Consider the following code

rootLogger = "ERROR,stdout"
param("log4j.logger.corp.mega", "INFO")
param("log4j.additivity.corp.mega", "false")

Here we refer to a logger called stdout by typing it's name as a string. There are several keys used to encode the logger. Let's normalize values and improve readability by spliting appender binging and level.

interface Log4JLogger : Log4JBase {
  var additivity : Boolean?
  var level : Log4JLevel?
  var appenders : List<String>
}

fun Log4J.logger(category: String, builder : Log4JLogger.() -> Unit)

fun Log4J.rootLogger(builder : Log4JLogger.() -> Unit)

Now the generated code would look like that

rootLogger {
  level = ERROR
  appenders += "stdout"
}

logger("corp.mega") {
  additivity = false
  level = INFO
}

Now one can specify parameters explicitly. And it reads way better.

Step 1 & 2 & 3. Outcome

At that point we managed to remove all common strings, encoded keys and values. Readability is now better as we replaced all bare Log4J#param calls with a dedicated API calls from a dedicated builders.

There is a domain model created. We now have Logger, Appender, Layout entities. Each with a dedicated interfaces. Semantic checks are now implemented on compilation, meaning incorrect code would not compile at all. The rest of checks are implemented in the \(emitter\) implementation from the other.

Thanks to Kotlin static typed DSLs, IntelliJ IDEA understands code and provides code completion and navigation for every expression.

The DSL code is more typo-resistant. All strings are now used once. There are no more tricky-encoded keys too. It' much harder now to author a misprint.

The generated DSL code is more expressive. One can read it and understand the meaning. There is no requirement to know Kotlin for that

Step 4. Find Usages and Rename for Appenders

Now we are ready to implement an IDE feature. We'd like to be able to rename appenders as well as be able to see where a given appender is used.

For every possible IDE feature we need for \(Original Language\). We need to find an equivalent construction in the \(Target Language\) and a similarly looking IDE feature for \(Target Language\). Next we shall find the way to use such construction in the DSL.

For appender usages and rename feature the Kotlin variable declaration suites the best.

We introduce Log4JAppenderRef interface. Make Log4J#appender function to return it. Next, in logger configuration we replace the type of appender from String into Log4JAppenderRef.

Now appender usages are found via the respective variable usages. The appender name is specified only in Log4JLogger#appender function call. All other places uses the variable. Not it's safe to rename appender by changing this field.

Outcome

This is a DSL for Log4j configurations usage example

log4j {
  val stdout = appender<ConsoleAppender>("stdout") {
    layout<PatternLayout> {
      conversionPattern = "%p\t%d{ISO8601}\t%r\t%c\t[%t]\t%m%n"
    }
  }

  rootLogger {
    level = ERROR
    appenders += stdout
  }

  logger("corp.mega.itl.web.metrics") {
    level = INFO
  }

  logger("corp.mega") {
    level = INFO
    appenders += stdout
  }
}

Creating a DSL is a iterative process. It is strongly dependent on subjective things like 'readability' or 'good looking'. Different DSLs are possible. And the way they are created depends on one's taste.

Conclusion

By those steps we turned a .properties file of Log4j configuration into a well-looking DSL code in Kotlin. The DSL Way is implemented with that DSL and provides IDE support for authoring and reading Log4j configuration files.

The \(generator\) and \(execution\) parts implementation details are left uncovered. You may ask me for details in the comments.

The DSL we created illustrates how once can turn a IDE language support problem into The DSL Way approach.

You may have a look (or contribute) to the project sources on my GitHub

You may follow to this post for details on how to create a zero-configuration package for such DSLs and for The DSL Way approach.