Время от времени, когда я читал о Roslyn и его анализаторах, у меня постоянно возникала мысль: "А ведь этой штукой можно сделать nuget, который будет ходить по коду и делать кодогенерацию". Быстрый поиск не показал ничего интересного, по этому было принято решение копать. Как же я был приятно удивлен, когда обнаружил что моя затея не только реализуемая, но все это будет работать почти без костылей.
И так кому интересно посмотреть на то как можно сделать "маленькую рефлексию" и запаковать ее в nuget прошу под кат.
Введение
Думаю, первое что стоить уточнить это то, что понимается под "маленькой рефлексией". Я предлагаю реализовать для всех типов метод Dictionary<string, Type> GetBakedType(), который будет возвращать имена пропертей и их типы. Поскольку это должно работать со всеми типами, то самым простым вариантом будет генерация метода расширения(extention method) для каждого типа. Ручная его реализация будет иметь следующий вид:
using System; using System.Collections.Generic; public static class testSimpleReflectionUserExtentions { private static Dictionary<string, Type> properties = new Dictionary<string, Type> { { "Id", typeof(System.Guid)}, { "FirstName", typeof(string)}, { "LastName", typeof(string)}, }; public static Dictionary<string, Type> GetBakedType(this global::testSimpleReflection.User value) { return properties; } }
Ничего сверхъестественного здесь нет, но реализовывать его для всех типов это муторное и не интересное дело которое, кроме того, грозит опечатками. Почему бы нам не попросить компилятор помочь. Вот тут на арену выходит Roslyn с его анализаторами. Они предоставляют возможность проанализировать код и изменить его. Так давайте же научим компилятор новому трюку. Пускай он ходит по коду и смотрит где мы используем, но еще не реализовали наш GetBakedType и реализовывает его.
Чтобы "включить" данный функционал нам нужно будет лишь установить один nuget пакет и все заработает сразу же. Далее просто вызываем GetBakedType там где нужно, получаем ошибку компиляции которая говорит что рефлексия для данного типа еще не готова, вызываем codefix и все готово. У нас есть метод расширения который вернет нам все публичные проперти.
Думаю в движении будет проще понять как оно вообще работает, вот короткая визуализация:
Кому интересно попробовать это локально, можно установить nuget пакет под именем SimpleReflection:
Install-Package SimpleReflection
Кому интересны исходники, они лежат тут.
Хочу предупредить данная реализация не рассчитана на реальное применение. Я лишь хочу показать способ для организации кодогенерации при помощи Roslyn.
Предварительная подготовка
Перед тем как начать делать свои анализаторы необходимо установить компонент 'Visual Studio extention development' в студийном Installer-е. Для VS 2019 нужно не забыть выбрать ".NET Compiler Platform SDK" как опциональный компонент.
Реализация анализатора
Я не буду поэтапно описывать как реализовать анализатор, поскольку он ну очень прост, а лишь пройдусь по ключевым моментам.
И первым ключевым моментом станет то, что если у нас есть настоящая ошибка компиляции, то анализаторы не запускаются вовсе. Как результат, если мы попытаемся вызвать наш GetBakedType() в контексте типа для которого он не реализован, то получим ошибку компиляции и все наши старания не будут иметь смысла. Но тут нам поможет знание о том с каким приоритетом компилятор вызывает методы расширения. Весь сок в том, что конкретные реализации имеют больший приоритет чем универсальные методы(generic method). То есть в следующем примере будет вызван второй метод, а не первый:
public static class SomeExtentions { public static void Save<T>(this T value) { ... } public static void Save(this User user) { ... } } public class Program { public static void Main(string[] args) { var user = new User(); user.Save(); } }
Данная особенность очень кстати. Мы просто определим универсальный GetBakedType следующим образом:
using System; using System.Collections.Generic; public static class StubExtention { public static Dictionary<string, Type> GetBakedType<TValue>(this TValue value) { return new Dictionary<string, Type>(); } }
Это позволит нам избежать ошибки компиляции в самом начале и сгенерировать нашу собственную "ошибку" компиляции.
Рассмотрим сам анализатор. Он будет предлагать две диагностики. Первая отвечает за случай когда кодогенерация вообще не запускалась, а вторая тогда когда нам нужно обновить уже существующий код. Они будут иметь следующие названия SimpleReflectionIsNotReady и SimpleReflectionUpdate соответственно. Первая диагностика будет генерировать "ошибку" компиляции, а вторая лишь сообщать о том что здесь можно запустить кодогенерацию повторно.
Описание диагностик имеет следующий вид:
public const string SimpleReflectionIsNotReady = "SimpleReflectionIsNotReady"; public const string SimpleReflectionUpdate = "SimpleReflectionUpdate"; public static DiagnosticDescriptor SimpleReflectionIsNotReadyDescriptor = new DiagnosticDescriptor( SimpleReflectionIsNotReady, "Simple reflection is not ready.", "Simple reflection is not ready.", "Codegen", DiagnosticSeverity.Error, isEnabledByDefault: true, "Simple reflection is not ready."); public static DiagnosticDescriptor SimpleReflectionUpdateDescriptor = new DiagnosticDescriptor( SimpleReflectionUpdate, "Simple reflection update.", "Simple reflection update.", "Codegen", DiagnosticSeverity.Info, isEnabledByDefault: true, "Simple reflection update.");
Далее необходимо определится что мы вообще будем искать, в данном случаи это будет вызов метода:
public override void Initialize(AnalysisContext context) { context.RegisterOperationAction(this.HandelBuilder, OperationKind.Invocation); }
Потом уже в HandelBuilder идет анализ синтаксического дерева. На вход мы будем получать все вызовы которые были найдены, поэтому необходимо отсеять все кроме нашего GetBakedType. Сделать это можно обычным if в котором мы проверим имя метода. Дальше достаем тип переменной над которой вызывается наш метод и сообщаем компилятору о результатах нашего анализа. Это может быть ошибка компиляции, если кодогенерация пока не запускалась или возможность ее перезапустить.
Все это выглядит следующим образом:
private void HandelBuilder(OperationAnalysisContext context) { if (context.Operation.Syntax is InvocationExpressionSyntax invocation && invocation.Expression is MemberAccessExpressionSyntax memberAccess && memberAccess.Name is IdentifierNameSyntax methodName && methodName.Identifier.ValueText == "GetBakedType") { var semanticModel = context.Compilation .GetSemanticModel(invocation.SyntaxTree); var typeInfo = semanticModel .GetSpeculativeTypeInfo(memberAccess.Expression.SpanStart, memberAccess.Expression, SpeculativeBindingOption.BindAsExpression); var diagnosticProperties = ImmutableDictionary<string, string>.Empty.Add("type", typeInfo.Type.ToDisplayString()); if (context.Compilation.GetTypeByMetadataName(typeInfo.Type.GetSimpleReflectionExtentionTypeName()) is INamedTypeSymbol extention) { var updateDiagnostic = Diagnostic.Create(SimpleReflectionUpdateDescriptor, methodName.GetLocation(), diagnosticProperties); context.ReportDiagnostic(updateDiagnostic); return; } var diagnostic = Diagnostic.Create(SimpleReflectionIsNotReadyDescriptor, methodName.GetLocation(), diagnosticProperties); context.ReportDiagnostic(diagnostic); } }
[DiagnosticAnalyzer(LanguageNames.CSharp)] public class SimpleReflectionAnalyzer : DiagnosticAnalyzer { public const string SimpleReflectionIsNotReady = "SimpleReflectionIsNotReady"; public const string SimpleReflectionUpdate = "SimpleReflectionUpdate"; public static DiagnosticDescriptor SimpleReflectionIsNotReadyDescriptor = new DiagnosticDescriptor( SimpleReflectionIsNotReady, "Simple reflection is not ready.", "Simple reflection is not ready.", "Codegen", DiagnosticSeverity.Error, isEnabledByDefault: true, "Simple reflection is not ready."); public static DiagnosticDescriptor SimpleReflectionUpdateDescriptor = new DiagnosticDescriptor( SimpleReflectionUpdate, "Simple reflection update.", "Simple reflection update.", "Codegen", DiagnosticSeverity.Info, isEnabledByDefault: true, "Simple reflection update."); public override ImmutableArray<DiagnosticDescriptor> SupportedDiagnostics => ImmutableArray.Create(SimpleReflectionIsNotReadyDescriptor, SimpleReflectionUpdateDescriptor); public override void Initialize(AnalysisContext context) { context.RegisterOperationAction(this.HandelBuilder, OperationKind.Invocation); } private void HandelBuilder(OperationAnalysisContext context) { if (context.Operation.Syntax is InvocationExpressionSyntax invocation && invocation.Expression is MemberAccessExpressionSyntax memberAccess && memberAccess.Name is IdentifierNameSyntax methodName && methodName.Identifier.ValueText == "GetBakedType" ) { var semanticModel = context.Compilation .GetSemanticModel(invocation.SyntaxTree); var typeInfo = semanticModel .GetSpeculativeTypeInfo(memberAccess.Expression.SpanStart, memberAccess.Expression, SpeculativeBindingOption.BindAsExpression); var diagnosticProperties = ImmutableDictionary<string, string>.Empty.Add("type", typeInfo.Type.ToDisplayString()); if (context.Compilation.GetTypeByMetadataName(typeInfo.Type.GetSimpleReflectionExtentionTypeName()) is INamedTypeSymbol extention) { var updateDiagnostic = Diagnostic.Create(SimpleReflectionUpdateDescriptor, methodName.GetLocation(), diagnosticProperties); context.ReportDiagnostic(updateDiagnostic); return; } var diagnostic = Diagnostic.Create(SimpleReflectionIsNotReadyDescriptor, methodName.GetLocation(), diagnosticProperties); context.ReportDiagnostic(diagnostic); } } }
Реализация кодогенератора
Кодогенерацию мы будем делать через CodeFixProvider, который подписан на наш анализатор. В первую очередь нам необходимо проверить что получилось найти нашему анализатору.
Выглядит это следующим образом:
public sealed override async Task RegisterCodeFixesAsync(CodeFixContext context) { var diagnostic = context.Diagnostics.First(); var title = diagnostic.Severity == DiagnosticSeverity.Error ? "Generate simple reflection" : "Recreate simple reflection"; context.RegisterCodeFix( CodeAction.Create( title, createChangedDocument: token => this.CreateFormatterAsync(context, diagnostic, token), equivalenceKey: title), diagnostic); }
Вся магия происходит внутри CreateFormatterAsync. В нем мы достаем полное описание типа. После чего стартуем кодогенерацию и добвляем новый файл в проект.
Получение информации и добаление файла:
private async Task<Document> CreateFormatterAsync(CodeFixContext context, Diagnostic diagnostic, CancellationToken token) { var typeName = diagnostic.Properties["type"]; var currentDocument = context.Document; var model = await context.Document.GetSemanticModelAsync(token); var symbol = model.Compilation.GetTypeByMetadataName(typeName); var rawSource = this.BuildSimpleReflection(symbol); var source = Formatter.Format(SyntaxFactory.ParseSyntaxTree(rawSource).GetRoot(), new AdhocWorkspace()).ToFullString(); var fileName = $"{symbol.GetSimpleReflectionExtentionTypeName()}.cs"; if (context.Document.Project.Documents.FirstOrDefault(o => o.Name == fileName) is Document document) { return document.WithText(SourceText.From(source)); } var folders = new[] { "SimpeReflection" }; return currentDocument.Project .AddDocument(fileName, source) .WithFolders(folders); }
Сообствено кодогенерация(подозреаю что хабр сломает всю подвсетку):
private string BuildSimpleReflection(INamedTypeSymbol symbol) => $@" using System; using System.Collections.Generic; // Simple reflection for {symbol.ToDisplayString()} public static class {symbol.GetSimpleReflectionExtentionTypeName()} {{ private static Dictionary<string, Type> properties = new Dictionary<string, Type> {{ { symbol .GetAllMembers() .OfType<IPropertySymbol>() .Where(o => (o.DeclaredAccessibility & Accessibility.Public) > 0) .Select(o => $@" {{ ""{o.Name}"", typeof({o.Type.ToDisplayString()})}},") .JoinWithNewLine() } }}; public static Dictionary<string, Type> GetBakedType(this global::{symbol.ToDisplayString()} value) {{ return properties; }} }} "; }
using Microsoft.CodeAnalysis; using Microsoft.CodeAnalysis.CodeActions; using Microsoft.CodeAnalysis.CodeFixes; using Microsoft.CodeAnalysis.CSharp; using Microsoft.CodeAnalysis.Formatting; using Microsoft.CodeAnalysis.Text; using SimpleReflection.Utils; using System; using System.Collections.Generic; using System.Collections.Immutable; using System.Composition; using System.IO; using System.Linq; using System.Text; using System.Threading; using System.Threading.Tasks; namespace SimpleReflection { [ExportCodeFixProvider(LanguageNames.CSharp, Name = nameof(SimpleReflectionCodeFixProvider)), Shared] public class SimpleReflectionCodeFixProvider : CodeFixProvider { public sealed override ImmutableArray<string> FixableDiagnosticIds => ImmutableArray.Create(SimpleReflectionAnalyzer.SimpleReflectionIsNotReady, SimpleReflectionAnalyzer.SimpleReflectionUpdate); public sealed override FixAllProvider GetFixAllProvider() { return WellKnownFixAllProviders.BatchFixer; } public sealed override async Task RegisterCodeFixesAsync(CodeFixContext context) { var diagnostic = context.Diagnostics.First(); var title = diagnostic.Severity == DiagnosticSeverity.Error ? "Generate simple reflection" : "Recreate simple reflection"; context.RegisterCodeFix( CodeAction.Create( title, createChangedDocument: token => this.CreateFormatterAsync(context, diagnostic, token), equivalenceKey: title), diagnostic); } private async Task<Document> CreateFormatterAsync(CodeFixContext context, Diagnostic diagnostic, CancellationToken token) { var typeName = diagnostic.Properties["type"]; var currentDocument = context.Document; var model = await context.Document.GetSemanticModelAsync(token); var symbol = model.Compilation.GetTypeByMetadataName(typeName); var symbolName = symbol.ToDisplayString(); var rawSource = this.BuildSimpleReflection(symbol); var source = Formatter.Format(SyntaxFactory.ParseSyntaxTree(rawSource).GetRoot(), new AdhocWorkspace()).ToFullString(); var fileName = $"{symbol.GetSimpleReflectionExtentionTypeName()}.cs"; if (context.Document.Project.Documents.FirstOrDefault(o => o.Name == fileName) is Document document) { return document.WithText(SourceText.From(source)); } var folders = new[] { "SimpeReflection" }; return currentDocument.Project .AddDocument(fileName, source) .WithFolders(folders); } private string BuildSimpleReflection(INamedTypeSymbol symbol) => $@" using System; using System.Collections.Generic; // Simple reflection for {symbol.ToDisplayString()} public static class {symbol.GetSimpleReflectionExtentionTypeName()} {{ private static Dictionary<string, Type> properties = new Dictionary<string, Type> {{ { symbol .GetAllMembers() .OfType<IPropertySymbol>() .Where(o => (o.DeclaredAccessibility & Accessibility.Public) > 0) .Select(o => $@" {{ ""{o.Name}"", typeof({o.Type.ToDisplayString()})}},") .JoinWithNewLine() } }}; public static Dictionary<string, Type> GetBakedType(this global::{symbol.ToDisplayString()} value) {{ return properties; }} }} "; } }
Итоги
В результате у нас получился Roslyn анализатор-кодогенератор при помощи которого реализовывается "маленькая" рефлексия с использованием кодогенерации. Будет сложно придумать реальное применение текущей библиотеке, но она будет прекрасным примером для реализации легко доступных кодогенераторов. Данный подход может быть, как и любая кодогенерация, полезен для написания сериализаторов. Моя тестовая реализация MessagePack-а работала на ~20% быстрее чем neuecc/MessagePack-CSharp, а более быстрого сериализатора я пока не видал. Кроме того данный подход не требует Roslyn.Emit, что прекрасно подходит для Unity и AOT сценариях.
