2023/12/25

ヒープだけでコンパイル&クラスロード (Compiler API補遺)

このエントリーをはてなブックマークに追加

本エントリーはJava Advent Calendarシリーズ2の最後のエントリーです。

qiita.com

ここまで、3回に渡ってString Templateについて紹介してきました。

www.javainthebox.com

www.javainthebox.com

www.javainthebox.com

また、動的にテンプレートを作るために使ったのがCompiler APIです。

www.javainthebox.com

動的にテンプレートを作れるようになったのはいいのですが、微妙に使いにくい点があります。

たとえば、DynamicTemplate.jarにクラスをまとめて実行してみると。

C:\sample>dir
 ドライブ C のボリューム ラベルは OS です
 ボリューム シリアル番号は BCC7-8689 です

 C:\sample のディレクトリ

2023/12/23  22:21    <DIR>          .
2023/12/23  22:20             3,896 DynamicTemplate.jar
2023/12/21  21:09                60 hello.temp
               2 個のファイル               3,956 バイト
               1 個のディレクトリ  378,476,552,192 バイトの空き領域

C:\sample>java --enable-preview -cp DynamicTemplate.jar TemplateTest
ノート: /Template.javaはJava SE 22のプレビュー機能を使用します。
ノート: 詳細は、-Xlint:previewオプションを指定して再コンパイルしてください。
Exception in thread "main" java.lang.ClassNotFoundException: Template
        at java.base/jdk.internal.loader.BuiltinClassLoader.loadClass(BuiltinClassLoader.java:641)
        at java.base/jdk.internal.loader.ClassLoaders$AppClassLoader.loadClass(ClassLoaders.java:188)
        at java.base/java.lang.ClassLoader.loadClass(ClassLoader.java:525)
        at DynamicTemplateBuilder.createTemplate(DynamicTemplateBuilder.java:42)
        at TemplateTest.main(TemplateTest.java:13)

C:\sample>

といように、動的に作成したTemplateクラスをロードできないため、失敗しています。

この理由は簡単で、ワーキングディレクトリに生成されたTemplate.classファイルが出力されてしまっているからです。

C:\sample>dir
 ドライブ C のボリューム ラベルは OS です
 ボリューム シリアル番号は BCC7-8689 です

 C:\sample のディレクトリ

2023/12/23  22:24    <DIR>          .
2023/12/23  22:20             3,896 DynamicTemplate.jar
2023/12/21  21:09                60 hello.temp
2023/12/23  22:24               728 Template.class
               3 個のファイル               4,684 バイト
               1 個のディレクトリ  378,476,572,672 バイトの空き領域

C:\sample>

しかし、クラスパスはJARファイルしか指定していないため、クラスロードできなくなってしまいます。

これを動作させるためには、ワーキングディレクトリもクラスパスに追加すればOKです。

C:\sample>java --enable-preview -cp .;DynamicTemplate.jar TemplateTest
ノート: /Template.javaはJava SE 22のプレビュー機能を使用します。
ノート: 詳細は、-Xlint:previewオプションを指定して再コンパイルしてください。
Bob Dylan様
いつもお世話になっております

C:\sample>

このように実行はできるものの、クラスパスを毎回指定しなければいけないのは、ちょっとめんどうです。

そもそも自動生成したクラスなのですから、クラスファイルをファイルとして出力せずにヒープ内でどうにかしてくれないかなぁと思うわけです。

ところが、そんなことをしてくれる機能が実はすでにあるんです。

たとえば、実行すると単にSystem.outに"Hello, World!"を出力するHelloWorldクラスがあったとします。Java 11から、1つのJavaファイルであればコンパイルせずに直接実行できるようになっています。

C:\sample>dir
 ドライブ C のボリューム ラベルは OS です
 ボリューム シリアル番号は BCC7-8689 です

 C:\sample のディレクトリ

2023/12/23  22:53    <DIR>          .
2023/12/23  22:53               123 HelloWorld.java
               1 個のファイル                 123 バイト
               1 個のディレクトリ  378,463,444,992 バイトの空き領域

C:\sample>java HelloWorld.java
Hello, World!

C:\sample>dir
 ドライブ C のボリューム ラベルは OS です
 ボリューム シリアル番号は BCC7-8689 です

 C:\sample のディレクトリ

2023/12/23  22:53    <DIR>          .
2023/12/23  22:53               123 HelloWorld.java
               1 個のファイル                 123 バイト
               1 個のディレクトリ  378,464,772,096 バイトの空き領域

C:\sample>

実行してみれば分かりますが、Javaファイルを直接実行した場合、HelloWorld.classファイルは出力されていません。

この機能はコンパイルした結果をヒープに保持し、クラスロードはヒープから行うということを行っているからです。

この機能を行っているのが、java.compilerモジュールのcom.sun.tools.javac.launcherパッケージのクラス群です。

なので、このパッケージにあるクラスを使えば、DynamicTemplateもクラスファイルを出力せずに実行することができるはず... なのですが、残念なことにcom.sun.tools.javac.launcherパッケージは外部に公開されていないパッケージなのです(module-info.javaでexport指定されていないのです)。

しかたないので、これらを参考に作ってみましょう。

ここで作成したファイルはGistにおいておきます。


Gist: 動的にString Templateのテンプレートを作成し、ヒープだけでコンパイル、クラスロードまで行う例


まずはJavaFileManagerインタフェースです。

通常はStandardJavaFileManagerインタフェースを使用しますが、部分的に機能を拡張するために提供されているのがForwardingJavaFileManagerクラスです。

ForwardingJavaFileManagerクラスは、コンストラクタでベースとなるJavaFileManagerオブジェクトを指定します。そして、このベースとなるJavaFileManagerオブジェクトに処理を委譲するようになっています。

機能を拡張したい部分だけメソッドをオーバーライドすればいいというわけです。

ここでは、バイトコードを出力するためのgetJavaFileForOutputメソッドをオーバーライドします。

    private Map<String, byte[]> classBytes;

    public Map<String, byte[]> getClassBytes() {
        return classBytes;
    }

    private class ClassOutputBuffer extends SimpleJavaFileObject {
        private final String name;

        ClassOutputBuffer(String name) {
            super(toURI(name), Kind.CLASS);
            this.name = name;
        }

        @Override
        public OutputStream openOutputStream() {
            return new FilterOutputStream(new ByteArrayOutputStream()) {
                @Override
                public void close() throws IOException {
                    out.close();
                    ByteArrayOutputStream bos = (ByteArrayOutputStream)out;
                    classBytes.put(name, bos.toByteArray());
                }
            };
        }
    }

    @Override
    public JavaFileObject getJavaFileForOutput(JavaFileManager.Location location,
                                    String className,
                                    Kind kind,
                                    FileObject sibling) throws IOException {
        if (kind == Kind.CLASS) {
            return new ClassOutputBuffer(className);
        } else {
            return super.getJavaFileForOutput(location, className, kind, sibling);
        }
    }

出力用にSimpleJavaFileObjectクラスを派生させたClassOutputBufferクラスを用意して、それを出力用に使用します。

ClassOutputBufferクラスでは出力にByteArrayOutputStreamクラスを使用しているため、バイト配列に結果が出力されるわけです。このバイトコードはマップのclassBytesにクラス名と一緒に保持しておきます。

classBytes変数はgetClassBytesメソッドで取り出すことができます。


続いて、クラスローダーの方です。

バイト配列を利用してクラスロードするMemoryClassLoaderクラスを定義しました。このMemoryClassLoaderクラスはURLClassLoaderクラスのサブクラスになっています。

public final class MemoryClassLoader extends URLClassLoader {
    private final Map<String, byte[]> classBytes;

    public MemoryClassLoader(Map<String, byte[]> classBytes) {
        super(new URL[]{});
        this.classBytes = classBytes; 
    }

    @Override
    protected Class findClass(String className) throws ClassNotFoundException {
        byte[] buf = classBytes.get(className);
        if (buf != null) {
            classBytes.put(className, null);
            return defineClass(className, buf, 0, buf.length);
        } else {
            return super.findClass(className);
        }
    }
}

MemoryJavaFileManagerクラスで作られたバイトコードを保持したマップをコンストラクタで指定するようにしてあります。

findClassメソッドではマップのclassBytesにクラスがあれば、バイトコードであるバイト配列を取り出して、defineClassメソッドに渡すようにしています。

これで、ヒープからクラスロードができます。


最後にDynamicTemplateクラスの変更点を示します。

オレンジで示したところが、MemoryJavaFileManagerクラスを使用するところです。その前の行でStandardJavaFileManagerオブジェクトを取得しておき、そのオブジェクトをMemoryJavaFileManagerクラスのコンストラクタ引数に使用します。

これで、MemoryJavaFileManagerクラスで変更した部分以外の機能はStandardJavaFileManagerオブジェクトに委譲するようになります。

青字の部分がMemoryClassLoaderクラスに変更した部分です。先ほどのMemoryJavaFileMangerオブジェクトでバイトコードを保持させているマップをgetClassBytesメソッドで取得して、コンストラクタ引数にしています。

        // 仮想ファイルマネージャの取得
        try (StandardJavaFileManager fm
                = compiler.getStandardFileManager(null, null, null);
                var fileManager = new MemoryJavaFileManager(fm)) {
            
            
            // コンパイルするファイルの準備
            List<? extends JavaFileObject> fileobjs
                    = createJavaFileObjects(template, argName);

            // コンパイルタスクの生成
            JavaCompiler.CompilationTask task
                    = compiler.getTask(null,
                            fileManager,
                            null,
                            List.of("--release", "22", "--enable-preview"),
                            null,
                            fileobjs);

            // コンパイル
            if (task.call()) {
                // クラスのロード
//                ClassLoader loader = ClassLoader.getSystemClassLoader();
                ClassLoader loader = new MemoryClassLoader(fileManager.getClassBytes());
                Class<?> clss = loader.loadClass("Template");


では、実行してみましょう。

C:\sample>java --enable-preview -cp DynamicTemplate.jar TemplateTest
ノート: /Template.javaはJava SE 22のプレビュー機能を使用します。
ノート: 詳細は、-Xlint:previewオプションを指定して再コンパイルしてください。
Bob Dylan様
いつもお世話になっております

C:\sample>dir
 ドライブ C のボリューム ラベルは OS です
 ボリューム シリアル番号は BCC7-8689 です

 C:\sample のディレクトリ

2023/12/23  22:24    <DIR>          .
2023/12/23  23:18            16,248 DynamicTemplate.jar
2023/12/21  21:09                60 hello.temp
               2 個のファイル              16,308 バイト
               1 個のディレクトリ  378,393,817,088 バイトの空き領域

C:\sample>

クラスパスにワーキングディレクトリーを指定せずに実行することができました。

また、Template.classも出力されていません!

これで、ソースが文字列、バイトコードはヒープに作成することができ。また、ヒープ上に保持してあるバイトコードを利用してクラスロードをすることもできました。


動的にコード生成をする場合、ヒープだけでバイトコードを保持し、クラスロードできるようになれば、クラスパスなどを気にすることがなくなり、使う上でのハードルは下がるはずです。

ここでは、String Templateを例にとりましたが、汎用に使えるテクニックなので、動的にコードを生成したい場合にはぜひご活用ください。


0 件のコメント: