Впроваджуйте статичний аналіз у процес, а не шукайте за його допомогою баги

Написати цю статтю мене спонукала велику кількість матеріалів про статичний аналіз, які все частіше трапляються на очі. По-перше, це блог PVS-studio, який активно просуває себе на Хабрі за допомогою оглядів помилок, знайдених їх інструментом у проектах з відкритим кодом. Нещодавно PVS-studio реалізували підтримку Java, і, звичайно, розробники IntelliJ IDEA, чий вбудований аналізатор є на сьогодні, напевно, найпросунутішим для Java, не могли залишитися осторонь.

При читанні таких оглядів виникає відчуття, що йдеться про чарівний еліксир: натисніть на кнопку, і ось він – список дефектів перед очима. Здається, що в міру вдосконалення аналізаторів, багів автоматично буде все більше і більше, а продукти, проскановані цими роботами, ставатимуть все краще і краще, без будь-яких зусиль з нашого боку.

Але чарівних еліксирів не буває. Я хотів би поговорити про те, про що зазвичай не говорять у постах виду «ось які штуки може знайти наш робот»: чого не можуть аналізатори, в чому їхня реальна роль і місце в процесі постачання софту, і як впроваджувати їх правильно.

Впроваджуйте статичний аналіз у процес, а не шукайте за його допомогою баги
Хроповик (джерело: вікіпедія).

Чого ніколи не зможуть статичні аналізатори

Що таке, з практичного погляду, аналіз вихідного коду? Ми подаємо на вхід деякі вихідники, і на виході за короткий час (набагато короткіший, ніж прогін тестів) отримуємо деякі відомості про нашу систему. Принципове та математично непереборне обмеження полягає в тому, що отримати ми таким чином можемо лише досить вузький клас відомостей.

Найзнаменитіший приклад завдання, яке не вирішується за допомогою статичного аналізу. проблема зупинки: це теорема, яка доводить, що неможливо розробити загальний алгоритм, який за вихідним кодом програми визначав, зациклиться вона або завершиться за кінцевий час. Розширенням цієї теореми є теорема Райса, Яка стверджує, для будь-якої нетривіальної властивості обчислюваних функцій визначення того, чи обчислює довільна програма функцію з такою властивістю, є алгоритмічно нерозв'язною задачею. Наприклад, неможливо написати аналізатор, за будь-яким вихідним кодом визначальний, є аналізована програма імплементацією алгоритму, що обчислює, скажімо, зведення в квадрат цілого числа.

Таким чином, функціональність статичних аналізаторів має непереборні обмеження. Статичний аналізатор ніколи не зможе у всіх випадках визначити такі речі, як, наприклад, виникнення null pointer exception у мовах, що допускають значення null, або у всіх випадках визначити виникнення attribute not found у мовах з динамічною типізацією. Все, що може найдосконаліший статичний аналізатор - це виділяти окремі випадки, кількість яких серед усіх можливих проблем з вашим вихідним кодом є, без перебільшення, краплею в морі.

Статичний аналіз – це не пошук багів

Зі сказаного вище висновок: статичний аналіз — це засіб зменшення кількості дефектів у програмі. Ризикну стверджувати: будучи вперше застосований до вашого проекту, він знайде в коді «зайняті» місця, але, швидше за все, не знайде ніяких дефектів, що впливають на якість вашої програми.

Приклади дефектів, знайдених автоматично аналізаторами, вражають, але не слід забувати, що ці приклади знайдені за допомогою сканування великого набору великих кодових баз. За таким же принципом зломщики, які мають можливість перебрати кілька простих паролів на великій кількості облікових записів, зрештою знаходять ті облікові записи, на яких стоїть простий пароль.

Чи це означає, що статичний аналіз не треба застосовувати? Звичайно, ні! І саме з тієї ж причини, через яку варто перевіряти кожен новий пароль на потрапляння в стоп-лист «простих» паролів.

Статичний аналіз – це більше, ніж пошук багів

Насправді, задачі, які практично вирішуються аналізом, набагато ширші. Адже загалом статичний аналіз — це будь-яка перевірка вихідників, що здійснюється до їхнього запуску. Ось деякі речі, які можна робити:

  • Перевірка стилю кодування у сенсі цього терміну. Сюди входить як перевірка форматування, так і пошук використання порожніх/зайвих дужок, встановлення, порогових значень на метрики на кшталт кількості рядків/цикломатичної складності методу і т. д. — всього, що потенційно ускладнює читання та підтримуваність коду. У Java таким інструментом є Checkstyle, у Python - flake8. Програми такого класу зазвичай називаються "лінтери".
  • Аналізу може піддаватися як виконуваний код. Файли ресурсів, такі як JSON, YAML, XML, .properties можуть (і повинні!) автоматично перевірятися на валідність. Адже краще дізнатися про те, що через якісь непарні лапки порушено структуру JSON на ранньому етапі автоматичної перевірки Pull Request, ніж при виконанні тестів або в Run time? Відповідні інструменти є: наприклад, YAMLlint, JSONLint.
  • Компіляція (чи парсинг для динамічних мов програмування) — це також вид статичного аналізу. Як правило, компілятори здатні видавати попередження, що сигналізують про проблеми з якістю вихідного коду, і їх не слід ігнорувати.
  • Іноді компіляція — це компіляція виконуваного коду. Наприклад, якщо у вас документація у форматі AsciiDoctor, то в момент перетворення її в HTML/PDF обробник AsciiDoctor (Плагін Maven) може видавати попередження, наприклад, про порушені внутрішні посилання. І це вагомий привід не прийняти Pull Request зі змінами документації.
  • Перевірка правопису – теж вид статичного аналізу. Утиліта aspell здатна перевіряти правопис у документації, а й у вихідних кодах програм (коментарах і літералах) різними мовами програмування, включаючи C/C++, Java і Python. Помилка правопису в інтерфейсі користувача або документації - це теж дефект!
  • Конфігураційні тести (про те, що це таке – див. цей и цей доповіді), хоч і виконуються в середовищі виконання модульних тестів типу pytest, насправді також є різновидом статичного аналізу, тому що не виконують вихідні коди в процесі виконання.

Як бачимо, пошук багів у цьому списку займає найменш важливу роль, а решта доступно шляхом використання безкоштовних open source інструментів.

Який із цих типів статичного аналізу слід застосовувати у вашому проекті? Звичайно, все чим більше — тим краще! Головне, впровадити це правильно, про що й йтиметься далі.

Конвеєр постачання як багатоступінчастий фільтр та статичний аналіз як його перший каскад

Класичною метафорою безперервної інтеграції є трубопровід (pipeline), яким протікають зміни — від зміни вихідного коду до поставки в production. Стандартна послідовність етапів цього конвеєра виглядає так:

  1. статичний аналіз
  2. компіляція
  3. модульні тести
  4. інтеграційні тести
  5. UI тести
  6. ручна перевірка

Зміни, забраковані на N-му етапі конвеєра, не передаються на етап N+1.

Чому саме так, а чи не інакше? У тій частині конвеєра, що стосується тестування, тестувальники дізнаються про широко відому піраміду тестування.

Впроваджуйте статичний аналіз у процес, а не шукайте за його допомогою баги
Тестова піраміда. Джерело: стаття Мартіна Фаулер.

У нижній частині цієї піраміди розташовані тести, які легше писати, які швидше виконуються та не мають тенденції до помилкового спрацьовування. Тому їх має бути більше, вони повинні покривати більше коду та виконуватися першими. У верхній частині піраміди все навпаки, тому кількість інтеграційних і UI тестів має бути зменшено до необхідного мінімуму. Людина в цьому ланцюжку - найдорожчий, повільний і ненадійний ресурс, тому він знаходиться в самому кінці і виконує роботу тільки в тому випадку, якщо попередні етапи не виявили дефектів. Однак за тими самими принципами будується конвеєр і в частинах, які не пов'язані безпосередньо з тестуванням!

Я хотів би запропонувати аналогію у вигляді багатокаскадної системи фільтрації води. На вхід подається брудна вода (зміни з дефектами), на виході ми повинні отримати чисту воду, всі небажані забруднення в якій відсіяні.

Впроваджуйте статичний аналіз у процес, а не шукайте за його допомогою баги
Багатоступінчастий фільтр. Джерело: Wikimedia Commons

Як відомо, фільтри, що очищають, проектуються так, що кожен наступний каскад може відсіювати все дрібнішу фракцію забруднень. При цьому каскади більш грубого очищення мають більшу пропускну здатність та меншу вартість. У нашій аналогії це означає, що вхідні quality gates мають більшу швидкодію, вимагають менше зусиль для запуску і самі по собі невибагливіші в роботі — і саме в такій послідовності вони й збудовані. Роль статичного аналізу, який, як ми тепер розуміємо, здатний відсіяти лише грубі дефекти - це роль грати-«грязевика» на самому початку каскаду фільтрів.

Статичний аналіз сам собою не покращує якість кінцевого продукту, як «грязевик» робить воду питної. І тим не менш, у спільній зв'язці з іншими елементами конвеєра його важливість очевидна. Хоча в багатокаскадному фільтрі вихідні каскади потенційно здатні вловити все те ж, що і вхідні — ясно, до яких наслідків приведе спроба обійтися лише каскадами тонкого очищення, без вхідних каскадів.

Мета «грязевика» — розвантажити наступні каскади від уловлювання дуже грубих дефектів. Наприклад, як мінімум, людина, яка робить code review, не повинна відволікатися на неправильно відформатований код і порушення встановлених норм кодування (на кшталт зайвих дужок або надто глибоко вкладених розгалужень). Баги на кшталт NPE повинні вловлюватися модульними тестами, але якщо ще до тесту аналізатор нам вказує на те, що баг повинен неминуче статися, це значно прискорить його виправлення.

Вважаю, тепер ясно, чому статичний аналіз не покращує якість продукту, якщо застосовується епізодично, і повинен застосовуватися постійно для відсіювання змін із грубими дефектами. Питання, чи покращить застосування статичного аналізатора якість вашого продукту, приблизно еквівалентний питанню «чи покращаться питні якості води, взятої з брудної водойми, якщо її пропустити через друшляк?»

Впровадження у legacy-проект

Важливе практичне питання: як впровадити статичний аналіз у процес безперервної інтеграції як «quality gate»? У випадку з автоматичними тестами, все очевидно: є набір тестів, падіння будь-якого з них — достатня підстава вважати, що збірка не пройшла quality gate. Спроба так само встановити gate за результатами статичного аналізу провалюється: на legacy-коде попереджень аналізу занадто багато, повністю ігнорувати їх хочеться, а й зупиняти постачання продукту лише оскільки у ньому є попередження аналізатора, неможливо.

Будучи застосований вперше, на будь-якому проекті аналізатор видає величезну кількість попереджень, переважна більшість яких не мають відношення до правильного функціонування продукту. Виправляти одразу всі ці зауваження неможливо, а багато – і не потрібно. Зрештою, ми знаємо, що наш продукт загалом працює, і до впровадження статичного аналізу!

У результаті, багато хто обмежується епізодичним використанням статичного аналізу, або використовують його лише в режимі інформування, коли при складанні легко видається звіт аналізатора. Це еквівалентно відсутності будь-якого аналізу, тому що якщо у нас вже є безліч попереджень, то виникнення ще одного (як завгодно серйозного) при зміні коду залишається непоміченим.

Відомі такі способи запровадження quality gates:

  • Встановлює ліміт загальної кількості попереджень або кількості попереджень, поділених на кількість рядків коду. Працює це погано, тому що такий gate вільно пропускає зміни з новими дефектами, доки їх ліміт не перевищено.
  • Фіксація, у певний момент, всіх старих попереджень у коді як ігнорованих, і відмова у складання при виникненні нових попереджень. Таку функціональність надає PVS-studio та деякі онлайн-ресурси, наприклад Codacy. Мені не довелося працювати в PVS-studio, що стосується мого досвіду з Codacy, то основна їхня проблема полягає в тому, що визначення що є «стара», а що «нова» помилка — досить складний алгоритм, що не завжди правильно працює, особливо якщо файли сильно змінюються або перейменовуються. На моїй пам'яті Codacy міг пропускати в пулл-реквесті нові попередження, і водночас не пропускати pull request через попередження, що не стосуються змін у коді цього PR.
  • На мій погляд, найбільш ефективним рішенням є описаний у книзі Безперервна доставка метод храповика (ratcheting). Основна ідея у тому, що властивістю кожного релізу є кількість попереджень статичного аналізу, і допускаються такі зміни, які загальну кількість попереджень не збільшують.

Хроповик

Працює це таким чином:

  1. На початковому етапі реалізується запис метаданих про реліз кількості попереджень у коді, знайдених аналізаторами. Таким чином, при складанні основної гілки у ваш менеджер репозиторіїв записується не просто "реліз 7.0.2", але "реліз 7.0.2, що містить 100500 Checkstyle-попереджень". Якщо ви використовуєте просунутий менеджер репозиторіїв (такі як Artifactory), зберегти такі метадані про ваш реліз легко.
  2. Тепер кожен pull request при складанні порівнює кількість попереджень з тим, яка кількість є в поточному релізі. Якщо PR призводить до збільшення цього числа, код не проходить quality gate за статичним аналізом. Якщо кількість попереджень зменшується або не змінюється, то проходить.
  3. При наступному релізі перерахована кількість попереджень буде знову записано метадані релізу.

Так потроху, але неухильно (як під час роботи храповика), число попереджень прагнутиме нулю. Звичайно, систему можна обдурити, внісши нове попередження, але виправивши чуже. Це нормально, тому що на довгій дистанції дає результат: попередження виправляються, як правило, не по одиночці, а відразу групою певного типу, і всі попередження, що легко усуваються, досить швидко виявляються усунені.

На цьому графіку показано загальну кількість Checkstyle-попереджень за півроку роботи такого «храповика» на одному з наших OpenSource проектів. Кількість попереджень зменшилася на порядок, причому це сталося природним чином, паралельно з розробкою продукту!

Впроваджуйте статичний аналіз у процес, а не шукайте за його допомогою баги

Я застосовую модифіковану версію цього методу, окремо підраховуючи попередження в розбивці за модулями проекту та інструментами аналізу, що формується при цьому YAML-файл з метаданими про складання виглядає приблизно так:

celesta-sql:
  checkstyle: 434
  spotbugs: 45
celesta-core:
  checkstyle: 206
  spotbugs: 13
celesta-maven-plugin:
  checkstyle: 19
  spotbugs: 0
celesta-unit:
  checkstyle: 0
  spotbugs: 0

У будь-якій просунутій CI-системі «храповик» можна реалізувати для будь-яких інструментів статичного аналізу, не покладаючись на плагіни та сторонні інструменти. Кожен із аналізаторів видає свій звіт у простому текстовому або XML форматі, що легко піддається аналізу. Залишається прописати лише необхідну логіку у CI-скрипті. Подивитись, як це реалізовано в наших open source проектах на базі Jenkins та Artifactory, можна тут або тут. Обидва приклади залежать від бібліотеки ratchetlib: метод countWarnings() звичайним чином підраховує xml-теги у файлах, що формуються Checkstyle і Spotbugs, а compareWarningMaps() реалізує той самий храповик, викидаючи помилку у разі, коли кількість попереджень у якійсь із категорій підвищується.

Цікавий варіант реалізації «храповика» можливий для аналізу правопису коментарів, текстових літералів та документації за допомогою aspell. Як відомо, при перевірці правопису не всі невідомі стандартному словнику слова є неправильними, вони можуть бути додані в словник користувача. Якщо зробити словник користувача частиною вихідного коду проекту, то quality gate по правопису може бути сформульований таким чином: виконання aspell зі стандартним і словником користувача не повинно знаходити жодних помилок правопису.

Про важливість фіксації версії аналізатора

На закінчення потрібно відзначити наступне: хоч би яким чином ви б не впроваджували аналіз у ваш конвеєр поставки, версія аналізатора повинна бути фіксована. Якщо допустити мимовільне оновлення аналізатора, то при складанні чергового pull request можуть «спливти» нові дефекти, які не пов'язані зі зміною коду, а пов'язані з тим, що новий аналізатор просто здатний знаходити більше дефектів - і це поламає вам процес приймання pull request-ів . Апгрейд аналізатора має бути усвідомленою дією. Втім, жорстка фіксація версії кожної компоненти складання — це загалом необхідна вимога та тема для окремої розмови.

Висновки

  • Статичний аналіз не знайде вам баги та не покращить якість вашого продукту внаслідок одноразового застосування. Позитивний ефект якості дає лише його постійне застосування у процесі поставки.
  • Пошук багів взагалі не є головним завданням аналізу, переважна більшість корисних функцій доступна в Opensource інструментах.
  • Впроваджуйте quality gates за результатами статичного аналізу на першому етапі конвеєра поставки, використовуючи «храповик» для legacy-коду.

Посилання

  1. Безперервна доставка
  2. А. Кудрявцев: Аналіз програм: як зрозуміти, що ти добрий програміст доповідь про різні методи аналізу коду (не лише статичному!)

Джерело: habr.com

Додати коментар або відгук