В этой статье я расскажу, как мы написали собственные анализаторы кода и чистим с их помощью нашу кодовую базу .net от наиболее острых / частых косяков. Главный посыл — сделать это довольно просто, не бойтесь писать свои анализаторы для борьбы с именно вашими багами. Вторичный посыл — попробуйте наши анализаторы и сообщите о результатах. Полное руководство я писать не буду, их довольно много в интернете, а вот небольшой обзор, что это как и с какими проблемами я столкнулся, надеюсь, окажется вам полезным.

Проблема


В декабре 2016 года я сменил работу и перешел в новую компанию. Новая контора находилась в стадии перехода от «отдел в полтора программиста на коленке лабает небольшой сервис для внутренних нужд» в «ИТ-компания в 100 человек разрабатывает ERP систему для нужд группы компаний и готовит продукт для внешних пользователей».

Естественно, в процессе этого перехода многие наколеночные «так принято» вещи становились серьезными и соответствующими разнообразным best practices. В частности, в компании появилась за несколько месяцев до моего прихода полноценная иерархия тестовых сред и отдел тестирования неуклонно боролся за соответствие тестовых сред — среде боевой.

Авторы унаследованной архитектуры исповедовали микросервисный подход, сроки были жесткими, процент джуниоров — большой. Тестеры при развертывании на тестовую среду столкнулись с тем, что внезапно микросервис А, развернутый на тестовой среде, мог вызывать микросервис Б прямо на проде. Смотрим в код — и видим — URL микросервиса Б зашит внутрь кода микросервиса А.

В процессе работы помимо проблемы «захардкоженная ссылка на микросервис» выявились и другие проблемы кода. Code-review check list все рос, ругань становилась все злее. Необходимо было решение, которое выявит все проблемы и предотвратит появление таких ситуаций в будущем.

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


Новый компилятор языка C# — Roslyn — полностью open source и включает поддержку pluggable static analyzers. Анализаторы могут выдавать ошибки, предупреждения и хинты, работают как во время компиляции, так и в реальном времени в IDE.

Анализаторы имеют доступ к AST(абстрактному синтаксическому дереву) и могут подписываться на элементы определенного типа, например на числа или строки или на вызовы методов. В самом простом виде это выглядит как-то так:

private static void AnalyzeLiteral(SyntaxNodeAnalysisContext context)
{
  if (syntaxNode is LiteralExpressionSyntax literal)
  {
    var value = literal.Token.ValueText;
    
    if (value != "@" && value.Contains("@"))
    {
      context.ReportDiagnostic( Diagnostic.Create(Rule, context.Node.GetLocation(), value));
     }
}

(Это моя самая первая попытка написать анализатор для захардкоженных email).

По дереву можно ходить вверх, вниз и вбок, и нет необходимости отличать, скажем, комментарий от строкового литерала — парсер компилятора делает все это за нас. Впрочем, уровня синтаксиса может быть недостаточно. Например,

   var guid = new Guid("...");

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

private static void AnalyzeCtor(SyntaxNodeAnalysisContext context)
{
  if (context.Node.GetContainingClass().IsProbablyMigration())
  {
    return;
  }

  if (context.Node is ObjectCreationExpressionSyntax createNode)
  {
    var type = context.SemanticModel.GetTypeInfo(createNode).Type as INamedTypeSymbol;
    var guidType = context.SemanticModel.Compilation.GetTypeByMetadataName(typeof(System.Guid).FullName);
  
    if (Equals(type, guidType))
    {
      ..
    }
  }
}

Семантическая модель — это промежуточные результаты компиляции. В отличие от синтаксиса, она может быть неполна (напоминаю, мы работаем в контексте IDE, программист может прямо сейчас писать анализируемый класс). Поэтому готовьтесь, методы семантической модели много и часто возвращают null.

Анализаторы ставятся в проект, как обычные NuGet пакеты. После перекомпиляции начинаются проявляться ошибки и предупреждения. Мы в конторе сделали внутренний NuGet пакет (у нас вообще вся сборка всего идет через пакеты), который ставит рекомендованную комбинацию анализаторов. Планируем еще туда дописать наши специфические конторские штуки, и туда же можно добавлять не наши пакеты (я например добавил AsyncFixer, очень удобная штука, всем рекомендую).

Вот так это выглядит в редакторе



А так в итогах компиляции



Какие анализаторы мы сделали


Анализатор захардкоженных URL


Самый важный для нас анализатор. Тупо ищет все литералы с http, https, ftp и так далее. Список исключений довольно большой — в основном разнообразный автогенеренный код, SOAP атрибуты, namespace и все такое.

Анализатор захардкоженной ставки НДС


Редко, но метко встречающаяся в нашем коде проблема. Иногда «сумма с НДС» высчитывается из «суммы без НДС» умножением на 1.18. Это сразу две проблемы. Во-первых, ставка НДС может измениться, и искать все места в коде — большая проблема. Во-вторых, ставка НДС вообще-то — не константа, а атрибут конкретного заказа — он может быть со ставкой НДС 0% (такие юрлица часто бывают у перевозчиков), может относится к другой юрисдикции, наконец, при изменении ставки НДС она изменится для новых заказов, а для старых окажется прежней.
Поэтому никакого захардкоженного НДС!

В исключения тут попали в основном функции вырезания подстрок (substring). Иногда там имеет смысл передавать жестко прописанное 18. Приходится ловить по именам параметров метода.

Анализатор захардкоженного Guid


Мы часто из кода ссылаемся на id базы (у нас приняты Guid). Это плохо. Особенно мы страдаем из-за таблиц статусов, которым на самом деле стоило бы просто сделать enum (от них мы избавляемся, но они есть). Пока в качестве промежуточного решения мы договорились, что в коде могут встречать id, если они одинаковы во всех средах (значит, создаются миграциями) и если они располагаются непосредственно в коде соответствующей Entity (да, мы используем Entity Framework 6 и не стесняемся).

Что у нас в планах


— анализатор захардкоженных email
— анализатор, обязывающий писать методы контроллера асинхронными

Возникшие трудности и странные моменты


Общая библиотека


Естественным было разделить анализаторы по принципу 1 анализатор = 1 assembly = 1 nuget пакет. Дело в том, что все написанные нами анализаторы очень opinionated, и логично предоставить возможность подключать / отключать их по одному. В процесс написания анализаторов я стал активно выносить общий код из анализаторов в общую библиотеку и возникла проблема: если разные версии анализаторов загружены в студию — они не могут загрузить разные версии общей библиотеки. Самым простым решением оказалось сливать на этапе билда каждый анализатор с общей библиотекой в единый assembly. Для этого я воспользовался ILRepack, у которого есть удобный биндинг для MSBuild (ILRepack.MSBuild.Task). К сожалению, биндинг немного устарел, но это вроде бы пока не влияет.

Стандартные зависимости анализаторов (Microsoft.CodeAnalysis.*) таскать с собой не нужно, и зависеть от них NuGet тоже не должен. Visual Studio сама выдаст нам нужные библиотеки.

Сборка при помощи Appveyor


Я привык настраивать непрерывную сборку проектов с исходным кодом при помощи Appveyor. Собирать и публиковать NuGet он тоже умеет. Но анализаторы — особая магия. В частности, в плане структуры директорий внутри пакета, там все по-особому, нужно раскладывать в папку analyzers вместо стандартной lib. Я немного помучился и пришел к решению — в csproj как эта сборки прописан вызов Nuget.exe, который мы таскаем с собой в пакете. Некрасиво, как мне кажется, но более красивого решения я не нашел.

Написание юнит-тестов


Юнит-тесты для анализаторов — это супер классно и удобно. Я использовал стандартную MS обертку для юнит-тестов анализатора, любой false позитив тут же оформлял юнит-тестом. Классно! Гораздо приятнее, чем возиться с нечетким человеческим ТЗ.

Переход на .netstandard


Перейти на .netstandart мне пока не удалось. Попытка с ходу оказалась неудачной, официальный template все еще генерит portable class библиотеки. Ждем обновленного template и попробуем сделать по нему. https://github.com/dotnet/roslyn/issues/18414

Заключение


Буду рад пинкам по поводу моего подхода. Исходники можно посмотреть здесь.

Буду также рад, если кто-то посоветует хорошие опенсорсные C# проекты, на которых проверить анализаторы, или проверит у себя на своих (может даже не опенсорсных) и зарепортит баги.

Очень хотелось бы ссылок на другие полезные анализаторы, желательно не дублирующие функционал Resharper.

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


  1. Oxoron
    22.08.2017 11:19

    Очень хотелось бы ссылок на другие полезные анализаторы

    github.com/icsharpcode/RefactoringEssentials


    1. leotsarev Автор
      22.08.2017 11:40
      +1

      Выглядит скорее как «open-source R#»
      Вот еще в ту же степь https://github.com/JosefPihrt/Roslynator


      1. Oxoron
        22.08.2017 12:17

        Посмотрите тут, хотя я не уверен что автор открыл исходники.


    1. Vadem
      22.08.2017 19:53
      +2

      Вот тут ещё можно посмотреть github.com/Cybermaxs/awesome-analyzers


  1. m08pvv
    22.08.2017 12:53
    +1

    и логично предоставить возможность подключать / отключать их по одному

    Так есть же возможность через ruleset (или как он там называется)


    1. leotsarev Автор
      22.08.2017 14:44

      Я почему-то думал, что шарить их сложно. Беглый гугл показал, что это реалистично, надо подумать тогда.


  1. ProgerMan
    22.08.2017 14:24
    -1

    А не лучше ли сразу писать хорошо? Например, перетащить все константы в отдельный модуль.
    Или это для тех, кто регулярками для поиска пользоваться не умеет, чтобы немного порефакторить?


    1. leotsarev Автор
      22.08.2017 14:33
      +8

      Да, конечно лучше бы сразу писать хорошо. Я выбрал анализаторы перед другим рассматриваемом вариантом «машина времени». Код по факту уже был написан так, когда мне достался.
      Но и это еще не все. Вопрос в том, как добиться этого «сразу писать хорошо». Какие варианты? Изначально мы полагались на внедрение code review. Но вопрос в том, что code review требует времени ревьюеров. Если их мало, они становятся узким местом. Если много, то вопрос в том, как добиться унификации и соблюдения стандартов среди ревьюеров. Процесс распространения культурных изменений в компании — дело не одного дня, и часто — не одного месяца.


    1. leotsarev Автор
      22.08.2017 14:40
      +3

      Про рефакторинг. В принципе, для небольших проектов просто поискать по солюшену — не сложно. Для крупных — сложнее. Начинаешь утопать в false позитивах и допустимых случаях — в комментариях можно. В SoapDocumentMethodAttribute можно. Пропустить один — довольно легко.


      К тому же анализаторы поддерживают однажды свершенные победы (выпилены все хардкоды URLов) в актуальном состоянии.


    1. Deosis
      23.08.2017 07:23
      +3

      Вы ещё попросите разработчиков сразу писать код без багов.


  1. nixel
    23.08.2017 09:52
    +1

    https://www.sonarqube.org


    Может интегрироваться в IDE — http://www.sonarlint.org


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


    1. Andrey2008
      23.08.2017 09:57

      Кстати, да. Хороший способ контролировать качество кода. Плюс, если есть желание искать не только запахи и частные случаи, можно добавить сторонние плагины. Я думаю вы знаете, что я рекомендую :).


  1. Andrey2008
    23.08.2017 09:53

    Идея искать особые случаи понятно. А не было ли желания, использовать ещё и полноценный анализатор кода (например, PVS-Studio) для поиска широкого спектра ошибок? При бурном развитии проекта и росте количества сотрудников, анализатор окажется хорошим помощником по контролю того, что творится в коде.


    1. leotsarev Автор
      23.08.2017 09:56

      Вообще на предыдущем месте работы мы попробовали PVS-Studio. Вывод был — хороший инструмент, но непонятно насколько много added value по сравнению с Resharper (который у нас все равно есть).
      Впрочем, если пришлете тестовый ключ, попробую и на этой кодовой базе, она должна быть хуже. leo@bastilia.ru


      1. Andrey2008
        23.08.2017 10:06
        +1

        Ключ отправил.
        P.S. Отмечу, что попробовать PVS-Studio for Windows можно и без запроса лицензии. Скачиваем, устанавливаем, проверяем проект и рассматриваем ошибки.


  1. Sinatr
    23.08.2017 10:49

    В статье слишком много «захардкоженно». Можно было бы один раз написать что-то вроде «жестко вшитых» (в скобочках указать hardcoded) и не использовать этот термин больше.


  1. eugenebb
    23.08.2017 16:57

    Редко встречаемая, но очень полезных функция, это возможность игнорировать проблемы в старом коде, но падать на новом/измененном.

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


    1. Oxoron
      23.08.2017 19:42

      А как вы отделяете новый код от старого? На уровне проектов, классов?


      1. eugenebb
        23.08.2017 20:00
        +1

        На уровне «магии», т.е. файлов.

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

        Можно делать как решарпер и т.д., т.е. добавлять комментирий с пометкой игнорирования, но тогда старый, проблемный код начинает пестрить ими как новогодняя ёлка, а если отключать на более высоком уровне то новый код также благополучно игнорируется.

        Можно по diff из source control, но тогда сложнее идентифицировать только что изменённый код.


        1. Oxoron
          23.08.2017 20:07

          Все равно не понимаю. Вы просто тянете дату последнего коммита, и если коммит старше 3 месяцев — помечаете файл как старый? Или какой-то кеш поддерживаете, куда изредка заносите новые файлы?


          1. eugenebb
            23.08.2017 20:47
            +1

            В момент «Х», прогоняем анализатор для файлов в проекте, весь вывод анализатора по каждому файлу сохраняем (в source control, чтобы синхронизировать в команде). Считаем что это «старые» ошибки и может их игнорировать.

            Если сделали изменения в файле, прогоняем анализатор снова, сравниваем вывод анализатора с сохраненной копией (игнорируя номера строк, чтобы не срабатывать на добавление/удаление нерелевантного кода), если есть различия, сообщаем об этом и показываем.

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

            Просто, но для нас работает. Можно конечно сделать более правильно, если у тебя это основной бизнес (как у PVS-Studio/Resharper).


    1. leotsarev Автор
      23.08.2017 22:40

      Я вот так хотел бы сделать с правилом «обязательно xmldoc ко всему публичному», но пока не разобрался, как.


  1. alhimik45
    23.08.2017 19:31

    Можно ли с помощью Roslyn написать например анализатор того, что в catch блоке программист не забыл залогировать ошибку? Основная проблема — логирование может быть вызвано не сразу, а например может быть вызван метод, который уже и залогирует. То есть нужно лезть внутрь по цепочке вызовов. Поможет ли в этом случае семантическая модель или roslyn не подходит для такого вида проверок?


    1. Oxoron
      23.08.2017 19:41
      +1

      Семантическая не факт что поможет. Синтаксическая с семантической — да. Только для средних-больших проектов не рекомендую делать именно анализатор, лучше Standalone tool — построение семантической модели может много времени занять.


    1. eugenebb
      23.08.2017 20:25
      +1

      Удобство анализаторов специфичных под конкретно Ваш проект заключается в том что скорее всего у вас всё сделано в одном (двух-трех-пяти-...) стиле(ях) и реализовать под этот стиль сильно проще, чем что-то общее, для всех случаев.

      Плюс в проекте всегда можно договориться делаем вот так, всё что по другому, считаем ошибкой.

      Мы хотели сделать автоматическое внедрение логики логирования и профилирования во все public/protected методы, но вместо этого используем AppDynamics и довольные как паравозы.

      Так что иногда вопрос «какую проблему вы этим хотите решить?» гораздо лучше, чем «как понять что логирование не пропущено?»


    1. leotsarev Автор
      23.08.2017 22:38

      Чем перебирать весь стек вниз, я бы сделал так:
      Опознавать вызовы нескольких стандартных библиотек для логирования;
      Предоставить атрибут, которым можно пометить метод, чтобы он считался «логирующим». Тогда вы можете прометить им свои обертки.