Аннотации в Java и их обработка в RUNTIME с помощью рефлексии

Аннотации — это своего рода маркеры, которые служат знаком либо компилятору, либо обработчику скомпилированных классов, либо самому приложению во время его выполнения — знаком что-то сделать.  Для аннотаций всегда нужен обработчик, сами они ничего не делают.
Пример обработчика — тестовый фреймворк JUnit. Для него мы помечаем метод аннотацией @Test, и он обрабатывает этот метод как тестовый. Вне фреймворка аннотация  @Test ничего не значит.

Другой пример — ORM-фреймворк Hibernate.  Еще есть Spring Framework,  проект Lombook.

Задача

В этой статье мы сконцентрируемся на обработке аннотаций в Runtime. Мы напишем свой собственный обработчик — упрощенный фреймворк для тестирования, который запускает тесты. Обработчик будет искать методы, аннотированные аннотацией @Test, и запускать их.

Не обязательно было помечать методы аннотацией @Test, можно было придумать некий naming convension — например, тестовые методы должны начинаться  с «test», ранние версии фреймворка JUnit именно так и работали. Просто аанотациями помечать удобнее.
Тестовые методы для запуска должны быть статические, так как у нас учебный пример, а статические методы проще запускать (с помощью библиотеки Java Reflection).

 

Наша задача создать статический метод, которому передается объект типа Class, содержащий тестируемые методы:

TestRunner.run(Class testClass)

Метод должен запустить все аннотированные с помощью @Test методы класса testClass и посчитать, сколько тестов прошло, а сколько нет.

Для начала мы создадим аннотацию @Test (свою, а не ту, что есть в JUnit). Хотя теоретически можно было бы переиспользовать и ту, просто написать для нее свой обработчик.

Создание аннотации @Test

Синтаксис аннотаций довольно прост, в нашем случае определение аннотации @Test выглядит так:

@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.METHOD)
public @interface Test {
}

@Target и @Retention — это метаанотации, то есть аннотации, которые применяются к аннотации.

Значение ElementType.METHOD в @Target показывает, что наша аннотация @Test применима к методам, то есть с помощью нее можно аннотировать только метод:

@Test
public static void m1() {
}

А вообще аннотации можно применять практически к любому элементу кода — классу, параметрам, полям…Просто в нашем случае требуется аннотировать только методы, что мы и указываем в определении аннотации. Если теперь в коде применить аннотацию @Test к полю, классу или другому элементу, то компилятор выдаст ошибку.

Виды RetentionPolicy 

Сделаем  небольшое отступление о метааннотации @Retention(RetentionPolicy.RUNTIME). Она, как и @Target, доступна в JDK по умолчанию и служит для оформления пользовательских аннотаций.

Вообще есть три уровня RetentionPolicy:

  1. RetentionPolicy.SOURCE
  2. RetentionPolicy.CLASS (действует по умолчанию, если метааннотация @Retention не указана)
  3. RetentionPolicy.RUNTIME

«Retention» переводится как «сохранение», «удержание», имеется в виду где (на каком этапе) сохраняются/выживают аннотации.

Уровни вы списке выше упорядочены по степени выживания аннотаций  (1 —  самая короткоживущая).

  1. Аннотации с RetentionPolicy.SOURCE остаются только в исходниках, в скомпилированных файлах их уже нет. Они интересны  либо статическим анализаторам кода, либо обработчику аннотаций на этапе компиляции. Как создать дополнительный обработчик исходного кода подробно рассматривается в статье — в ней мы генерируем новый класс на этапе компиляции. Так работает Lombok — он на основе аннотаций генерирует сеттеры и геттеры в исходниках.

Аннотации с RetentionPolicy.CLASS остаются в скомпилированных файлах, но на этапе выполнения программы в машинном коде их уже нет. Эти аннотации могут быть интересны обфускатору кода (который переименовывает и сокращает скомпилированный код). Или, например, служебной библиотеке Javassist. Как изменить байт-код с помощью этой библиотеки есть в другой статье.

В этой статье мы рассмотрим, как работать с аннотациями на этапе выполнения кода в (runtime), для этого аннотации должны иметь самый сильный уровень RetentionPolicy.RUNTIME. Мы будем использовать Java Reflection — библиотеку, которая может считать и изменить информацию о классе в процессе выполнения программы. А также запустить методы класса.

Класс Class

Теперь пара слов о параметре метода TestRunner.run(Class testClass).

Тестировать мы будем класс Sample1, а конкретно, его аннотированные с помощью @Test методы:

public class Sample1 {
    @Test
    public static void m1() {
    } // Test should pass

    public static void m2() {
    }

    @Test
    public static void m3() { // Test should fail
        throw new RuntimeException("Boom");
    }

    public static void m4() {
    }

    public static void m6() {
    }

    @Test
    public static void m7() { // Test should fail
        throw new RuntimeException("Crash");
    }

    public static void m8() {
    }

    @Test
    public void m5() {

    } // INVALID USE: nonstatic method
}

Значит в параметр мы будем передавать объект Class, полученный из класса Sample1:

TestRunner.run(Sample1.class);

Библиотека Java Reflection имеет дело с объектом типа Class. Она может получить из этого объекта список методов, полей, конструкторов, аннотаций для данного класса.

Объект типа Class создается для каждого класса приложения при его загрузке. То есть при первом обращении к классу, например при создании объекта этого класса, загружается скомпилированный .class файл, содержащий байт-код класса, Sample1.class. Его содержимое загружается в память VM и на его основе в heap создается объект типа Class<SampleTest>, в котором есть вся информация о классе — какие в нем методы, поля, конструкторы, аннотации.

Давайте приступим к реализации логики — получим из этого объекта список тестовых методов и запустим их.

Метод TestRunner.run()

Чтобы запустить методы, надо получить их список. В цикле мы идем по полученным методам класса и выбираем аннотированные:

 public static void run(Class testClass) {
        int tests = 0;
        int passed = 0;
        for (Method m : testClass.getDeclaredMethods()) {
            if (m.isAnnotationPresent(Test.class)) {
                tests++;
                try {
                    m.invoke(null);
                    passed++;
                } catch (InvocationTargetException wrappedExc) {
                    Throwable exc = wrappedExc.getCause();
                    System.out.println(m + " failed: " + exc);
                } catch (IllegalAccessException exc) {
                    System.out.println("Invalid @Test: " + m);
                }
            }

        }

        System.out.printf("Passed: %d, Failed: %d%n",
                passed, tests - passed);
    }
Затем мы их запускаем с помощью invoke(). В метод m.invoke(null) мы передаем null, поскольку метод статический. Именно для этого по условию задачи мы и сделали  методы статическими. Иначе пришлось бы создавать и передавать экземпляр класса, на котором вызывается метод, вместо null.

Далее, в InvocationTargetException оборачивается исключение, выброшенное изнутри тестового метода. То есть попадание в этот блок catch() считается провалом теста.

В блок IllegalAccessException мы попадаем, если некорректно вызвали invoke(): например, передали ему null, когда m не статический. Эта ситуация считается невалидным тестом.

Если же ни в какой блок catch() мы не попали, что тест считается валидным и пройденным.

В принципе все, таким образом подсчитывается и выводится количество пройденных и не пройденных тестов.

В реальном фреймворке типа JUnit все гораздо сложнее, потому что поддерживается много аннотаций — @Before, @After и т.д.

А мы давайте еще сделаем поддержку аннотации @ExceptionTest — она используется, чтобы показать, что тест должен выбрасывать определенное исключение.

Тестирование методов на выбрасывание исключения

Напишем отдельный класс с методами, которые должны выбрасывать исключение, и нам надо протестировать, что они их действительно выбрасывают.

Как маркер выбрасывания исключения у нас создана аннотация @ExceptionTest:

public class Sample2 {
    @ExceptionTest(ArithmeticException.class)
    public static void m1() { // Test should pass
        int i = 0;
        i = i / i;
    }

    @ExceptionTest(ArithmeticException.class)
    public static void m2() { // Should fail (wrong exception)
        int[] a = new int[0];
        int i = a[1];
    }

    @ExceptionTest(ArithmeticException.class)
    public static void m3() {
    } // Should fail (no exception)

    // Code containing an annotation with an array parameter
    @ExceptionTest(IndexOutOfBoundsException.class)
    @ExceptionTest(NullPointerException.class)
    public static void doublyBad() {
        List<String> list = new ArrayList<>();
// The spec permits this method to throw either
// IndexOutOfBoundsException or NullPointerException
        list.addAll(5, null);
    }
}

Внутри аннотации указан тип исключения, который должен выбрасывать метод.

Обратите внимание, что метод doublyBad() аннотирован аннотацией @ExceptionTest дважды. Это означает, что надо протестировать, что метод выбрасывает любое из этих исключений.

Здесь мы подошли к понятию repeatable annotation.

Repeatable аннотации

Аннотации, которыми можно аннотировать элемент несколько раз, называются repeatable. @ExceptionTest является repeatable-аннотацией. Синтаксис их не прост, а особенно обработка.

Чтобы  определить аннотацию как repeatable, мы должны аннотировать ее дополнительной метааннотацией @Repeatable(ExceptionTestContainer.class):

@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.METHOD)
@Repeatable(ExceptionTestContainer.class)
public @interface ExceptionTest {
    Class<? extends Exception> value();
}

Здесь внутри @Repeatable задана вторая аннотация ExceptionTestContainer.class, и ее нам тоже надо определить. Она служит контейнером для повторяемых значений:

@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.METHOD)

public @interface ExceptionTestContainer {
    ExceptionTest[] value();
}

В ExceptionTestContainer должен быть массив типа ExceptionTest. Вот так они закручены, и с обработкой дела обстоят не лучше.

Обрабатывать повторяемые аннотации надо осторожно.

Особенность в том, что
m.isAnnotationPresent(ExceptionTest.class) возвращает false, если метод аннотирован ею как минимум дважды. В этом случае m.isAnnotationPresent(ExceptionTestContainer.class)== true. Зато если метод аннотирован ею единожды, то m.isAnnotationPresent(ExceptionTest.class)==true, а m.isAnnotationPresent(ExceptionTestContainer.class)==false.

 

Итак, допишем наш цикл так, чтобы тесты, аннотированные с помощью @ExceptionTest, тоже запускались и проверялись.

Поддержим @ExceptionTest 

Для этого допишем еще один блок if внутри цикла в TestRunner.run():

    public static void run(Class testClass) {
        int tests = 0;
        int passed = 0;
        for (Method m : testClass.getDeclaredMethods()) {
             //Здесь обработка аннотации @Test

            // Обработка repeatable annotations
            if (m.isAnnotationPresent(ExceptionTest.class)
                    || m.isAnnotationPresent(ExceptionTestContainer.class)) {
                tests++;
                try {
                    m.invoke(null);
                    System.out.printf("Test %s failed: no exception%n", m);
                } catch (Throwable wrappedExc) {
                    Throwable exc = wrappedExc.getCause();
                    int oldPassed = passed;
                    ExceptionTest[] excTests =
                            m.getAnnotationsByType(ExceptionTest.class);
                    for (ExceptionTest excTest : excTests) {
                        if (excTest.value().isInstance(exc)) {
                            passed++;
                            break;
                        }
                    }
                    if (passed == oldPassed)
                        System.out.printf("Test %s failed: %s %n", m, exc);
                }
            }
        }

 

В соответствии с нашим замечанием выше об особенностях работы метода isAnnotationPresent() с повторяемыми аннотациями, мы проверяем методы на наличие любой из двух аннотаций: ExceptionTest и ExceptionTestContainer.

Далее ситуация противоположная — чтобы тест был пройден, исключение должно быть выброшено, то есть мы должны попасть в блок catch(). Причем исключение должно быть именно одного из тех типов, перечисленных в аннотации. Что мы и проверяем.

Исходники

Полный код примера находится тут.

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *