ХабрПостыНовостиМегапосты?

Как я реализовывал switch exhaustiveness checker для Java 8

В последние время многие промышленные языки вроде C#, Kotlin и Java стали реализовывать switch exhaustiveness проверки для разных языковых элементов: sealed classes, records и enums. Я могу предположить, что это связано с популяризацией Data Oriented Programming. К сожалению, я пока привязан к Java 11, где эта функциональность компилятором не реализована. Поэтому, я решил сделать что-нибудь, что будет проверять switch exhaustiveness для Enums и будет работать на Java 8 и выше.


TL;DR Я реализовал процессор аннотаций, который можно подключить одной строкой к проеткам на maven и gradle. Посмотреть его можно тут — https://github.com/Hixon10/switch-exhaustiveness-checker.

Зачем это всё?


Как вообще можно проверить, что все ветки switch покрыты для данного Enum? Мне известны несколько способов со своими плюсами и минусами:

  1. Использовать готовый статический анализатор, например, https://github.com/checkstyle/checkstyle. После этого можно сконфигурировать правило в CI, чтобы пайплайн падал, если есть непокрытые switch-бранчи. Плюс — всё сделано до нас, нам стоит условно бесплатно. Минус — не узнаем о проблеме до CI (если не будем специально запускать правила локально). Еще — решение требует наличия нужной инфраструктуры, что не всегда есть на мелких проектах, где тоже хочется чувствовать себя безапастно.
  2. Писать unit tests для switch с enums, которые проверят, что все бранчи покрыты. Плюс — не нужен установленный статический анализатор. Минус — очень нудно и времязатратно писать столько тестов.
  3. Использовать последние версии языков. Плюс — вы получите лучший developer experience из всех возможных опций. Минус — не всегда это доступно на проекте по разным причинам.

Как можно видеть, идеального решения, которые доступно для меня, нет. Поэтому я решил попробовать написать что-то своё, попутно получив кучу удовольствия от программирования.

Первая неудачная попытка — Анализ байткода в class-files


Интересно, что в этот раз у меня не получилось сразу найти правильное решение. Обычно, когда я делаю что-то на коленке, я примерно представляю, куда стоит двигаться. Тут же вышло немного иначе. Наверное, это связано с тем, что я крайне редко пишу что-то вокруг анализа кода, поэтому есть серые пятна в знаниях.

Я начал с достаточно прямолинейного решения. Давайте возьмем библиотеку для анализа байтокода из class-файлов, и поанализируем их. После этого, можно выполнить этот анализ как отдельный шаг компиляции программы с помощью gradle/maven плагина. Я взял стандартную для этого https://asm.ow2.io/, и начал прототипировать.

Что я вообще хотел получить с помощью ASM библиотеки? Я решил, что мне будет достаточно текстового представления байткодов, которое я потом смогу прямолинейным образом проанализировать сам. То есть, я хотел получить подобный массив строк:

  public example1(Ljava/math/RoundingMode;)V
   L0
    LINENUMBER 44 L0
    GETSTATIC Person$1.$SwitchMap$java$math$RoundingMode : [I
    ALOAD 1
    INVOKEVIRTUAL java/math/RoundingMode.ordinal ()I
    IALOAD
    TABLESWITCH
      1: L1
      2: L2
      3: L3
      4: L4
      5: L5
      6: L6
      7: L7
      8: L7
      default: L7


В целом, никаких проблем с получением текстового представления класс файлов у меня не возникло. Мне нужно было вызвать несколько готовых функций из библиотеки. Что-то подобное:

public String[] getDisassembledViewOfClass(InputStream inputStream) {
    try (ByteArrayOutputStream out = new ByteArrayOutputStream()) {
        CustomTraceClassVisitor classVisitor = new CustomTraceClassVisitor(null, new Textifier(), new PrintWriter(out, true));
        new ClassReader(inputStream).accept(classVisitor, 0);
        return out.toString("UTF-8").split("\n");
    } catch (Exception e) {
        System.out.println("[ERROR]: getDisassembledViewOfClass(): got exception: e=" + e.getMessage());
        throw new RuntimeException(e);
    }
}


Метод getDisassembledViewOfClass(..) принимает стрим класс-файла, обходит его, вызвая попутно наш Visitor. Ничего хитрого, за нас всё делает библиотека.

К сожалению, этот подход не заработал. Оказалось, что есть 2 вещи, которые я не знал про switch в java:
  1. В байткоде switch может быть представлен двумя разными инструкциями, в зависимости от того, как много пропусков между значениями у нас есть — либо с помощью TABLESWITCH, либо с помощью LOOKUPSWITCH.
  2. Для TABLESWITCH компилятор может генерировать фейковые ветки в байткоде, для перфоманс целей — https://github.com/openjdk/jdk/blob/master/src/java.xml/share/classes/com/sun/org/apache/bcel/internal/generic/SWITCH.java#L85.

Почему нас это волнует? Оказывается, можно построить таких два switch statements, что байткод у них будет одинаковый, а Java-код — разный, причём в одном случае будут покрыты все switch-ветки, а в другом — нет. То есть, формально по байткоду мы не сможем сказать точно, покрыл ли именно пользователь все Enum-ветки, или часть веток сгенерирована компилятором.

Например, можно рассмотреть два метода example1() и example2() и заметить, что case HALF_EVEN есть только в example1(..), но в байткоде вы не увидите этого.

public void example1(RoundingMode roundingMode) {
    switch (roundingMode) {
        case UP:
            break;
        case DOWN:
            break;
        case CEILING:
            break;
        case FLOOR:
            break;
        case HALF_UP:
            System.out.println("Q");
            break;
        case HALF_DOWN:
            break;
        case HALF_EVEN:
        case UNNECESSARY:
        default:
            System.out.println("Default");
            break;
    }
}

public void example2(RoundingMode roundingMode) {
    switch (roundingMode) {
        case UP:
            break;
        case DOWN:
            break;
        case CEILING:
            break;
        case FLOOR:
            break;
        case HALF_UP:
            System.out.println("Q");
            break;
        case HALF_DOWN:
            break;
        case UNNECESSARY:
        default:
            System.out.println("Default");
            break;
    }
}


На практике, вероятно, эта проблема не будет часто воспроизводиться, но я решил, что хочу более надежное решение, поэтому стал искать что-то еще.

Вторая неудачная попытка — Реализация процессора аннотаций


Далее я решил попробовать написать свой процессор аннотаций. Java даёт возможность получить кучу мета-информации о коде через API процессинга аннотаций. Я был уверен, что мне этого хватит чтобы понять, все ли бранчи свитчей для Enums покрыты.

Я создал свою аннотацию @SwitchExhaustive, и написал какой-то базовый код, чтобы получить свитч statements в процессоре аннотаций:

...
MethodTree methodTree = trees.getTree(method);
BlockTree blockTree = methodTree.getBody();

for (StatementTree statementTree : blockTree.getStatements()) {
    if (statementTree.getKind() == Tree.Kind.SWITCH) {
        SwitchTree switchTree = (SwitchTree) statementTree;
        ExpressionTree switchTreeExpression = switchTree.getExpression();
        // Тут мне надо понять, какой же тип у switchTreeExpression, чтобы работать только с Enum
    }
}


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

Annotation processing occurs at a specific point in the timeline of a compilation, after all source files and classes specified on the command line have been read, and analyzed for the types and members they contain, but before the contents of any method bodies have been analyzed.

Из этого следует простой, но грустный для меня вывод. Когда я попытался получить тип выражения, для которого выполняется switch, я всегда получал null (что вполне легально, если почитать документацию) — TreePath.getPath(compilationUnit, switchTreeExpression).

В этом месте я уже немного отчаился и думал о разных вещах:

Я решил пойти по легкому совету и спросил совета на SO. К счастью, там мне помогли.

Удачная попытка — Реализация процессора аннотаций с небольшим хаком.


Рабочее решение также строится на процессоре аннотаций. Главное отличие от предыдущего решения — анализ покрытости switch-бранчей начинается не тогда, когда был вызван обработчик для моей аннотации, а когда компилятор скажет, что ПОРА.

Оказалось, что в процессоре аннотаций можно зарегистрировать своего TaskListener, который будет вызываться в течение всего процесса компиляции с разными типами евентов:

public class SwitchExhaustiveCheckerProcessor extends AbstractProcessor {
    @Override
    public synchronized void init(ProcessingEnvironment processingEnv) {
        // ...
        JavacTask.instance(processingEnv).addTaskListener(analyzeTaskListener);
    }
    // ...
}


Нам просто нужно ждать TaskEvent.Kind.ANALYZE для нужных нам элементов, и запускать анализ switch для них:

class AnalyzeTaskListener implements TaskListener {
    // ...
    @Override
    public void finished(final TaskEvent e) {
        if (e.getKind() != TaskEvent.Kind.ANALYZE) {
            return;
        }
        processor.handleAnalyzedType(e.getTypeElement(), e.getCompilationUnit());
    }
}


В этом месте я тоже наткнулся на одну проблему. По какой-то причине Listener вызывается только для top-level классов, но не для inner классов. Из-за этого пришлось написать целую пачку костылей, и покрыть всё хорошенько тестами, чтобы быть уверенным в работоспособности.

Про тестирование


Это забавно, но никогда ранее мне не приходилось писать тесты, которые проверяют что программа завершила компиляцию с ошибкой. Понятно, что просто взять и написать стандартно юнит-тест тут не выйдет, так как тесты запускаются после успешной компиляции. Если немного подумать, то можно прийти к нескольким решениям задачи:

Я положил в ресурсы проекта тестовые классы, которые либо должны завершится с ошибкой, либо нет. Из java-теста просто выполняю примерно такой код, который берет файл для компиляции, компилирует его с нашим процессором аннотаций и говорит код/текст результата компиляции.

JavaFileObject file = new JavaSourceFromString(className, classLines);

JavaCompiler compiler = ToolProvider.getSystemJavaCompiler();
DiagnosticCollector<JavaFileObject> diagnostics = new DiagnosticCollector<>();
StandardJavaFileManager fileManager = compiler.getStandardFileManager(diagnostics, null, null);
fileManager.setLocation(StandardLocation.CLASS_OUTPUT, Collections.singletonList(tempDir.getRoot()));

Iterable<? extends JavaFileObject> compilationUnits = Collections.singletonList(file);
JavaCompiler.CompilationTask compilerTask = compiler.getTask(null, fileManager, diagnostics, null, null, compilationUnits);
compilerTask.setProcessors(Collections.singletonList(new SwitchExhaustiveCheckerProcessor()));
boolean ok = compilerTask.call();


Про интеграционное тестирование


Понятно, что юнит тестирование для процессора аннотаций не может дать гарантий, что всё работает ожидаемо. Слишком много разных частей, которые могут стать причиной поломки — версия gradle/maven, версия Java, наличие Lombok, особенности Spring, и тд. Покрыть все возможные комбинации не выйдет, поэтому я решил потестировать что-то совсем очевидное:
Изначально я просто хотел генерировать тестовые проекты из какого-то шаблона, потом запускать тестконтейнеры, выполнять компиляцию там, проверять результат. Однако потом я подумал, а зачем все эти телодвижения, если Github Actions уже даёт некоторую среду выполнения тестов с конфигурироемой версией Java, и возможностью узнать результат выполнения Job. Решение грязное, с кучей копипаста, но зато было крайне быстро его реализовать. Полный конфиг на гитхабе, а тут кусочек:

integrationtests-jdk-11-gradle:
  runs-on: ubuntu-18.04
  steps:
    - uses: actions/checkout@v3
    - name: Set up JDK 11
      uses: actions/setup-java@v2
      with:
        java-version: '11'
        distribution: 'adopt'
    - name: Run integration test - Java 11 + Spring + Gradle + Should Succeed
      run: cd ./integration-tests/java11-gradle-spring-success && ./gradlew clean test -i
      id: java11-gradle-spring-success
    - name: Run integration test - Java 11 + Spring + Gradle + Should Fail
      run: cd ./integration-tests/java11-gradle-spring-fail && ./gradlew clean test -i
      id: java11-gradle-spring-fail
      continue-on-error: true
    - name: Check on failures
      if: steps.java11-gradle-spring-fail.outcome != 'failure'
      run: exit 1


Тут можно увидеть, что Github Actions позволяют проверять результат выполнения шага, что мне было необходимо, чтобы тестировать ожидаемо неуспешные компиляции проектов.

Заключительные слова


Я получил массу удовольствия, когда делал проект, поэтому я уже счастлив. Но если кто-то начнет использовать эту штуку, пожалуйста, шлите баги и фича реквесты, и я постараюсь их поправить.
@Hixon10
11.07.2022 11:58 UTC
Первоисточник

Комментарии

@ris58h
11.07.2022 13:01 UTC
-2

Мне известны несколько способов со своими плюсами и минусами:

Есть ещё неплохой способ "Использовать IDE".

@valery1707
11.07.2022 13:27 UTC
0

А чем этот вариант лучше checkstyle?
Да, тут не нужно "локально запускать руками checkstyle", но зато идёт внедрение в процесс сборки что не может его не замедлять — тогда как запуск checkstyle можно (да и "нужно") привязать к одному из "высоко-уровневых" этапов сборки проекта (например package у maven и build у gradle) и тогда его уже не нужно будет "запускать" руками и к тому же его можно будет игнорировать для "ускорения" этапов более важных при непосредственной разработке (компиляция).

11.07.2022 15:31 UTC
0
Наверное, если у нас серьезный проект, то у нас будет CI, с checkstyle, или sonarqube. Моя подделка больше для мелких проектов, в которых тоже хочется иметь гарантии компилятора, но нет никакой инфраструктуры.
12.07.2022 08:16 UTC
0

Подключить checkstyle и на маленьком проекте безо всяких CI совершенное не сложно, а профтов это даст всё равно больше чем подключение этой либы проверяющей только данный кейс.

@ololx
12.07.2022 04:59 UTC
+1

Приветствую!
Спасибо за статью.
Интересная проблема и решение через обработку аннотаций)
В свое время тоже столкнулся с подобной проблемой поведения AnnotationProcessor и реализовал через такой же хак, чтобы достучатся до переменных внутри методов в одном пет проекте - https://ololx.github.io/cranberry/cranberry-statement/.

Позвольте мне немного дополнить статью и задать вопрос.

В Java 8+ можно зарегистрировать кастомный TaskListener через свою реализацию com.sun.source.util.Plugin; В вашем случае это выглядело бы следующим образом:
1 - Написать свою реализацию Plugin вместо SwitchExhaustiveCheckerProcessor

public class SwitchExhaustiveChecker implements Plugin {

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

    @Override
    public void init(JavacTask task, String... args) {
	task.addTaskListener(new AnalyzeTaskListener());
    }
}

2 - Зарегистрировать свой плагин в …/src/main/resources/META-INF/services/com.sun.source.util.Plugin ( аналогично тому, как Вы регистрировали свой процессор)

ru.hixon.switchexhaustivenesschecker.SwitchExhaustiveCheckerPlugin

3 - Для реализации такого плагина потребуется еще указать зависимость на JDK tools в Вашем pom.xml

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

После чего можно использовать Ваш плагин, подключив его к проекту и указав аргумент в maven-compiler-plugin

<plugin>
          <artifactId>maven-compiler-plugin</artifactId>
          <version>${version}</version>
          <configuration>
            <compilerArgs>
              <arg>-Xplugin:SwitchExhaustiveCheckerPlugin</arg>
            </compilerArgs>
          </configuration>
        </plugin>

Это пример подобного плагина, который я смог найти на github (надеюсь, что авто рне будет против за то, что выложил лине) - https://github.com/moditect/deptective

Вопрос: Рассматривали ли Вы описанный мной способ реализации Вашей задачи? Если - да, то почему остановились на своем варианте - чем он показался Вам выигрышней?

12.07.2022 07:00 UTC
0
Привет!
Спасьбо за комментарий.

> Вопрос: Рассматривали ли Вы описанный мной способ реализации Вашей задачи? Если — да, то почему остановились на своем варианте — чем он показался Вам выигрышней?

Честно говоря, не рассматривал, так как не знал / не помнил о такой возможности, в то время как кастомные процессоры аннотаций когда-то делал.

А как в случае с com.sun.source.util.Plugin унзнавать, какие элементы нужно обрабатывать, а какие — нет? Как я понимаю, зарегистрировать свой TaskListener мы легко можем, а вот часть про обработку кастомной аннотации, или чего-то другого, что даст мне список нужных классов для анализа, я пока не вижу.
12.07.2022 09:20 UTC
+1

Благодарю за хороший ответ. Точно также, как это реализовано у Вас сейчас, с небольшими изменениями. Вы создаете новый TaskListener в методе init() Вашего процессора; я лишь хотел показать, что инициализировать TaskListener можно иначе, тогда AbstractProcessor не нужен. К сожалению сейчас не обладаю достаточным временем, чтобы написать что-то конкретное по Вашему примеру. Но, в общих словах:
Сейчас логика поиска нужных элементов по аннотирпованным классам и реализация проверки у Вас реуализована в SwitchExhaustiveCheckerProcessor и TestMethodTreePathScanner
Можете просто объединить эту логику в одном Вашем TestMethodTreePathScanner - это конечно очень некрасиво и лучше бы потом отрефакторить и разбить ответственности, но зато позволит быстрее опробовать другой подход. 
Далее можно в самом AnalyzeTaskListener имплементить визитера TestMethodTreePathScanner к каждому элементу и все должно быть ОК, хотя чую, что с первого раза все не заведется, учитывая что пока предлдожение абстрактное.

class AnalyzeTaskListener implements TaskListener {
    
private final SwitchExhaustiveCheckerProcessor processor;

    AnalyzeTaskListener() {}

    @Override
    public void started(TaskEvent taskEvent) {

    }

    @Override
    public void finished(final TaskEvent e) {
        if (e.getKind() != TaskEvent.Kind.ANALYZE) {
            return;
        }

	CompilationUnitTree compilationUnit = event.getCompilationUnit();
        if (compilationUnit == null) {
	    return;
        }

        compilationUnit.accept(scanningVisitor, null);
    }
}

А в самом плагине уже проинициализировать AnalyzeTaskListener как писали ранее. Если нужны какие-нибудь утилитные класссы, наподобии тех , что используются у Вас в процессоре или визитере, то можно в классе плагина их проинициализировать и передать через конструктор AnalyzeTaskListener далее.
Например ``

…
@Override
    public void init(JavacTask task, String... args) {
        final Context context = ((BasicJavacTask)task).getContext();
        Log log = Log.instance(context);
    }
...

Тут есть хорошие примеры кода для такого подхода - https://annimon.com/article/2626

13.07.2022 05:24 UTC
+1

Заметил кучу ошибок в примерах кода - не надо было спешить вчера.
Внесу небольшие правки, чтобы в будущем никог оне путать.

class AnalyzeTaskListener implements TaskListener {
    
    private final TestMethodTreePathScanner testMethodTreePathScanner;

    AnalyzeTaskListener() {}

    @Override
    public void started(TaskEvent taskEvent) {

    }

    @Override
    public void finished(final TaskEvent e) {
        if (e.getKind() != TaskEvent.Kind.ANALYZE) {
            return;
        }

	CompilationUnitTree compilationUnit = e.getCompilationUnit();
        if (compilationUnit == null) {
	    return;
        }

        compilationUnit.accept(testMethodTreePathScanner, null);
    }
}
13.07.2022 07:11 UTC
0
Спасибо!
@tsypanov
14.07.2022 12:52 UTC
0

Спасибо за статью! Как на русский правильно перевести "switch exhaustiveness"?

14.07.2022 12:58 UTC
0
Спасибо за комментарий.

Извините, но не знаю, какой верный перевод на русский язык.
14.07.2022 14:00 UTC
+1

Можно ещё вопрос? Вот эта ссылка - https://github.com/openjdk/jdk/blob/master/src/java.xml/share/classes/com/sun/org/apache/bcel/internal/generic/SWITCH.java#L85 - она же не на логику компилятора укзаывает.

14.07.2022 18:12 UTC
0
Хм, мне изначально казалось, что именно этот код вызывается, но после вашего вопроса я стал сомневаться, так как пакет Apache BCEL.

Я попробовал поискать по исходникам, но сходу не нашёл никакого другого места с такой же логикой. Так что, пока не могу сказать точно, ошибя я изначально или нет.
14.07.2022 19:15 UTC
0

Если честно, меня насторожило даде не наименование пакета, а вот эта фраза

компилятор может генерировать фейковые ветки в байткоде, для перфоманс целей

Не очень понял, как создание избыточного кода улучшает производительность.

14.07.2022 22:32 UTC
+2
Можно вот тут про это почитать — stackoverflow.com/a/12938176/1756750
15.07.2022 10:40 UTC
0

Благодарю, гляну