Пишем кастомный Plugin SonarQube

от автора

Привет всем! Недавно я решил поэкспериментировать с SonarQube и создать свой собственный кастомный плагин для проверки кода на соответствие моим правилам разработки. В этой статье я поделюсь с вами своим опытом и покажу, как вы тоже можете создать такой плагин своими руками.

Небольшие вводные

Disclaimer: Данная статья повторяет многое из официального гайда.

Давайте начнем с выдуманного use-case’a:

Нам нужен инструмент для проверки кода во время MR, который сможет определять не соответствия установленному код-стайлу команды.

Тут на самом деле у нас есть 2 варианта.

Использование готовых Lint-инструментов

Примером таких инструментов могут служить ESLint (для JavaScript/TypeScript) или Checkstyle (для Java). Этот вариант, на мой взгляд, является самым простым и удобным. В случаях, когда требуется кастомная проверка, процесс также несложен: разработайте свой чек и добавьте его в директорию plugins или extensions.

Плюсы:

  • Простота и удобство использования.

  • Легкость добавления кастомных проверок.

Минусы:

  • Сложно разделять правила проверки кода по разным командам с различными стандартами разработки. В таких случаях правила одной команды могут конфликтовать с правилами другой.

Использование SonarQube с кастомным плагином

Этот вариант привлекает меня тем, что все проверки, связанные с код-стайлингом и безопасностью кода, находятся в одном месте и объединены в красивом интерфейсе. SonarQube отлично подходит для компаний с большим количеством команд разработки, так как позволяет создавать кастомные профили.

Плюсы:

  • Все проверки кода в одном месте.

  • Удобный и красивый интерфейс.

  • Подходит для крупных компаний с множеством команд разработки благодаря возможности создания кастомных профилей.

Минусы:

  • Требования к инфраструктуре: SonarQube требует отдельного сервера и ресурсов для своей работы, что может быть проблематично для небольших команд или проектов.

Подготавливаем локальную среду

Давайте начнем разработку нашего первого кастомного чека спулив проект с гитхаба: ссылка.

Я немного почистил и изменил проект, чтобы облегчить процесс разработки и избавить вас от необходимости заниматься конфигурацией Maven.

По рекомендации разработчиков SonarQube, мы должны придерживаться подхода TDD (разработка через тестирование) при написании наших кастомных чеков. Лично я не являюсь большим поклонником этого подхода, но в данном случае он показывает себя очень эффективно.

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

Давайте напишем тесты для чека, который будет проверять, написаны ли URL эндпойнтов в kebab-case.

import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.PutMapping; import org.springframework.web.bind.annotation.DeleteMapping;  public class MyController {      @GetMapping("/kebab-case-url") // Compliant     public String getCompliant() {         return "compliant";     }      @GetMapping("/camelCaseUrl") // Noncompliant     public String getNonCompliant() {         return "noncompliant";     }      @PostMapping("/another-kebab-case-url") // Compliant     public void postCompliant() {     }      @PostMapping("/AnotherCamelCaseUrl") // Noncompliant     public void postNonCompliant() {     }      @PutMapping("/yet-another-kebab-case-url") // Compliant     public void putCompliant() {     }      @PutMapping("/YetAnotherCamelCaseUrl") // Noncompliant     public void putNonCompliant() {     }      @DeleteMapping("/final-kebab-case-url") // Compliant     public void deleteCompliant() {     }      @DeleteMapping("/FinalCamelCaseUrl") // Noncompliant     public void deleteNonCompliant() {     } }

Важные аспекты тест-кейсов

  1. Расположение тестов:

    • Все ваши тесты должны находиться в следующей директории: src/test/files/.

  2. Сценарии тестирования:

    • Обязательно расписывайте как негативные, так и позитивные сценарии.

    • Негативные сценарии помечайте как // Noncompliant, а позитивные как // Compliant.

  3. Импорты:

    • Не забывайте добавлять необходимые импорты. Без них тесты не пройдут успешно.

Теперь давайте напишем непосредственно саму проверку на kebab-case.

@Rule(key = "KebabCaseUrlCheck") public class KebabCaseUrlCheck extends BaseTreeVisitor implements JavaFileScanner {      private static final Pattern KEBAB_CASE_PATTERN = Pattern.compile("^(/[a-z0-9]+(-[a-z0-9]+)*)*$");      private JavaFileScannerContext context;      @Override     public void scanFile(JavaFileScannerContext context) {         this.context = context;         scan(context.getTree());     }      @Override     public void visitMethod(MethodTree tree) {         List<AnnotationTree> annotations = tree.modifiers().annotations();         for (AnnotationTree annotationTree : annotations) {             TypeTree annotationType = annotationTree.annotationType();             if (annotationType.is(Tree.Kind.IDENTIFIER)) {                 IdentifierTree identifier = (IdentifierTree) annotationType;                 String annotationName = identifier.name();                 if (annotationName.equals("GetMapping") || annotationName.equals("PostMapping") ||                         annotationName.equals("PutMapping") || annotationName.equals("DeleteMapping")) {                     checkUrl(annotationTree);                 }             }         }         super.visitMethod(tree);     }      private void checkUrl(AnnotationTree annotationTree) {         if (annotationTree.arguments().isEmpty()) {             return;         }          Tree argument = annotationTree.arguments().get(0);         if (argument.is(Tree.Kind.ASSIGNMENT)) {             argument = ((org.sonar.plugins.java.api.tree.AssignmentExpressionTree) argument).expression();         }          if (argument.is(Tree.Kind.STRING_LITERAL)) {             LiteralTree literal = (LiteralTree) argument;             String url = literal.value().substring(1, literal.value().length() - 1);              if (!KEBAB_CASE_PATTERN.matcher(url).matches()) {                 context.reportIssue(this, literal, "The URL should be in kebab-case.");             }         }     } }

Начнем с аннотации @Rule(key = «KebabCaseUrlCheck»), которая определяет ключ правила, используемого в SonarQube для идентификации этого чека. Класс KebabCaseUrlCheck наследуется от BaseTreeVisitor и реализует интерфейс JavaFileScanner, что позволяет ему сканировать Java-файлы.

Шаблон для проверки соответствия URL формату kebab-case определяется с помощью регулярного выражения:

private static final Pattern KEBAB_CASE_PATTERN = Pattern.compile("^(/[a-z0-9]+(-[a-z0-9]+))$");.

Также создается контекст сканирования, который используется для отчета об ошибках:

private JavaFileScannerContext context;

Основной метод для сканирования файла scanFile сохраняет контекст и начинает процесс сканирования дерева синтаксиса файла:

@Override public void scanFile(JavaFileScannerContext context);

Сканирование начинается вызовом

scan(context.getTree());

Метод visitMethod переопределяется для обработки каждого метода в файле:

 public void visitMethod(MethodTree tree);

Он получает все аннотации метода:

List<AnnotationTree> annotations = tree.modifiers().annotations();

В цикле for каждая аннотация проверяется, и если она является одной из @GetMapping, @PostMapping, @PutMapping или @DeleteMapping, вызывается метод checkUrl.

Метод checkUrl проверяет URL в аннотации. Он начинается с проверки наличия аргументов у аннотации. Если аргументов нет, метод завершается. Затем извлекается первый аргумент аннотации и проверяется, является ли он строковым литералом. Если это так, значение строки извлекается без кавычек:

String url = literal.value().substring(1, literal.value().length() - 1);

Далее, с помощью регулярного выражения проверяется, соответствует ли URL шаблону kebab-case: if (!KEBAB_CASE_PATTERN.matcher(url).matches()). Если URL не соответствует шаблону, создается отчет об ошибке с сообщением «The URL should be in kebab-case.»

Запускаем наш первый тест

После того как мы настроили тест-кейсы и сам класс проверки, нам нужно непосредственно данный тест запустить.
Для этого в директории src/test/java/org/sonar/samples/java/checks создаем новый класс KebabCaseUrlTest и пишим простую конструкцию для теста:

package org.sonar.samples.java.checks;  import org.junit.jupiter.api.Test; import org.sonar.java.checks.verifier.CheckVerifier;  public class KebabCaseUrlCheckTest {      @Test     void test() {         CheckVerifier.newVerifier()                 .onFile("src/test/files/KebabCaseUrlCheck.java")                 .withCheck(new KebabCaseUrlCheck())                 .verifyIssues();     } }

Далее, запускаем тест и радуемся, что наш метод проверки url работает!

Финальные штрихи

Начнем с конфигурации нейминга нашего плагина. Переходим к этому файлу:

src/main/java/org/sonar/samples/java/MyJavaRulesDefinition.java и меняем поля REPOSITORY_KEY и REPOSITORY_NAME на нужные вам.

public static final String REPOSITORY_KEY = "sonar-plugin-habr"; public static final String REPOSITORY_NAME = "Habr Guide Plugin";

Далее, Перед тем, как компилить наш .jar файл, нам нужно зарегистрировать наш кастомный плагин. Для этого переходим к этому файлу: src/main/java/org/sonar/samples/java/RulesList.java

и в метод getJavaChecks добавляем наш KebabCaseUrlCheck.

public static List<Class<? extends JavaCheck>> getJavaChecks() {     return Collections.unmodifiableList(Arrays.asList(             SpringControllerRequestMappingEntityRule.class,             AvoidAnnotationRule.class,             AvoidBrandInMethodNamesRule.class,             AvoidMethodDeclarationRule.class,             AvoidSuperClassRule.class,             AvoidTreeListRule.class,             MyCustomSubscriptionRule.class,             KebabCaseUrlCheck.class,             SecurityAnnotationMandatoryRule.class));   }

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

class MyJavaFileCheckRegistrarTest {    @Test   void checkRegisteredRulesKeysAndClasses() {     TestCheckRegistrarContext context = new TestCheckRegistrarContext();      MyJavaFileCheckRegistrar registrar = new MyJavaFileCheckRegistrar();     registrar.register(context);      assertThat(context.mainRuleKeys).extracting(RuleKey::toString).containsExactlyInAnyOrder(             "omni-sonar:SpringControllerRequestMappingEntity",             "omni-sonar:AvoidAnnotation",             "omni-sonar:AvoidBrandInMethodNames",             "omni-sonar:AvoidMethodDeclaration",             "omni-sonar:AvoidSuperClass",             "omni-sonar:AvoidTreeList",             "omni-sonar:AvoidMethodWithSameTypeInArgument",             "omni-sonar:KebabCaseUrlCheck",             "omni-sonar:SecurityAnnotationMandatory");      assertThat(context.mainCheckClasses).extracting(Class::getSimpleName).containsExactlyInAnyOrder(             "SpringControllerRequestMappingEntityRule",             "AvoidAnnotationRule",             "AvoidBrandInMethodNamesRule",             "AvoidMethodDeclarationRule",             "AvoidSuperClassRule",             "AvoidTreeListRule",             "MyCustomSubscriptionRule",             "KebabCaseUrlCheck",             "SecurityAnnotationMandatoryRule");      assertThat(context.testRuleKeys).extracting(RuleKey::toString).containsExactly(             "omni-sonar:NoIfStatementInTests");      assertThat(context.testCheckClasses).extracting(Class::getSimpleName).containsExactly(             "NoIfStatementInTestsRule");   }  }

Как последний пункт, нам нужно добавить документацию к нашему кастомному плагину. Это делается довольно легко, мы создаем 2 файла(html и json) в директории src/main/resources/org/sonar/l10n/java/rules/java с идентичным названием нашей проверки. В нашем случае, файлы будут называться: KebabCaseUrlCheck.html и KebabCaseUrlCheck.json.

В HTML файле, нам нужно описать нашу проверку и как она работает, код помещайте в тэг <pre>.

<h1>Kebab-Case URL Check</h1> <p>This rule ensures that the URLs in <code>@GetMapping</code>, <code>@PostMapping</code>, <code>@PutMapping</code>, and <code>@DeleteMapping</code> annotations are in kebab-case format.</p>  <h2>Noncompliant Code Example</h2> <pre> import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.PutMapping; import org.springframework.web.bind.annotation.DeleteMapping;  public class MyController {      @GetMapping("/camelCaseUrl") // Noncompliant     public String getNonCompliant() {         return "noncompliant";     }      @PostMapping("/AnotherCamelCaseUrl") // Noncompliant     public void postNonCompliant() {     }      @PutMapping("/YetAnotherCamelCaseUrl") // Noncompliant     public void putNonCompliant() {     }      @DeleteMapping("/FinalCamelCaseUrl") // Noncompliant     public void deleteNonCompliant() {     }      @GetMapping("/nested/camelCaseUrl") // Noncompliant     public String getNestedNonCompliant() {         return "noncompliant";     } }     </pre>  <h2>Compliant Solution</h2> <pre> import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.PutMapping; import org.springframework.web.bind.annotation.DeleteMapping;  public class MyController {      @GetMapping("/kebab-case-url") // Compliant     public String getCompliant() {         return "compliant";     }      @PostMapping("/another-kebab-case-url") // Compliant     public void postCompliant() {     }      @PutMapping("/yet-another-kebab-case-url") // Compliant     public void putCompliant() {     }      @DeleteMapping("/final-kebab-case-url") // Compliant     public void deleteCompliant() {     }      @GetMapping("/nested/kebab-case-url") // Compliant     public String getNestedCompliant() {         return "compliant";     } } </pre>

В Json вам нужно предоставить метаданные вашей проверки:

{   "title": "URLs in @GetMapping, @PostMapping, @PutMapping, @DeleteMapping should be in kebab-case",   "type": "code_smell",   "status": "ready",   "tags": [     "convention",     "style",     "rest"   ],   "defaultSeverity": "Minor" } 

Тут интуитивно все понятно, с type можете ознакомиться по ссылке.

Как последний штрих, давайте запустим mvn clean install и получим .jar файл в директории /target

Интеграция в SonarQube

Для начала, давайте мы sonar развернем локально и добавим наш .jar файл.
Для этого используйте данный Dockerfile:

# Use the official SonarQube image as the base image FROM sonarqube:latest  # Set the SonarQube home directory as an environment variable ENV SONAR_HOME=/opt/sonarqube  # Copy the custom rules JAR file to the SonarQube plugins directory COPY target/java-custom-rules-example-1.0.0-SNAPSHOT.jar $SONAR_HOME/extensions/plugins/ 

и запустите его следующей командой:

docker build . -t sonar-habr docker run -d -p 9000:9000 sonar-habr

Активируем Плагин

  1. Заходите в Quality Profiles и делаете фильтр на язык Java.

  2. Зайдите в профиль SonarWay и скопируйте его назовите habr.

  3. В профиле habr нажмите activate more и во вкладке Repository вы увидите наш плагин «Habr Guide Plugin».

  4. Найдите нашу проверку и активируйте ее, она будет называться URLs in @GetMapping, @PostMapping, @PutMapping, @DeleteMapping should be in kebab-case.

Поздравляю у вас все получилось!

Поздравляю у вас все получилось!

Весь наш код можете найти по данной ссылке.


ссылка на оригинал статьи https://habr.com/ru/articles/823590/


Комментарии

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

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