Java-Script-Shell - Run java code dynamically
Mar 4, 2017
4 minute read

Intro

We are going to build a simple shell application which allows us to run java code dynamically like we would run normal shell or groovy scripts.

java-script-shell

Why writing scripts in java? Why not? Maybe your java code is the default choice for your company or your coworkers only understand java? Also with lambdas and streams java become much more concise, so let’s give it a try in scripting ;).

Overview

The project (source at github) will contain two modules: compiler and shell. The compiler module consists of a InMemory-JavaCompiler, which is basicly a wrapper for the official JavaCompiler class. The shell module uses JLine3 to have an interactive shell experience where we can run our java scrips by telling the shell to run [path/to/java/source].

The Shell

In our main method we create a basic jline loop. The only interesting lines here are new RunScript().execute(line);, which evaluates the user input and the exception handling for ShellException | CompilationException. As we do not want our shell to crash if the java source is invalid/not compilable or the user input does not match our intention.


public class JavaScriptShell {

    public static void main(String[] args) throws IOException {
        LineReader reader = LineReaderBuilder.builder()
                .terminal(TerminalBuilder.terminal())
                .build();
        String prompt = "java-script-engine> ";
        while (true) {
            String line;
            try {
                line = reader.readLine(prompt);
                new RunScript().execute(line);
            } catch (ShellException | CompilationException e) {
                System.out.println(e.getMessage());
                e.printStackTrace();
            } catch (UserInterruptException e) {
                // Ignore
            } catch (EndOfFileException e) {
                return;
            }
        }
    }
}

The RunScript class evaluates the user input and calls the RunCompile and RunMain classes. The input must match run [path/to/java/source] additional arguments.

public class RunScript {

    private final static String id = "run";
    private final RunCompile runCompile = new RunCompile();

    public void execute(String line) {
        String[] input = validateInput(line);
        String[] args = Arrays.copyOfRange(input, 1, input.length);
        Path scriptPath = Paths.get(input[0]);
        Class<?> aClass = runCompile.compile(scriptPath);
        new RunMain().run(aClass, args);
    }

    private String[] validateInput(String line) {
        if (!line.startsWith(id)) throw new ShellException("No matching command found! Try 'run'!");
        String input = line.substring(id.length());
        String[] args = Arrays.stream(input.split(" "))
                .filter(s -> !s.isEmpty()).toArray(String[]::new);
        if (args.length == 0) throw new ShellException("Specify a path to a java source file!");
        return args;
    }

}

The RunCompile class, given a path, reads the java source file into a string, runs the JavaStringCompiler and returns the compiled class file.

public class RunCompile {

    private static final JavaStringCompiler compiler = new JavaStringCompiler();

    public Class<?> compile(Path scriptPath) {
        HashMap<String, String> map = new HashMap<>();
        String fileName = readFileName(scriptPath);
        map.put(fileName, readScript(scriptPath));
        try {
            Map<String, byte[]> compile = compiler.compile(map);
            String className = fileName.substring(0, fileName.indexOf("."));
            return loadClass(className, compile);
        } catch (IOException e) {
            throw new IllegalStateException("Error trying to compile path " + scriptPath);
        }
    }

    private Class<?> loadClass(String className, Map<String, byte[]> classData) {
        try {
            return compiler.loadClass(className, classData);
        } catch (ClassNotFoundException | IOException e) {
            throw new IllegalStateException("Error trying to load class " + className);
        }
    }

    private String readFileName(Path path) {
        return path.getFileName().toString();
    }

    private String readScript(Path path) {
        try {
            return new String(Files.readAllBytes(path));
        } catch (IOException e) {
            throw new IllegalStateException("Error trying to read file " + path);
        }
    }
}

The RunMain class finds the main method via reflection and invokes it.

public final class RunMain {

    public void run(Class<?> aClass, String[] args) {
        try {
            Method main = aClass.getMethod("main", String[].class);
            Object o = aClass.newInstance();
            main.invoke(o, (Object) args);
        } catch (Throwable throwable) {
            throw new ShellException("Could not execute 'main' method of " + aClass.getSimpleName(), throwable);
        }
    }

}

The InMemory-Compiler

As mentioned above, the InMemory-compiler wraps the JavaCompiler class and uses a custom class loader to define classes based on a byte array. The original code I’ve got from this repo. I modified it to compile more than one java source files at once (maybe you want to load all your scripts on startup and store them). This method return the compiled bytes for each compiled class in a map.

public Map<String, byte[]> compile(Map<String, String> fileNameToSources) throws IOException {
        try (MemoryJavaFileManager manager = new MemoryJavaFileManager(stdManager)) {
            List<JavaFileObject> fileObjects = fileNameToSources.entrySet().stream()
                    .map(it -> manager.makeStringSource(it.getKey(), it.getValue()))
                    .collect(Collectors.toList());
            CompilationTask task = compiler.getTask(null, manager, null, null, null, fileObjects);
            Boolean result = task.call();
            if (result == null || !result) {
                throw new CompilationException("Compilation failed.");
            }
            return manager.getClassBytes();
        }
}

The class loopup now uses the InMemory-classloader which defines the compiled class by overriding the findClass method.

public Class<?> loadClass(String name, Map<String, byte[]> classBytes) throws ClassNotFoundException, IOException {
        try (MemoryClassLoader classLoader = new MemoryClassLoader(classBytes)) {
            return classLoader.loadClass(name);
        }
}
@Override
protected Class<?> findClass(String name) throws ClassNotFoundException {
    byte[] buf = classBytes.get(name);
    if (buf == null) {
        return super.findClass(name);
    }
    classBytes.remove(name);
    return defineClass(name, buf, 0, buf.length);
}

Run it

Our test java file is a simple Hello World applocation, but you know, theoretically you can use all the standard java libraries :).


import java.util.Arrays;

public class HelloWorld {
        
    public static void main(String[] args) {
        System.out.println("Hello World!");
        System.out.println(Arrays.toString(args));
    }    
    
}

We build the application with gradle shadow and start it with java -jar. We enter run /home/artur/HelloWorld.java additional arguments and get the Hello World printed :).

Conclusion

I was surprised how great the java platform is to build extensible applications. We can load classes, jars and now even source code dynamically.