Перейти к содержимому

Detekt и Danger в Android проектах

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

Почему нам нужно заботиться о качестве кода?

  • Поддерживать единый стиль кода во всей команде
  • Не тратить время на код-ревью, споря о лишних строках
  • Писать код, который более понятен и атомарен в заданных границах

Статический анализ — это известный подход, который помогает с этим. С небольшой автоматизацией мы можем сделать работу разработчиков более понятной и предсказуемой. Представьте, что вы как разработчик пишете код, пушите его в репозиторий, создаете pull request. Просто обычная работа, верно? И этот код проверяется автоматически с конкретными предупреждениями, которые сообщаются вам в том же pull request.

Есть множество инструментов для статического анализа в Android проектах. Мы можем использовать ktlint, detekt, Android Lint, spotless и так далее. Давайте остановимся на detekt и посмотрим, как он работает.

Detekt

detekt — это инструмент статического анализа для Kotlin. Он может использоваться как standalone или через Gradle plugin.

Правила

Если мы хотим что-то проверить и сказать, правильно это или нет, нам нужны правила. Detekt поставляется с большим количеством правил внутри. Кстати, есть набор правил форматирования, который по сути является правилами ktlint, работающими внутри detekt.

Конфигурация

Мы можем управлять включенными правилами с помощью yaml конфигурационного файла, предоставляя его detekt gradle plugin. Например, вы можете просто скопировать конфиг по умолчанию в свой проект и изменить его позже в соответствии с вашими потребностями.

Кастомные правила

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

Baseline

Вы можете спросить, как интегрировать detekt в огромный legacy проект, в котором detekt выдает тысячи предупреждений с этой конфигурацией. Ответ — baseline. Baseline — это по сути просто файл со списком предупреждений, которые игнорируются при последующих запусках.

Настройка

Настройка detekt так же проста, как применение плагина id("io.gitlab.arturbosch.detekt").version("1.20.0") в build.gradle.kts какого-либо gradle модуля. Но когда вы применяете такой плагин, вам также нужно его где-то настроить в одном месте для удобства переиспользования. Решение — вынести логику применения и конфигурации.

Кстати, вам действительно стоит заботиться о конфигурационных файлах build.gradle.kts в ваших проектах. Я видел слишком много проектов с полным беспорядком внутри них. Вы должны относиться к логике конфигурации как к любому другому коду. Со всеми этими принципами разделения ответственности и так далее. Разделяйте логику конфигурации, если это имеет смысл. Например, вы можете захотеть абстрагировать любой сторонний Gradle Plugin вот так.

Большинство проектов, которые я видел, использовали лямбды allprojects или subprojects, что на самом деле является плохой практикой. Хорошая практика — Gradle Plugin. Но написание настоящего Gradle Plugin — не просто. Здесь на помощь приходит идея convention plugins. Вы можете знать подход использования модуля buildSrc для таких случаев, который по сути является тем же included build, но больше похож на магию под капотом Gradle. Я бы рекомендовал создавать отдельные included builds для лучшего контроля.

Gradle convention plugin

  • Сделайте includeBuild нового модуля (допустим, он называется build-logic) в корневом settings.gradle.kts
includeBuild("build-logic")
  • Добавьте зависимость implementation Kotlin Gradle Plugin в этот модуль build-logic
plugins {
    base
    id("io.gitlab.arturbosch.detekt").version("1.20.0")
}

buildscript {
    dependencies {
        classpath("org.jetbrains.kotlin:kotlin-gradle-plugin:1.6.20")
    }
}
  • Создайте файл younameit.gradle.kts внутри
  • Теперь вы можете применить этот плагин к любому другому модулю просто так
plugins {
    id("younameit")
}
  • Хорошая идея — префиксировать все ваши кастомные convention plugins. Мне нравится идея префиксировать их словом convention.

Detekt

Давайте напишем detekt.convention.gradle.kts с некоторой конфигурацией detekt.

import io.gitlab.arturbosch.detekt.Detekt
import io.gitlab.arturbosch.detekt.DetektPlugin
import io.gitlab.arturbosch.detekt.report.ReportMergeTask

plugins {
    base
    id("io.gitlab.arturbosch.detekt") // Нам нужно применить этот плагин с версией в build.gradle.kts
}

dependencies {
    detektPlugins("io.gitlab.arturbosch.detekt:detekt-formatting:1.20.0") // Это набор правил форматирования
}

val configFile = files("$rootDir/config/detekt/config.yml") // Здесь будет храниться наша конфигурация правил
val baselineFile = file("config/detekt/baseline.xml") // Расположение baseline файлов в каждом модуле, будет игнорироваться, если не найдено
val mergedReportFile = file("${rootProject.buildDir}/reports/detekt/report.xml") // Расположение объединенного отчета. По сути финальный результат работы detekt

/**
 * Расположение отчета одного модуля внутри его build директории
 * Должен называться detekt.xml
 * Workaround для https://github.com/detekt/detekt/issues/4192#issuecomment-946325201
 */
val outputReportFile = file("$buildDir/reports/detekt/detekt.xml")

detekt {
    ignoreFailures = true // Мы не хотим крашиться, если найдены предупреждения, так как они будут использованы позже

    parallel = true // Запускает проверки detekt параллельно

    config.setFrom(configFile)
    buildUponDefaultConfig = true // Говорит detekt использовать конфиг по умолчанию, если его настройки не переопределены в явной конфигурации

    baseline = baselineFile  // Расположение baseline файла
}

val reportMerge: TaskProvider<ReportMergeTask> = rootProject.registerMaybe("reportMerge") {
    description = "Runs merge of all detekt reports into single one"
    output.set(mergedReportFile)
}

/**
 * Включить только вывод XML отчетов и установить их расположение
 */
tasks.withType<Detekt>().configureEach {
    reports {
        html.required.set(false)
        sarif.required.set(false)
        txt.required.set(false)
        xml.required.set(true)
        xml.outputLocation.set(outputReportFile)
    }
}

/**
 * Финализирует каждую задачу detekt с помощью ReportMergeTask
 */
plugins.withType<DetektPlugin> {
    tasks.withType<Detekt> {
        finalizedBy(reportMerge)
        reportMerge.configure {
            input.from(xmlReportFile)
        }
    }
}

/**
 * Workaround для получения [TaskProvider] по имени задачи, если она уже существует в данном [Project]
 * или зарегистрировать её в противном случае
 * [Github - Introduce TaskContainer.maybeNamed](https://github.com/gradle/gradle/issues/8057)
 */
inline fun <reified T : Task> Project.registerMaybe(
    taskName: String,
    configuration: Action<in T> = Action {},
): TaskProvider<T> {
    if (taskName in tasks.names) {
        return tasks.named(taskName, T::class, configuration)
    }
    return tasks.register(taskName, T::class, configuration)
}

Вы можете заметить логику слияния здесь. Есть официальное руководство на эту тему. Я предполагаю, что наш проект многомодульный, поэтому мы, возможно, хотим видеть один отчет (много модулей — один отчет), а не по одному на каждый. Официальное руководство рекомендует использовать конфигурацию submodules всех detekt модулей с переиспользованием той же задачи слияния, но таким образом мы пропускаем нашу абстракцию detekt в корневой проект. Вместо этого мы регистрируем задачу reportMerge в rootProject только для первого модуля и переиспользуем её во всех остальных.

Как использовать

Консоль

Эти команды запускают Gradle задачи, сообщая о каждом найденном предупреждении в консоль и в финальный файл отчета.

# Запуск для всего проекта
./gradle detekt

# Запуск для одного модуля (app в данном случае)
./gradle :app:detekt

Вы можете понять, что не так, просто посмотрев на сообщенные сообщения и погуглив правила. Что касается baselines — есть специальная задача для создания baselines

# Создать baselines во всех модулях
./gradlew detektBaseline

# Создать baseline для одного модуля (app в данном случае)
./gradle :app:detektBaseline
Автокоррекция

Проблемы форматирования могут быть исправлены автоматически с помощью параметра --auto-correct

./gradle detekt --auto-correct

IntelliJ Plugin

Есть Detekt Intellij Plugin. Вы можете захотеть установить его для более быстрой обратной связи. Просто установите его из IntelliJ marketplace и установите конфигурационные файлы.

К сожалению, я не знаю способа сгенерировать единый baseline файл для всего проекта на данный момент. Поэтому вам нужно менять baselines в настройках плагина, когда вы работаете в другом модуле. Это актуально только для проектов с несколькими baselines, конечно.

Danger

Danger — это по сути standalone инструмент для сообщения чего-либо обратно в pull requests. Есть хорошо известные реализации на Ruby и JS с конфигами на этих языках. И мы получили релиз 1.0.0 версии на Kotlin не так давно. Она использует JS под капотом, но предоставляет возможность писать Kotlin Script конфиги. Мы будем использовать её.

Конфигурация

Настройка Danger — это настоящее удовольствие. Мы можем использовать danger plugins и писать свои собственные правила. Давайте посмотрим на мой простой пример

@file:DependsOn("io.github.ackeecz:danger-kotlin-detekt:0.1.4")

import io.github.ackeecz.danger.detekt.DetektPlugin
import systems.danger.kotlin.*
import java.io.File

register.plugin(DetektPlugin)

danger(args) {
    warnDetekt()

    onGitHub {
        warnWorkInProgress()
    }
}

fun warnDetekt() {
    val detektReport = File("build/reports/detekt/report.xml")
    if (!detektReport.exists()) {
        warn(
            ":see_no_evil: No detekt report found",
        )
        return
    }
    DetektPlugin.parseAndReport(detektReport)
}

fun GitHub.warnWorkInProgress() {
    if ("WIP" in pullRequest.title) {
        warn(
            ":construction: PR is marked with Work in Progress (WIP)",
        )
    }
}

Я использовал плагин AckeeCZ/danger-kotlin-detekt здесь для парсинга и сообщения предупреждений detekt из данного файла отчета. Есть специальные конструкции вроде onGit и onGithub для многих инструментов. Вы можете проверить что-то в git коммитах, Github PR и так далее. В моем случае я также проверяю наличие букв WIP в заголовке pull request и делаю комментарий, чтобы выделить это. Много пространства для креативности, да?

Имейте в виду, что есть лимит символов в API комментариев Github, поэтому могут быть проблемы с большим количеством предупреждений, выведенных danger.

Github Actions

Github Actions — это решение для CI, если ваш проект небольшой и простой. Чтобы добавить Actions workflow, нам просто нужно создать .github/workflows/pull-request.yml в нашем репозитории и запушить репозиторий в Github.

name: Pull Request

on:
  pull_request:
    branches: [ main ]

jobs:
  pipeline:

    runs-on: ubuntu-latest

    permissions:
      pull-requests: write

    steps:
      - name: Checkout
        uses: actions/checkout@v2

      - name: Set up environment
        uses: actions/setup-java@v1
        with:
          java-version: 11

      - name: Change wrapper permissions
        run: chmod +x ./gradlew

      - name: detekt # Запуск detekt
        run: ./gradlew detekt

      - name: Danger # И Danger action
        uses: docker://ghcr.io/danger/danger-kotlin:1.0.0
        with:
          args: --dangerfile config/danger/config.df.kts # Заметьте, у меня конфиг расположен так
        env:
          GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} # Извлекается автоматически

Таким образом мы исключаем всю работу по настройке бот-аккаунта для комментариев. Github решает это сам.

Другой CI инструмент

Настройка Github Actions для публичного репозитория легка, но настройка любого другого CI инструмента не намного сложнее. В большинстве случаев нам нужно сгенерировать токен доступа к репозиторию и установить его как переменную окружения DANGER_GITHUB_API_TOKEN внутри CI job, привязанного к PR. Больше информации здесь в документации Ruby версии. Danger должен быть установлен на этой машине перед запуском, он может быть установлен из brew или из исходного кода.

danger-kotlin ci --dangerfile config/danger/config.df.kts

Пример

Я создал пример репозитория для этого поста в блоге, чтобы вы могли увидеть базовую реализацию, которую я попытался здесь описать. Вы также можете посмотреть на этот pull request с комментарием Github Actions бота, написанным Danger.

Заключение

Настройка статического анализа на CI — одна из самых простых вещей, которая делает разработку проекта более продуктивной. Я бы рекомендовал настраивать CI как можно раньше. Это хороший фундамент для создания более автоматических проверок в будущем. По сути каждое обсуждение в код-ревью может закончиться написанным правилом detekt или danger. Объявите политику нулевых предупреждений и не мержьте никакие PR с предупреждениями. Это очень помогает и намного проще, чем исправлять все после лет неконтролируемой разработки.