概要

Java のアノテーションは、コードにメタデータを付与する仕組みです。@Override や @Deprecated といった標準アノテーションは日常的に目にしますが、独自のアノテーションを定義して実行時に処理する方法となると、一気にハードルが上がります。この記事では、カスタムアノテーションの定義から @Retention・@Target の設定、リフレクションで実行時に値を読み取る方法までを、2つの実装例で整理します。1つ目はメソッドに @TestCase を付けて自動実行する簡易テストランナー、2つ目はフィールドに @NotNull を付けて null チェックを行うバリデーション処理です。いずれも外部ライブラリなしで動作する完結したコードで、アノテーション処理の基本パターンを押さえられます。

使いどころ

共通基盤で独自の @Validate アノテーションを定義し、DTO のフィールドに付けるだけで入力チェックを自動化する

バッチ処理のステップに @Step(order=1) のようなアノテーションを付け、実行順序をメタデータから動的に組み立てる

社内フレームワークで @Endpoint(path="/api/users") を定義し、ルーティングテーブルをアノテーションスキャンで自動生成する

コード例

CustomAnnotationDemo.java
import java.lang.annotation.ElementType;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;
import java.lang.reflect.Method;

public class CustomAnnotationDemo {

    @Retention(RetentionPolicy.RUNTIME)
    @Target(ElementType.METHOD)
    @interface TestCase {
        String description() default "テストケース";
        int priority() default 1;
    }

    @Retention(RetentionPolicy.RUNTIME)
    @Target(ElementType.FIELD)
    @interface NotNull {
        String message() default "null は許可されていません";
    }

    static class CalculatorTest {

        @TestCase(description = "正の数の加算", priority = 1)
        void testAddPositive() {
            System.out.println("  3 + 5 = " + (3 + 5));
        }

        @TestCase(description = "ゼロの加算", priority = 2)
        void testAddZero() {
            System.out.println("  0 + 5 = " + (0 + 5));
        }

        void notATest() {
            System.out.println("  テスト対象外");
        }
    }

    static void runTests(Class<?> testClass) throws Exception {
        System.out.println("=== テスト実行: " + testClass.getSimpleName() + " ===");
        var instance = testClass.getDeclaredConstructor().newInstance();

        for (var method : testClass.getDeclaredMethods()) {
            if (method.isAnnotationPresent(TestCase.class)) {
                var tc = method.getAnnotation(TestCase.class);
                System.out.println("[Priority " + tc.priority() + "] "
                    + method.getName() + " - " + tc.description());
                method.invoke(instance);
            }
        }
    }

    static class UserForm {
        @NotNull(message = "ユーザー名は必須です")
        String username;

        String email; // アノテーションなし

        UserForm(String username, String email) {
            this.username = username;
            this.email = email;
        }
    }

    static void validate(Object obj) throws IllegalAccessException {
        System.out.println("=== バリデーション: "
            + obj.getClass().getSimpleName() + " ===");

        for (var field : obj.getClass().getDeclaredFields()) {
            if (field.isAnnotationPresent(NotNull.class)) {
                field.setAccessible(true);
                var value = field.get(obj);
                var annotation = field.getAnnotation(NotNull.class);

                if (value == null) {
                    System.out.println("  NG [" + field.getName() + "]: "
                        + annotation.message());
                } else {
                    System.out.println("  OK [" + field.getName() + "]: "
                        + value);
                }
            }
        }
    }

    public static void main(String[] args) throws Exception {
        // テストランナー実行
        runTests(CalculatorTest.class);

        System.out.println();

        // バリデーション実行
        validate(new UserForm("田中太郎", "[email protected]"));
        validate(new UserForm(null, "[email protected]"));
    }
}

Java 8 / 17 / 21 の完全なサンプルコードは GitHub リポジトリ で確認できます。

Version Coverage

var で記述を簡潔にできる。record と組み合わせてバリデーション結果を不変オブジェクトで返すパターンが書きやすい。

Java 17
// Java 17: var で簡潔に、record でバリデーション結果を表現
var instance = testClass.getDeclaredConstructor().newInstance();
for (var method : testClass.getDeclaredMethods()) {
    if (method.isAnnotationPresent(TestCase.class)) {
        var tc = method.getAnnotation(TestCase.class);
        System.out.println(tc.description());
        method.invoke(instance);
    }
}

Library Comparison

標準 API(java.lang.annotation + reflect)アノテーション定義と実行時処理の基本を押さえたいとき。小規模なバリデーションやテストランナーなら標準 API で十分。リフレクションの記述が冗長になりやすく、大量のアノテーション処理ではパフォーマンスに注意が必要。
Jakarta Bean Validation (Hibernate Validator)フィールドバリデーションを本格的に導入し、@NotNull・@Size・@Pattern などを標準仕様に沿って使いたいとき。Jakarta EE / Hibernate Validator への依存が発生する。小規模プロジェクトではオーバースペックになりやすい。
Annotation Processor(javax.annotation.processing)コンパイル時にアノテーションを処理してコード生成やチェックを行いたいとき(Lombok、MapStruct 等が利用)。Processor の実装は複雑で学習コストが高い。実行時処理とは仕組みが根本的に異なる点に注意。

注意点

@Retention(RetentionPolicy.RUNTIME) を付け忘れると、実行時にアノテーションを取得できない。デフォルトは CLASS(.class には残るが実行時取得不可)なので、リフレクションで使う場合は必ず RUNTIME を指定する

@Target を省略するとあらゆる要素に付与可能になり、意図しない箇所への付与を防げない。METHOD、FIELD、TYPE など対象を明示するのが安全

アノテーションの属性に指定できる型は限定されている(プリミティブ、String、Class、enum、アノテーション、およびそれらの配列のみ)。List や Map は使えない

getDeclaredMethods の返却順序は JVM の実装に依存し、ソースコード上の記述順とは限らない。実行順序が重要な場合は priority 属性などで明示的に制御すること

FAQ

@Retention の RUNTIME と CLASS と SOURCE はどう使い分けますか。

リフレクションで実行時に読むなら RUNTIME、バイトコード解析ツール向けなら CLASS、コンパイル時チェックやコード生成のみなら SOURCE です。業務で自作する場合はほぼ RUNTIME 一択です。

アノテーションの属性にデフォルト値を設定しないとどうなりますか。

default を省略した属性は、アノテーション使用時に必ず値を指定する必要があります。省略するとコンパイルエラーになるため、任意指定にしたい場合は default を付けてください。

1つのフィールドに同じアノテーションを複数付けられますか。

Java 8 以降、@Repeatable を付けたアノテーションは同一要素に複数付与できます。コンテナアノテーション(配列を持つアノテーション)の定義が必要です。

関連書籍

この記事のテーマをさらに深く学びたい方へ。

※ Amazon アソシエイトリンクを含みます