[Перевод] Кодогенерацию с использованием Roslyn можно использовать и без перехода на .Net 5
Недавно, когда я просматривал новые возможности, которые будут включены в .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())
{
var classSymbol = semantic.GetDeclaredSymbol(classDeclarationSyntax);
который может предоставить информацию о конструкторах и свойствах классов:
//Properties
var properties = GetProperties(classSymbol);
List GetProperties(INamedTypeSymbol symbol)
{
List result = new List();
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 (list := true)
T? => T (nullable := true)
List? => 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 SubNodes { get; }
public IReadOnlyList 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 код «на лету». Однако бывают ситуации, когда такая генерация кода может быть весьма полезна (одну из них ситуаций я описал в этой статье). Поэтому стоит, как минимум, знать об этой возможности и понимать ее принципы и ограничения.
Ссылка на исходный код на github