Привет всем! Недавно я решил поэкспериментировать с 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.

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

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

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