2023/12/21

Compiler API

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

本エントリーはJava Advent Calendarの21日目のエントリーです。

qiita.com


もともとの予定ではString Template その 3として、ちょっとマニアックな使い方を紹介するつもりだったのですが、そこで使用するAPIのCompiler APIをまず紹介しておきます。


Compiler APIは名前の通り、コンパイラーを使うためのAPIで、Java 6で導入されました。Javaのコンパイラーといえばjavacです。これをプログラムの中から使うためのAPIになります。

ところで、プログラム中にソースコードをコンパイルすることがあるのでしょうか?

 古いところだと、JSPがそうです。

JSPはHTML中にJavaのコードを埋め込んだようになっていますが、サーブレットエンジンによってJavaのサーブレットに変換され、動的にコンパイルして実行されます。

最近だと(と言っても、すでに10年前ですが)、ラムダ式がそうです。ラムダ式は実行中にソースコードが生成され、コンパイルして実行されます。

この動作はJava 7で導入された新しいバイトコードのInvokeDynamic(通称 indy)によるものです。

他にもindyを使って動的にソースコード生成し、コンパイルしてから実行されるものとして、文字列の + 演算子などがあります。

以前は、+ 演算子を使用した文字列連結は、コンパイル時にStringBuilderクラスを使用するコードに置き換えられていましたが、今は違います(StringBufferクラスを使っていたのは、さらに前の話です)。

今はindyを使用して、動的にソースコードを生成、コンパイル、実行と進みます。

このように、アプリケーションの動作中にJavaのコードをコンパイルするために使われるのがCompiler APIです。


ところで、Compiler APIは前述したようにJava 6で導入されたのですが、JSPなどJava 6以前からあるものはどうやってコンパイルしていたのでしょう。

その答えは、javacを直接コールしていたです。

意外かもしれませんが、javacはJavaで書かれていて、そのメインクラスはcom.sun.tools.java.Mainクラスです。モジュールが導入される前はtools.jarに含まれていましたが、今はjdk.compilerモジュールに含まれています。

このMainクラスのmainメソッドをコールすればコンパイルできます。当然ですが、mainメソッドの引数は、javacの引数です。

たとえば、Hello.javaをコンパイルするのであれば、次のように記述します。

import com.sun.tools.javac.Main;
 
public class CompileTest {
  public static void main(String... args) {
    try {
      String[] javacArgs 
          = new String[] {"Hello.java"};
      Main.main(javacArgs );
    } catch (Exception ex) {
      ex.printStackTrace();
    }
  }
}

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

C:\sample>dir /b
CompileTest.class
CompileTest.java
Hello.java

C:\sample>java CompileTest

C:\sample>dir /b
CompileTest.class
CompileTest.java
Hello.class
Hello.java

Hello.javaがコンパイルされて、Hello.classが生成されました。

このように、javacを呼び出すのと同じことをプログラム中でできました。

では、なぜCompiler APIが必要なのでしょうか?

理由はいくつかあります。

  • Mainクラスのパッケージがcom.sun.tools.javacであり、今後公開され続けるとは限らない
  • コンパイルエラーが扱いにくい
  • コンパイル対象がファイルに限定される

java.compilerモジュールではMainクラスが含まれているcom.sun.tools.javacパッケージはexportされています。ただし、パッケージがcom.sunで始まることから分かるように、このパッケージは内部で使用すること想定しているパッケージです。このため、今後このパッケージがexportされなくなることも考えなくてはいけません。

また、Eclipseなどが提供しているjavacコンパイラなどを使用することもできません。

それよりも、標準的なインタフェースを定義して、実装を隠す方が使いやすくなります。

その他の2つの理由はjavacが標準出力やファイルを対象としているため、しかたありません。とはいえ、特に最後の理由はソースコードをプログラム中で自動生成してコンパイルする場合にちょっとやっかいです。

ということで、Compiler APIが導入されたわけです。

ただ、いろいろと柔軟な使い方ができるようになった一方で、コード量が増えてしまうのはいかんともしがたいところではあります。


Compiler API

Compiler APIでは、ソースコードをコンパイルするために、下に示したインタフェースを使用します。

  • コンパイラ: JavaCompilerインタフェース
  • コンパイルタスク: JavaCompiler.CompilationTaskインタフェース
  • 仮想ファイルマネージャ: JavaFileManagerインタフェース
  • 抽象ソースファイル: JavaFileObjectインタフェース
  • エラー処理: DiagnosticListenerインタフェース

これらのインタフェースはいずれもjava.compilerモジュールのjavax.toolsパッケージで定義されています。

ソースコードのコンパイルは、以下のような流れになります。

  1. JavaCompilerオブジェクトの取得
  2. 必要に応じてコンパイルエラー処理コールバックを記述
  3. 仮想ファイルマネージャを生成し、コンパイル対象のソースファイルを取得
  4. ファイルマネージャ、抽象ソースファイル、エラー処理を指定してコンパイルタスクを生成
  5. コンパイル

ここでは、必要最低限の説明にとどめるため、コンパイルエラー処理は省略します。

では、順々に説明していきましょう。


JavaCompilerオブジェクトの取得

Javaのコンパイラを表すJavaCompilerインタフェースは、javax.tools.Toolインタフェースのサブインタフェースです。

Toolインタフェースはアプリケーション中から何らかの処理を行うための汎用インタフェースで、標準ではJavaコンパイラのJavaCompilerインタフェースと、Javadocを処理するDocletを実行するDocumentationToolインタフェースが提供されています。

実際にコンパイルを行うにはJavaCompilerインタフェースのgetTaskメソッドでCompilationTaskオブジェクトを生成し、CompilationTaskオブジェクトのcallメソッドをコールします。

さて、このJavaCompilationオブジェクトを取得するには、javax.tools.ToolProviderクラスのgetSystemJavaCompilerメソッドを利用します。

ただし、getSystemJavaCompilerメソッドで取得できるのはOpenJDKが提供しているjavacコンパイラです。

    JavaCompiler compiler = ToolProvider.getSystemJavaCompiler();
    if (compiler == null) {
      // 例外処理
    }

コンパイラが取得できない場合、getSystemJavaCompilerメソッドはnullを返すので必要に応じて例外処理をしてください。


仮想ファイルマネージャ

続いて、仮想ファイルマネージャを生成し、ソースファイルを用意します。

仮想ファイルマネージャを表すJavaFileManagerインタフェースは汎用のインタフェースなので、物理ファイルを扱う場合はサブインタフェースのStandardJavaFileManagerインタフェースを使用します。

StandardJavaFileManagerオブジェクトはJavaCompilerオブジェクトから取得することができます。物理ファイル以外のソースファイルを使用する場合は、JavaFileManagerインタフェースを実装したクラスを用意します。

ここでは、まず標準的な物理ファイルをコンパイルすることを考えます。

StandardJavaFileManagerオブジェクトを取得するには、JavaCompilerインタフェースのgetStandardFileMangerメソッドを使用します。

getStandardFileManagerメソッドは第1引数がコンパイルエラー処理のコールバックであるDiagnosticListenerインタフェースです。エラー処理を行わないのであれば、nullを指定します。

第2引数がロケール、第3引数が文字セットになります。これらは引数がnullの場合、デフォルトの値が使用されます。

また、JavaFileManagerインタフェースはAutoClosableインタフェースを実装しているので、try-with-resources形式でクローズすることができます。


次に抽象化したソースファイルです。こちらはJavaFileObjectインタフェースを使用します。

通常はソースファイルは1つとは限りません。複数のソースファイルはリストではなくIterableインタフェースで表します。Iterableインタフェースが使われていたのは、そういう時代だったからだと思ってくださいw

このIterableオブジェクトはStandardJavaFileManagerインタフェースのgetJavaFileObjectsメソッドで取得で取得できます。getJavaFileObjectsメソッドは可変長引数なので、ソースファイルを列挙していきます。

    // 仮想ファイルマネージャの取得
    // 第1引数 コンパイルエラー処理コールバック
    // 第2引数 ロケール nullだとデフォルトロケール
    // 第3引数 文字セット nullだとデフォルト文字セット
    try (StandardJavaFileManager fileManager 
      = compiler.getStandardFileManager(null, null, null)) {

      // 抽象ソースファイルの取得
      Iterable<? extends JavaFileObject> files 
        = fileManager.getJavaFileObjects("Hello.java");


コンパイルタスク生成とコンパイル

コンパイルタスクはJavaCompilerインタフェースのgetTaskメソッドで生成します。

このメソッドは引数が6個もあるので、指定するのがちょっとめんどうですが、使わないものはnullを指定しておけば大丈夫です。

CompilationTaskオブジェクトを生成出来たら、callメソッドでコンパイルを行います。コンパイルが成功すればtrueが戻ります。

    // コンパイルタスクの生成
    // 第1引数 コンパイラメッセージの出力 nullの場合System.errが使われる
    // 第2引数 仮想ファイルマネージャー
    // 第3引数 コンパイルエラーのコールバック 指定しない場合はnull
    // 第4引数 コンパイラオプション 指定しない場合はnull
    // 第5引数 アノテーションプロセッサ 指定しない場合はnull
    // 第6引数 ソースファイル群
    JavaCompiler.CompilationTask task = compiler.getTask(
            null, fileManager, null, null, null, files);
    
    // コンパイル
    // 成功すればtrueが戻る
    var result = task.call();

上記のコードではHello.javaをコンパイルしているので、実行すればHello.classが出力されるはずです。


String Templateを含んだソースをコンパイル

コンパイルできるようになったので、String Templateを含んだソースをコンパイルしてみましょう。

Hello.javaを次のようにしたとします。

public class Hello {
  public void sayHello(String name) {
    System.out.println(STR."Hello, \{name}!");
  }
}

これで実行してみましょう。

C:\sample>java CompilerTest
Hello.java:3: エラー: 文字列テンプレートはプレビュー機能であり、デフォルトで無効になっています。
        System.out.println(STR."Hello, \{name}!");
                           ^
  (文字列テンプレートを有効にするには--enable-previewを使用します)
エラー1個

String TemplateはJava 22ではプレビュー機能なので、コンパイルが失敗してしまいました。

これを解決するのは簡単で、CompilationTaskオブジェクトを生成する時に、コンパイルオプションを指定するだけです。

    // コンパイルタスクの生成
    // 第1引数 コンパイラメッセージの出力 nullの場合System.errが使われる
    // 第2引数 仮想ファイルマネージャー
    // 第3引数 コンパイルエラーのコールバック 指定しない場合はnull
    // 第4引数 コンパイラオプション 指定しない場合はnull
    // 第5引数 アノテーションプロセッサ 指定しない場合はnull
    // 第6引数 ソースファイル群
    JavaCompiler.CompilationTask task = compiler.getTask(
            null, fileManager, null,
            List.of("--enable-preview", "--release", "22"),
            null, files);
    
    // コンパイル
    // 成功すればtrueが戻る
    var result = task.call();

--release 22は1つの意味を示すオプションですが、タスクを生成する時は別々に指定するようにします。

これでコンパイルできるはずです。


文字列をソース対象としてコンパイルする

ここまでは、ソースはファイルとして扱っていました。

これに対し、文字列をソースとして扱うこともできます。

ソースファイルを表すJavaFileObjectオブジェクトは仮想ファイルマネージャStandardJavaFileManagerオブジェクトから取得していましたが、文字列をソースとして扱う場合はJavaFileObjectオブジェクトを自分で生成しなくてはなりません。

JavaFileObjectインタフェースを実装したクラスとして、SimpleJavaFileObjectクラスが提供されています。このSimpleJavaFileObjectクラスでは、ソースコードを返すメソッドとしてgetCharContentというメソッドが定義されています。SimpleJavaFileObjectクラスの実装ではUnsupportedOperationException例外をスローするようになっているので、このメソッドをオーバーライドします。

この拡張に関してはJavaCompilerインタフェースのAPIドキュメントに書いてあるので、それをそのまま使います。

public class StringJavaFileObject extends SimpleJavaFileObject {
  private String content;

  public StringJavaFileObject(String className, String content) {
    super(URI.create("string:///"
                    + className.replace('.', '/')
                    + Kind.SOURCE.extension),
          Kind.SOURCE);

    this.content = content;
  }

  @Override
  public CharSequence getCharContent(boolean ignoreEncodingErrors) {
    return content;
  }
}

StringJavaFileObjectクラスを使用して、抽象ソースを生成します。

    String HELLOCLASS = """
        public class Hello {
            public void sayHello(String name) {
                System.out.println("Hello, " + name + "!");
            }
        }
        """;

    // 文字列を抽象ソースとして扱う
    Iterable<? extends JavaFileObject> files 
            = List.of(new StringJavaFileObject("Hello", HELLOCLASS));

他の部分は先ほどと同じで大丈夫です。

これで実行すれば、Hello.classが作成されるはずです。

思ったよりも簡単にできたと思いませんか。javacのMainクラスを直接使うよりも、Compiler APIを使うとこのような応用的な使い方が簡単にできるのです。


ということで、Compiler APIの簡単な紹介でした。次回はCompiler APIを使ったString Templateのちょっとマニアックな使い方を紹介します。


ここで使用したソースはGistにのせてあります。

Compiler APIサンプル その1

Compiler APIサンプル その2 文字列をソースにする

0 件のコメント: