Недавно, когда я просматривал новые возможности, которые будут включены в .Net 5, я натолкнулся на одну весьма интересную — генераторы исходного кода. Этот функционал меня особенно заинтересовал, так как я использую аналогичный подход в течение последних… 5 лет, и то, что предлагает Microsoft — это просто более глубокая интеграция этого подхода в процесс сборки проектов.
Примечание: Оригинал был написан в момент, когда релиз .Net 5 только-только собирался выйти, но актуальности этот текст, на мой взгляд, не потерял, поскольку переход на новую версию платформы занимает какое-то время, да и принципы работы с Roslyn никак не поменялись.
Далее я поделюсь своим опытом использования Roslyn при генерации кода, и надеюсь, что это поможет вам лучше понять, что именно предлагает Microsoft в .Net 5 и в каких случаях это можно использовать.
Для начала, давайте рассмотрим типичный сценарий генерации исходного кода. У вас есть некий внешний источник информации например такой как база данных или JSON описание какого-нибудь REST сервиса или другая .Net сборка (через рефлексию) или что-нибудь еще, и с помощью этой информации вы можете сгенерировать различные типы исходного кода, такие как DTO, классы моделей базы данных или прокси для REST сервисов.
Однако иногда возникают ситуации, когда нет какого-либо внешнего источника информации и все, что вам нужно, содержится в исходном коде самого проекта, куда вы хотите добавить какой-то сгенерированный код.
По совпадению, я недавно опубликовал проект с открытым исходным кодом, в котором есть пример такой ситуации. В проекте более 100 классов, которые представляют узлы синтаксического дерева SQL, и мне нужно было создать посетителей (visitors — реализации интерфейса IVisitor в советующем шаблоне проектирования), которые будут обходить и изменять объекты дерева (больше информации о проекте вы можете найти в моей предыдущей статье "Дерево синтаксиса и альтернатива LINQ при взаимодействии с базами данных SQL").
Причина, по которой генерация кода является здесь хорошим выбором, заключается в том, что каждый раз, когда я делаю даже небольшое изменение в классах, мне нужно помнить об изменении посетителей (visitors), и эти изменения должны выполняться очень осторожно. Однако я не могу использовать рефлексию для генерации кода, так как сборка (assembly), которая содержит эти новые изменения, просто еще не существует, и если эти изменения несовместимы с предыдущей версией и приводят к ошибкам компиляции, то эта сборка никогда и не появится до тех пор, пока я вручную не исправлю все ошибки.
На первый взгляд, у этой проблемы нет решения, но на самом деле, чтобы её решить, я могу использовать компилятор Roslyn и заранее пре-компилировать классы модели, получив таким образом информацию, аналогичную той, которую я мог бы получить через рефлексию.
Давайте создадим простое консольное приложение и добавим в него пакет Microsoft.CodeAnalysis.CSharp.
Примечание: теоретически это можно сделать и через t4 (без консольного приложения), но я предпочитаю не бороться с добавлением в него ссылок на dll и странным синтаксисом, при отсутствии нормального редактора.
Для начала, нам нужно прочитать все .cs файлы, содержащие классы модели, и извлечь из них синтаксические деревья:
var files = Directory.EnumerateFiles( Path.Combine(projectFolder, "Syntax"), "*.cs", SearchOption.AllDirectories); files = files.Concat(Directory.EnumerateFiles(projectFolder, "IExpr*.cs")); var trees = files .Select(f => CSharpSyntaxTree.ParseText(File.ReadAllText(f))) .ToList();
Синтаксические деревья содержат много информации об исходном коде с точки зрения текста (имена классов, имена методов и т. д.), но часто этой информации недостаточно, поскольку мы хотим знать, что же этот текст означает, и поэтому нам нужно попросить Roslyn проанализировать синтаксические деревья для того, чтобы получить семантические данные:
var cSharpCompilation = CSharpCompilation.Create("Syntax", trees); foreach (var tree in trees) { var semantic = cSharpCompilation.GetSemanticModel(tree); ...
Используя семантические данные, мы можем получить объект типа INamedTypeSymbol:
foreach (var classDeclarationSyntax in tree .GetRoot() .DescendantNodesAndSelf() .OfType<ClassDeclarationSyntax>()) { var classSymbol = semantic.GetDeclaredSymbol(classDeclarationSyntax);
который может предоставить информацию о конструкторах и свойствах классов:
//Properties var properties = GetProperties(classSymbol); List<ISymbol> GetProperties(INamedTypeSymbol symbol) { List<ISymbol> result = new List<ISymbol>(); while (symbol != null) { result.AddRange(symbol.GetMembers() .Where(m => m.Kind == SymbolKind.Property)); symbol = symbol.BaseType; } return result; } //Constructors foreach (var constructor in classSymbol.Constructors) { ... }
Поскольку все классы модели неизменяемы, то все значения свойств этих классов должны быть установлены через их конструкторы, поэтому переберем все параметры конструкторов и получим их типы:
foreach (var parameter in constructor.Parameters) { ... INamedTypeSymbol pType = (INamedTypeSymbol)parameter.Type;
Теперь необходимо проанализировать каждый тип параметра и выяснить следующее:
- Является ли этот тип списком?
- Является ли тип Nullable (в проекте используются "Nullable reference types")?
- Наследуется ли от этот тип от базового типа (в нашем случае интерфейса), для которого мы и создаем "Посетителей" (Visitors).
Семантическая модель дает ответы на эти вопросы:
var ta = AnalyzeSymbol(ref pType); .... (bool IsNullable, bool IsList, bool Expr) AnalyzeSymbol( ref INamedTypeSymbol typeSymbol) { bool isList = false; var nullable = typeSymbol.NullableAnnotation == NullableAnnotation.Annotated; if (nullable && typeSymbol.Name == "Nullable") { typeSymbol = (INamedTypeSymbol)typeSymbol.TypeArguments.Single(); } if (typeSymbol.IsGenericType) { if (typeSymbol.Name.Contains("List")) { isList = true; } if (typeSymbol.Name == "Nullable") { nullable = true; } typeSymbol = (INamedTypeSymbol)typeSymbol.TypeArguments.Single(); } return (nullable, isList, IsExpr(typeSymbol)); }
Примечание: метод AnalyzeSymbol извлекает фактический тип из коллекций и значений Nullables::
List<T> => T (list := true) T? => T (nullable := true) List<T>? => T (list := true, nullable := true)
Проверка базового типа в семантической модели Roslyn является более сложной задачей, чем такая же при использовании рефлексии, но это также возможно:
bool IsExpr(INamedTypeSymbol symbol) { while (symbol != null) { if (symbol.Interfaces.Any(NameIsExpr)) { return true; } symbol = symbol.BaseType; } return false; bool NameIsExpr(INamedTypeSymbol iSym) { if (iSym.Name == "IExpr") { return true; } return IsExpr(iSym); } }
Теперь мы можем поместить всю эту информацию в простой контейнер:
public class NodeModel { ... public string TypeName { get; } public bool IsSingleton { get; } public IReadOnlyList<SubNodeModel> SubNodes { get; } public IReadOnlyList<SubNodeModel> Properties { get; } } public class SubNodeModel { ... public string PropertyName { get; } public string ConstructorArgumentName { get; } public string PropertyType { get; } public bool IsList { get; } public bool IsNullable { get; } }
и использовать его при генерации кода, получая при этом что-то вроде этого (большой класс с кучей однотипных методов). Ссылка на сам генератор в конце статьи.
В моем проекте я запускаю генерацию кода как консольную утилиту, но в .Net 5 вы сможете встроить эту генерацию в класс реализующий интерфейс ISourceGenerator и помеченный специальным атрибутом Generator. Экземпляр этого класса будет автоматически создаваться и запускаться во время сборки проекта для добавления недостающих частей кода. Это, конечно, удобнее, чем отдельная утилита, но идея аналогична.
Примечание: Я здесь не буду описывать саму кодогенерацию в .Net 5 так как в интернете есть много информации об этом, например ссылка 1 или ссылка 2
В завершение, я хочу сказать, что вы не должны воспринимать эту новую возможность .Net 5 как невероятное нововведение, которое коренным образом изменит подход к генерации динамического кода, используемый в таких библиотеках, как AutoMapper, Dapper и т. д. (слышал и такие мнения) Не изменит! Дело в том, что описанная выше генерация кода работает в статическом контексте, где все заранее известно, но, например, AutoMapper не знает заранее, с какими классами он будет работать, и ему все равно придется динамически генерировать IL код "на лету". Однако бывают ситуации, когда такая генерация кода может быть весьма полезна (одну из них ситуаций я описал в этой статье). Поэтому стоит, как минимум, знать об этой возможности и понимать ее принципы и ограничения.
ссылка на оригинал статьи https://habr.com/ru/post/544274/
Добавить комментарий