【Modern Java】JEP 330 Launch Single-File Source-Code Programs

f:id:Naotsugu:20200724174249p:plain

blog1.mammb.com


JEP 330: Launch Single-File Source-Code Programs

Java11 より単一のファイルの Java コードを、明示的なコンパイル操作無しで直接実行できるようになりました。

小さなユーティリティプログラムなどをスクリプト言語のような取り回しで実行することができます。

ただし、public static void main(String[] args) は依然として必要で、Java 言語自体をスクリプト言語として進化させるものではなく、あくまでも Java ランチャーにおける機能強化になります。


以下のようなソース・ファイルを Hello.java として作成します。

public class App {
    public static void main(String... args) {
        System.out.println("Hello World");
    }
}

java コマンドから以下のように実行することができます。

$ java Hello.java
Hello World

クラス名とファイル名の対応関係が不要な点に注意してください。


"Shebang" ファイルとすることで、java コマンドをコマンドラインから指定することも不要にできます。

Hello というファイルを作成して実行権限を付与します。

% touch Hello
% chmod +x Hello

以下のように、ファイルの先頭に "Shebang" としてコマンドを指定します(コマンドのパスはそれぞれの環境に応じたパスを設定します)。

#!/usr/bin/java --source 11

public class App {
    public static void main(String... args) {
        System.out.println("Hello World");
    }
}

シェルスクリプトと同様に、以下のように実行することができます。

$ ./Hello
Hello World

ソースファイルに渡すコマンドライン引数がある場合には、単純にファイル名の後に続けるだけです。

$ ./Hello <args>


Source-file mode

ここまでで見てきた java コマンドによる実行は、Source-file mode と呼ばれます。

java コマンドが Source-file mode で動作する条件は以下のいずれかです。

  • コマンドライン中のクラス名が指定される場所で、.java 拡張子のファイルが指定された
  • --source version オプションが指定された


java コマンドのヘルプに有る通り、4つ目のモードが追加されたことになります。

Usage: java [options] <mainclass> [args...]
           (to execute a class)
   or  java [options] -jar <jarfile> [args...]
           (to execute a jar file)
   or  java [options] -m <module>[/<mainclass>] [args...]
       java [options] --module <module>[/<mainclass>] [args...]
           (to execute the main class in a module)
   or  java [options] <sourcefile> [args]
           (to execute a single source-file program)


java コマンドが Source-file mode で動作した場合、jdk.compiler モジュールのソース・ランチャー実装クラスがプログラム的にコンパイラを呼び出すことで、ソース・ファイルをインメモリでコンパイルしてクラスファイルをメモリ展開します。

ソース・ランチャー実装クラスはカスタム・クラスローダを作成してメモリ展開されたクラスファイルのメインメソッドを呼び出します。


Source-file mode の処理の流れ

具体的には、jdk.compiler モジュールにある com.sun.tools.javac.launcher.Main.java が実行され、この中でソース・ファイルのコンパイルと実行が処理されます。

少し処理の流れを追ってみましょう。

com.sun.tools.javac.launcher.Main.java のメインは以下のようになっています。

public static void main(String... args) throws Throwable {
    try {
        new Main(System.err).run(VM.getRuntimeArguments(), args);
    } catch (Fault f) {
        //...
    }
}

ランタイム引数とコマンドライン引数で run() メソッドを呼んでいます。

run() メソッドでは、引数で指定されたソース・ファイルを compile() でコンパイルして execute() で実行する単純なものです。

public void run(String[] runtimeArgs, String[] args) throws Fault, InvocationTargetException {
    Path file = getFile(args);

    Context context = new Context(file.toAbsolutePath());
    String mainClassName = compile(file, getJavacOpts(runtimeArgs), context);

    String[] appArgs = Arrays.copyOfRange(args, 1, args.length);
    execute(mainClassName, appArgs, context);
}

compile() でコンパイルして execute() に先立ち、Context をインスタンス化しています。 これは以下のようになっています。

private static class Context {
    private final Path file;
    private final Map<String, byte[]> inMemoryClasses = new HashMap<>();

    Context(Path file) { this.file = file; }

    JavaFileManager getFileManager(StandardJavaFileManager delegate) {
        return new MemoryFileManager(inMemoryClasses, delegate);
    }

    ClassLoader getClassLoader(ClassLoader parent) {
        return new MemoryClassLoader(inMemoryClasses, parent, file);
    }
}

インメモリでコンパイルとクラスロードを行う MemoryFileManagerMemoryClassLoader を扱っています。

これらのクラスは、同インナークラスとして定義されています。

compile() メソッドは以下のようになっています。

private String compile(Path file, List<String> javacOpts, Context context) throws Fault {
    JavaFileObject fo = readFile(file);

    JavacTool javaCompiler = JavacTool.create();
    StandardJavaFileManager stdFileMgr = javaCompiler.getStandardFileManager(null, null, null);
    try {
        stdFileMgr.setLocation(StandardLocation.SOURCE_PATH, Collections.emptyList());
    } catch (IOException e) {
        throw new java.lang.Error("unexpected exception from file manager", e);
    }
    JavaFileManager fm = context.getFileManager(stdFileMgr);
    JavacTask t = javaCompiler.getTask(out, fm, null, javacOpts, null, List.of(fo));
    MainClassListener l = new MainClassListener(t);
    Boolean ok = t.call();
    if (!ok) {
        throw new Fault(Errors.CompilationFailed);
    }
    if (l.mainClass == null) {
        throw new Fault(Errors.NoClass);
    }
    String mainClassName = l.mainClass.getQualifiedName().toString();
    return mainClassName;
}

MemoryFileManager を経由してコンパイル処理されます。

そして execute() メソッドは以下のようになっています。

private void execute(String mainClassName, String[] appArgs, Context context)
        throws Fault, InvocationTargetException {
    System.setProperty("jdk.launcher.sourcefile", context.file.toString());
    ClassLoader cl = context.getClassLoader(ClassLoader.getSystemClassLoader());
    try {
        Class<?> appClass = Class.forName(mainClassName, true, cl);
        Method main = appClass.getDeclaredMethod("main", String[].class);
        int PUBLIC_STATIC = Modifier.PUBLIC | Modifier.STATIC;
        if ((main.getModifiers() & PUBLIC_STATIC) != PUBLIC_STATIC) {
            throw new Fault(Errors.MainNotPublicStatic);
        }
        if (!main.getReturnType().equals(void.class)) {
            throw new Fault(Errors.MainNotVoid);
        }
        main.setAccessible(true);
        main.invoke(0, (Object) appArgs);
    } catch (ClassNotFoundException e) {
        // ...
    }
}

MemoryClassLoader にてクラスがロードされて、ソース・ファイルを main.invoke() で実行する流れです。

OpenJDKのコミットhttps://github.com/openjdk/jdk/commit/fe24730ed99f3b12bc0ae72de8654b96412427b7が該当します。


ランタイム・オプション

com.sun.tools.javac.launcher.Main.javagetJavacOpts() を見るのが手っ取り早いです。

以下のようなランタイム・オプションが利用できます。

switch (opt) {
    // The following options all expect a value, either in the following
    // position, or after '=', for options beginning "--".
    case "--class-path": case "-classpath": case "-cp":
    case "--module-path": case "-p":
    case "--add-exports":
    case "--add-modules":
    case "--limit-modules":
    case "--patch-module":
    case "--upgrade-module-path":
        if (value == null) {
            value = runtimeArgs[++i];
        }
        if (opt.equals("--add-modules") && value.equals("ALL-DEFAULT")) {
            // this option is only supported at run time;
            // it is not required or supported at compile time
            break;
        }
        javacOpts.add(opt);
        javacOpts.add(value);
        break;
    case "--enable-preview":
        javacOpts.add(opt);
        break;
    default:
        // ignore all other runtime args
}

依存ライブラリなどがある場合は --class-path で必要な jar を指定することで実行が可能です。

コンパイルされたクラスは --add-modules=ALL-DEFAULT と指定されたものと同等で、無名モジュール(unnamed module)扱いとなります。


まとめ

Java11 で追加された Single-File Source-Code Programs について具体的な実装を交えて紹介しました。

クラス定義や public static void main(String[] args) が依然として必要なため、スクリプト言語のと同等な扱いはできませんが、Java エコシステムとしての充実したライブラリを簡単な取り回しで利用できるのはうれしいものですね。