Обнаружение и удаление кода без ссылок с помощью ArchUnit

Автор оригинала: Tim te Beek
  • Перевод
  • Tutorial

Когда вы поддерживаете большой Java-проект в течение длительного периода, моменты, когда вы, наконец, можете удалить неиспользуемый код, могут быть очень приятными. Больше не нужно поддерживать и переносить версии библиотеки или поддерживать темные закоулки кода, который больше не используется. Но выяснение того, какие части кодовой базы можно удалить, может быть проблемой, и инструменты в этой области, похоже, отстают от новых практик разработки на Java. В этом посте мы опишем подход к поиску кода без ссылок с помощью ArchUnit, который позволяет итеративно обнаруживать и удалять неиспользуемый код из ваших проектов Java.

ArchUnit

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

- Сайт Archunit

Сам ArchUnit уже рассматривался ранее Нильсом, который подчеркивал его полезность для контроля архитектурных соглашений в проекте.

В этом посте мы расширим пользовательские правила, кратко упомянутые ранее, а также используем новый стиль тестирования ArchUnit с JUnit5 и лямбда-выражениями. Мы будем использовать граф зависимостей между классами и методами, создаваемый ArchUnit, для обнаружения классов и методов, на которые нет ссылок и которые являются кандидатами на удаление. Мы также рассмотрим, как общие, так и конкретные способы предотвращения искажения результатов ложными срабатываниями.

Репозиторий GitHub и структура тестов

К этому посту прилагается репозиторий GitHub. Он содержит минимальное приложение Spring Boot, а также правила ArchUnit для поиска классов и методов, на которые нет ссылок.

На момент написания мы использовали ArchUnit 0.15.0. В частности, мы используем поддержку JUnit5 через com.tngtech.archunit:archunit-junit5:0.15.0. Это приводит к базовой структуре теста:

@AnalyzeClasses(
  packagesOf = ArchunitUnusedRuleApplication.class,
  importOptions = {
    ImportOption.DoNotIncludeArchives.class,
    ImportOption.DoNotIncludeJars.class,
    ImportOption.DoNotIncludeTests.class
})
class ArchunitUnusedRuleApplicationTests {
  @ArchTest
  static ArchRule classesShouldNotBeUnused = classes()
    .that()
      ...
    .should(
      ...
    );
  @ArchTest
  static ArchRule methodsShouldNotBeUnused = methods()
    .that()
      ...
    .should(
      ...
    );
}

Обратите внимание, как мы ограничиваем классы, анализируемые с помощью аргументов packagesOf и importOptions в аннотации @AnalyzeClasses().Кроме того, использование аннотации @ArchTest в статическом поле типа ArchRule избавляет нас от необходимости вызывать ArchRule.check(JavaClasses), как показано в предыдущем посте в блоге.

С помощью селекторов classes() и methods() из ArchRuleDefinition выбираем элементы, которые мы хотим проверить с помощью нашего правила. Обычно эти элементы затем дополнительно ограничиваются последовательностью вызовов после .that(), чтобы отсеять потенциально ложные срабатывания. Наконец, с помощью .should()мы проверяем, что все оставшиеся классы удовлетворяют заданному условию, и при обнаружении каких-либо классов вызываем исключение.

Обнаружение неиспользуемых методов

Имея указанную выше базовую структуру, мы можем приступить к определению нашего первого правила для обнаружения неиспользуемых методов. Как мы уже говорили ранее, ArchUnit строит граф зависимостей между блоками кода Java, который мы можем использовать для поиска блоков кода, на которые никогда не ссылаются другие блоки кода Java.

Конечно, в типичном приложении Spring Boot есть причины, по которым метод никогда не вызывается напрямую, в частности, когда аннотируется конечная точка сети, прослушиватель сообщений или обработчик команд, событий или исключений. В этих случаях фреймворк будет вызывать методы за вас, поэтому вы не хотите, чтобы они случайно помечались как неиспользуемые в вашем правиле тестирования. То же самое касается общих методов, добавляемых, в частности, при использовании Lombok's @Dataor @Value, которые добавляют equalshashCodeи toStringв классы.

Собирая все эти ограничения вместе, мы приходим к следующему правилу ArchRule, чтобы найти неиспользуемые методы.

@ArchTest
static ArchRule methodsShouldNotBeUnused = methods()
  .that().doNotHaveName("equals")
  .and().doNotHaveName("hashCode")
  .and().doNotHaveName("toString")
  .and().doNotHaveName("main")
  .and().areNotMetaAnnotatedWith(RequestMapping.class)
  .and(not(methodHasAnnotationThatEndsWith("Handler")
    .or(methodHasAnnotationThatEndsWith("Listener"))
    .or(methodHasAnnotationThatEndsWith("Scheduled"))
    .and(declaredIn(describe("component", clazz -> clazz.isMetaAnnotatedWith(Component.class))))))
  .should(new ArchCondition<>("not be unreferenced") {
    @Override
    public void check(JavaMethod javaMethod, ConditionEvents events) {
      Set<JavaMethodCall> accesses = new HashSet<>(javaMethod.getAccessesToSelf());
      accesses.removeAll(javaMethod.getAccessesFromSelf());
      if (accesses.isEmpty()) {
        events.add(new SimpleConditionEvent(javaMethod, false, String.format("%s is unreferenced in %s",
          javaMethod.getDescription(), javaMethod.getSourceCodeLocation())));
      }
    }
  });

static DescribedPredicate<JavaMethod> methodHasAnnotationThatEndsWith(String suffix) {
  return describe(String.format("has annotation that ends with '%s'", suffix),
   method -> method.getAnnotations().stream()
     .anyMatch(annotation -> annotation.getRawType().getFullName().endsWith(suffix)));
}

Обнаружение неиспользуемых классов

Чтобы обнаружить целые классы, на которые нет ссылок из других классов, мы можем применить тот же подход с несколькими незначительными изменениями. Мы также не хотели бы ошибочно идентифицировать любой @Componentобъект, содержащий конечную точку, прослушиватель (listener) или обработчик, поэтому нам нужен еще один настраиваемый предикат. В нашей проверке состояния мы также контролируем обнаружение в JavaClass.getDirectDependenciesToSelf() каких-либо зависимостей, чтобы отсеять еще один источник ложных срабатываний.

В конце концов, мы получаем следующее правило ArchRule для поиска неиспользуемых классов.

@ArchTest
static ArchRule classesShouldNotBeUnused = classes()
  .that().areNotMetaAnnotatedWith(org.springframework.context.annotation.Configuration.class)
  .and().areNotMetaAnnotatedWith(org.springframework.stereotype.Controller.class)
  .and(not(classHasMethodWithAnnotationThatEndsWith("Handler")
    .or(classHasMethodWithAnnotationThatEndsWith("Listener"))
    .or(classHasMethodWithAnnotationThatEndsWith("Scheduled"))
    .and(metaAnnotatedWith(Component.class))))
  .should(new ArchCondition<>("not be unreferenced") {
    @Override
    public void check(JavaClass javaClass, ConditionEvents events) {
      Set<JavaAccess<?>> accesses = new HashSet<>(javaClass.getAccessesToSelf());
      accesses.removeAll(javaClass.getAccessesFromSelf());
      if (accesses.isEmpty() && javaClass.getDirectDependenciesToSelf().isEmpty()) {
        events.add(new SimpleConditionEvent(javaClass, false, String.format("%s is unreferenced in %s",
          javaClass.getDescription(), javaClass.getSourceCodeLocation())));
      }
    }
  });

static DescribedPredicate<JavaClass> classHasMethodWithAnnotationThatEndsWith(String suffix) {
  return describe(String.format("has method with annotation that ends with '%s'", suffix),
    clazz -> clazz.getMethods().stream()
      .flatMap(method -> method.getAnnotations().stream())
      .anyMatch(annotation -> annotation.getRawType().getFullName().endsWith(suffix)));
}

Ограничения

Хотя приведенные выше правила являются отличной отправной точкой для выявления потенциально неиспользуемого кода, к сожалению, именно здесь мы также начнем сталкиваться с некоторыми (текущими) ограничениями ArchUnit. В зависимости от того, как настроен ваш проект, вы можете обнаружить, что ссылка на метод не рассматривается как зависимость. Или вы можете обнаружить, что аргументы шаблонного типа не определены как зависимости. И, поскольку ArchUnit работает с байтовым кодом, вы можете обнаружить, что строковые константы установлены во время компиляции.

Замораживание ложных (или истинных!) срабатываний

К счастью, есть элегантный способ обработки ложных срабатываний в отношении наших пользовательских условий ArchConditions: Freezing Arch Rules. Передав наше правило ArchRule, в FreezingArchRule.freeze(ArchRule) мы можем записать все текущие нарушения и остановить добавление новых нарушений.

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

- Сайт Archunit

Если вы заметили какие-либо общие закономерности в нарушениях, конечно, предпочтительнее исключить такие классы из анализа с помощью предиката .that(). Однако для конкретных нарушений замораживание - отличный способ подтвердить их существование в кодовой базе, не загрязняя общее правило.

Протестируйте сами правила ArchUnit

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

@Nested
class VerifyRulesThemselves {
  @Test
  void verifyClassesShouldNotBeUnused() {
     JavaClasses javaClasses = new ClassFileImporter()
       .withImportOption(ImportOption.Predefined.DO_NOT_INCLUDE_ARCHIVES)
       .withImportOption(ImportOption.Predefined.DO_NOT_INCLUDE_JARS)
       .withImportOption(ImportOption.Predefined.DO_NOT_INCLUDE_TESTS)
       .importPackagesOf(ArchunitUnusedRuleApplication.class);
     AssertionError error = assertThrows(AssertionError.class,
       () -> classesShouldNotBeUnused.check(javaClasses));
     assertEquals("""
       Architecture Violation [Priority: MEDIUM] - Rule 'classes that are not meta-annotated with @Configuration and are not meta-annotated with @Controller and not has method with annotation that ends with 'Handler' or has method with annotation that ends with 'Listener' or has method with annotation that ends with 'Scheduled' and meta-annotated with @Component should not be unreferenced' was violated (3 times):
       Class <com.github.timtebeek.archunit.ComponentD> is unreferenced in (ArchunitUnusedRuleApplication.java:0)
       Class <com.github.timtebeek.archunit.ModelF> is unreferenced in (ArchunitUnusedRuleApplication.java:0)
       Class <com.github.timtebeek.archunit.PathsE> is unreferenced in (ArchunitUnusedRuleApplication.java:0)""",
       error.getMessage());
  }

  @Test
  void verifyMethodsShouldNotBeUnused() {
    JavaClasses javaClasses = new ClassFileImporter()
      .withImportOption(ImportOption.Predefined.DO_NOT_INCLUDE_ARCHIVES)
      .withImportOption(ImportOption.Predefined.DO_NOT_INCLUDE_JARS)
      .withImportOption(ImportOption.Predefined.DO_NOT_INCLUDE_TESTS)
      .importPackagesOf(ArchunitUnusedRuleApplication.class);
    AssertionError error = assertThrows(AssertionError.class,
      () -> methodsShouldNotBeUnused.check(javaClasses));
    assertEquals("""
      Architecture Violation [Priority: MEDIUM] - Rule 'methods that do not have name 'equals' and do not have name 'hashCode' and do not have name 'toString' and do not have name 'main' and are not meta-annotated with @RequestMapping and not has annotation that ends with 'Handler' or has annotation that ends with 'Listener' or has annotation that ends with 'Scheduled' and declared in component should not be unreferenced' was violated (2 times):
      Method <com.github.timtebeek.archunit.ComponentD.doSomething(com.github.timtebeek.archunit.ModelD)> is unreferenced in (ArchunitUnusedRuleApplication.java:102)
      Method <com.github.timtebeek.archunit.ModelF.toUpper()> is unreferenced in (ArchunitUnusedRuleApplication.java:143)""",
      error.getMessage());
  }
}

Заключение

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

Комментарии 0

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

Самое читаемое