В последние время многие промышленные языки вроде 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).

В этом месте я уже немного отчаился и думал о разных вещах:
  • Решить, что проблема из первого способа — вовсе не проблема, и надо делать её.
  • Попытаться вывести тип самому, ну или по крайне мере попытаться скопировать код по resolve типа из условной IDEA. Сразу было понятно, что эта задача — на порядки сложнее моей, и не стоит сюда двигаться.
  • Попросить помощи у экспертов.

Я решил пойти по легкому совету и спросил совета на 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-теста просто стартуем докер контейнер (testcontainers), и проверяем вывод контейнера. Проблема — тесты будут довольно медленными.
  • Писать какой-то баш, который будет стартовать javac. Проблема — надо писать баш-скрипт.
  • Запускать из java-теста сразу экземляр java-компилятора. Проблем тут не вижу, поэтому пошел по этому пути.

Я положил в ресурсы проекта тестовые классы, которые либо должны завершится с ошибкой, либо нет. Из 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, и тд. Покрыть все возможные комбинации не выйдет, поэтому я решил потестировать что-то совсем очевидное:
  • Java 8, 11, 17
  • Как gradle, так и maven
  • Как с 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 позволяют проверять результат выполнения шага, что мне было необходимо, чтобы тестировать ожидаемо неуспешные компиляции проектов.

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


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

Комментарии (9)


  1. ris58h
    11.07.2022 16:01
    -1

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

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


  1. valery1707
    11.07.2022 16:27

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


    1. Hixon10 Автор
      11.07.2022 18:31

      Наверное, если у нас серьезный проект, то у нас будет CI, с checkstyle, или sonarqube. Моя подделка больше для мелких проектов, в которых тоже хочется иметь гарантии компилятора, но нет никакой инфраструктуры.


      1. valery1707
        12.07.2022 11:16

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


  1. ololx
    12.07.2022 07:59
    +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

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


    1. Hixon10 Автор
      12.07.2022 10:00

      Привет!
      Спасьбо за комментарий.

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

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

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


      1. ololx
        12.07.2022 12:20
        +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


        1. ololx
          13.07.2022 08:24
          +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);
              }
          }
          


          1. Hixon10 Автор
            13.07.2022 10:11

            Спасибо!