Рефакторим вместе с Roslyn

в 11:57, , рубрики: .net, C#, Блог компании «Veeam Software», Программирование, Проектирование и рефакторинг

Обычно рефакторинг представляется тяжелой работой над ошибками. Монотонное исправление ошибок прошлого вручную. Но если наши действия можно свести к алгоритму преобразований над A, чтобы получить B, то почему бы не автоматизировать этот процесс?

Таких кейсов может быть очень много — инверсия зависимостей (как пример архитектурных измений), добавление аттрибутов, внедрение аспектов (пример добавления сквозной функциональности) и разнообразные компоновки кода в классы и методы, а также переход к новой версии API — в этой статье рассмотрим этот случай подробно.

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

Выбор инструмента

Veeam предоставляет своим разработчикам все инструменты, которые сам разработчик посчитает необходимыми. И у меня есть лучшее, что может пригодиться для рефакторинга — ReSharper. Но…

В 2015 у ReSharper был issue. В начале 2016 у issue RSRP-451569 сменился статус на Submitted. Также в 2016 запрос обновили.

Проверила на последнем обновлении — нужной функциональности нет, нет ни подсказок от решарпера, ни специального аттрибута в JetBrains.Annotations. Вместо того, чтобы ждать пока эта функциональность появится у ReSharper, я решила сделать эту задачу своими силами.

Первое что приходит в голову при работе над рефакторингом .NET кода — это IntelliSense. Но его API, на мой взгляд, довольно сложный и запутанный, к тому же сильно завязан на Visual Studio. Потом мне под руку попалась такая вещь как DTE (EnvDTE) — Development Tools Environment, по сути, это интерфейс ко всем возможностям студии, которые доступны через UI или командную строку, с его помощью можно автоматизировать любую последовательность действий, которую можно совершить в Visual Studio. Но DTE — штука неудобная, она постоянно требует контекст действия, т.е. эмулировать целого программиста. Тривиальные действия, вроде поиска определения метода, давались с трудом. В процессе преодоления трудностей работы с DTE, мне попалось видео доклада Александра Кугушева:

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

Так я выбрала своим инструментом .NET Compiler Platform "Roslyn".

Автоматизуем нашу задачу

Рассмотрим эту задачу на искусственном примере. Пусть мы имеем интерфейс API с устаревшими методами, маркируем их аттрибутом [Obsolete] с указанием в сообщении, на какой метод его следует заменить

public interface IAppService
{
    [Obsolete("Use DoSomethingNew")]
    void DoSomething();

    void DoSomethingNew();

    [Obsolete("Use TestNew")]
    void Test();

    void TestNew();
}

и его неразмеченную аттрибутами реализацию

public class DoService : IAppService
{
    public void DoSomething()
    {
        Console.WriteLine("This is obsolete method. Use DoSomethingNew.");
    }

    public void DoSomethingNew()
    {
        Console.WriteLine("Good.");
    }

    public void Test()
    {
        Console.WriteLine("This is obsolete method. Use TestNew.");
    }

    public void TestNew()
    {
        Console.WriteLine("Good.");
    }
}

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

    public class Test
    {
        public IAppService service
        {
            get;
            set;
        }

        public Test()
        {
            service = new DoService();
            service.DoSomething();
            service.Test();
        }
    }

А здесь мы используем уже экземпляр реализации этого интерфейса и никакого предупреждения уже не получим.

class Program
{
    static void Main(string[] args)
    {
        //no warning highlighted
        var doService = new DoService();
        doService.DoSomething();
        //will be highlighted
        //IAppService service = doService as IAppService;
        //service.DoSomething();
        doService.Test();
    }

    static void Test()
    {
        var doService = new DoService();
        doService.DoSomething();
        doService.Test();
    }
}

Исправляем ситуацию

Использование Roslyn влечет за собой использование декларативного программирования и функционального подхода, а здесь главное задаться Главной Целью и тщательно ее описать. У нас Главная Цель — заменить все устаревшие методы на их новые аналоги. Описываем.

  1. Замени устаревший метод на новый.
    Как?
  2. Найди пару устаревший-новый метод и замени устаревший метод на новый.
    Где?
  3. В классе интерфейса API найди пару устаревший-новый метод и замени устаревший метод на новый.
    Как?
  4. Найди определение метода, у которого есть атрибут [Obsolete] в классе интерфейса API и найди пару устаревший-новый метод и замени устаревший метод на новый.
    Где взять новый?
  5. В сообщении атрибута [Obsolete] найдешь имя нового метода для найденного определения метода, у которого есть атрибут [Obsolete] в классе интерфейса API, где найдешь пару устаревший-новый метод и замени устаревший метод на новый.
    Где заменить?
  6. По всем ссылкам на устаревший метод (хотя можешь сделать исключения для некоторых проектов и классов), в сообщении атрибута [Obsolete] которого найдешь имя нового метода для найденного определения метода, у которого есть атрибут [Obsolete] в классе интерфейса API, где найдешь пару устаревший-новый метод и замени устаревший метод на новый.

Алгоритм рефакторинга готов, за исключением отсутствия в нем технических моментов работы с тремя столпами .NET кода — Document, Syntax Tree, Semantic Model. Это всё сильно напоминает лямбды. В них мы и будем выражать нашу Главную Цель.

Инфраструктура
Инфраструктурой для нашего решения служат Document, Syntax Tree, Semantic Model.

public Project Project { get; set; }
public MSBuildWorkspace Workspace { get; set; }
public Solution Solution { get; set; }

public Refactorer(string solutionPath, string projectName)
{
    // start Roslyn workspace
    Workspace = MSBuildWorkspace.Create();
    // open solution we want to analyze
    Solution = Workspace.OpenSolutionAsync(solutionPath).Result;
    // find target project
    Project = Solution.Projects.FirstOrDefault(p => p.Name == projectName);
}

public void ReplaceObsoleteApiCalls(string interfaceClassName, string obsoleteMessagePattern)
{...}

недостающие данные возьмем извне, понадобится полный путь к солюшену, в котором лежит проект с API и проекты, в котором он используется — при небольшой доработке можно размещать их в разных солюшенах. А еще нужно указать имя класса интерфейса API, если ваш API будет построен на абстрактном классе или еще как-то, то посмотрите с помощью Syntax Visualizer какой тип определения этого класса.

var solutionPath = "..Sample.sln";
var projectName = "ObsoleteApi";
var interfaceClassName = "IAppService";
var refactorererApi = new Refactorer(solutionPath, projectName);
refactorererApi.ReplaceObsoleteApiCalls(interfaceClassName, "Use ");

частные элементы инфраструктуры получим в ReplaceObsoleteApiCalls

var document = GetDocument(interfaceClassName);
var model = document.GetSemanticModelAsync().Result;
SyntaxNode root = document.GetSyntaxRootAsync().Result;

Возвращаемся к алгоритму и ответим на простые вопросы в нем, начинать нужно с конца.
4. Найди определение метода, у которого есть атрибут [Obsolete] 3. В классе интерфейса API

// direction from point 3
var targetInterfaceClass =
    root.DescendantNodes().OfType<InterfaceDeclarationSyntax>()
        .FirstOrDefault(c => c.Identifier.Text == interfaceClassName);
var methodDeclarations = targetInterfaceClass.DescendantNodes().OfType<MethodDeclarationSyntax>().ToList();

var obsoleteMethods = methodDeclarations
    .Where(m => m.AttributeLists
        .FirstOrDefault(a => a.Attributes
            .FirstOrDefault(atr => (atr.Name as IdentifierNameSyntax).Identifier.Text == "Obsolete") != null) != null).ToList();

2. Найди пару устаревший-новый метод

List<ObsoleteReplacement> replacementMap = new List<ObsoleteReplacement>();
foreach (var method in obsoleteMethods)
{
    // find new mthod for replace - explain in point 5
    var methodName = GetMethodName(obsoleteMessagePattern, method);
    if (methodDeclarations.FirstOrDefault(m => m.Identifier.Text == methodName) != null)
    {
        // find all reference of obsolete call - explain in point 6
        var usingReferences = GetUsingReferences(model, method);
        replacementMap.Add(new ObsoleteReplacement() 
        { 
            ObsoleteMethod = SyntaxFactory.IdentifierName(method.Identifier.Text),
            ObsoleteReferences = usingReferences,
            NewMethod = SyntaxFactory.IdentifierName(methodName) 
        });
    }
}

1. Замени устаревший метод на новый

private void UpdateSolutionWithAction(List<ObsoleteReplacement> replacementMap, Action<DocumentEditor, ObsoleteReplacement, SyntaxNode> action)
{
    var workspace = MSBuildWorkspace.Create();
    foreach (var item in replacementMap)
    {
        var solution = workspace.OpenSolutionAsync(Solution.FilePath).Result;
        var project = solution.Projects.FirstOrDefault(p => p.Name == Project.Name);
        foreach (var reference in item.ObsoleteReferences)
        {
            var docs = reference.Locations.Select(l => l.Document);
            foreach (var doc in docs)
            {
                var document = project.Documents.FirstOrDefault(d => d.Name == doc.Name);
                var documentEditor = DocumentEditor.CreateAsync(document).Result;
                action(documentEditor, item, document.GetSyntaxRootAsync().Result);
                document = documentEditor.GetChangedDocument();
                solution = solution.WithDocumentSyntaxRoot(document.Id, document.GetSyntaxRootAsync().Result.NormalizeWhitespace());
            }
        }
        var result = workspace.TryApplyChanges(solution);
        workspace.CloseSolution();
    }
    UpdateRefactorerEnv();
}

private void ReplaceMethod(DocumentEditor documentEditor, ObsoleteReplacement item, SyntaxNode root)
{
    var identifiers = root.DescendantNodes().OfType<IdentifierNameSyntax>();
    var usingTokens = identifiers.Where(i => i.Identifier.Text == item.ObsoleteMethod.Identifier.Text);
    foreach (var oldMethod in usingTokens)
    {
        // The Most Impotant Moment Of Point 1
        documentEditor.ReplaceNode(oldMethod, item.NewMethod);
    }
}

Отвечаем на вспомогательные вопросы.
5. В сообщении атрибута [Obsolete] найдешь имя нового метода

private string GetMethodName(string obsoleteMessagePattern, MethodDeclarationSyntax method)
{
    var message = GetAttributeMessage(method);
    int index = message.LastIndexOf(obsoleteMessagePattern) + obsoleteMessagePattern.Length;
    return message.Substring(index);
}

private static string GetAttributeMessage(MethodDeclarationSyntax method)
{
    var obsoleteAttribute = method.AttributeLists.FirstOrDefault().Attributes.FirstOrDefault(atr => (atr.Name as IdentifierNameSyntax).Identifier.Text == "Obsolete");
    var messageArgument = obsoleteAttribute.ArgumentList.DescendantNodes().OfType<AttributeArgumentSyntax>()
        .FirstOrDefault(arg => arg.ChildNodes().OfType<LiteralExpressionSyntax>().Count() != 0);
    var message = messageArgument.ChildNodes().FirstOrDefault().GetText();
    return message.ToString().Trim('"');
}

6. По всем ссылкам на устаревший метод (хотя можешь сделать исключения для некоторых проектов и классов)

private IEnumerable<ReferencedSymbol> GetUsingReferences(SemanticModel model, MethodDeclarationSyntax method)
{
    var methodSymbol = model.GetDeclaredSymbol(method);
    var usingReferences = SymbolFinder.FindReferencesAsync(methodSymbol, Solution).Result.Where(r => r.Locations.Count() > 0);
    return usingReferences;
}

Уточнение об исключениях можно представить следующими фильтрами.

/// <param name="excludedClasses">Exclude method declarations that using in excluded classes in current Solution</param>
private bool ContainInClasses(IEnumerable<ReferencedSymbol> usingReferences, List<string> excludedClasses)
{
    if (excludedClasses.Count <= 0)
    {
        return false;
    }

    foreach (var reference in usingReferences)
    {
        foreach (var location in reference.Locations)
        {
            var node = location.Location.SourceTree.GetRoot().FindNode(location.Location.SourceSpan);
            ClassDeclarationSyntax classDeclaration = null;
            if (SyntaxNodeHelper.TryGetParentSyntax(node, out classDeclaration))
            {
                if (excludedClasses.Contains(classDeclaration.Identifier.Text))
                {
                    return true;
                }
            }
        }
    }
    return false;
}

/// <param name="excludedProjects">Exclude method declarations that using in excluded projects in current Solution</param>
private bool ContainInProjects(IEnumerable<ReferencedSymbol> usingReferences, List<Microsoft.CodeAnalysis.Project> excludedProjects)
{
    if (excludedProjects.Count <= 0)
    {
        return false;
    }
    foreach (var reference in usingReferences)
    {
        if (excludedProjects.FirstOrDefault(p => reference.Locations.FirstOrDefault(l => l.Document.Project.Id == p.Id) != null) != null)
        {
            return true;
        }
    }
    return false;
}

Запускаем и получаем вот такую красоту.
Рефакторим вместе с Roslyn - 1
Рефакторим вместе с Roslyn - 2

Заключение

Проект можно оформить как расширение студии vsix или, например, положить на сервер контроля версий и использовать как анализатор. А можно запускать при необходимости как тулу.

Весь проект опубликован на гитхабе.

Автор: cyberkiso

Источник


* - обязательные к заполнению поля


https://ajax.googleapis.com/ajax/libs/jquery/3.4.1/jquery.min.js