Java アノテーションプロセッサの使い方(実践編)


はじめに

以下では、Java アノテーションプロセッサの使い方の基本事項について説明しました。

blog1.mammb.com

今回は、アノテーションプロセッサを使った簡単なサンプルを用いて説明します。

アノテーションを付与した Bean に対して、その内容を toString() するクラスを自動生成することを考えます。

実運用上でメリットがあるものではありませんが、アノテーションプロセッサによるクラス生成のサンプルとして考えてください。

なお、ビルドには Gradle を使うものとして進めます。


プロジェクトの作成

Gradle でプロジェクトを作成し、build.gradle.kkts を以下のようにします(ここではライブラリプロジェクトとして進めます)。

plugins {
    `java-library`
}

repositories {
    mavenCentral()
}

dependencies {
    testImplementation("org.junit.jupiter:junit-jupiter:5.7.2")
    testAnnotationProcessor(project(":lib", "archives"))
}

tasks.named<Test>("test") {
    useJUnitPlatform()
}

tasks.withType<JavaCompile> {
    options.encoding = Charsets.UTF_8.name()
}

java {
    toolchain {
        languageVersion.set(JavaLanguageVersion.of(18))
    }
}

アノテーションプロセッサ用のプロジェクトは、マルチモジュールプロジェクトとすることが多いですが、以下のようにテストの依存にアーカイブを指定することで、シングルモジュールプロジェクトでアノテーションのテストが行えます。

testAnnotationProcessor(project(":lib", "archives"))

その他の定義は、特に何でも良いです。


アノテーションプロセッサの設定

アノテーションプロセッサは、サービスローダで動かすので、以下のファイルを用意し、

META-INF/services/javax.annotation.processing.Processor

ファイルの中身は以下のようにします。

pap.MyProcessor

今回は MyProcessor という名前でアノテーションプロセッサを作成しますが、好きな名前で構いません。


Gradle の増分コンパイルで扱うために、以下のファイルも用意しておきます。

META-INF/services/incremental.annotation.processors

ファイルの中身は以下のようにします。

pap.MyProcessor,isolating

詳細は以下のマニュアルを参照してください。

Incremental annotation processing


アノテーションの作成

ここでは単なるマーカーアノテーションとして以下を作成します。

package pap;

import java.lang.annotation.*;

@Documented
@Target({ ElementType.TYPE })
@Retention(RetentionPolicy.RUNTIME)
public @interface MyAnnotation {
}


このアノテーションはテストパッケージに以下のクラスを用意してアノテーションを付与して置きましょう。

package pap;

@MyAnnotation
public class Person {
  public final FullName fullName;
  public final int age;
  public Person(FullName fullName, int age) {
    this.fullName = fullName;
    this.age = age;
  }
  public FullName getFullName() { return fullName; }
  public int getAge() { return age; }
}
package pap;

public class FullName {
  public final String givenName;
  public final String familyName;
  public FullName(String givenName, String familyName) {
    this.givenName = givenName;
    this.familyName = familyName;
  }
  public String getGivenName() { return givenName; }
  public String getFamilyName() { return familyName; }
}

@MyAnnotation を付与した Person クラス用の toString を行うクラスを自動生成することになります。


プロセッサの作成

プロセッサは AbstractProcessor を継承したクラスとして以下のように作成します。

@SupportedAnnotationTypes("pap.MyAnnotation")
public class MyProcessor extends AbstractProcessor {

    @Override
    public void init(ProcessingEnvironment env) {
        super.init(env);
        env.getMessager().printMessage(Diagnostic.Kind.NOTE, "init MyProcessor.");
    }

    @Override
    public SourceVersion getSupportedSourceVersion() {
        return SourceVersion.latestSupported();
    }

    @Override
    public boolean process(Set<? extends TypeElement> annotations, RoundEnvironment roundEnv) {

        try {
            for (Element element : roundEnv.getElementsAnnotatedWith(MyAnnotation.class)) {
                TypeElement type = (TypeElement) element;
                // ...
            }

        } catch (Exception e) {
            processingEnv.getMessager().printMessage(Diagnostic.Kind.ERROR, e.getMessage());
        }
        return false;
    }
}

@SupportedAnnotationTypes で先ほど作成したアノテーションを指定しています。

process() メソッド内では、MyAnnotation が付与された要素についてループして処理を行います(実際の処理はまだありません)。


要素からアクセサーを取得する

Element からアクセサーを取得するメソッドを用意します。

private List<ExecutableElement> accessor(TypeElement element) {
  return element.getEnclosedElements().stream()
        .filter(e -> e.getKind() == ElementKind.METHOD)
        .map(ExecutableElement.class::cast)
        .filter(e -> e.getModifiers().contains(Modifier.PUBLIC))
        .filter(e -> e.getReturnType().getKind() != TypeKind.VOID)
        .filter(e -> e.getParameters().size() == 0)
        .filter(e -> e.getSimpleName().toString().startsWith("get") ||
                     e.getSimpleName().toString().startsWith("is"))
        .toList();
}

getEnclosedElements() で当該クラスの要素が取得できるため、getXxxisXxx といったメソッドを抽出しています。


抽出したアクセサーのプロパティ名を抽出するメソッドも用意しておきます。

private String name(ExecutableElement accessor) {
    var name = accessor.getSimpleName().toString();
    if (name.startsWith("get")) {
        return name.substring(3, 4).toLowerCase() + name.substring(4);
    } else if (name.startsWith("is")) {
        return name.substring(2, 3).toLowerCase() + name.substring(3);
    } else {
        return name;
    }
}

get() というメソッドがあった場合など、いろいろと考慮していないです。あくまでサンプルです。


アクセサーのtoString()を行うコードを生成

アクセサーの種類に応じて、toString() を行うコードを作成するコードです。

private String build(String parentPath, Element element) {

    if (!element.getKind().isClass()) {
        return "";
    }
    TypeElement typeElement = (TypeElement) element;

    String code = accessor(typeElement).stream().map(accessor -> {

            TypeMirror returnType = accessor.getReturnType();

            if (returnType.getKind().isPrimitive()) {

                return """
                "%s:" + %s.%s()"""
                        .formatted(name(accessor), parentPath, accessor.getSimpleName());

            } else if (returnType.toString().startsWith("java.") ||
                       returnType.toString().startsWith("javax.")) {

                return """
                "%s:" + %s.%s().toString()"""
                        .formatted(name(accessor), parentPath, accessor.getSimpleName());

            } else {
                return write(parentPath + "." + accessor.getSimpleName() + "()",
                        processingEnv.getTypeUtils().asElement(returnType)).toString();
            }

        }).collect(Collectors.joining(" + \",\" + "));

    return """
        "%s{" + %s + "}" """
            .formatted(typeElement.getSimpleName(), code);
}

プリミティブ型やjavaパッケージのものはそのまま toString を行い、それ以外のものは再帰的に処理を行います。


クラス生成

先ほど空白にしておいた process メソッドの中身を実装します。

for (Element element : roundEnv.getElementsAnnotatedWith(MyAnnotation.class)) {

    TypeElement type = (TypeElement) element;
    FileObject fo = processingEnv.getFiler().createSourceFile(type.getQualifiedName() + "_");

    try (PrintWriter pw = new PrintWriter(fo.openOutputStream())) {

        pw.println("package " + processingEnv.getElementUtils().getPackageOf(type).getSimpleName() + ";");
        pw.println("");
        pw.println("""
            public class %s {
                public static String toStr(%s obj) {
                    return %s;
                }
            }
            """.formatted(type.getSimpleName() + "_", type.getSimpleName(),
                build("obj", element)));
    }
}

processingEnv.getFiler().createSourceFile() で取得した FileObject へ書き込みを行います。

パッケージ文と、クラス定義を行います(今回の例ではimport文は不要です)。


テストの実行

テストを実行すれば、アノテーションプロセッサにより以下のクラスが生成されます(インデントは手で整形しています)。

package pap;

public class Person_ {
    public static String toStr(Person obj) {
        return "Person{" +
            "FullName{" +
                "givenName:" + obj.getFullName().getGivenName().toString() + "," +
                "familyName:" + obj.getFullName().getFamilyName().toString() +
            "}" + "," +
            "age:" + obj.getAge() +
        "}";
    }
}

テストとしては以下のようになります。

class MyProcessorTest {

    @Test void test() {
        var parson = new Person(new FullName("Bob", "dylan"), 81);
        assertEquals("Person{FullName{givenName:Bob,familyName:dylan},age:81}", Person_.toStr(parson));
    }
}


まとめ

アノテーションプロセッサのコード生成について例を使って説明しました。

プロセッサの全体像は以下のようになりました。

package pap;

import javax.annotation.processing.AbstractProcessor;
import javax.annotation.processing.ProcessingEnvironment;
import javax.annotation.processing.RoundEnvironment;
import javax.annotation.processing.SupportedAnnotationTypes;
import javax.lang.model.SourceVersion;
import javax.lang.model.element.*;
import javax.lang.model.type.TypeKind;
import javax.lang.model.type.TypeMirror;
import javax.tools.Diagnostic;
import javax.tools.FileObject;
import java.io.PrintWriter;
import java.util.List;
import java.util.Set;
import java.util.stream.Collectors;

@SupportedAnnotationTypes("pap.MyAnnotation")
public class MyProcessor extends AbstractProcessor {

    @Override
    public void init(ProcessingEnvironment env) {
        super.init(env);
        env.getMessager().printMessage(Diagnostic.Kind.NOTE, "init MyProcessor.");
    }

    @Override
    public SourceVersion getSupportedSourceVersion() {
        return SourceVersion.latestSupported();
    }


    @Override
    public boolean process(Set<? extends TypeElement> annotations, RoundEnvironment roundEnv) {

        try {
            for (Element element : roundEnv.getElementsAnnotatedWith(MyAnnotation.class)) {

                TypeElement type = (TypeElement) element;
                FileObject fo = processingEnv.getFiler().createSourceFile(type.getQualifiedName() + "_");

                try (PrintWriter pw = new PrintWriter(fo.openOutputStream())) {

                    pw.println("package " + processingEnv.getElementUtils().getPackageOf(type).getSimpleName() + ";");
                    pw.println("");
                    pw.println("""
                        public class %s {
                            public static String toStr(%s obj) {
                                return %s;
                            }
                        }
                        """.formatted(type.getSimpleName() + "_", type.getSimpleName(),
                            build("obj", element)));
                }
            }

        } catch (Exception e) {
            processingEnv.getMessager().printMessage(Diagnostic.Kind.ERROR, e.getMessage());
            e.printStackTrace();
        }
        return false;
    }


    private String build(String parentPath, Element element) {

        if (!element.getKind().isClass()) {
            return "";
        }
        TypeElement typeElement = (TypeElement) element;

        String code = accessor(typeElement).stream().map(accessor -> {

                TypeMirror returnType = accessor.getReturnType();

                if (returnType.getKind().isPrimitive()) {

                    return """
                    "%s:" + %s.%s()"""
                            .formatted(name(accessor), parentPath, accessor.getSimpleName());

                } else if (returnType.toString().startsWith("java.") ||
                           returnType.toString().startsWith("javax.")) {

                    return """
                    "%s:" + %s.%s().toString()"""
                            .formatted(name(accessor), parentPath, accessor.getSimpleName());

                } else {
                    return write(parentPath + "." + accessor.getSimpleName() + "()",
                            processingEnv.getTypeUtils().asElement(returnType)).toString();
                }

            }).collect(Collectors.joining(" + \",\" + "));

        return """
            "%s{" + %s + "}" """
                .formatted(typeElement.getSimpleName(), code);
    }


    private List<ExecutableElement> accessor(TypeElement element) {
        return element.getEnclosedElements().stream()
                .filter(e -> e.getKind() == ElementKind.METHOD)
                .map(ExecutableElement.class::cast)
                .filter(e -> e.getModifiers().contains(Modifier.PUBLIC))
                .filter(e -> e.getReturnType().getKind() != TypeKind.VOID)
                .filter(e -> e.getParameters().size() == 0)
                .filter(e -> e.getSimpleName().toString().startsWith("get") ||
                             e.getSimpleName().toString().startsWith("is"))
                .toList();
    }


    private String name(ExecutableElement accessor) {
        var name = accessor.getSimpleName().toString();
        if (name.startsWith("get")) {
            return name.substring(3, 4).toLowerCase() + name.substring(4);
        } else if (name.startsWith("is")) {
            return name.substring(2, 3).toLowerCase() + name.substring(3);
        } else {
            return name;
        }
    }
}

アノテーションを使う機会は少ないかも知れませんが、取っ掛かりとして良い日本語の情報源が少ないため、ここにまとめてみました。