Кодогенерацию с использованием Roslyn можно использовать и без перехода на .Net 5

Автор оригинала: Dmitry Tikhonov
  • Перевод


Недавно, когда я просматривал новые возможности, которые будут включены в .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;

Теперь необходимо проанализировать каждый тип параметра и выяснить следующее:


  1. Является ли этот тип списком?
  2. Является ли тип Nullable (в проекте используются "Nullable reference types")?
  3. Наследуется ли от этот тип от базового типа (в нашем случае интерфейса), для которого мы и создаем "Посетителей" (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 код "на лету". Однако бывают ситуации, когда такая генерация кода может быть весьма полезна (одну из них ситуаций я описал в этой статье). Поэтому стоит, как минимум, знать об этой возможности и понимать ее принципы и ограничения.


Ссылка на исходный код на github

Комментарии 4

    +1
    теоретически это можно сделать и через t4 (без консольного приложения), но я предпочитаю не бороться с добавлением в него ссылок на dll и странным синтаксисом, при отсутствии нормального редактора

    В качестве T4 редактора есть плагин ForTea для ReSharper, не так давно его выпустили и для Rider. Для предпочитающих студию без решарпера есть и другие плагины, например нагуглился Devart T4 Editor, но с ним не сталкивался.


    Не защищаю какой-то конкретный подход, когда-то удобнее T4, когда-то — Roslyn, просто для информации о том, что такие редакторы есть (может и не самые удобные, но лучше, чем полное отсутствие редактора, как было раньше)

      0

      Спасибо за информацию про плагин! Попробую.


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

        +1

        Преимущества T4 в том что его знают очень многие.


        Плюс те кто когда либо работали с asp, jsp или php можно считать что тоже в одном шаге от знания.


        Ну и на самом деле не T4 vs Roslyn, а можно T4 + Roslyn, у меня знакомые плотно с этим работают, интересно получается.

      +1

      Немного дополню, что Roslyn Source Generators никогда не зависили от .NET 5, только от С# 9, а точнее от версии компилятора, который его поддерживает.
      В этом можно убедиться и по официальным примерам.


      Чтобы быть уверенным, что генератор отработает на всех окружениях можно


      • либо добавить в транзитивную зависимость Microsoft.Net.Compilers.Toolset 3.8.0+, что точно подойдет не всем, особенно публичным библиотекам.
      • либо воспользоваться Uno.SourceGeneration, опыт использования которого я описывал тут.

      Только полноправные пользователи могут оставлять комментарии. Войдите, пожалуйста.

      Самое читаемое