Генерация шаблонного кода с Roslyn Source Generators

Каждый разработчик часто сталкивается с написанием большого объема рутинного и повторяющегося кода, который имеет один и тот же смысл.
Начиная с C# версии 9 появилась возможность генерации кода, которая интегрирована напрямую с компилятором. Такой подход позволяет избавиться от множества строк шаблонного кода. О нём сегодня и поговорим.

Введение в Source Generators.

Генератор позволяет “на лету” создавать новые файлы исходного кода на языке C#.
Сгенерированные им файлы будут добавлены в процесс компиляции вместе с остальной частью кода.

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

Генератор, проанализировав пользовательский код, составляет синтаксическое дерево. Важным аспектом является то, что данное дерево является неизменяемым, то есть генератор диктует такие правила, которые позволяют генерировать (добавлять) новый код, но запрещает изменять уже существующий.

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

Compilation process

Изображение 1 – Визуализация шагов компиляции с использованием Source Generators

Какое применение мы этому нашли?

Неотъемлемой частью разработки внутри компании являются desktop приложения, реализуемые с помощью платформы WPF.

При работе над такими приложениями часто возникает потребность в реализации таких интерфейсов, как INotifyPropertyChanged и INotifyPropertyChanging. Их использование позволяет сделать наш интерфейс более интерактивным и гибким. Они позволяют уведомить всех подписчиков об изменении какого-либо свойства, которые пользователь видит в своем интерфейсе.

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


/// <summary>
/// Login model.
/// </summary>
internal class LoginModel : INotifyPropertyChanged
{
    /// <inheritdoc/>
    public event PropertyChangedEventHandler? PropertyChanged;

    private string? email;
    private string? password;

    /// <summary>
    /// User email.
    /// </summary>
    public string? Email
    {
        get => email;
        set
        {
            email = value;
            RaisePropertyChanged();
        }
    }

    /// <summary>
    /// User password.
    /// </summary>
    public string? Password
    {
        get => password;
        set
        {
            password = value;
            RaisePropertyChanged();
        }
    }

    /// <summary>
    /// Raise <see cref="INotifyPropertyChanged.PropertyChanged"/> event.
    /// </summary>
    /// <param name="propertyName">Property name.</param>
    protected void RaisePropertyChanged([CallerMemberName] string? propertyName = null)
    {
        PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(propertyName));
    }
}

Здесь мы видим, что свойства данного класса имеют одинаковую сигнатуру, единственное отличие – это их наименование. Отличный пример для того, чтобы отдать лишнюю работу генератору кода.

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


/// <summary>
/// Login model.
/// </summary>
internal partial class LoginModel : INotifyPropertyChanged
{
    private string? email;
    private string? password;
}

Результат ощутимый, не правда ли? Мы убрали дублирующийся код, который был присущ каждой модели и добавили лишь ключевое слово partial, чтобы генератор мог расширить данный класс новым кодом.

Давайте посмотрим, какой сгенерированный код скрывается за занавесом:


internal partial class LoginModel
{
    public event PropertyChangedEventHandler? PropertyChanged;

    public string? Email
    {
        get => email;
        set
        {
            email = value;
            OnPropertyChanged();
        }
    }

    public string? Password
    {
        get => password;
        set
        {
            password = value;
            OnPropertyChanged();
        }
    }

    protected void OnPropertyChanged(
[CallerMemberNameAttribute] System.String? propertyName = null)
    {
        PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(propertyName));
    }
}

Проанализировав наш класс, генератор обнаружил в нем два поля (email и password) и счёл нужным сгенерировать для них свойства, автоматически вызвав метод OnPropertyChanged (также сгенерированный), который уведомит всех подписчиков об изменениях этих свойств.

И тут встаёт вопрос, а что, если нет необходимости уведомлять подписчиков об изменении свойства? Тогда просто пометим данное поле атрибутом, генератор сможет проанализировать и этот случай.


/// <summary>
/// Employee model.
/// </summary>
internal partial class EmployeeModel : INotifyPropertyChanged
{
    private string? firstname;
    private string? lastname;

    [DoNotNotify]
    private double salary;
}

Нередко возникают случаи, когда между свойствами возникают зависимости, при изменении одного – необходимо уведомить, что изменилось также и другое. Данный пример проиллюстрирован ниже.


/// <summary>
/// Employee model.
/// </summary>
internal partial class EmployeeModel : INotifyPropertyChanged
{
    [AlsoNotify(nameof(FullName))]
    private string? firstname;

    [AlsoNotify(nameof(FullName))]
    private string? lastname;

    /// <summary>
    /// Employee full name.
    /// </summary>
    public string FullName => string.Join(" ", firstname, lastname);
}

Сгенерированный код:


internal partial class EmployeeModel
{
    public event PropertyChangedEventHandler? PropertyChanged;

    public string? Firstname
    {
        get => firstname;
        set
        {
            firstname = value;
            OnPropertyChanged();
            OnPropertyChanged(nameof(FullName));
        }
    }

    public string? Lastname
    {
        get => lastname;
        set
        {
            lastname = value;
            OnPropertyChanged();
            OnPropertyChanged(nameof(FullName));
        }
    }

    protected void OnPropertyChanged(
[CallerMemberNameAttribute] System.String? propertyName = null)
    {
        PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(propertyName));
    }

}

На данный момент среды для разработки Visual Studio и Rider прекрасно поддерживают автодополнение кода и подсказки, указывают на сгенерированный код ещё до начала процесса компиляции. Это также является неоспоримым преимуществом данной технологии.

Использование IL-Weaving

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

В настоящий момент наиболее популярным аналогом для генерации INotifyPropertyChanged и INotifyPropertyChanging является предоставляемый Fody пакет Fody.PropertyChanged, который как раз и занимается переписыванием IL-кода.

К сожалению, данный подход не позволяет добавить код, от которого зависит компиляция проекта.
А также, разработчик не видит кода, который был добавлен в ходе данного процесса – это добавляет сложностей в процессе отладки программы.
Посмотрим, как бы выглядел код с использованием Fody.


/// <summary>
/// Employee model.
/// </summary>
internal class EmployeeModel : INotifyPropertyChanged
{
    /// <inheritdoc/>
    public event PropertyChangedEventHandler? PropertyChanged;

    /// <summary>
    /// Employee first name.
    /// </summary>
    public string? Firstname { get; set; }

    /// <summary>
    /// Employee last name.
    /// </summary>
    public string? Lastname { get; set; }

    /// <summary>
    /// Employee full name.
    /// </summary>
    public string FullName => string.Join(" ", Firstname, Lastname);
}

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

Сравнение подходов

Оба из рассмотренных подходов имеют свои преимущества и недостатки, выделим основные из них.

Плюсы и минусы Генераторов кода:

  • +/- Добавляют, но не изменяют код
  • + Можно смотреть и отлаживать код
  • + Проверка кода компилятором
  • + Никакой магии, лишь генерация явного кода
  • – Нельзя изменять существующий код (иногда очень хочется)
  • – Требуется постоянно использовать partial в коде

Плюсы и минусы IL-Weaving:

  • +/- Модифицирует IL-код
  • +/- Не всегда обязательно знать о деталях того, что именно модифицируется и как влияет на ход выполнения программы
  • + Может изменять существующий код
  • – Невозможность отладки кода
  • – Сначала компилируется код, лишь потом модифицируется IL. Из этого вытекают такие ограничения, как: невозможность добавления кода от которого зависит компиляция проекта и невозможность отладки кода, который был добавлен (изменен) путем модификации IL-кода
  • – Возможность возникновения непредвиденных ошибок, так как модифицированный IL-код не проходит проверку компилятором

Реализация генератора кода

Настало время поговорить о реализации данного генератора кода. Он представляет из себя обычный C# проект, где есть ссылки на необходимые зависимости для анализа кода.

Project structure

Изображение 2 – Структура проекта Source Generator

Для начала необходимо разобраться, где же находится точка входа у данной программы? Перейдем в файл SourceGenerator.cs.


/// <summary>
/// Generate <b>partial class</b> for each class which implement 
/// <see cref="INotifyPropertyChanged"/> interface.
/// <br/>Generator will create the <i>public property</i> for each <i>private field</i>.
/// <br/>Generated <i>public property</i> will raise 
/// <see cref="INotifyPropertyChanged.PropertyChanged"/> event.
/// Be aware, the consumer project cannot access the 
/// source code of Roslyn Source Generator.
/// </summary>
[Generator(LanguageNames.CSharp)]
internal class SourceGenerator : IIncrementalGenerator
{
    /// <inheritdoc/>
    public void Initialize(IncrementalGeneratorInitializationContext context)
    {
        context.RegisterPostInitializationOutput(
ctx => ctx.AddSource("Attributes", Constants.Attributes));

        var options = context.AnalyzerConfigOptionsProvider.Select((opt, _) => opt);
        var syntaxManager = new SyntaxManager(context.SyntaxProvider);

        var partialClassFilter = new ClassDeclarationFilter(
SyntaxKind.PublicKeyword, SyntaxKind.InternalKeyword);
        var classSymbols = syntaxManager
.GetSymbols<ClassDeclarationSyntax, ITypeSymbol>(partialClassFilter);
        var classAnalysisAndDiagnostics = classSymbols
.Combine(options)
.Select(GetAnalyzedNode);

        var diagnostics = classAnalysisAndDiagnostics
.SelectMany((pair, token) => pair.Scope.GetDiagnostics());
        context.RegisterSourceOutput(diagnostics, Report);

        var analysis = classAnalysisAndDiagnostics
                .Select((pair, token) => (pair.Analysis, pair.Options))
                .Where(pair => pair.Analysis.ShouldBuild);
        context.RegisterSourceOutput(analysis, Build);
    }
}

Данный класс реализует единственный интерфейс IIncrementalGenerator, который требует реализации метода Initialize, данный метод будет вызван в момент начала компиляции программы, которая является потребителем данного генератора.

Контекст данного метода предоставляет нам синтаксическое дерево, которое составлено на основе проанализированного пользовательского кода. Далее мы можем обойти данное дерево и найти те структуры данных, на основе которых мы хотим сгенерировать наш будущий код.
Так же, проанализировав код, генератор позволяет предупредить разработчика, либо сообщить об ошибке используя Diagnostic Roslyn API.


/// <summary>
/// Base diagnostics repoter.
/// </summary>
/// <typeparam name="TDescriptor">Diagnostic descriptor.</typeparam>
public abstract class ReporterBase<TDescriptor> : IDiagnosticReporter<TDescriptor>
    where TDescriptor : IDiagnosticDescriptor
{
    private readonly ImmutableArray<Diagnostic>.Builder diagnostics =
ImmutableArray.CreateBuilder<Diagnostic>();

    /// <inheritdoc/>
    public ImmutableArray<Diagnostic> Diagnostics => diagnostics.ToImmutable();

    /// <inheritdoc/>
    public virtual void AddDiagnostic(TDescriptor descriptor)
    {
        var diagnosticDescriptor = new DiagnosticDescriptor(
            descriptor.Code,
            descriptor.Title,
            descriptor.Message,
            descriptor.Category,
            descriptor.Severity,
            isEnabledByDefault: true);
        var diagnostic = Diagnostic.Create(diagnosticDescriptor, Location.None);
        AddDiagnostic(diagnostic);
    }

    /// <summary>
    /// Add a diagnostic.
    /// </summary>
    /// <param name="diagnostic">Diagnostic.</param>
    protected void AddDiagnostic(Diagnostic diagnostic) => diagnostics.Add(diagnostic);
}

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

Например, мы показываем предупреждение для разработчика в том случае, если генератор сконфигурирован некорректно.


/// <summary>
/// Contains an application diagnostics.
/// </summary>
internal class Diagnostics
{
    /// <summary>
    /// Backing field convention mismatch warning.
    /// </summary>
    public static readonly WarningDescriptor BackingFieldConventionMismatch = new(
        code: "ST01",
        title: "Backing field options mismatch.",
        message: "Backing field PascalCase naming convention cannot be used without underscore.",
        category: "Convention");
}

Далее, отобрав необходимые элементы из дерева, мы приступаем к их анализу. На примере ниже продемонстрирован анализ полей на предмет содержания атрибутов и их параметров.


/// <summary>
/// Analyzer of <see cref="IFieldSymbol"/>.
/// </summary>
public class FieldAnalyzer : ISyntaxAnalyzer<IFieldSymbol, FieldAnalysis>
{
    private readonly IEnumerable<IFieldSymbol> fields;
    private readonly IEnumerable<IPropertySymbol> properties;

    /// <summary>
    /// Constructor.
    /// </summary>
    /// <param name="fields">Field symbols.</param>
    /// <param name="properties">Property symbols.</param>
    public FieldAnalyzer(
IEnumerable<IFieldSymbol> fields, 
IEnumerable<IPropertySymbol> properties)
    {
        this.fields = fields;
        this.properties = properties;
    }

    /// <inheritdoc/>
    public FieldAnalysis Analyze(
IFieldSymbol symbol,
SemanticModel semanticModel, 
IDiagnosticsScope scope)
    {
        var analysis = new FieldAnalysis
        {
            Name = symbol.Name,
            Type = SymbolUtils.GetType(symbol),
            Modifier = SymbolUtils.GetModifier(symbol),
            DoNotNotify = SymbolUtils.ContainsAttribute(
symbol, attributeName:  Constants.DoNotNotifyAttributeName),
        };

        var containsAlsoNotify = SymbolUtils.ContainsAttribute(
symbol, attributeName: Constants.AlsoNotifyAttributeName);
        if (containsAlsoNotify)
        {
            var alsoNotify = SymbolUtils.GetAttribute(
symbol, attributeName: Constants.AlsoNotifyAttributeName)!;

            var fieldNames = fields.Select(symbol => symbol.Name);
            var propertyNames = properties.Select(symbol => symbol.Name);
            var names = fieldNames.Concat(propertyNames);

            analysis.AlsoNotifyMembers = alsoNotify.ConstructorArguments
                .SelectMany(GetArgumentValues)
                .OfType<string>()
                .Where(names.Contains);
        }

        return analysis;
    }
}

После полного анализа полей необходимо сгенерировать их релевантные свойства.
Для этого введём дополнительную структуру данных, которая будет описывать дальнейшие правила генерации кода.


/// <summary>
/// Constructor.
/// </summary>
/// <param name="fieldOptions">Field options.</param>
/// <param name="invokePropertyChanged">
/// Invocation method of property changed.</param>
/// <param name="invokePropertyChanging">
/// Invocation method of property changing.</param>
public PropertyBuilder(
    FieldOptions fieldOptions,
    InvocationMethodMetadata? invokePropertyChanged = null,
    InvocationMethodMetadata? invokePropertyChanging = null)
{
    this.fieldOptions = fieldOptions;

    this.invokePropertyChanged = invokePropertyChanged;
    this.invokePropertyChanging = invokePropertyChanging;
}

/// <inheritdoc/>
public PropertyMetadata Build(FieldAnalysis analysis)
{
    var fieldMetadata = new MemberMetadata
    {
        Name = analysis.Name,
        Type = analysis.Type,
        Modifier = analysis.Modifier,
    };

    var setter = new SetterMetadata(fieldMetadata);

    AddSetterDelegates(setter);

    foreach (var fieldName in analysis.AlsoNotifyMembers)
    {
        var fieldPropertyName = FieldUtils.GetPropertyName(
fieldName, fieldOptions);
        AddSetterDelegates(setter, fieldPropertyName);
    }

    return new PropertyMetadata()
    {
        Name = FieldUtils.GetPropertyName(analysis.Name, fieldOptions),
        Type = analysis.Type,
        Getter = new GetterMetadata(fieldMetadata),
        Setter = setter,
    };
}

Рассмотрим правила генерации кода для структуры PropertyMetadata.


/// <inheritdoc/>
public override string Build(IndentWriter writer)
{
    var builder = new StringBuilder();

    builder.Append(Modifier);

    if (IsDelegate)
    {
        builder.Append(" event");
    }

    builder.Append($" {Type}");

    if (IsNullable)
    {
        builder.Append("?");
    }

    builder.Append($" {Name}");

    var shouldBuildAccessors = Getter != null && Setter != null;
    if (!shouldBuildAccessors)
    {
        var property = builder.Append(";").ToString();
        return writer.Append(property).ToString();
    }

    var declaration = builder.ToString();

    writer.Append(declaration);

    writer.AppendLine().Append("{");

    using (writer.IncreaseIndent())
    {
        Getter?.Build(writer);
        Setter?.Build(writer);
    }

    writer.AppendLine().Append("}");

    return writer.ToString();
}

Таким образом происходит анализ классов, полей, свойств и методов, которые отвечают нашим требованиям.

Выводы

Генераторы кода приобрели большую популярность, можно встретить много библиотек с открытым исходным кодом и использовать их. Но бывают такие ситуации, что данные библиотеки имеют не совсем ту реализацию, которая бы подходила к требованиям команды.

В качестве генератора кода нами был выбран именно Roslyn Source Generators, он имеет понятное и дружелюбное API, большое количество открытых исходных кодов и также является частью экосистемы Microsoft, что гарантирует долгосрочную поддержку.

Резюмируя, генераторы:

  • Позволяют легко генерировать шаблонный код
  • Лишь добавляют код, но не изменяют существующий
  • Не представляют никакой магии, сгенерированный код легко отслеживать и заниматься его отладкой
  • Проходят проверку компилятором

Ресурсы

  1. Roslyn Source Generators Cookbook – https://github.com/dotnet/roslyn/blob/main/docs/features/source-generators.cookbook.md
  2. Microsoft Source Generators Overview – https://learn.microsoft.com/en-us/dotnet/csharp/roslyn-sdk/source-generators-overview
  3. Saritasa Source Generator – https://github.com/Saritasa/SaritasaTools
  4. List of most popular Source Generators –
    https://github.com/amis92/csharp-source-generators

Читайте также

Наверх