1. 概要

Java 8は、Javacプラグインを作成するためのAPIを提供します。 残念ながら、そのための適切なドキュメントを見つけるのは困難です。

この記事では、*。classファイルにカスタムコードを追加するコンパイラ拡張機能を作成するプロセス全体を紹介します。

2. 設定

まず、プロジェクトの依存関係としてJDKのtools.jarを追加する必要があります。

<dependency>
    <groupId>com.sun</groupId>
    <artifactId>tools</artifactId>
    <version>1.8.0</version>
    <scope>system</scope>
    <systemPath>${java.home}/../lib/tools.jar</systemPath>
</dependency>

すべてのコンパイラ拡張機能は、com.sun.source.util.Pluginインターフェイスを実装するクラスです。この例で作成してみましょう。

この例で作成してみましょう。

public class SampleJavacPlugin implements Plugin {

    @Override
    public String getName() {
        return "MyPlugin";
    }

    @Override
    public void init(JavacTask task, String... args) {
        Context context = ((BasicJavacTask) task).getContext();
        Log.instance(context)
          .printRawLines(Log.WriterKind.NOTICE, "Hello from " + getName());
    }
}

今のところ、コードが正常に取得されてコンパイルに含まれていることを確認するために、「Hello」を出力しています。

最終的な目標は、特定のアノテーションでマークされたすべての数値引数にランタイムチェックを追加するプラグインを作成し、引数が条件に一致しない場合は例外をスローすることです。

拡張機能をJavacで検出可能にするために必要なもう1つの手順があります:ServiceLoaderフレームワークを介して公開する必要があります。

これを実現するには、プラグインの完全修飾クラス名( com.baeldung.javac.SampleJavacPlugin )であるコンテンツを含むcom.sun.source.util.Pluginという名前のファイルを作成する必要があります。 )、 META-INF /servicesディレクトリに配置します。

その後、 -Xplugin:MyPluginスイッチを使用してJavacを呼び出すことができます。

baeldung/tutorials$ javac -cp ./core-java/target/classes -Xplugin:MyPlugin ./core-java/src/main/java/com/baeldung/javac/TestClass.java
Hello from MyPlugin

プラグインのgetName()メソッドから返された文字列を-Xpluginオプション値として常に使用する必要があることに注意してください。

3. プラグインのライフサイクル

プラグインは、init()メソッドを介してコンパイラによって1回だけ呼び出されます。

後続のイベントの通知を受け取るには、コールバックを登録する必要があります。 これらは、ソースファイルごとのすべての処理ステージの前後に到着します。

  • PARSE 抽象構文木(AST)を構築します
  • ENTER –ソースコードのインポートが解決されました
  • ANALYZE –パーサー出力(AST)のエラーが分析されます
  • GENERATE –ターゲットソースファイルのバイナリを生成します

さらに2つのイベントの種類があります– ANNOTATION_PROCESSINGANNOTATION_PROCESSING_ROUNDですが、ここではそれらに関心がありません。

たとえば、ソースコード情報に基づいていくつかのチェックを追加することでコンパイルを強化したい場合、PARSEfinishedイベントハンドラーでそれを行うのが合理的です。

public void init(JavacTask task, String... args) {
    task.addTaskListener(new TaskListener() {
        public void started(TaskEvent e) {
        }

        public void finished(TaskEvent e) {
            if (e.getKind() != TaskEvent.Kind.PARSE) {
                return;
            }
            // Perform instrumentation
        }
    });
}

4. ASTデータを抽出する

TaskEvent.getCompilationUnit()を介してJavaコンパイラによって生成されたASTを取得できます。その詳細は、TreeVisitorインターフェイスを介して調べることができます。

accept()メソッドが呼び出される Tree 要素のみが、指定された訪問者にイベントをディスパッチすることに注意してください。

たとえば、 ClassTree.accept(visitor)を実行すると、 visitClass()のみがトリガーされます。 たとえば、 visitMethod()が、指定されたクラスのすべてのメソッドに対してアクティブ化されることは期待できません。

TreeScanner を使用して、問題を解決できます。

public void finished(TaskEvent e) {
    if (e.getKind() != TaskEvent.Kind.PARSE) {
        return;
    }
    e.getCompilationUnit().accept(new TreeScanner<Void, Void>() {
        @Override
        public Void visitClass(ClassTree node, Void aVoid) {
            return super.visitClass(node, aVoid);
        }

        @Override
        public Void visitMethod(MethodTree node, Void aVoid) {
            return super.visitMethod(node, aVoid);
        }
    }, null);
}

この例では、 super.visitXxx(node、value)を呼び出して、現在のノードの子を再帰的に処理する必要があります。

5. ASTを変更する

ASTを変更する方法を示すために、@Positiveアノテーションでマークされたすべての数値引数のランタイムチェックを挿入します。

これは、メソッドパラメータに適用できる単純なアノテーションです。

@Documented
@Retention(RetentionPolicy.CLASS)
@Target({ElementType.PARAMETER})
public @interface Positive { }

アノテーションの使用例は次のとおりです。

public void service(@Positive int i) { }

最後に、バイトコードを次のようなソースからコンパイルされたように見せたいと思います。

public void service(@Positive int i) {
    if (i <= 0) {
        throw new IllegalArgumentException("A non-positive argument ("
          + i + ") is given as a @Positive parameter 'i'");
    }
}

これが意味するのは、0以下の@Positiveでマークされたすべての引数に対してIllegalArgumentExceptionがスローされることです。

5.1. インストルメントする場所

計装を適用する必要のあるターゲットの場所を見つける方法を見つけましょう。

private static Set<String> TARGET_TYPES = Stream.of(
  byte.class, short.class, char.class, 
  int.class, long.class, float.class, double.class)
 .map(Class::getName)
 .collect(Collectors.toSet());

簡単にするために、ここではプリミティブ数値タイプのみを追加しました。

次に、 shouldInstrument()メソッドを定義して、パラメーターにTARGET_TYPESセットのタイプと、@Positiveアノテーションがあるかどうかを確認します。

private boolean shouldInstrument(VariableTree parameter) {
    return TARGET_TYPES.contains(parameter.getType().toString())
      && parameter.getModifiers().getAnnotations().stream()
      .anyMatch(a -> Positive.class.getSimpleName()
        .equals(a.getAnnotationType().toString()));
}

次に、 SampleJavacPluginクラスのfinished()メソッドを続行し、条件を満たすすべてのパラメーターにチェックを適用します。

public void finished(TaskEvent e) {
    if (e.getKind() != TaskEvent.Kind.PARSE) {
        return;
    }
    e.getCompilationUnit().accept(new TreeScanner<Void, Void>() {
        @Override
        public Void visitMethod(MethodTree method, Void v) {
            List<VariableTree> parametersToInstrument
              = method.getParameters().stream()
              .filter(SampleJavacPlugin.this::shouldInstrument)
              .collect(Collectors.toList());
            
              if (!parametersToInstrument.isEmpty()) {
                Collections.reverse(parametersToInstrument);
                parametersToInstrument.forEach(p -> addCheck(method, p, context));
            }
            return super.visitMethod(method, v);
        }
    }, null);

この例では、複数の引数が @Positiveでマークされている可能性があるため、パラメーターリストを逆にしました。すべてのチェックが最初のメソッド命令として追加されるため、それらをRTLで処理します。正しい順序を確認してください。

5.2. 楽器の作り方

問題は、「ASTの読み取り」が public API領域にあるのに対し、「nullチェックの追加」などの「ASTの変更」操作はプライベート APIであるということです。 。

これに対処するために、TreeMakerインスタンスを介して新しいAST要素を作成します。

まず、Contextインスタンスを取得する必要があります。

@Override
public void init(JavacTask task, String... args) {
    Context context = ((BasicJavacTask) task).getContext();
    // ...
}

次に、 TreeMarker.instance(Context)メソッドを使用してTreeMarkerオブジェクトを取得できます。

これで、新しいAST要素を作成できます。たとえば、 if 式は、 TreeMaker.If()を呼び出すことで作成できます。

private static JCTree.JCIf createCheck(VariableTree parameter, Context context) {
    TreeMaker factory = TreeMaker.instance(context);
    Names symbolsTable = Names.instance(context);
        
    return factory.at(((JCTree) parameter).pos)
      .If(factory.Parens(createIfCondition(factory, symbolsTable, parameter)),
        createIfBlock(factory, symbolsTable, parameter), 
        null);
}

チェックから例外がスローされたときに、正しいスタックトレースラインを表示したいことに注意してください。 そのため、 factory.at(((JCTree)parameter).pos)を使用して新しい要素を作成する前に、ASTファクトリの位置を調整します。

The createIfCondition() メソッドは「 parameterId <0″ もしも調子:

private static JCTree.JCBinary createIfCondition(TreeMaker factory, 
  Names symbolsTable, VariableTree parameter) {
    Name parameterId = symbolsTable.fromString(parameter.getName().toString());
    return factory.Binary(JCTree.Tag.LE, 
      factory.Ident(parameterId), 
      factory.Literal(TypeTag.INT, 0));
}

次に、 createIfBlock()メソッドは、 IllegalArgumentException:を返すブロックを作成します。

private static JCTree.JCBlock createIfBlock(TreeMaker factory, 
  Names symbolsTable, VariableTree parameter) {
    String parameterName = parameter.getName().toString();
    Name parameterId = symbolsTable.fromString(parameterName);
        
    String errorMessagePrefix = String.format(
      "Argument '%s' of type %s is marked by @%s but got '", 
      parameterName, parameter.getType(), Positive.class.getSimpleName());
    String errorMessageSuffix = "' for it";
        
    return factory.Block(0, com.sun.tools.javac.util.List.of(
      factory.Throw(
        factory.NewClass(null, nil(), 
          factory.Ident(symbolsTable.fromString(
            IllegalArgumentException.class.getSimpleName())),
            com.sun.tools.javac.util.List.of(factory.Binary(JCTree.Tag.PLUS, 
            factory.Binary(JCTree.Tag.PLUS, 
              factory.Literal(TypeTag.CLASS, errorMessagePrefix), 
              factory.Ident(parameterId)), 
              factory.Literal(TypeTag.CLASS, errorMessageSuffix))), null))));
}

新しいAST要素を作成できるようになったので、パーサーによって準備されたASTにそれらを挿入する必要があります。 これは、 public A PI要素をprivateAPIタイプにキャストすることで実現できます。

private void addCheck(MethodTree method, VariableTree parameter, Context context) {
    JCTree.JCIf check = createCheck(parameter, context);
    JCTree.JCBlock body = (JCTree.JCBlock) method.getBody();
    body.stats = body.stats.prepend(check);
}

6. プラグインのテスト

プラグインをテストできる必要があります。 これには以下が含まれます。

  • テストソースをコンパイルする
  • コンパイルされたバイナリを実行し、期待どおりに動作することを確認します

このために、いくつかの補助クラスを導入する必要があります。

SimpleSourceFile は、指定されたソースファイルのテキストをJavacに公開します。

public class SimpleSourceFile extends SimpleJavaFileObject {
    private String content;

    public SimpleSourceFile(String qualifiedClassName, String testSource) {
        super(URI.create(String.format(
          "file://%s%s", qualifiedClassName.replaceAll("\\.", "/"),
          Kind.SOURCE.extension)), Kind.SOURCE);
        content = testSource;
    }

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

SimpleClassFile は、コンパイル結果をバイト配列として保持します。

public class SimpleClassFile extends SimpleJavaFileObject {

    private ByteArrayOutputStream out;

    public SimpleClassFile(URI uri) {
        super(uri, Kind.CLASS);
    }

    @Override
    public OutputStream openOutputStream() throws IOException {
        return out = new ByteArrayOutputStream();
    }

    public byte[] getCompiledBinaries() {
        return out.toByteArray();
    }

    // getters
}

SimpleFileManager は、コンパイラがバイトコードホルダーを使用することを保証します。

public class SimpleFileManager
  extends ForwardingJavaFileManager<StandardJavaFileManager> {

    private List<SimpleClassFile> compiled = new ArrayList<>();

    // standard constructors/getters

    @Override
    public JavaFileObject getJavaFileForOutput(Location location,
      String className, JavaFileObject.Kind kind, FileObject sibling) {
        SimpleClassFile result = new SimpleClassFile(
          URI.create("string://" + className));
        compiled.add(result);
        return result;
    }

    public List<SimpleClassFile> getCompiled() {
        return compiled;
    }
}

最後に、これらすべてがメモリ内のコンパイルにバインドされます。

public class TestCompiler {
    public byte[] compile(String qualifiedClassName, String testSource) {
        StringWriter output = new StringWriter();

        JavaCompiler compiler = ToolProvider.getSystemJavaCompiler();
        SimpleFileManager fileManager = new SimpleFileManager(
          compiler.getStandardFileManager(null, null, null));
        List<SimpleSourceFile> compilationUnits 
          = singletonList(new SimpleSourceFile(qualifiedClassName, testSource));
        List<String> arguments = new ArrayList<>();
        arguments.addAll(asList("-classpath", System.getProperty("java.class.path"),
          "-Xplugin:" + SampleJavacPlugin.NAME));
        JavaCompiler.CompilationTask task 
          = compiler.getTask(output, fileManager, null, arguments, null,
          compilationUnits);
        
        task.call();
        return fileManager.getCompiled().iterator().next().getCompiledBinaries();
    }
}

その後、バイナリを実行するだけで済みます。

public class TestRunner {

    public Object run(byte[] byteCode, String qualifiedClassName, String methodName,
      Class<?>[] argumentTypes, Object... args) throws Throwable {
        ClassLoader classLoader = new ClassLoader() {
            @Override
            protected Class<?> findClass(String name) throws ClassNotFoundException {
                return defineClass(name, byteCode, 0, byteCode.length);
            }
        };
        Class<?> clazz;
        try {
            clazz = classLoader.loadClass(qualifiedClassName);
        } catch (ClassNotFoundException e) {
            throw new RuntimeException("Can't load compiled test class", e);
        }

        Method method;
        try {
            method = clazz.getMethod(methodName, argumentTypes);
        } catch (NoSuchMethodException e) {
            throw new RuntimeException(
              "Can't find the 'main()' method in the compiled test class", e);
        }

        try {
            return method.invoke(null, args);
        } catch (InvocationTargetException e) {
            throw e.getCause();
        }
    }
}

テストは次のようになります。

public class SampleJavacPluginTest {

    private static final String CLASS_TEMPLATE
      = "package com.baeldung.javac;\n\n" +
        "public class Test {\n" +
        "    public static %1$s service(@Positive %1$s i) {\n" +
        "        return i;\n" +
        "    }\n" +
        "}\n" +
        "";

    private TestCompiler compiler = new TestCompiler();
    private TestRunner runner = new TestRunner();

    @Test(expected = IllegalArgumentException.class)
    public void givenInt_whenNegative_thenThrowsException() throws Throwable {
        compileAndRun(double.class,-1);
    }
    
    private Object compileAndRun(Class<?> argumentType, Object argument) 
      throws Throwable {
        String qualifiedClassName = "com.baeldung.javac.Test";
        byte[] byteCode = compiler.compile(qualifiedClassName, 
          String.format(CLASS_TEMPLATE, argumentType.getName()));
        return runner.run(byteCode, qualifiedClassName, 
        "service", new Class[] {argumentType}, argument);
    }
}

ここでは、 @Positiveでアノテーションが付けられたパラメーターを持つservice()メソッドを使用して Test クラスをコンパイルしています。次に、を実行しています。 ]メソッドパラメータに-1のdouble値を設定することにより、クラスをテストします。

プラグインを使用してコンパイラーを実行した結果、テストは負のパラメーターに対してIllegalArgumentExceptionをスローします。

7. 結論

この記事では、Javaコンパイラプラグインを作成、テスト、実行する完全なプロセスを示しました。

例の完全なソースコードは、GitHubにあります。