Боремся с дубликатами с помощью Roslyn API

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

Совершенно очевидно, что может быть всего один атрибут с определённым названием. И учитывая это, нам нужно придумать что-то, что поможет избежать ошибок. Один из вариантов – сделать автоматический анализатор кода, который будет проверять наш проект на наличие команд с одним и тем же именем.

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

Если навести на ошибку, то мы увидим текст о том, что команда с таким именем уже есть. И это действительно так. Пососедству я сделал класс и пометил его командой с таким же именем.

Начинать разработку своего анализатора нужно с запуска установщика Visual Studio 2017 и доустановки следующих компонентов:

  • Разработка расширений VisualStudio:
    • SDK-пакет для .Net Compiler Platform;
  • Кроссплатформенная разработка .Net Core;

После этого, при создании проекта вам будет доступен шаблон “Analyzer With Code Fix”. При его создании, вы получите исчерпывающий шаблон вместе с Unit тестами.

С помощью предоставленного нами API, мы можем создавать проверки, которые могут содержать исправления кода. Т.е если мы нашли ошибку, то мы можем предложить автоматическое исправление. В нашем случае, я отказался от этого и мы ограничились только сообщением об ошибке.

После того, как анализатор готов, вы можете его скомпилировать и добавить его в разделе “Ссылки” вашего проекта, далее он начнёт работать.

Листинг WendingMachine/Commands/Commands/CommandsAnalyzer.cs

using System;
using System.Collections.Generic;
using System.Collections.Immutable;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.CodeAnalysis;
using Microsoft.CodeAnalysis.CSharp;
using Microsoft.CodeAnalysis.CSharp.Syntax;
using Microsoft.CodeAnalysis.Diagnostics;

namespace Commands
{
    [DiagnosticAnalyzer(LanguageNames.CSharp)]
    public class CommandsAnalyzer : DiagnosticAnalyzer
    {
        public const string DiagnosticId = "CommandDublicate";

        private static readonly LocalizableString Title = "Command Dublicate";
        private static readonly LocalizableString MessageFormat = "Есть команда с таким же названием";
        private static readonly LocalizableString Description = "В проекте есть команда с таким же названием";
        private const string Category = "Error";

        private static DiagnosticDescriptor Rule = new DiagnosticDescriptor(DiagnosticId, Title, MessageFormat, Category, DiagnosticSeverity.Error, isEnabledByDefault: true, description: Description);

        public override ImmutableArray<DiagnosticDescriptor> SupportedDiagnostics { get { return ImmutableArray.Create(Rule); } }
        public static readonly string AttributeName = "CommandAttribute";

        public override void Initialize(AnalysisContext context)
        {
            context.RegisterSymbolAction(AnalyzeSymbol, SymbolKind.NamedType);
        }

        private static void AnalyzeSymbol(SymbolAnalysisContext context)
        {
            var namedTypeSymbol = (INamedTypeSymbol)context.Symbol;
            var commands = new GetAllCommandsVisitor(namedTypeSymbol);
            commands.Visit(context.Compilation.GlobalNamespace);

            var declaredAttributes = namedTypeSymbol.GetAttributes();
            foreach (var attribute in declaredAttributes)
            {
                if (attribute.AttributeClass.Name == AttributeName)
                {
                    if (commands.Commands.Contains(attribute.ConstructorArguments[0].ToCSharpString()))
                    {
                        var diagnostic = Diagnostic.Create(Rule, namedTypeSymbol.Locations[0], namedTypeSymbol.Name);

                        context.ReportDiagnostic(diagnostic);
                    }
                }
            }
        }
    }

    public class GetAllCommandsVisitor : SymbolVisitor
    {
        public List<string> Commands = new List<string>();
        public INamedTypeSymbol Ignored;

        public GetAllCommandsVisitor(INamedTypeSymbol ignored)
        {
            Ignored = ignored;
        }

        public override void VisitNamespace(INamespaceSymbol symbol)
        {
            Parallel.ForEach(symbol.GetMembers(), s => s.Accept(this));
        }

        public override void VisitNamedType(INamedTypeSymbol symbol)
        {
            if (symbol != Ignored)
            {
                foreach (var attribute in symbol.GetAttributes())
                {
                    if (attribute.AttributeClass.Name == CommandsAnalyzer.AttributeName)
                    {
                        Commands.Add(attribute.ConstructorArguments[0].ToCSharpString());
                    }
                }
            }
        }
    }
}

На первый взгляд, листинг довольно сложный. Но на самом деле это конечно же не так. Мы просто имеем некий код, который выполняется для каждого класса (фактически для каждого именованного типа) и некоторую обвязку, для его выполнения.

В корне у нас лежит этот метод.

Фрагмент 2.45

public override void Initialize(AnalysisContext context)
{
    context.RegisterSymbolAction(AnalyzeSymbol, SymbolKind.NamedType);
}

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

Фрагмент 2.46

private static void AnalyzeSymbol(SymbolAnalysisContext context)
{
    var namedTypeSymbol = (INamedTypeSymbol)context.Symbol;
    var commands = new GetAllCommandsVisitor(namedTypeSymbol);
    commands.Visit(context.Compilation.GlobalNamespace);

    var declaredAttributes = namedTypeSymbol.GetAttributes();
    foreach (var attribute in declaredAttributes)
    {
        if (attribute.AttributeClass.Name == AttributeName)
        {
            if (commands.Commands.Contains(attribute.ConstructorArguments[0].ToCSharpString()))
            {
                var diagnostic = Diagnostic.Create(Rule, namedTypeSymbol.Locations[0], namedTypeSymbol.Name);

                context.ReportDiagnostic(diagnostic);
            }
        }
    }
}

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

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

Код нашего визитера и вообще схема взаимодействия с деревом сущностей может оказаться вам интересной. Это хороший пример использования паттерна Visitor.

Фрагмент 2.47

public class GetAllCommandsVisitor : SymbolVisitor
{
    public List<string> Commands = new List<string>();
    public INamedTypeSymbol Ignored;

    public GetAllCommandsVisitor(INamedTypeSymbol ignored)
    {
        Ignored = ignored;
    }

    public override void VisitNamespace(INamespaceSymbol symbol)
    {
        Parallel.ForEach(symbol.GetMembers(), s => s.Accept(this));
    }

    public override void VisitNamedType(INamedTypeSymbol symbol)
    {
        if (symbol != Ignored)
        {
            foreach (var attribute in symbol.GetAttributes())
            {
                if (attribute.AttributeClass.Name == CommandsAnalyzer.AttributeName)
                {
                    Commands.Add(attribute.ConstructorArguments[0].ToCSharpString());
                }
            }
        }
    }
}

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

Если вы нашли ошибку, пожалуйста выделите её и нажмите Ctrl+Enter.


Leave a Reply

Your email address will not be published. Required fields are marked *