Silnik skryptów

  • Post author:
  • Post category:Java

Wykorzystując silnik skryptów dostępny w wirtualnej maszynie Javy masz możliwość połączenia swojego kodu z logiką umieszczoną w zewnętrznym źródle. Łatwo wyobrazić sobie korzyści płynące z takiego rozwiązania. Możesz np. spersonalizować zaimplementowane w kodzie algorytmy.

Wyobraźmy sobie program do obsługi magazynu, którego jednym z zadań jest obliczanie dostępnego stanu towaru. Każdy z klientów może chcieć obliczać tę wartość trochę inaczej. Firma A będzie chciała widzieć tylko stan towaru dostępnego “fizycznie” w danej chwili, firma B chce natomiast uwzględnić nie tylko posiadany towar, lecz również ilość którą dostawca zobowiązał się dostarczyć w danym dniu.

Innym przykładem może być np. program obliczający premię pracownika; każdy z klientów zapewne będzie chciał ją zdefiniować inaczej.

ScriptEngineManger

Za pomocą ScriptEngineManager możesz utworzyć obiekt silnika skryptów, możesz również sprawdzić dostępne fabryki oraz uzyskać o nich szczegółowe informacje:

ScriptEngineManager manager = new ScriptEngineManager();
manager.getEngineFactories()
        .forEach(scriptEngineFactory -> {
            System.out.println("Engine name: " + scriptEngineFactory.getEngineName());
            System.out.println("Language name: " + scriptEngineFactory.getLanguageName());
            System.out.println("Names: " + scriptEngineFactory.getNames());
            System.out.println("MIME types: " + scriptEngineFactory.getMimeTypes());
            System.out.println("Extensions" + scriptEngineFactory.getExtensions());
        });

Zarówno dla Javy 8 jak i 11 będzie to Oracle Nashorn. Poniżej zestawienie informacji o tym silniku.

Engine name: Oracle Nashorn
Language name: ECMAScript
Names: [nashorn, Nashorn, js, JS, JavaScript, javascript, ECMAScript, ecmascript]
MIME types: [application/javascript, application/ecmascript, text/javascript, text/ecmascript]
Extensions: [js]

Jeżeli uruchamiasz kod w Javie 11 to podczas kompilacji otrzymasz ostrzeżenie o porzuceniu silnika w przyszłych wydaniach JDK:

Warning: Nashorn engine is planned to be removed from a future JDK release

Możesz zechcieć użyć parametrów -Dnashorn.args=–no-deprecation-warning aby wyciszyć to ostrzeżenie. Więcej w tym temacie pod linkiem: https://openjdk.java.net/jeps/335

Począwszy od Javy 15 aby skorzystać z Nashorn’a dodaj zależność Mavena:

<dependency>
  <groupId>org.openjdk.nashorn</groupId>
  <artifactId>nashorn-core</artifactId>
  <version>15.2</version>
</dependency>

Wykonywanie skryptów

Spróbujmy wykonać prosty skrypt, którego zadaniem będzie zsumowanie dwóch zmiennych.

@Test
void testAddingExpression() throws ScriptException {
    ScriptEngineManager manager = new ScriptEngineManager();
    ScriptEngine engine = manager.getEngineByName("nashorn");

    String scriptSource = "" +
            "var foo = 2; " +
            "var bar = 1; " +
            "foo + bar";
    Object result = engine.eval(scriptSource);

    assertEquals(3.0, result);
    assertEquals(Double.class, result.getClass());
}

Wynik wykonania skryptu ustalany jest na podstawie ostatniego wyrażania. W powyższym przypadku będzie to liczba 3. Zwracane liczby są typu Double.

Wiązania zmiennych, klas i funkcji

Pojawia się pytanie, jak przekazać do skryptu własną zmienną – spójrz na poniższy przykład:

@Test
void testPassingVariable() throws ScriptException {
    ScriptEngineManager manager = new ScriptEngineManager();
    ScriptEngine engine = manager.getEngineByName("nashorn");

    Bindings bindings = engine.createBindings();
    bindings.put("bar", 1);

    String scriptSource = "" +
            "var foo = 2; " +
            "foo + bar";
    Object result = engine.eval(scriptSource, bindings);

    assertEquals(3.0, result);
}

Jak widzisz, tym razem wywołując skrypt przekazaliśmy obiekt wiążący Bindings, w którym przypisano do zmiennej bar wartość 1.

Wykorzystując wiązania można udostępnić również funkcje i klasy Java. Na poniższym przykładzie zdefiniujemy i przekażemy do silnika własną funkcję log.

@Test
void testConsumerPassing() throws ScriptException {
    ScriptEngineManager manager = new ScriptEngineManager();
    ScriptEngine engine = manager.getEngineByName("nashorn");

    Bindings bindings = engine.createBindings();
    bindings.put("log", (Consumer) message -> 
            System.out.println("LOG={" + message + "}"));

    String scriptSource = "" +
            "log('Hello scripting world');" +
            "var utc = new Date().toJSON();" +
            "log(utc);";
    engine.eval(scriptSource, bindings);
}

Wynikiem wykonania skryptu będzie wyświetlenie w konsoli przekazanych do funkcji parametrów:

LOG={Hello scripting world}
LOG={2021-05-29T07:35:49.545Z}

Jak widzisz za pomocą obiektu Bindings przekazaliśmy do skryptu implementację interfejsu funkcyjnego Consumer.

W ten sam sposób możesz zdefiniować funkcję wykorzystującą dwa argumenty. Do tego celu zaimplementujemy inny interfejs funkcyjny: BiFunction.

@Test
void testBiFunctionPassing() throws ScriptException {
    ScriptEngineManager manager = new ScriptEngineManager();
    ScriptEngine engine = manager.getEngineByName("nashorn");

    Bindings bindings = engine.createBindings();
    bindings.put("add", (BiFunction<Integer, Integer, Integer>) (v1, v2) -> v1 + v2);

    String scriptSource = "" +
            "var result = add(1, 2);" +
            "result";
    Object result = engine.eval(scriptSource, bindings);

    assertEquals(3, result);
    assertEquals(Integer.class, result.getClass());
}

A co jeśli, chciałbyś przekazać do funkcji tablicę? Spójrz na poniższą funkcję max

@Test
void testArrayArgFunctionPassing() throws ScriptException {
    ScriptEngineManager manager = new ScriptEngineManager();
    ScriptEngine engine = manager.getEngineByName("nashorn");

    Bindings bindings = engine.createBindings();
    bindings.put("max", (Function<Object, Integer>) inp -> {
        if (inp instanceof ScriptObjectMirror) {
            int[] ints = ((ScriptObjectMirror) inp).to(int[].class);
            return Arrays
                    .stream(ints)
                    .max()
                    .orElse(0);
        } else {
            return 0;
        }
    });

    String scriptSource = "" +
            "var result = max([5, 3, 1, 9, 7]);" +
            "result";
    Object result = engine.eval(scriptSource, bindings);

    assertEquals(9, result);
}

Jak wcześniej wspomniałem, Bindings umożliwia również udostępnianie klas. Mogą to być zarówno klasy “twoje” jak i systemowe. Spójrz na poniższą klasę Foo:

package pl.javascratches.eval;

import java.util.Random;

public class Foo {
    public Integer abs(int value) {
        return Math.abs(value);
    }
    public static Integer random() {
        Random random = new Random();
        return random.nextInt();
    }

Tym razem skrypt umieściłem w osobnym pliku. Do jego wykonania użyjemy metody eval z wykorzystaniem FileReader.

@Test
void testClassPassing() throws ScriptException, IOException {
    ScriptEngineManager manager = new ScriptEngineManager();
    ScriptEngine engine = manager.getEngineByName("nashorn");

    Bindings bindings = engine.createBindings();
    bindings.put("outputFile", "result.txt");
    bindings.put("Files", engine.eval("Java.type('java.nio.file.Files')"));
    bindings.put("Paths", engine.eval("Java.type('java.nio.file.Paths')"));
    bindings.put("Foo", engine.eval("Java.type('pl.javascratches.eval.Foo')"));

    engine.eval(new FileReader("script.js"), bindings);

    String result = Files.readString(Paths.get("result.txt"));
    System.out.println(result);
}

W powyższym przykładzie do skryptu przekazana została zmienna outputFile oraz klasy Foo, Files i Paths.

Poniżej zawartość wykonanego skryptu script.js

var r = Foo.random();
if (r > 0) {
    r = r * -1;
}
r = new Foo().abs(r);
Files.writeString(Paths.get(outputFile), r.toString());

Wynikiem wykonania skryptu będzie wylosowana, zawsze dodatnia liczba. Liczbę tę zapisano w pliku result.txt.

Wywoływanie funkcji

Silnik Nashhorn pozwala na wykonanie funkcji zdefiniowanej w skrypcie. Spójrz na poniższy przykład:

@Test
void testCallFunction() throws ScriptException, NoSuchMethodException {
    ScriptEngineManager manager = new ScriptEngineManager();
    ScriptEngine engine = manager.getEngineByName("nashorn");

    String script = "function sum(x, y) { return x + y}";
    engine.eval(script);
    Invocable invocable = (Invocable) engine;

    Object result = invocable.invokeFunction("sum", 1, 2);

    assertEquals(3.0, result);
}

Jak widzisz, wywołujemy funkcję sum zdefiniowaną w skrypcie.

Możemy pójść o krok dalej i zdefiniować za pomocą silnika skryptów implementację interfejsu. Załóżmy prosty interfejs Greeter:

    public interface Greeter {
        String greet(String name);
    }

Poniżej jego implementacja i wywołanie:

@Test
void testInterfaceImplementation() throws ScriptException  {
    ScriptEngineManager manager = new ScriptEngineManager();
    ScriptEngine engine = manager.getEngineByName("nashorn");

    String script = "function greet(x) { return 'Hello, ' + x + '!';}";
    engine.eval(script);

    Invocable invocable = (Invocable) engine;

    Greeter greeter = invocable.getInterface(Greeter.class);
    String result = greeter.greet("Tom");

    assertEquals("Hello, Tom!", result);
}

Kompilacja skryptu

Kompilacja do postaci pośredniej umożliwiaj efektywniejsze wykonywanie, szczególnie w przypadku gdy skrypt ma być wykonywany wielokrotnie.

Spójrz na nieco zmodyfikowany kod z poprzedniego listingu:

@Test
void testCompiledScriptCallFunction() 
  throws ScriptException, IOException, NoSuchMethodException {
    ScriptEngineManager manager = new ScriptEngineManager();
    ScriptEngine engine = manager.getEngineByName("nashorn");

    String script = "function sum(x, y) { return x + y}";
    CompiledScript compiledScript = ((Compilable) engine).compile(script);

    Invocable invocable = (Invocable) compiledScript.getEngine();

    Bindings bindings = compiledScript.getEngine().getBindings(ScriptContext.ENGINE_SCOPE);
    compiledScript.eval(bindings);

    Object result = invocable.invokeFunction("sum", 1, 2);

    assertEquals(3.0, result);
}