Tuesday, October 23, 2012

In order to go far beyond of simple configuration files

I'm fed up with classic configuration approach through static files content using various formats such as properties, JSON, XML, ... That's right, that they are easy to implement and use, but they do not allow to extend or personalize application in depth.

One usage case that I've in mind, is to allow insertion of small application code fragments directly inside the configuration file. Once loaded (and of course automatically compiled), the code fragment is directly executed, it becomes directly part of the application ! It will allow very good performances, no systematic internal tests, no parsing, simplicity, syntax coherency and type safety.

One would argue that using a programming language directly inside a configuration file would make it becoming in fact a source file... but by using DSL techniques, on the fly compile/load, it is possible to make it transparent in particular with simple configuration content.

Of course, there are drawbacks, one of them concerns security, this configuration power must only be available to application administrator, it must not become accessible to anyone... an other one is that the compiler invocation has a high cpu cost, it is mandatory to save compiled classes for future usage.

The approach is the following :
  • the configuration file is the content of a scala class which inherits config DSL dedicated to your requirements.
  • when the application starts, it looks for an already compiled config file, if none is found then the configuration file is compiled and saved
  • Then the compiled configuration file is loaded, and you get an instance of your generated configuration class


Now let's have a look at my first implementation, following the described approach :
import java.net.URLClassLoader
import java.util.jar.Manifest
import scala.tools.nsc.Global
import scala.tools.nsc.reporters.ConsoleReporter
import scala.tools.nsc.Settings
import scala.tools.nsc.io.VirtualDirectory
import scala.tools.nsc.interpreter.AbstractFileClassLoader
import scala.tools.nsc.io.File
import scala.tools.nsc.io.JarWriter
import scala.tools.nsc.util.BatchSourceFile
import java.util.jar.JarFile
import java.util.jar.JarEntry
import scala.reflect.io._

/**
 * A config base class, a place where to put generic DSL logic (perhaps)
 */
trait ConfigBase {}

/**
 * Config compiler logic
 */
class ConfigCompiler[T <% ConfigBase](val configFilename: String,
                                      val baseclass: Class[T],
                                      val chosenClassNameOption: Option[String]) {

  class CustomConsoleReporter(settings: Settings) extends ConsoleReporter(settings) {
  }

  private def computeConfigClassName(): String = chosenClassNameOption getOrElse {
    "Generated" + (baseclass.getName().split("[.]").map(_.capitalize).mkString(""))
  }

  val chosenClassName = computeConfigClassName()
  val configFile = File(configFilename)
  val backupFile = File(configFilename.split("[.]").init.mkString("", ".", ".jar"))

  /**
   * Compile configuration file
   */
  private def compileConfig(): VirtualDirectory = {
    val settings = new Settings()
    settings.usejavacp.value = true
    settings.deprecation.value = true

    val body = io.Source.fromFile(configFilename)
    val code =
      """class %s extends %s {
        |%s
        |}""".stripMargin.format(
        chosenClassName,
        baseclass.getName(),
        body.getLines.mkString("\n"))

    val sources = List(new BatchSourceFile(configFilename, code))
    val outdir = new VirtualDirectory("(memory)", None)

    settings.outputDirs.setSingleOutput(outdir)

    val global = new Global(settings, new ConsoleReporter(settings))
    lazy val compiler = new global.Run()

    compiler.compileSources(sources)

    outdir
  }

  /**
   * Backup compiled file
   */
  private def backup(generatedClasses: VirtualDirectory): VirtualDirectory = {
    val storedir = configFile.parent

    val writer = new JarWriter(backupFile, new java.util.jar.Manifest())
    for (f <- generatedClasses) writer.addStream(new JarEntry(f.name), f.input)
    writer.close

    generatedClasses
  }

  /**
   * Generate the class load that will allow just compiled class to be loaded
   */
  private def buildConfigClassLoader(): ClassLoader = {
    if (backupFile.exists && configFile.lastModified <= backupFile.lastModified)
      new URLClassLoader(Array(backupFile.toURL), getClass.getClassLoader())
    else
      new AbstractFileClassLoader(backup(compileConfig()), getClass.getClassLoader())
  }

  val configClassLoader = buildConfigClassLoader
  val configClass = configClassLoader.loadClass(chosenClassName)
  val config: T = configClass.newInstance().asInstanceOf[T]
}

/**
 * ConfigCompiler Object, the main entry point to get your config
 */

object ConfigCompiler {
  def loadConfig[T <% ConfigBase](
    filename: String,
    baseclass: Class[T],
    chosenClassName: Option[String] = None): T = {
    val compiler = new ConfigCompiler(filename, baseclass, chosenClassName)
    compiler.config
  }
}

Usage

val config =
  ConfigCompiler.loadConfig(
            "conf/dummy.conf", 
            classOf[examples.DummyConfigBase]
  )
println(config.projectName)
println(config.servers)



Remaining caveats I still need to work on :
  • Modify compiler messages, to have the right line number printed
  • Bring various DSL techniques that help to get simple configuration
  • Example, example, example, ...