четверг, 22 октября 2015 г.

Пишем простой анализатор с помощью Roslyn

С выходом новой версии студии (VS2015) у каждого из нас появилась возможность почувствовать себя причастным к разработке инструментов для разработчиков. Камрады из команд компиляторов C#/VB проделали отличную работу по «выставлению» внутренностей компилятора наружу, что позволяет теперь писать свои собственные анализаторы кода, затрачивая вполне разумные на это силы.

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

Для чего нужны свои собственные анализаторы?

Вопрос вполне разумный. Есть «реактивные мозги», есть DevExpress, есть же мелкомягкие товарищи из DevDiv-а, которые пилят инструменты для разработчиков. Зачем мне разбираться со всякими иммутабельными синтаксическими деревьями и control flow анализом? Это довольно весело, но разве этого достаточно, чтобы тратить на это свое ценное время?

Любой инструмент общего назначения рассчитан на решение наиболее типичных задач. Достаточно открыть список анализаторов Решарпера, чтобы понять, о чем идет речь. Такие анализаторы отлично справиться с поиском недостижимого кода или с предупреждением о неправильной реализации синглтона, но он не «подскажет» о правилах специфичных для вашего проекта и/или библиотеки.

Например, вы можете захотеть реагировать более жестко на некорректное логгирование исключений (детектить и «бить по пальцам», если вашему методу логирования передается ex.Message, а не ex.ToString()), или же это может быть кастомное правило, запрещающее использовать LINQ в определенных сборках во избежание потери производительности. Если в вашей команде есть правило или набор правил, которому должны следовать все члены команды, но которое нельзя выразить в виде правил FxCop/StyleCop. Все эти задачи отлично будут решаться с помощью самописных анализаторов.

Способы распространения анализаторов

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

  • Путем установки VSIX
  • С помощью NuGet-пакетов
  • Путем явного добавления анализатора через Analyzers -> Add Analyzer

Первый способ позволит установить «глобальный» анализатор, который будет работать для всех проектов, а два последних – позволяет использовать анализаторы, специфичные для определенного проекта.

Первый способ наиболее подходит для универсальных анализаторов, аналогичных правилам Решарпера. Анализаторы же на базе NuGet-пакетов, позволяет использовать один и тот же набор правил всеми участниками команды (включая билд-сервер). Поскольку кастомные анализаторы ничем не отличаются от ошибок компиляции, то их использование на билд сервере позволит «ломать» билд, если код вдруг перестанет следовать некоторым правилам.

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

Первый анализатор. Постановка задачи

В качестве упражнения, давайте напишем следующий анализатор. Предположим, у нас есть библиотека MvvmUltraLight с единственной структурой RelayCommand:

public struct RelayCommand
{
   
private readonly Action
_action;
   
public RelayCommand(Action
action)
    {
        Contract
.Requires(action != null
);
        _action
=
action;
    }
   
public void Run()
    {
        _action();
    }
}

Структуры в C# плохо дружат с ООП, но могут помочь в плане производительности, поскольку не приводят к выделению памяти в куче. Наша задача, написать анализатор, который будет выдавать предупреждение об использовании конструктора по умолчанию для данной структуры.

clip_image002

ПРИМЕЧАНИЕ
Данный пример, как и многие синтетические примеры, несколько надуман. Но, если вам будет легче, вместо структуры RelayCommand это мог бы быть List<T>.Enumerator, или другая кастомная структура, использование конструктора по умолчанию которой не имеет смысла или приводит к ошибке времени исполнения. Так, например, следующий приводит к NullReferenceException: new List<int>.Enumerator().MoveNext().

Базовая структура анализаторов

Для создания своего анализатора достаточно создать новый проект: File -> New -> Project -> Analyzer with Code Fix (NuGet + VSIX), после чего будет создан проект анализатора (плюс NuGet-пакет), проект с тестами и проект с VSIX. «Пустой» проект сразу же будет содержать простой анализатор и фиксер (класса, который устраняет проблему, задетекченную анализатором).

Ключевые классы показаны на следующем рисунке (учитывая, что наш анализатор носит название DoNotUseDefaultCtorAnalyzer, а фиксер – UseNonDefaultCtorCodeFixProvider).

clip_image004

Каждый анализатор должен содержать идентификатор, DiagnosticDescriptor (который, в свою очередь состоит из Id, названия, форматированного сообщения и уровня сообщения – Warning/Error). А фиксер должен возвращать список анализаторов, проблемы которых он готов решить.

Анализ вызова конструктора

Начинаем разработку собственного анализатора. Для начала, нужно создать DiagnosticDescriptor с нужной информацией:

[DiagnosticAnalyzer(LanguageNames.CSharp)]
public class DoNotUseDefaultCtorAnalyzer : DiagnosticAnalyzer
{
   
public const string DiagnosticId = "DoNotUseRelayCommandDefaultCtor"
;

   
private static readonly string Title =
 
           
"Default constructor for 'RelayCommand' considered harmful"
;

   
public static readonly string MessageFormat =
 
           
"Do not use default constructor for 'RelayCommand' struct"
;

   
private const string Category = "CodeStyle"
;

   
private static readonly DiagnosticDescriptor Rule = new DiagnosticDescriptor
(
        DiagnosticId, Title, MessageFormat, Category,
       
DiagnosticSeverity.Warning, isEnabledByDefault: true);

Теперь нужно переопределить метод Initializer и зарегистрировать определенный метод обратного вызова для обработки определенных узлов синтаксического дерева (там можно зарегистрировать много чего, но об это как-нибудь в другой раз). Чтобы понять, какой же узел синтаксического дерева нас интересует в этом случае, можно воспользоваться Roslyn Syntax Tree Visualizer или же 5-й версией LINQ Pad-а, в которую эта функциональность встроена. Для поиска нужного типа узла достаточно открыть LINQ Pad и вбить в него выражение var cmd = new RelayCommand():

clip_image006

Ок, нам нужно обрабатывать ObjectCreationExpression. Регистрируем нужный обработчик и делаем первую реализацию:

public override void Initialize(AnalysisContext context)
{
    context
.
RegisterSyntaxNodeAction(ObjectCreationHandler,
       
SyntaxKind.
ObjectCreationExpression);
}

private void ObjectCreationHandler(SyntaxNodeAnalysisContext
context)
{
   
var objectCreation = (ObjectCreationExpressionSyntax)context.
Node;

   
if (IsRelayCommandType(context, objectCreation.Type) &&
        objectCreation.ArgumentList.Arguments.Count == 0
)
    {
        context
.ReportDiagnostic(Diagnostic.Create(Rule, context.Node.
GetLocation()));
    }
}

private bool IsRelayCommandType(SyntaxNodeAnalysisContext context, TypeSyntax
type)
{
   
var expectedType = typeof(RelayCommand
);
   
// Сверх наивная реализация
    return type.GetText().ToString().Contains(expectedType.Name) == true;
}

Данная реализация весьма примитивна. Мы просто проверяем, что создается экземпляр нужной нам команды, и что число аргументов конструктора равно 0. При этом проверка типа осуществляется на уровне «синтаксиса» - путем проверки, содержит ли тип создаваемого экземпляра текст “RelayCommand”. Дальше мы посмотрим, как сделать эту проверку более вменяемым образом.

Все, наш анализатор готов, и можно приступать к тестированию. Поскольку наш солюшн уже содержит проект с тестами (и класс с тестами «встроенного» анализатора), то написать тест будет довольно просто:

[TestMethod]
public void
ShouldBeWarningOnDefaultConstructor()
{
   
var test = 
@"
namespace ConsoleApplication1 {
  class TypeName {
    public static void Run() {
     var r = new MvvmUltraLight.Core.RelayCommand();
    }  
  }
}"
;
   
var expected = new DiagnosticResult
    {
        Id
= DoNotUseDefaultCtorAnalyzer.
DiagnosticId,
        Message
= DoNotUseDefaultCtorAnalyzer.
MessageFormat,
        Severity
= DiagnosticSeverity.
Warning,
        Locations
= new[] { new DiagnosticResultLocation("Test0.cs", 5, 14) }
    };

    VerifyCSharpDiagnostic(test, expected);
}

Методы VerifyCSharpDiagnostic уже находятся в созданном проекте с юнит-тестами и главная наша задача заключается в подборе правильных координат для объекта DiagnosticResultLocation.

Пишем фиксер

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

Первая задача фиксера – подсказать инфраструктуре диагностики, какие проблемы он может решить. Делается это с помощью свойства FixableDiagnosticIds:

[ExportCodeFixProvider(LanguageNames.CSharp, 
        Name
= nameof(UseNonDefaultCtorCodeFixProvider)), Shared]
public class UseNonDefaultCtorCodeFixProvider : CodeFixProvider
{
   
private const string title = "Use non-default constructor"
;

   
public sealed override ImmutableArray<string
> FixableDiagnosticIds
    {
       
get { return ImmutableArray.Create(DoNotUseDefaultCtorAnalyzer.DiagnosticId); }
    }

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

public sealed override async Task RegisterCodeFixesAsync(CodeFixContext context)
{
   
var root = await context.Document.GetSyntaxRootAsync(context.CancellationToken).ConfigureAwait(false
);

   
var diagnostic = context.Diagnostics.
First();
   
var diagnosticSpan = diagnostic.Location.
SourceSpan;

   
// Фикс доступен только для вызова конструктора.
// Для фабричных методов ничего не получится!

    var construction =
 
        root
.FindToken(diagnosticSpan.Start).
Parent
       
.AncestorsAndSelf().OfType<ObjectCreationExpressionSyntax
>()
       
.
First();

   
// Регистрируем действие, которое выполнит нужное преобразование
    var action = CodeAction.
Create(
        title: title,
        createChangedDocument: c
=> AddEmptyLambda(context.
Document, construction, c),
        equivalenceKey: title);

    context
.RegisterCodeFix(action, diagnostic);
}

Тут мы видим одну особенность: в текущей инфраструктуре диагностики не существует возможности передать данные между диагностикой и фиксером. Данный фиксер знает, что диагностика «жаловалась» на вызов конструктора, поэтому нам нужно вручную получить экземпляр ObjectCreationExpression. Это приводит к довольно жесткой связи между фиксером и анализатором, что может вызвать проблемы, когда диагностика будет выдаваться на различные узлы дерева.

Главное действие в этом методе происходит в методе AddEmptyLambda, который принимает старый документ, проблемный узел ObjectCreationExpression, и возвращает обновленный документ:

private async Task<Document> AddEmptyLambda(
   
Document document, ObjectCreationExpressionSyntax expression, CancellationToken
ct)
{
   
var arguments = SyntaxFactory.ParseArgumentList("(() => {})"
);
   
var updatedNewExpression = expression.
WithArgumentList(arguments);

   
var root = await document.
GetSyntaxRootAsync(ct);

   
return document.WithSyntaxRoot(root.ReplaceNode(expression, updatedNewExpression));
}

Поскольку синтаксическое дерево в Roslyn-е является неизменяемым, то нам нужно использовать набор методов WithXXX для создания новых элементов с замененными узлами. Еще одной задачей этого метода является создание аргументов конструктора – пустого лямбда-выражения, что делается с помощью класса SyntaxFactory.

Все, наш фиксер готов и можно приступать к тестированию:

[TestMethod]
public void
FixShouldAddLambdaToConstructor()
{
   
var code = 
@"
namespace ConsoleApplication1 {
    class TypeName {
        public static void Run() {
                var r = new MvvmUltraLight.Core.RelayCommand();
        }  
    }
}"
;
   
var fixedCode = 
@"
namespace ConsoleApplication1 {
    class TypeName {
        public static void Run() {
                var r = new MvvmUltraLight.Core.RelayCommand(() => {});
        }  
    }
}"
;

    VerifyCSharpFix(code, fixedCode);
}

Наш проект с тестами уже содержит вспомогательный метод VerifyCSharpFix, что делает тестирование фиксера достаточно простой задачей.

Семантическая информация и правильный поиск типов

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

Главное отличие синтаксических деревьев от символов (получаемых через класс SemanticModel), в том, что последние определяют «связанные символы», которые указывают на конкретные типы. Синтаксическое дерево определяет низкоуровневыми узлами, такими как имена, пробельные символы, комментарии и т.п., и не знает ничего о правилах перегрузки методов или видимости. Семантическая модель же знает, где и кем использовался именно этот метод, как найти определение вызванного метода и что за тип здесь использовался. Синтаксическое дерево может быть создано даже для некомпилируемой программы, а семантическая модель будет доступна только в случае успешной компиляции.

private bool IsRelayCommandType(SyntaxNodeAnalysisContext context, TypeSyntax type)
{
   
// Старый подход
    // return type.GetText().ToString().Contains(expectedType.Name) == true;

   
// Получаем семантическую информацию типа
    var symbolInfo = context.SemanticModel.
GetSymbolInfo(type);
   
// Получаем семантическую информацию класса RelayCommand
    var relayCommandSymbol = context.SemanticModel.Compilation.
GetTypeByMetadataName(
       
typeof(RelayCommand).
FullName);

   
return symbolInfo.Symbol?.Equals(relayCommandSymbol) == true;
}

Теперь, метод IsRelayCommandType вернет true, только при создании экземпляра структуры RelayCommand, определенной в сборке MvvmUltraLight.

Заключение

В данной заметке создан очень простой анализатора, который покрывает лишь малую толику требуемого функционала. Например, здесь не покрыты фабричные методы, или создание структуры с помощью default(RelayCommand).

Главная моя цель в том, чтобы показать сложность (точнее, относительную простоту) решения. Скачайте пример этого проекта с гитхаба и попробуйте расширить функционал. Или же подумайте, какой анализатор был бы полезен в вашем собственном проекте и попробуйте его реализовать. Вы удивитесь, насколько это проще, чем кажется!

Дополнительные ссылки

17 комментариев:

  1. Смотрел Ваш доклад 10/17/2015 с этой-же темой. Спасибо большое за информацию!

    ОтветитьУдалить
  2. Простой анализатор, занявший под сотню строк кода. Куда катится мир :)

    ОтветитьУдалить
    Ответы
    1. Без тестов и фиксирует там их полтора десятка.
      А разве раньше где-то было проще?

      Удалить
  3. Сереж, все равно не понимаю, как и где это использовать. Сценарий, я прихожу к прожекту и говорю, что нам надо допилить анализатор, который поможет нам... что? Быстрее станем писать - вряд ли. Но это ладно, продавить можно. Но как это увидеть? Это как себя внутренне воспитать надо, чтобы увидеть этот "арбитраж" и сказать, вот точно, это не увидели (VS, Resharper...) и мне и команде это поможет... С точки зрения технология и крутости - вопросов нет. У меня по-прежнему есть вопросы к практическому применению этих фич...

    ОтветитьУдалить
    Ответы
    1. Ну, смотри, первое - не всегда нужно ходить к проджектам:), чтобы спрашивать. Где-то было (кажись в каком-то фильме), что проще извиняться за что-то, что пошло не так, чем просить разрешение сделать что-то;)

      Второе, если ты делаешь непростую библиотеку или компонент повторного использования, то твои пользователи будут тебе благодарны, если с ним вместе будет идти анализатор. Здесь, я думаю, не должно быть вопросов в пользе (если они есть, готов обсудить).

      Ну и теперь последний вариант - кастомные анализаторы. Тут я готов привести некоторые примеры.

      Например, у нас в команде много всего иммутабельного, но при этом R# используется не всеми, поэтому иногда проскакивают баги, что вызывается метод "мутатор", возвращающий новый экземпляр, но при этом он никуда не присваивается (если кажется, что это нереальный кейс, то пару месяцев назад был злой баг в Ролзине, полностью аналогичный приведенному).

      Или, другой пример, у нас есть проблемы с подавлением исключений и записью ex.Message. Обычно такие вещи ловятся во время ревью, но иногда проскакивают. Подобный анализатор уже сейчас выполняет успешно эту роль.

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

      З.Ы. Я правда предлагаю лично тебе попробовать написать простой анализатор, чтобы ощущение "крутости" тебя покинуло.

      Удалить
  4. "Крутость" - здесь это возможности. Я попробую написать анализатор - какой - надо решить по Code review. Выяснить "что болит".

    ОтветитьУдалить
    Ответы
    1. Понял. Буду ждать фидбека/вопросов, с радостью помогу, если друг возникнут вопросы/предложения/пожелания...

      Удалить
  5. "Структуры в C# плохо дружат с ООП…" - Ой, ну как так!!?

    ОтветитьУдалить
    Ответы
    1. О, увидел твой развернутый ответ в твоем блоге:) Спасибо за развернутое мнение.
      Я уже работаю над ответом;)

      Удалить
  6. Сергей, а как дебажить анализаторы и фиксеры?

    ОтветитьУдалить
    Ответы
    1. С помощью тестов, или же просто запустить проект по F5, откроется еще одна версия студии, в которой можно открыть тестовый проект, запустить анализатор и брякнуться в коде.

      Удалить
    2. Сергей, а можно ли настроить анализатор на слом билда?
      Я использую код
      new DiagnosticDescriptor(DiagnosticId, Title, MessageFormat, Category, DiagnosticSeverity.Error, isEnabledByDefault: true, description: Description);
      проблемные места подсвечиваются красным, сообщение высвечивается, но Build или Rebuild проходит без проблем.

      Удалить
    3. @Alex: только что проверил на ErrorProne.NET, и если срабатывает анализатор, которвый детектит ошибку, то билд фейлится.

      А как ты добавляешь анализаторы? Попробуй перезапустить студию, что ли...
      Вообще, смысл анализаторов именно в том, чтобы они были first class citizen и ломали билд.

      Удалить
    4. "А как ты добавляешь анализаторы?"
      Взял VSIX файл после билда, дабл кликнул, далее->далее->далее.
      Если ткнуть ПКМ на проект с проблемным кодом и выбрать Analyze - ошибка от анализатора висит в Error List. Короче анализатор работает, но билд не стопит. Перезагрузка студии (и винды до кучи) не сработала.

      Удалить
    5. @Sergey: если устанавливать как NuGet пакет - билд падает.

      Удалить
    6. Хм.. странно... Ну, на билд машине он-то и должен устанавливаться как нюгет, но странно, что через vsix не падает...

      Удалить
  7. Сергей, добрый день.
    Я таки сотворил рабочий анализатор, со всем CI, все ок. При этом пересматривал видео твоего выступления https://www.youtube.com/watch?v=6_nNkagaVyY - ты был абсолютно прав, утверждая что анализаторы отлично подходят для простых вещей. Пару раз по работе сталкивался с потенциально анализируемым кодом, но каждый раз сложность зашкаливала (кастомный код, часто проскакивали исключения). Необходимо было либо учитывать очень много вариантов, либо менять структуру методов, либо работать с меняющимся кодом...

    В конце концов возникла ситуация, в которой анализатор был полезен, и при этом прост как табуретка. Опять-таки, как ты и говорил, на написание ушло полчаса.

    Плюс, заметил побочный эффект: последнее время в процессе проектирования я начинаю задумываться: как и что можно проанализировать, что положительно сказывается на результате.

    ОтветитьУдалить