Привет всем! Недавно я решил поэкспериментировать с 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() {
}
}
Важные аспекты тест-кейсов
-
Расположение тестов:
Все ваши тесты должны находиться в следующей директории:
src/test/files/
.
-
Сценарии тестирования:
Обязательно расписывайте как негативные, так и позитивные сценарии.
Негативные сценарии помечайте как
// Noncompliant
, а позитивные как// Compliant
.
-
Импорты:
Не забывайте добавлять необходимые импорты. Без них тесты не пройдут успешно.
Теперь давайте напишем непосредственно саму проверку на 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
Активируем Плагин
Заходите в Quality Profiles и делаете фильтр на язык Java.
Зайдите в профиль SonarWay и скопируйте его назовите habr.
В профиле habr нажмите activate more и во вкладке Repository вы увидите наш плагин "Habr Guide Plugin".
Найдите нашу проверку и активируйте ее, она будет называться URLs in @GetMapping, @PostMapping, @PutMapping, @DeleteMapping should be in kebab-case.
Весь наш код можете найти по данной ссылке.