opel — asynchronous expression language

In everyday work programmers are facing various problems. We would like to focus on two of them: big systems with non-blocking API and specific business needs that can be solved using Expression Language. Hold tight!

How to use a non-blocking API in an expression language? The answer is the new expression language — opel. It was designed to enable writing simple, single line asynchronous expressions. It uses Parboiled as a language grammar engine and Java 8 CompletableFuture.

Getting started #

To use opel, just add this dependency to your project:

gradle #

compile group: 'pl.allegro.tech', name: 'opel', version: '1.0.1'

maven #

<dependency>
<groupId>pl.allegro.tech</groupId>
<artifactId>opel</artifactId>
<version>1.0.1</version>
</dependency>

And you’re ready to go!

Simple use case #

You can use opel just as you would use any other existing expression language such as SpEL or JEXL. The main difference is that opel always returns CompletableFuture:

String expression = "2 * 3 + 4";
OpelEngine engine = OpelEngineBuilder.create().build()
engine.eval(expression)
    .whenComplete((result, error) -> System.out.println(result));

this expression is transformed to equivalent code:

CompletableFuture.completedFuture(2)
    .thenCombine(CompletableFuture.completedFuture(3), (l, r) -> l * r)
    .thenCombine(CompletableFuture.completedFuture(4), (l, r) -> l + r)
    .whenComplete((result, error) -> System.out.println(result));

As you see, opel efficiently hides boilerplate of the CompletableFuture API.

Taste the asynchronous #

Typically, operations that use external systems through the REST API or database queries are the main source of Future objects in the system. This type of calls can be easily integrated with opel.

To achieve this, OpelAsyncFunction<T> interface has to be implemented. It is an implementation of the well known adapter design pattern.

public class ExampleTemperatureFunction implements OpelAsyncFunction<BigDecimal> {
    @Override
    public CompletableFuture<BigDecimal> apply(List<CompletableFuture<?>> args) {
        return args.get(0).thenApply(city -> {
            // add code to call external service about temperature in given city
            return BigDecimal.valueOf(22);
        });
    }
}

That function can be added to OpelEngine:

OpelEngine engine = OpelEngineBuilder.create()
                    .withFunction("temperature", new ExampleTemperatureFunction())
                    .build()

Then you can the evaluate expressions using the function:

String expression = "if (temperature('warsaw') > 25) 'Stay at home' else 'Go for a jog' ";
engine.eval(expression)
    .whenComplete((result, error) -> System.out.println(result));

Get rid of parsing overhead #

Using Future API is the first step towards improving performance. Next step is reducing the frequency of expression parsing. Expression parsing is an expensive operation and should be avoided.

OpelEngine returns the result of expressions parsing allowing re-use of it:

OpelEngine engine = OpelEngineBuilder.create().build();
OpelParsingResult parsingResult = engine.parse(expression);

parsingResult.evaluate()
    .whenComplete((result, error) -> System.out.println(result));

parsingResult.evaluate() // no parsing, only evaluation
    .whenComplete((result, error) -> System.out.println(result));

It is also worth mentioning that parsing result object is stateless and thread-safe, and can be used to evaluate expressions by many threads (as long as functions and values registered in the engine are thread-safe).

Evaluation context #

In all the above examples evaluation of the expression depends only on the expression itself and on registered functions and values. Sometimes we may want to achieve a behaviour where every evaluation of parsed expression depends on current context. Although it is not possible to pass arguments to opel expressions, it is possible to define a local evaluation context object and pass it to eval method. All functions and values registered in evaluation context will overwrite those defined in the engine. This situation is common for web applications, where context contains functions and values related to current request (eg. function or values returning information about signed in user).

String expression = "if (temperature(currentLocation) > 25) 'Stay at home' else 'Go for a jog' ";

OpelEngine engine =  OpelEngineBuilder.create()
                    .withFunction("temperature", new ExampleTemperatureFunction())
                    .build();
OpelParsingResult parsingResult = engine.parse(expression);

EvalContext context1 = EvalContext.Builder.create()
    .withValue("currentLocation", CompletableFuture.supplyAsync(() -> "Warsaw"))
    .build()

parsingResult.eval(expression, context1)
    .whenComplete((result, error) -> System.out.println(result));

EvalContext context2 = EvalContext.Builder.create()
    .withCompletedValue("currentLocation", "London")
    .build()

parsingResult.eval(expression, context2)
    .whenComplete((result, error) -> System.out.println(result));

Conclusion #

In these few examples we present a vision of opel and a way to write non-blocking expressions without boilerplate of Java 8 CompletableFuture API. But it is not the end. Opel has many more features to write sexy expressions such as implicit conversion, map and list concise notation, support for own values etc.

Since September 1st, opel is open source and you can find more information about its features at github.

Discussion