Friday, 7 December 2012

Groovy DSL: Executing scripts within a context

When we design a domain specific language, it will likely be used in scripts that are stored in files or a database. The scripts also need to be executed inside some context that defines your DSL. There are several ways to define this context. Let's illustrate this using a very simple calculator DSL that provides a function to calculate the hypotenuse from the width and the height:

class Calculator {
    double hypotenuse(double width, double height) {
        Math.sqrt(width * width + height * height)
    }
}

We want our Calculator to be decoupled from the way it is used, so nothing in this class indicates that it will be used from a script.

We can execute scripts by using the GroovyShell class, which as an evaluate method that parses a script and runs it.

Let's look at how to use Calculator implicitly from a script.

Binding

The first way is by passing variables in a groovy.lang.Binding, which the shell implicitly exposes to the script:

def calculator = new Calculator()
def binding = new Binding(hypotenuse: calculator.&hypotenuse)
def shell = new GroovyShell(binding)
assert 5.0 == shell.evaluate("hypotenuse(3.0, 4.0)")

The drawback is that we have to set each method explicitly as a closure inside the binding. We can do this automatically for all methods of Calculator:

def calculator = new Calculator()
def binding = new Binding()
calculator.metaClass.methods.each { method ->
    def name = method.name
    binding.setVariable(name, InvokerHelper.getMethodPointer(calculator, name))
}
def shell = new GroovyShell(binding)
assert 5.0 == shell.evaluate("hypotenuse(3.0, 4.0)")

But somehow it feels like there should be a better way.

Script subclass

The second way is by subclassing groovy.lang.Script, which implements our DSL by defining the DSL methods. As we already have them implemented inside the Calculator class, we can use the groovy.lang.Delegate annotation on a Calculator instance:

abstract class CalculatorScript extends Script {
    @Delegate
    final Calculator calculator = new Calculator()
}

This makes all public instance methods (not properties!) of Calculator available within CalculatorScript, and thus within the DSL, because the DSL script will be part of CalculatorScript.

The code becomes:

def config = new CompilerConfiguration()
config.scriptBaseClass = CalculatorScript.name
def shell = new GroovyShell(config)
assert 5.0 == shell.evaluate("hypotenuse(3.0, 4.0)")

The drawback is that this script class cannot be instantiated by ourselves, so any context for our Calculator cannot be passed directly to it. Our Calculator is stand-alone, but a real-world DSL probably isn't. A possibility is to parse the script into the script instance, pass our context to it, and then run the script:

def config = new CompilerConfiguration()
config.scriptBaseClass = CalculatorScript.name
def shell = new GroovyShell(config)
def script = shell.parse("hypotenuse(3.0, 4.0)")
// script.calculator.context = ...
assert 5.0 == script.run()

Still, this does not feel right, because the Script class is really an implementation detail that we don't want to be concerned about. And it's likely the case that our DSL contains nested contexts, which will be implemented differently, namely through a delegate set on a Closure.

Closure

What we want is to implement the top level DSL context in the same way as nested contexts: Through a closure delegate, like this:

def calculate(Closure script) {
    Calculator calculator = new Calculator()
    calculator.with(script)
}

The 'with' method is available for each Object and sets it as a delegate of the closure and executes it.

assert 5.0 == calculate {
    hypotenuse(3.0, 4.0)
}

How can we achieve this? We need a closure, but our script is a String. Somehow we have to convert the string to a closure and pass it to calculate().

What we can do is to force the DSL user to wrap his script inside a call to a method that accepts a closure. The script itself will look like this:

calculate {
    hypotenuse(3.0, 4.0)
}

We need to define the calculate method in the script class or set it as a closure in the binding.

def calculator = new Calculator()
def binding = new Binding()
binding.setVariable('calculate') { script ->
    calculator.with(script)
}
def shell = new GroovyShell(binding)
assert 5.0 == shell.evaluate("calculate { hypotenuse(3.0, 4.0) }")

But the user has to wrap each of his scripts inside a call to calculate. We can do this for him though, as we will see next.

Solution

We can wrap the script inside a 'calculate' call ourselves, but why not get rid of the calculate method too? We can achieve our goal of converting the script into a closure by wrapping it as a closure like this:

Closure convertToClosure(String script) {
    (Closure) new GroovyShell().evaluate("return {$script}")
}

The result of evaluate is a Closure. The return statement is used to disambiguate between a code block and a closure. Now we can call the script by calling the closure:

def calculator = new Calculator()
def script = convertToClosure("hypotenuse(3.0, 4.0)")
assert 5.0 == calculator.with(script)

The only requirement is that the closure should accept a parameter, as the delegate is also passed as a parameter by the 'with' method. If this is undesirable, we can just run the closure ourselves:

def runInContext(Object context, String script) {
    Closure cl = (Closure) new GroovyShell().evaluate("{->$script}")
    cl.delegate = context
    cl.resolveStrategy = Closure.DELEGATE_FIRST
    cl()
}

The closure doesn't need to be cloned as we have just instantiated it ourselves. Because we don't pass any parameters to the closure, our wrapper can be defined to have no parameters, so we don't expose the 'it' variable to our script.

The calculate method becomes:

def calculate(String script) {
    def calculator = new Calculator()
    runInContext(calculator, script)
}

Now we can execute our script as a string in a Calculator context, by calling calculate:

assert 5.0 == calculate("hypotenuse(3.0, 4.0)")

Another advantage to the Binding and Script base class solutions is that properties inside Calculator are also available inside the script. And Calculator can implement dynamic properties and methods with propertyMissing/methodMissing or getProperty/setProperty/invokeMethod, just like normal Groovy builders.

No comments:

Post a Comment