Как стать автором
Обновить
87.34
Циан
В топ-5 лучших ИТ-компаний рейтинга Хабр.Карьера

Как ускорить проверку приложения с помощью Impact-анализа: Статические анализаторы

Уровень сложностиПростой
Время на прочтение14 мин
Количество просмотров1.2K

Когда команда растёт, а кодовая база стремительно увеличивается, время выполнения проверок может стать настоящей проблемой. Unit-тесты, UI-тесты, статический анализ — все эти процессы начинают занимать слишком много времени, замедляя разработку. Звучит знакомо?

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

Хочется запускать проверки только там, где это действительно нужно, и ускорить работу своей команды? Тогда читайте дальше.


Введение

Чтобы понять, с чем мы имеем дело, быстро познакомлю вас с проектом. Это основное Android-приложение Циан и его масштабы на Q3 2024 такие:

  • 632 Gradle-модуля,

  • 650 UI-тестов (569 mock и 81 end-to-end),

  • 1.4 миллиона строк кода на Kotlin,

  • 13.8 тысяч Unit-тестов.

Наша команда из 21 Android-разработчика выпускает около 200 фичей в месяц. С такими цифрами любая лишняя минута, потраченная на сборку или проверки, серьёзно сказывается на удовлетворенности разработчиков временем их выполнения. Поэтому я слежу за тем, чтобы проверки работали быстро и не вызывали боли у команды.

На графике ниже видно, как растут кодовая база и количество UI-тестов — и именно этот рост требует оптимизации процессов.

Chart
Рост кодовой базы приложения

*Для подсчёта строк кода использовался инструмент cloc. Резкий рост количества Kotlin-кода и unit-тестов объясняется переводом legacy-классов с Java на Kotlin. Сейчас мы почти полностью избавились от Java и ожидаем, что эти показатели будут расти несколько медленнее.

Усреднённое время прохождения проверок сейчас такое:

  • 20 минут на выполнение всех unit-тестов,

  • 2 минуты на проверки статическими анализаторами,

  • 40 минут на прогон mock UI-тестов.

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

Предпосылки для оптимизации

Для начала разберёмся, как выглядит стандартный процесс завершения задачи.

  1. Разработчик запускает проверки локально, чтобы убедиться, что всё работает.

  2. Создаётся Pull Request (PR), и проверки повторно запускаются уже на CI.

  3. Пока проверки выполняются, идёт код-ревью.

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

Процесс слияния ветки с задачей в основную ветку
Процесс слияния ветки с задачей в основную ветку

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

А теперь представим, что именно в этот момент в трёх модулях оказываются сломаны тесты. CI сообщает, что проверки не прошли. Разработчику приходится тратить время на исправление ошибок, запускать проверки повторно и снова ждать их завершения.

Сценарий запуска проверок только на CI, минуя локальный компьютер
Сценарий запуска проверок только на CI, минуя локальный компьютер

Это замедляет процесс и создаёт дополнительные неудобства для команды. Такие ситуации — одна из причин, почему локальный запуск проверок и их оптимизация становятся необходимой.

Другая причина связана с особенностями выполнения Gradle задач. Как многие знают, Gradle активно использует кеши для ускорения работы. Однако между локальным запуском проверок и запуском на CI есть важное отличие.

На CI мы не знаем, на каком сервере запустится worker, который будет выполнять gradle-таски. Велика вероятность, что нужного кеша там может не оказаться, и проверки начнут выполняться с нуля, без его использования.

Это еще раз подчеркивает важность локального запуска проверок. Их запуск только на CI может существенно увеличить суммарное время выполнения всех этапов и создавать дополнительные задержки в процессе слияния ветки задачи с основной веткой.

Разница в использовании Gradle-кешей при локальном запуске и запуске на CI
Разница в использовании Gradle-кешей при локальном запуске и запуске на CI

Резюмирую, почему мы решили уменьшать длительность выполнения проверок:

  • Долгое ожидание проверок. Разработчики вынуждены ждать завершения проверок перед отправкой PR. Если проверки работают долго, а разработчик спешит, он может пропустить локальный запуск проверок, что увеличивает нагрузку на CI.

  • Задержки задач. Если проверка занимает n времени, задача попадёт в основную ветку не раньше чем через 2n после её завершения.

  • Рост нагрузки с увеличением числа фичей. При 200+ фичах в месяц каждая лишняя минута проверки добавляет более 200 минут работы CI и столько же времени ожидания для разработчиков.

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

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

  1. Параллельное выполнение проверок. Настройка сильно зависит от типа проверок: UI-тесты, Unit-тесты, анализаторы кода — каждый из них требует уникального подхода. Эта тема сложная и требует отдельного обсуждения, поэтому мы оставим её за рамками статьи.

  2. Выборочное выполнение проверок. На этом методе мы сосредоточимся в статье. Выборочный запуск проверок позволяет проверять только необходимые участки кода. Для определения таких участков используется Impact-анализ.

Impact-анализ — это концепция, позволяющая определить какие программные компоненты были затронуты изменениями внесенными в коде. Более формальные определения этой методики можно найти, например, в книге Владислава Еремеева «Библия QA».

Для упрощения подход к выборочному запуску проверок можно разделить на три этапа, упорядоченных по возрастанию сложности:

  1. Статические анализаторы. В первой части разберём, как анализировать изменения файлов и выборочно запускать статические анализаторы, например, detekt.

  2. Unit-тесты и Gradle-таски. Во второй части сосредоточимся на том, как выборочно выполнять unit-тесты и другие проверки, завязанные на Gradle.

  3. UI-тесты. В третьей части поговорим о подходах к выборочному запуску UI-тестов.

Статические анализаторы

В нашем проекте, где почти весь код написан на Kotlin, для статического анализа мы используем detekt. У него есть два основных способа запуска

  1. Через Gradle-плагин.
    Этот способ требует поднятия Gradle-демона, конфигурации проекта, построения графа Gradle-задач и их выполнения. Однако это дорогое удовольствие с точки зрения ресурсов и времени, поэтому мы решили использовать более лёгкий вариант.

  2. CLI-интерфейс detekt позволяет анализировать только выбранные файлы, что удобно для выборочных проверок. Для этого можно использовать опцию --input, указав пути к необходимым файлам:

--input, -i
Input paths to analyse. Multiple paths are separated by comma. If not specified the current working directory is used.

CLI detekt оказался для нас оптимальным выбором: он не требует поднимать Gradle-демон и конфигурировать проект, что значительно ускоряет процесс.

Но не обошлось без нюансов. CLI detekt принимает только один baseline-файл для анализа:

--baseline, -b
If a baseline xml file is passed in, only new code smells not in the baseline are printed in the console.

Это создаёт проблему, так как при запуске detekt через Gradle baseline-файл генерируется отдельно для каждого модуля. Мы стремимся сохранить возможность использования detekt через Gradle, поэтому не хотим отказываться от подобного разделения baseline-файлов в проекте.

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

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

Объединение baseline-файлов для detekt

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

Baseline.xml содержит два основных списка, каждый из которых выполняет свою роль:

  1. <ManuallySuppressedIssues> — предназначен для случаев false-positive. Сюда записываются проблемы, исключённые вручную.

  2. <CurrentIssues> — фиксирует текущие нерешённые проблемы, которые ещё предстоит исправить.

Каждая запись в этих списках имеет формат:
<RuleID>:<Codesmell_Signature>, где:

  • RuleID — идентификатор нарушенного правила.

  • Codesmell_Signature — уникальная сигнатура проблемы, содержащая информацию о файле, классе, методе или другом контексте, где найдено нарушение.

Пример структуры baseline.xml:

<SmellBaseline>
  <ManuallySuppressedIssues>
    <ID>CatchRuntimeException:Junk.kt$e: RuntimeException</ID>
  </ManuallySuppressedIssues>
  <CurrentIssues>
    <ID>NestedBlockDepth:Indentation.kt$Indentation$override fun procedure(node: ASTNode)</ID>
    <ID>TooManyFunctions:LargeClass.kt$io.gitlab.arturbosch.detekt.rules.complexity.LargeClass.kt</ID>
    <ID>ComplexMethod:DetektExtension.kt$DetektExtension$fun convertToArguments(): MutableList&lt;String&gt;</ID>
  </CurrentIssues>
</SmellBaseline>

Теперь создадим bash-скрипт generate-detekt-baseline.sh, который пройдётся по всем файлам detekt-baseline.xml в проекте и объединит их в один итоговый файл.

Для начала найдём все файлы с именем detekt-baseline.xml в подкаталогах текущей директории.

detekt_baseline_list=$(find . -name 'detekt-baseline.xml')

Далее определяем пути и создаем необходимые директории:

output_dir="./build/reports/detekt"
output_file="$output_dir/cli-baseline.xml"
mkdir -p "$output_dir"

И создаём XML-структуру, соответствующую правилам detekt baseline:

# Начинаем формирование результирующего файла
{
    echo "<?xml version='1.0' encoding='UTF-8'?>"
    echo "<SmellBaseline>"
} > "$output_file"

# Добавляем теги ManuallySuppressedIssues и CurrentIssues
add_issues "ManuallySuppressedIssues" "$output_file"
add_issues "CurrentIssues" "$output_file"

# Завершаем формирование файла
echo "</SmellBaseline>" >> "$output_file"

За перенос <ID> из исходных файлов в итоговый файл отвечает функция add_issues, которая принимает два параметра: имя тега $tag_name и путь к результирующему файлу $output. Она выполняет следующие шаги:

  1. Создаёт открывающий тег <$tag_name> в файле $output.

  2. Для каждого файла baseline Gradle-модуля вызывает команду awk, чтобы извлечь строки между открывающим и закрывающим тегами <$tag_name>.

  3. Фильтрует результат с помощью команды grep, оставляя только строки, содержащие <ID>.

  4. Добавляет отфильтрованные строки в файл $output.

  5. Завершает работу добавлением закрывающего тега </$tag_name>.

# Функция для добавления содержимого тегов <ID>
add_issues() {
    local tag_name="$1"
    local output="$2"
    echo "  <$tag_name>" >> "$output"
    for file in $detekt_baseline_list; do
        awk "/<$tag_name>/,/<\/$tag_name>/{print}" "$file" | grep '<ID>' >> "$output"
    done
    echo "  </$tag_name>" >> "$output"
}

Таким образом, содержимое всех baseline-файлов Gradle-модулей объединяется в один общий файл cli-baseline.xml. Полный текст скрипта оставляю под спойлером.

Спойлер №1
#!/bin/bash

# Записываем время начала выполнения скрипта
start_time=$(date +%s)

# Находим все файлы с именем detekt-baseline.xml в подкаталогах текущего каталога
detekt_baseline_list=$(find . -name 'detekt-baseline.xml')

# Определяем пути и создаем необходимые директории
output_dir="./build/reports/detekt"
output_file="$output_dir/cli-baseline.xml"
mkdir -p "$output_dir"

# Функция для добавления содержимого тегов <ID>
add_issues() {
    local tag_name="$1"
    local output="$2"
    echo "  <$tag_name>" >> "$output"
    for file in $detekt_baseline_list; do
        awk "/<$tag_name>/,/<\/$tag_name>/{print}" "$file" | grep '<ID>' >> "$output"
    done
    echo "  </$tag_name>" >> "$output"
}

# Начинаем формирование результирующего файла
{
    echo "<?xml version='1.0' encoding='UTF-8'?>"
    echo "<SmellBaseline>"
} > "$output_file"

# Добавляем теги ManuallySuppressedIssues и CurrentIssues
add_issues "ManuallySuppressedIssues" "$output_file"
add_issues "CurrentIssues" "$output_file"

# Завершаем формирование файла
echo "</SmellBaseline>" >> "$output_file"

# Записываем время окончания выполнения скрипта
end_time=$(date +%s)

# Вычисляем и выводим затраченное время
elapsed_time=$((end_time - start_time))
echo "Generation baseline duration: $elapsed_time seconds"

Итоговый файл cli-baseline.xml мы исключили из git index, чтобы он не попадал в репозиторий. Возможно, код получился не самым красивым, быстрым и надёжным, но текущая реализация генерирует baseline-файл на CI за 1-2 секунды. Мы решили, что такой результат нас устраивает и пока не требует оптимизации.

Список измененных файлов

Следующим этапом нам нужно получить список изменённых файлов, и для detekt нас интересуют файлы с расширением .kt.

Чтобы определить файлы, которые были изменены и закоммичены в текущем репозитории по сравнению с веткой origin/develop, используем команду git diff:

commited_changes=$(git diff --relative --name-only origin/develop... | grep '\.kt\?$')

Разберём аргументы команды:

  • --relative — возвращает пути к файлам относительно текущего каталога.

  • --name-only — отображает только имена файлов без дополнительных данных.

  • origin/develop... — сравнивает текущую ветку с origin/develop и выводит изменения, которых нет в последней.

  • grep '\.kt\?$' — фильтрует результат, оставляя только файлы с расширением .kt.

Теперь у нас есть список изменённых и закоммиченных файлов. Аналогичным способом можно получить список файлов, которые были изменены, но ещё не закоммичены в текущем репозитории.

uncommited_changes=$(git diff --relative --name-only HEAD | grep '\.kt\?$')

Далее склеиваем списки измененных файлов в одну строку через запятую:

if [ -z "$commited_changes" ]; then
  staged_changes="$uncommited_changes"
elif [ -z "$uncommited_changes" ]; then
  staged_changes="$commited_changes"
else
  staged_changes="$commited_changes,$uncommited_changes"
fi

Фильтруем список измененных файлов, чтобы не было пустых строк, и выводим его в лог, для упрощения отладки:

changes_list=$(echo "$staged_changes" | grep -v '^$' | paste -sd, -)
echo "changes_list="$changes_list

Существует ещё корнер кейс: если вы добавили файл в одном из предыдущих коммитов, но удалили его в текущих изменениях, он всё равно появится в списке commited_changes, хотя в файловой системе его уже не будет. Чтобы избежать ошибок, важно убедиться, что каждый файл из списка действительно существует. 

IFS=',' read -ra ADDR <<< "$changes_list"
changes_list=""
for file in "${ADDR[@]}"; do
  if [ -f "$file" ]; then
    if [ -z "$changes_list" ]; then
      changes_list="$file"
    else
      changes_list="$changes_list,$file"
    fi
  fi
done

Корректный набор аргументов и запуск CLI

Дальше формируем корректный набор аргументов и запускаем detekt-cli.

# Получаем список плагинов для detekt, включая собственные кастомные правила
plugins=$(find "$project_dir"/build_scripts/detekt/rulesets -type f -name "*.jar" | tr '\n' ',' | sed 's/,$//')

# Создаем папку и файл для конфигурации запуска. Файл нужен, чтобы не ловить ошибку запуска скрипта при большом
# количестве изменений в проекте (`Argument list too long`)
config_dir_path="$project_dir/build/tmp"
mkdir -p "$config_dir_path"
config_file_path="$config_dir_path/detekt_cli_args"

# Записываем конфигурацию запуска detekt (пример: https://github.com/detekt/detekt/pull/2397/files)
echo "--build-upon-default-config" > "$config_file_path"
echo "--config" >> "$config_file_path"
echo "$project_dir/build_scripts/detekt/detekt-config.yml" >> "$config_file_path"
echo "--input" >> "$config_file_path"
echo "$changes_list" >> "$config_file_path"
echo "--plugins" >> "$config_file_path"
echo "$plugins" >> "$config_file_path"
echo "--report" >> "$config_file_path"
echo "html:detekt_report.html" >> "$config_file_path"
echo "--parallel" >> "$config_file_path"
echo "--baseline" >> "$config_file_path"
echo "$project_dir/build/reports/detekt/cli-baseline.xml" >> "$config_file_path"

# Запускаем detekt на измененных файлах
output=$("$project_dir"/build_scripts/detekt/detekt-cli/cli/detekt-cli @"$config_file_path")

if [ -n "$output" ]; then
  report_path="file://$project_dir/detekt_report.html"
  echo "report_path="$report_path
  echo "$output" | sed 'G'
  echo "Detailed report available at $report_path"

  exit 1
else
  echo "No detekt violations indicated"
fi

Полную версию получившегося в итоге скрипта optimized-detekt.sh прикрепляю под спойлер №2.

Спойлер №2
#!/bin/bash


start_time=$(date +%s)  # Записываем время начала выполнения скрипта


function generateDetektBaseline() {
 echo "Generating Detekt baseline..."
 ./.tools/detekt/generate-detekt-baseline.sh -eq 0 || exit 1
 echo "Baseline was generated"
}


# Определяем рабочую директорию проекта
project_dir=$(git rev-parse --show-toplevel)
echo "project_dir="$project_dir
cd "$project_dir"


# Получаем список измененных файлов из git и фильтруем только .kt файлы
commited_changes=$(git diff --relative --name-only origin/develop... | grep '\.kt\?$')
uncommited_changes=$(git diff --relative --name-only HEAD | grep '\.kt\?$')


if [ -z "$commited_changes" ]; then
 staged_changes="$uncommited_changes"
elif [ -z "$uncommited_changes" ]; then
 staged_changes="$commited_changes"
else
 staged_changes="$commited_changes,$uncommited_changes"
fi


changes_list=$(echo "$staged_changes" | grep -v '^$' | paste -sd, -)
echo "changes_list="$changes_list


# Проверяем, что каждый файл в списке реально существует
IFS=',' read -ra ADDR <<< "$changes_list"
changes_list=""
for file in "${ADDR[@]}"; do
 if [ -f "$file" ]; then
   if [ -z "$changes_list" ]; then
     changes_list="$file"
   else
     changes_list="$changes_list,$file"
   fi
 fi
done


echo "Filtered changes_list="$changes_list


# Проверяем, что список изменений не пустой
if [ -z "$changes_list" ]; then
 echo "No files to process."
 exit 0
fi


# Вызываем скрипт для генерации baseline файла для detekt
generateDetektBaseline


# Получаем список плагинов для detekt, включая собственные кастомные правила
plugins=$(find "$project_dir"/build_scripts/detekt/rulesets -type f -name "*.jar" | tr '\n' ',' | sed 's/,$//')


# Создаем папку и файл для конфигурации запуска. Файл нужен, чтобы не ловить ошибку запуска скрипта при большом
# количестве изменений в проекте (`Argument list too long`)
config_dir_path="$project_dir/build/tmp"
mkdir -p "$config_dir_path"
config_file_path="$config_dir_path/detekt_cli_args"


# Записываем конфигурацию запуска Detekt (пример: https://github.com/detekt/detekt/pull/2397/files)
echo "--build-upon-default-config" > "$config_file_path"
echo "--config" >> "$config_file_path"
echo "$project_dir/build_scripts/detekt/detekt-config.yml" >> "$config_file_path"
echo "--input" >> "$config_file_path"
echo "$changes_list" >> "$config_file_path"
echo "--plugins" >> "$config_file_path"
echo "$plugins" >> "$config_file_path"
echo "--report" >> "$config_file_path"
echo "html:detekt_report.html" >> "$config_file_path"
echo "--parallel" >> "$config_file_path"
echo "--baseline" >> "$config_file_path"
echo "$project_dir/build/reports/detekt/cli-baseline.xml" >> "$config_file_path"


# Запускаем detekt на измененных файлах
output=$("$project_dir"/build_scripts/detekt/detekt-cli/cli/detekt-cli @"$config_file_path")


if [ -n "$output" ]; then
 report_path="file://$project_dir/detekt_report.html"
 echo "report_path="$report_path
 echo "$output" | sed 'G'
 echo "Detailed report available at $report_path"


 exit 1
else
 echo "No detekt violations indicated"
fi


end_time=$(date +%s)  # Записываем время окончания выполнения скрипта
elapsed_time=$((end_time - start_time))  # Вычисляем разницу во времени
echo "Optimized detekt duration: $elapsed_time seconds"  # Выводим время выполнения скрипта

Теперь мы можем запустить скрипт и увидеть результаты его работы:

./.tools/detekt/optimized-detekt.sh 
project_dir=/Users/d.konopelkin/AndroidStudioProjects/Cian_Android
changes_list= [...]
Filtered changes_list= [...]
Generating detekt baseline...
Generation baseline duration: 2 seconds
Baseline was generated
No detekt violations indicated
Optimised detekt duration: 14 seconds

Для сравнения, полное выполнение команды ./gradlew detekt на том же проекте занимает 2,5 минуты, из которых 86 секунд тратится только на конфигурацию проекта.

./gradlew detekt
Calculating task graph as no cached configuration is available for tasks: detekt

> Configure project :build-logic:plugins
:: Remote Cache is applied

> Configure project :buildSrc
> Task :buildSrc:checkKotlinGradlePluginConfigurationErrors SKIPPED
...
> Task :worktime_impl:detekt FROM-CACHE
> Task :wallet-ui:detekt FROM-CACHE
> Task :update-app-bl:detekt FROM-CACHE
> Task :ui-test:detekt
ConfigurationTimeReportPlugin: send configuration time = 86 sec

BUILD SUCCESSFUL in 2m 35s
660 actionable tasks: 5 executed, 644 from cache, 11 up-to-date
Gradle was unable to watch the file system for changes. The inotify watches limit is too low.
Configuration cache entry stored.

Результат

Согласно данным за последний месяц, среднее время выполнения detekt для полной проверки всего проекта составляет около 2 минут, тогда как проверка с использованием impact-анализа занимает в среднем 9 секунд. Это экономит 110 секунд на каждый PR, что в масштабах команды составляет около 6 часов в месяц. Но главное — ускорение в 10–13 раз, что значительно повышает эффективность работы и мотивирует не игнорировать запуск проверок локально.

Очень надеюсь, что мне удалось пробудить у вас интерес к использованию impact-анализа для выборочного запуска проверок в вашем проекте. Если да — оставайтесь на связи! В следующей части я расскажу, как мы добились двукратного ускорения unit-тестов, применяя этот подход. Спасибо за внимание!

Теги:
Хабы:
Всего голосов 11: ↑11 и ↓0+15
Комментарии5

Публикации

Информация

Сайт
www.cian.ru
Дата регистрации
Дата основания
Численность
1 001–5 000 человек
Местоположение
Россия
Представитель
Zina Bezzabotnova