Как стать автором
Обновить
Veeam Software
Продукты для резервного копирования информации

Знакомство с .Net Compiler Platform

Время на прочтение13 мин
Количество просмотров5.6K

.Net Compiler Platform, или Roslyn - это высокоуровневый API для анализа и рефакторинга кода, написанного на  С# и VB (языках .Net). С помощью Roslyn можно как создавать независимые инструменты анализа и рефакторинга, так и писать расширения, встраиваемые в Visual Studio. При правильном подходе использование .Net Compiler Platform позволяет упростить написание кода, автоматизировать рутинные задачи разработчика и тем самым сократить время и усилия, затрачиваемые на разработку. 

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

Для начала работы с платформой нам понадобится .Net Compiler Platform SDK. Его можно установить с помощью Visual Studio Installer (выбрав Modify - Individual Components - .Net Compiler Platform SDK). 

Платформу можно разделить на четыре основных слоя:

  • Code Analysis - базовый слой, описывающий все синтаксические и семантические конструкции языка. На базе этого слоя строятся компиляторы С# и VB. Это основной слой, с которым вам придется работать при создании анализаторов и генераторов. 

  • Workspaces - представление решений, проектов и логически связанных с ними файлов исходного кода. 

  • Features - набор готовых инструментов для анализа и рефакторинга. 

  • VS Integration - интеграция Workspaces и Features в IDE. Как пример - это подсветка синтаксиса, быстрый рефакторинг, IntelliSense.

Наиболее интересны с точки зрения разработки первые два слоя - Code Analysis и Workspaces . Именно их мы и рассмотрим более детально.

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

Допустим, нам необходимо изменить файл Customer.cs, тогда работа с Workspaces строится следующим образом: 

using (var workspace = MSBuildWorkspace.Create())
{
  var solutionPath = "С:\\RoslynWebinarDemo.sln";
  var solution = await workspace.OpenSolutionAsync(solutionPath);
  var compilations = await Task.WhenAll(
    solution.Projects.Select(x => x.GetCompilationAsync()));
  
  var project = solution.Projects
    .FirstOrDefault(p => p.Name == "RoslynWebinarDemoLibrary");
  if (project == null || !project.HasDocuments)
    return;
  
  var document = project.Documents
    .FirstOrDefault(d => d.Name == "Customer.cs");
  if (document == null)
    return;
  
  var updateNode = await EditDocument(document);
  
  var updatedSolution = solution
    .WithDocumentSyntaxRoot(document.Id, updateNode);
  
  if (!workspace.TryApplyChanges(updatedSolution))
    Console.WriteLine("Something went wrong");
}

Code Analysis - это базовый слой .Net Compiler Platform. Он отвечает за работу с файлами исходного кода. Каждый файл имеет два представления: синтаксическую модель и семантическую модель. 

  • Синтаксическая модель - это дерево, узлами которого являются основные языковые конструкции. Помимо них, в дереве представлены лексемы и дополнительные текстовые элементы.

  • Семантическая модель - это модель, полученная уже после этапа компиляции кода. Она содержит информацию о конкретных типах и объектах. Наиболее точным аналогом данной модели является Reflection. 

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

//Получаем исходный документ 
Document document = documents.FirstOrDefault(); 
//Получаем корневой элемент синтаксического дерева 
SyntaxNode node = await document.GetSyntaxRootAsync(); 
//Вносим изменения в исходное дерево, на выходе получаем новый корневой элемент 
SyntaxNode newNode = MethodToUpdateSyntax(node); 
//Создаем новое решение, где исходный документ имеет новое синтаксическое дерево 
Solution newSolution = 
  solution.WithDocumentSyntaxRoot(document.Id, newNode); 
//Применяем изменения на уровне workspace 
workspace.TryApplyChanges(newSolution); 
//workspace содержит ссылку на актуальную версию решения

Синтаксическая модель

Синтаксическая модель - это собственно язык. Состоит она из трех групп элементов: языковые конструкции, лексемы и дополнительные текстовые элементы. 

Кстати, если у вас установлен .Net Compiler Platform SDK, то в Visual Studio будет встроенная поддержка отображения синтаксической модели документа. (Включить ее можно так: View - Other Windows - Syntax Visualizer). 

Разберемся с основными элементами дерева. Для этого возьмем для небольшой фрагмент простого кода и рассмотрим его синтаксическое дерево.

Узлы

Основными элементами дерева - узлами - являются классы, наследованные от СSharpSyntaxNode. Это все основные конструкции языка. К ним относятся объявления, операторы, выражения, атрибуты, блоки, условия и т.д. Именно эти классы непосредственно используются для анализа и генерации кода. На схеме они подсвечены синим цветом. 

Методы, определеные в базовом классе СSharpSyntaxNode, позволяют выполнять исключительно базовые манипуляции с узлом. 

Каждый отдельный класс, унаследованный от базового, реализует свой, специфичный для данной языковой конструкции набор функций и свойств. Как пример, можно рассмотреть класс ClassDeclarationSyntaxи свойство PropertyDeclarationSyntax.

  • ClassDeclarationSyntax является контейнером для остальных элементов, поэтому одним из наиболее частых в использовании методов будет, например, AddMember(CMemberDecalrationSyntax[] items)

  • PropertyDeclarationSyntax как раз является типом, унаследованным от CMemberDecalrationSyntax и может быть потомком ClassDeclarationSyntax. Набор методов и свойств PropertyDeclarationSyntax будет следующим: доступ к списку атрибутов, возвращаемое значение, блоки и т.п. Особенности каждого типа выходят за рамки статьи. Более подробно можно ознакомиться с ними на сайте Microsoft

Лексемы

Следующий элемент синтаксического дерева - это лексемы. На рисунке они выделены зеленым. К ним относятся ключевые слова и специальные символы. Как правило, лексемы не участвуют в анализе кода и играют больше вспомогательную роль. 

Элементы-лексемы определены типом значения SyntaxToken. Для определения конкретного типа лексемыу SyntaxToken есть методы расширения IsKind(SyntaxToken, SyntaxKind) и Kind(SyntaxToken).

SyntaxTrivia

Последним элементом синтаксического дерева является SyntaxTrivia. На схеме они выделены красным цветом. Это вся дополнительная текстовая информация, а также форматирование документа. 

Все такие элементы имеют тип значения SyntaxTrivia. Аналогично лексемам, для определения типа необходимо использовать методы IsKind(SyntaxTrivia, SyntaxKind) и Kind(SyntaxTrivia). Хоть этот тип не участвует в компиляции и практически не используется в анализе, однако он является неотъемлемой частью документа, поэтому не стоит о нем забывать (особенно при генерации кода).

Генерация кода

Рассмотрев основные элементы синтаксического дерева, можно перейти к их созданию. Для сложных составных конструкций это не всегда является простой задачей. При написании кодогенератора необходимо последовательно объявить все элементы дерева, добавить лексемы и форматирование. Для упрощении этой задачи .Net Compiler Platform предоставляет класс SyntaxFactory. Есть два основных варианта его использования. 

Вариант 1, или решение "в лоб" - это разбор текста.

string statement = 
  $"var values = new Dictionary<string, string>{{{{\"Id\", $\"{{Id.ToString()}}\" }},{{\"Name\", $\"{{Name}}\"}}}};" 
  + $"{Environment.NewLine}var content = new FormUrlEncodedContent(values);" 
  + $"{Environment.NewLine}var response = await client.PostAsync(\"http://www.example.com/recepticle.aspx\", content);" 
  + $"{Environment.NewLine}var responseString = await response.Content.ReadAsStringAsync();";

StatementSyntax statementSyntax 
	= SyntaxFactory.ParseStatement(statement);
BlockSyntax blockSyntax 
	= SyntaxFactory.Block(new[] { statementSyntax });

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

TypeSyntax voidTypeSyntax 
	= SyntaxFactory.PredefinedType(
  SyntaxFactory.Token(SyntaxKind.VoidKeyword));

MethodDeclarationSyntax methodDeclarationSyntax 
= SyntaxFactory.MethodDeclaration(voidTypeSyntax, "Save");
methodDeclarationSyntax = methodDeclarationSyntax.AddModifiers(
	new[] 
  { 
		SyntaxFactory.Token(SyntaxKind.PublicKeyword), 
    SyntaxFactory.Token(SyntaxKind.AsyncKeyword) 
    });

methodDeclarationSyntax = methodDeclarationSyntax.WithBody(blockSyntax);
ClassDeclarationSyntax updatedClassDeclarationSyntax 
	= classDeclarationSyntax.AddMembers(methodDeclarationSyntax);

На практике, приходится комбинировать два этих способа. Код который, преведенный выше, создает метод Save.

public async void Save()
{
  var values = new Dictionary<String,String>()
  {
    {"Id", Id.ToString()},
    {"Name", Name}
  }
  var content = new FormUrlEncodedContent(values);
  var response = 
    await client.PostAsync("http://www.example.com/recepticle.aspx", 
                           content);
  var responseString = await response.Content.ReadAsStringAsync();
}

Семантическая модель

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

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

Project firstProject = _solution.Projects.FirstOrDefault(); 
//выполняем компиляцию проекта 
Compilation compilation =
  await firstProject.GetCompilationAsync())); 
//Получаем семантические модели 
IEnumarable<SemanticModel> sematicModels = compilation.SyntaxTrees
	.Select(syntaxTree => compilation.GetSemanticModel(syntaxTree));

Все объекты модели реализуют базовый интерфейс ISymbol. Он предоставляет методы и свойства для получения всей основной информации об объекте, в том числе о сборке, содержащей тип, о том, является ли объект статическими, виртуальным или перегруженным, какие он содержит атрибуты. 

Как и в случае с синтаксической моделью, от интерфейса *ISymbol *унаследовано довольно много производных интерфейсов, описывающих специфические объекты модели - такие, например как INameTypedSymbol, IPropertySymbol, ITypeSymbol и т.д. Для того, чтобы понимать, с каким именно типом мы работаем, интерфейс ISymbol содержит свойство SymbolKind Kind { get; }. 

Описывать всех потомков ISymbol не имеет смысла, однако один интерфейс заслуживает отдельного внимания. Это ITypeSymbol - интерфейс, с которым довольно часто приходится работать при разработке анализаторов. Данный интерфейс позволяет получить всю необходимую информацию о типе объекта, в частности: какие интерфейсы реализует данный тип, является ли он значением или ссылочным типом либо базовым типом, и т.д. 

Рефакторинг

В предыдущих разделах мы рассмотрели основные концепции анализа и кодогенерации, а также два базовых слоя .Net Compiler Platform. Теперь перейдем к практическому применению платформы и рассмотрим слой VS Integration, а именно - разберем, как можно создать простой инструмент быстрого рефакторинга, интегрированный в IDE. 

Если вы установили .Net Compiler Platform SDK, то в диалоге создания нового проекта у вас есть несколько шаблонов проектов Roslyn. Мы возьмем шаблон Code Refactoring. Он создает простое расширение для рефакторинга, которое будет доступно в IDE, в контекстном меню Quick Actions and Refactoring. При создании нового решения из шаблона будет создано два проекта: первый - это собственно сам анализатор и инструмент рефакторинга, а второй - с постфиксом vsix - это стандартный контейнер для расширений Visual Studio. Он содержит всю метаинформацию будущего расширения: наименование, версию, описание итп. 

Базовым классом для всех провайдеров является абстрактный класс CodeRefactoringProvider, который содержит единственный метод async Task ComputeRefactoringsAsync(CodeRefactoringContext context). Это асинхронный метод, который принимает структуру CodeRefactoringContext. Данная структура содержит минимальный набор данных: ссылку на открытый документ, а также Span, отступ от начала документа, что по сути определяет позицию курсора. Это та минимальная информация, которая нужна, чтобы найти конкретный узел синтаксического дерева. 

Для того, чтобы провайдер был виден для IDE, класс помечен атрибутом ExportCodeRefactoringProvider.

[ExportCodeRefactoringProvider(LanguageNames.CSharp, Name = 
nameof(CodeRemotingReadyInterfaceCodeRefactoringProvider)), Shared] 
internal class CodeRemotingReadyInterfaceCodeRefactoringProvider 
  : CodeRefactoringProvider 
{
	public sealed override async Task ComputeRefactoringsAsync
	(CodeRefactoringContext context) {}
}

Ответственность метода ComputeRefactoringsAsync - нахождение конкретного узла в документе и регистрация на нем действия CodeAction. Поиск требуемых узлов или других элементов - это работа с синтаксическим деревом, о которой мы говорили ранее. 

Вот простой пример поиска метода интерфейса и регистрация на нем действия My action.

var root = await context.Document
  	.GetSyntaxRootAsync(context.CancellationToken)
    	.ConfigureAwait(false); 
      
var node = root.FindNode(context.Span); 
if (node is MemberDeclarationSyntax 
			&& node.Parent is InterfaceDeclarationSyntax) 
{ 
	var action = CodeAction.Create("My action", c => 
  	DoMyRefacting(context.Document, node, c));  
  
  context.RegisterRefactoring(action); 
} 

CodeAction - абстрактный класс, описывающий действия, выполняемые CodeRefactoringProvider. Для создания экземпляра есть два статических метода:

public static CodeAction Create(string title, 
                                Func<CancellationToken, 
                                Task<Document>> createChangedDocument, 
                                string? equivalenceKey = null)

Этот метод рекомендуется использовать, когда изменения касаются одного документа. Он создает экземпляр класса DocumentChangeAction.

public static CodeAction Create(string title, 
                                Func<CancellationToken, 
                                Task<Solution>> createChangedSolution, 
                                string? equivalenceKey = null)

Этот метод создает экземпляр SolutionChangeAction. Из параметров видно, что он используется, когда рефакторинг затрагивает более одного документа. Кроме методов, выполняющих непосредственно изменения кода, CodeAction также содержит методы, вызываемые для построения предпросмотра.

В случае с рефакторингом в рамках одного документа это может быть оправдано. Но если требуется изменять несколько файлов в проекте (и учитывая, что платформа Roslyn довольно ресурсоемкая), построение превью может быть не всегда нужно. Предпросмотр будет строиться долго, так как по факту производятся изменения кода в памяти, и это может сильно замедлять работу IDE. Стандартного решения "из коробки" для таких сценариев не предусмотрено. Рекомендуется в этом случае реализовать свой класс для реализации CodeAction и выполнить перегрузку Task<IEnumerable> ComputePreviewOperationsAsync(CancellationToken cancellationToken), как сделано в этом примере: 

internal class NonPreviewCodeAction: CodeAction 
{ 
	private readonly Func<CancellationToken, Task<Solution>> 
 			_createChangedSolution;
      
	public override string Title { get; } 
  public override string EquivalenceKey { get; } 
  
  public NonPreviewCodeAction (	
      						string title,
      						Func<CancellationToken,
      						Task<Solution>> createChangedSolution,
      						string equivalenceKey = null)
	{ 
                  	Title = title; 
                    EquivalenceKey = equivalenceKey; 
                    _createChangedSolution = createChangedSolution; 
	} 
                  
	protected override Task<IEnumerable<CodeActionOperation>> 
			ComputePreviewOperationsAsync(CancellationToken cancellationToken) 
	{ 
 				//не выполняем никаких действий для построения предпросмотра 
 				return Task.FromResult(Enumerable.Empty<CodeActionOperation>()); 
	}
      
	protected override Task<Solution> GetChangedSolutionAsync( 
 																		CancellationToken cancellationToken) 
	{ 
 				return _createChangedSolution(cancellationToken); 
	} 
 }

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

Подведем итог. Для создания простого собственного расширения рефакторинга необходимо: 1. Создать проект, выполяющий рефакторинг, и проект расширения vsix. 

2. Реализовать свой CodeRefactoringProvider.

3. С помощью синтаксического дерева найти требуемые элементы. 

4. Определить метод, выполняющий рефакторинг. 

5. Создать CodeAction, использующий метод. 

6. Собрать проекты. 

7. Установить расширение VSIX.

Опыт использования

Понимание преимуществ средств анализа, кодогенерации и мета-программирования приходит обычно в тот момент, когда проект становится очень большим и стоимость архитектурных изменений стремительно растет. Может возникнуть такая ситуация: "Если мы хотим реализовать изменения А, то нам необходимо остановить разработку нового функционал В и С, так как для рефакторинга А требуется больше человеко-часов". Конечно, это скорее форс-мажорная ситуация, но она возможна и ее нужно решать.  Приведу пример из практики. 

Есть группа старых интерфейсов, которые широко используются в продукте. Задача - перевести сетевые взаимодействия через эти интерфейсы на новые рельсы. Кажется, что задача довольно простая, если бы не тот факт, что этих интерфейсов 400 - и в каждом из них среднем по 10-15 методов. Итого получается, что нам надо переписать около 4500 методов. Также не забываем, что нам надо выполнить изменения в моделях, с которыми работают эти методы. Если прикинуть, что на рефакторинг одного интерфейса уйдет в среднем один человеко-день, то получается, что нам понадобится больше 1 года. Допустим, если взять команду из 10 человек, то они справятся примерно за 40 дней. Округлив эти расчеты и учитывая форс-мажоры и прочие факторы, мы получим около 80 дней. Выглядит как довольно дорогое решение. 

Данную задачу можно упростить, используя кодогенерацию. Сценарий примерно такой: мы анализируем интерфейсы и типы, на основании шаблона генерируем новые серверные и клиентские классы. Проходим по всем типам, которые используются в интерфейсах, и расставляем требуемые атрибуты. Теперь наша исходная задача звучит уже так: создать инструмент, который решит первоначальную задачу. И согласитесь, для инженера это намного приятнее чем, потратить 2 месяца на машинальное переписывание кода. Теперь самое интересно - сколько на это было реально потрачено времени: 

1. Анализ исходного кода - 2 ч/д;

2. Написание автоматического анализатора - 7 ч/д;

3. Написание кодогенератора - 7 ч/д;

4. Написание юнит-тестов - 7 ч/д;

5. Кодогенерация и коммит изменений, который потребовал ручного мерджа - 1ч/д;

6. Стабилизация продукта - 7 ч/д.

Итого на реализацию задачи, которая требовала изначально 400 ч/д, ушло 31 ч/д. Другими словами, применение кодогенерации обошлось в 10 раз дешевле, чем машинальное переписывание.

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

Еще один интересный пример применения анализа. 

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

Если решать эту задачу в лоб, то нам надо открыть IDE и начать искать использование каждого метода, поднимаясь вверх по дереву вызовов. Хорошо, если в интерфейсе 10 методов. А если вызовов больше, и интерфейс не один? 

Эту задачу тоже можно решить с помощью .Net Compiler Platform. Ведь

ISymbol.FindReferencesAsync(ISymbol symbol, 
                            Solution solution, 
                            CancellationToken cancellationToken 
                            					= default(CancellationToken))

возвращает перечисление всех ссылок для заданного ISymbol

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

Конечно, в процессе реализации выяснилось, что это совсем нетривиально, так как могут возникать некоторые сценарии, которые пришлось отдельно обрабатывать (например, рекурсия). А еще на большом решении наш анализатор работал около 30 часов.

Заключение

Определенно, .Net Compiler Platform - инструмент, заслуживающий внимания. Довольно большая часть повседневных задач разработчиков может бы решена (или сильно упрощена) с использованием инструментов, предоставляемых платформой. Наш опыт тому подтверждением. Могу сказать, что внедрение средств автоматического анализа и кодогенерации в процесс разработки уменьшает стоимость разработки и минимизирует количество ошибок. Благодаря этому время, потраченное на разработку и внедрение инструментов на базе Roslyn, полностью окупается.

17 февраля 2022, 19:00 (МСК) пройдёт онлайн встреча SpbDotNet, на которой я буду рассказывать о том, как с помошью Roslyn, мы сэкономили более 3000 человеко-часов. Приходите послушать и задать свои вопросы.

Регистрация по ссылке

Теги:
Хабы:
+9
Комментарии8

Публикации

Информация

Сайт
veeam.com
Дата регистрации
Дата основания
Численность
1 001–5 000 человек
Местоположение
Швейцария

Истории