Как стать автором
Обновить
136.64
JUG Ru Group
Конференции для Senior-разработчиков

Source Generators в действии

Время на прочтение38 мин
Количество просмотров14K

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


Ранее Андрей Дятлов TessenR выступил на конференции DotNext с докладом «Source Generators в действии». А теперь, пока мы готовим следующий DotNext, сделали для Хабра текстовую расшифровку его доклада.



Что вообще такое эти Source Generators? Как их использовать? Как предоставить пользователю вашего генератора необходимую гибкость конфигурации и понятные сообщения о возникающих проблемах? Как разобраться, когда что-то пошло не так?


Ответы на все эти и другие вопросы — в тексте.



Оглавление



Помимо того, что в последнее время я работал над поддержкой Source Generators, за свою карьеру я также успел поработать и с другими технологиями метапрограммирования: IL Weaving, Fody, PostSharp, ILGenerator, CodeDOM, то есть практически со всем, что представлено в мире .NET. Сегодня я расскажу о том, какие преимущества и недостатки есть у Source Generators. Еще покажу, как вообще работают Source Generators, и даже напишу один, сравню их со старыми технологиями. Расскажу о некоторых проблемах, которые могут встретиться в процессе работы с ними, и дам несколько советов о том, как сделать работу с генераторами менее болезненной и не натыкаться на типичные проблемы.


Но сначала давайте разберемся, для чего нам вообще нужны генераторы.


Какие задачи должны решить генераторы?


В первую очередь — создание шаблонного кода. Если у вас, например, есть методы Equals, GetHashCode, операторы равенства и неравенства, скажем, обеспечивающие структурное сравнение данных, писать их вручную для каждого типа очень неудобно. Было бы неплохо отдать эту задачу генератору, который напишет этот код за нас. В том числе можно, например, добавить всем типам в проекте осмысленный метод ToString, создавать типы по схеме, добавить mapping, например, как в AutoMapper, материализацию объектов баз данных.


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


Перейдем к примеру того, чем нам могут быть полезны генераторы:


Я думаю, всем известен интерфейс INotifyPropertyChanged. В нём есть всего одно событие, сообщающее о том, что одно из свойств объекта изменилось и, например, его требуется обновить на пользовательском интерфейсе.


public interface INotifyPropertyChanged
{
    event PropertyChangedEventHandler PropertyChanged;
}

Его очень легко реализовать, например, если у меня есть класс, представляющий модель машины, у которого есть свойство скорости в километрах в час, мне надо просто добавить одну строчку, реализующую событие из интерфейса…


public class CarModel : INotifyPropertyChanged
{
    public double SpeedKmPerHour { get; set; }
    public event PropertyChangedEventHandler? PropertyChanged;
}

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


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


public class CarModel : INotifyPropertyChanged
{
    private double SpeedKmPerHourBackingField;
    public double SpeedKmPerHour
    }
        get => SpeedKmPerHourBackingField;
        set
        {
            SpeedKmPerHourBackingField = value;
            PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(nameof(SpeedKmPerHour)));
        }
    }
    public event PropertyChangedEventHandler? PropertyChanged;
}

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



Во-первых, с этим кодом будет проще работать — теперь тип данных и название поля упомянуты в коде всего один раз, и при рефакторинге не требуется синхронизировать их в нескольких местах.


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



Что такое Source Generators?


Source Generators — новая технология метапрограммирования от Microsoft. Вы пишете тип, который будет частью процесса компиляции, у него будет доступ к модели вашего кода, и результатом его работы будут новые C#-файлы.



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


Покажу это на примере небольшого демо.


Примечание: в исходном докладе использован пример с сайта sourcegen.dev, который более недоступен.


Можно посмотреть примеры генераторов от Microsoft, выложенные на гитхабе.


Начнем с примера реализации INotifyPropertyChanged при помощи генератора.


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


В нем есть partial-класс ExampleViewModel, в котором есть несколько полей:


// The view model we'd like to augment
public partial class ExampleViewModel
{
        [AutoNotify]
        private string _text = "private field text";

        [AutoNotify(PropertyName = "Count")]
        private int _amount = 5;
}

Есть атрибут AutoNotify и тест, который подписывается на событие PropertyChanged этой модели, меняет несколько свойств и записывает информацию о них в консоль.


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


Если запустить эту программу, она выведет:


Text = private field text
Count = 5
Property Text was changed
Property Count was changed

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


  • Декларацию атрибута AutoNotify

using System;
namespace AutoNotify
{
    [AttributeUsage(AttributeTargets.Field, Inherited = false, AllowMultiple = false)]
    [System.Diagnostics.Conditional("AutoNotifyGenerator_DEBUG")]
    sealed class AutoNotifyAttribute : Attribute
    {
            public AutoNotifyAttribute()
            {
            }
            public string PropertyName { get; set; }
    }
}

  • partial-декларацию использованного в основной программе типа ExampleViewModel, которая как раз реализует событие PropertyChanged и добавляет свойства, которые будут его вызывать:

namespace GeneratedDemo
{
  public partial class ExampleViewModel : System.ComponentModel.INotifyPropertyChanged
  {
    public event System.ComponentModel.PropertyChangedEventHandler PropertyChanged;
    public string Text
    {
      get
      {
        return this._text;
      }

      set
      {
        this._text = value;
        this.PropertyChanged?.Invoke(this, new System.ComponentModel.PropertyChangedEventArgs(nameof(Text))); 
      }
    }

    public int Count
    {
      get
      {
        return this._amount;
      }

       set
       {
         this._amount = value;
         this.PropertyChanged?.Invoke(this, new System.ComponentModel.PropertyChangedEventArgs(nameof(Count)));
       }
    }
  }
}

Можно также посмотреть на сам исходный текст генератора.


В нем есть исходный код для атрибута AutoNotify и метод Execute, который может посмотреть, что есть в проекте, найти объявленные в нем типы, посмотреть на их поля, посмотреть, какие из них отмечены атрибутом AutoNotify, и на основе этих полей создать partial-часть, реализующую NotifyPropertyChanged.


В этом примере может быть сложно сходу разобраться, особенно разработчикам, прежде не работавшим с код-моделью компилятора, — много кода, много неизвестных типов, к которым требуется явное приведение, и незнакомого API. Может показаться, что для работы с генераторами требуется иметь опыт работы с API компилятора или приложить серьезные усилия, чтобы разобраться в нём с нуля.


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


У меня есть проект с view-моделью CarModel, которую я показывал ранее. В ней есть три поля с данными, метод ускорения машины и 50 строк бойлерплейта, реализующего NotifyPropertyChanged.


Сейчас этот тип выглядит вот так:


public class CarModel : INotifyPropertyChanged
{
    private double SpeedKmPerHourBackingField;
    private int NumberOfDoorsBackingField;
    private string ModelBackingField = "";

    public void SpeedUp() => SpeedKmPerHour *= 1.1;

    public double SpeedKmPerHour
    {
        get => SpeedKmPerHourBackingField;
        set
        {
                SpeedKmPerHourBackingField = value;
                PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(nameof(SpeedKmPerHour)));
        }
    }

    public int NumberOfDoors
    {
        get => NumberOfDoorsBackingField;
        set
        {
                NumberOfDoorsBackingField = value;
                PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(nameof(NumberOfDoors)));
        }
    }

    public string Model
    {
        get => ModelBackingField;
        set
        {
                ModelBackingField = value;
                PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(nameof(Model)));
        }
    }

    public event PropertyChangedEventHandler? PropertyChanged;
  }

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



Исходную версию проекта до применения генератора можно посмотреть на гитхабе.


Далее я покажу, как можно избавиться от этого шаблонного кода с помощью генератора.


Для того чтобы добавить генератор, мне потребуется новый проект — это будет обычная .NET standard-библиотека и несколько NuGet-пакетов, чтобы работать с Roslyn и теми данными о проекте, которые мне предоставит компилятор.


Все проекты с генераторами должны быть под .NET standard 2.0, но версию языка C# в них можно использовать любую.


<Project Sdk="Microsoft.NET.Sdk">

  <PropertyGroup>
    <TargetFramework>netstandard2.0</TargetFramework>
    <LangVersion>preview</LangVersion>
  </PropertyGroup>

  <ItemGroup>
    <PackageReference Include="IndexRange" Version="1.0.0" />
    <PackageReference Include="Microsoft.CodeAnalysis.CSharp" Version="3.8.0-4.20464.1" PrivateAssets="all" />
    <PackageReference Include="Microsoft.CodeAnalysis.Analyzers" Version="3.0.0" PrivateAssets="all" />
  </ItemGroup>
</Project>

Дальше мне нужно подключить этот генератор к проекту с основной программой. Нужно указать, что это не просто ссылка на сборку, типы из которой я смогу использовать, а именно генератор, который может анализировать и дополнять код проекта. Для этого мне нужно указать, что эта ссылка имеет тип OutputItemType="Analyzer". Также поскольку генератор нужен только в момент компиляции, можно убрать зависимость от сборки с генератором в скомпилированной программе:


<ProjectReference Include="..\NotifyPropertyChangedGenerator\NotifyPropertyChangedGenerator.csproj"
                OutputItemType="Analyzer" ReferenceOutputAssembly="false"/>

Теперь пришло время написать сам генератор — он должен реализовать интерфейс ISourceGenerator и быть отмечен атрибутом [Generator]. В самом интерфейсе всего два метода: Initialize и Execute. Initialize для этого генератора не требуется, и я объясню его функцию в следующем демо. А в методе Execute есть контекст, в котором есть свойство Compilation, и это как раз вся информация, которую собрал о целевом проекте Roslyn, то есть какие типы, файлы там есть, можно на них посмотреть.


  [Generator]
  public class NotifyPropertyChangedGenerator : ISourceGenerator
  {
    public void Initialize(GeneratorInitializationContext context)
    {
    }

    public void Execute(GeneratorExecutionContext context)
    {
        var compilation = context.Compilation;
    }
  }

Первый метод, который нужно знать — GetTypeByMetadataName, который позволяет получить тип по его имени. Меня интересует System.ComponentModel.INotifyPropertyChanged интерфейс, который я и буду реализовывать в своих типах.


var compilation = context.Compilation;
    var notifyInterface = compilation.GetTypeByMetadataName("System.ComponentModel.INotifyPropertyChanged");

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


Второй метод, который нужно знать, — compilation.GetSemanticModel(syntaxTree). Он вернет семантическую модель, которая позволяет нам перейти от синтаксиса, то есть фактически текста, написанного в файле — ключевого слова class и какого-то имени для него, к семантике — то есть информации о том, что это за тип, какие у него есть атрибуты, какие интерфейсы он реализует, какие есть члены типа.


Дальше моему генератору нужно обойти все файлы в программе. Для этого я могу использовать метод GetRoot, метод DescendantNodesAndSelf. Меня будут интересовать только декларации классов, в Roslyn они будут представлены элементом типа ClassDeclarationSyntax.


Как правило, языковые конструкции названы относительно понятно, и можно просто поискать подходящий тип среди наследников типа, который вам вернул Roslyn API, в данном случае метод DescendantNodesAndSelf возвращает коллекцию SyntaxNode. Можно посмотреть список его наследников и легко найти типы для каких-то конкретных интересующих вас элементов, например, ClassDeclarationSyntax, InterfaceDeclarationSynttax, MethodDeclarationSyntax. Чаще всего примерное название можно просто угадать и найти элемент в поиске.


  foreach (var syntaxTree in compilation.SyntaxTrees)
  {
    var semanticModel = compilation.GetSemanticModel(syntaxTree);
    syntaxTree.GetRoot().DescendantNodesAndSelf()
      .OfType<ClassDeclarationSyntax>()

    }
  }
}

Есть и второй способ, если не хочется возиться с поиском нужного типа или угадать его название не получается — можно зайти на сайт SharpLab и выбрать режим отображения синтаксического дерева. Вы сможете просто набрать программу с нужными вам элементами и посмотреть, какие синтаксические элементы будут для него созданы. Например, если вы не знаете, как будет называться синтаксический элемент для вызова метода, можно ввести туда Console.WriteLine(); и узнать, что это будет ExpressionStatement, в котором будет находиться InvocationExpression.


Дальше, когда я получил типы, объявленные в проекте, для которого будет запущен генератор, мне нужно перейти как раз к семантической модели для моей декларации. Для этого нужно передать декларацию типа в метод semanticModel.GetDeclaredSymbol().


Три метода, которые я уже использовал — Compilation.GetTypeByMetadataName, Completion.GetSemanticModel и SemanticModel.GetDeclaredSymbol — это как раз те методы, которые вам, скорее всего, потребуются в любом генераторе и которые не так просто найти самостоятельно. Практически всё остальное можно быстро найти, посмотрев доступные методы в автодополнении или поискав нужный вам тип среди наследников интерфейса, который вам вернул API компилятора.


Например, я вижу, что вызов semanticModel.GetDeclaredSymbol вернул мне ISymbol, но я знаю, что буду работать только с декларациями типов и легко могу найти в наследниках ISymbol нужный мне тип ITypeSymbol, представляющий семантическую информацию о типе, например, классе или интерфейсе, объявленном в целевом проекте.


При помощи свойств этого объекта можно посмотреть, какие интерфейсы он реализует, и отфильтровать только типы, реализующие интерфейс INotifyPropertyChanged. Все такие типы в своем генераторе я сложу в HashSet, это те типы, которые мой генератор должен дополнить реализацией интерфейса INotifyPropertyChanged.


foreach (var syntaxTree in compilation.SyntaxTrees)
{
        var semanticModel = compilation.GetSemanticModel(syntaxTree);
        var immutableHashSet = syntaxTree.GetRoot()
        .DescendantNodesAndSelf()
            .OfType<ClassDeclarationSyntax>()
            .Select(x => semanticModel.GetDeclaredSymbol(x))
        .OfType<ITypeSymbol>()
            .Where(x => x.Interfaces.Contains(notifyInterface))
        .ToImmutableHashSet();
}

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


Для этого мне понадобится объявить еще одну декларацию этого типа с таким же неймспейсом и именем, объявить в ней событие PropertyChanged и добавить все нужные мне свойства.


private string GeneratePropertyChanged(ITypeSymbol typeSymbol)
    {
        return $@"
using System.ComponentModel;
namespace {typeSymbol.ContainingNamespace}
{{
  partial class {typeSymbol.Name}
  {{
    {GenerateProperties(typeSymbol)}
    public event PropertyChangedEventHandler? PropertyChanged;
  }}
}}";
    }

Дальше я буду создавать свойства. Я не знаю, сколько их будет, и мне потребуется StringBuilder. Я могу посмотреть, какие в моем типе есть члены, при помощи метода GetMembers и отфильтровать только поля по типу IFieldSymbol. И нужный метод и интерфейсы для конкретных членов типа легко можно найти в автодополнении просто по имени.


Так как я делаю максимально простой генератор, я буду просто обрабатывать те поля, у которых имя заканчивается на суффикс BackingField. То есть вместо атрибутов будет конвенция наименования. Если тип реализует NotifyPropertyChanged, то для всех его полей с суффиксом BackingField я буду создавать свойства с нотификациями.


Дальше мне просто потребуется создать шаблон для свойства, вызывающего событие PropertyChanged в сеттере, и подставить в него нужные данные — тип свойства, совпадающий с типом поля для хранения данных, имя свойства, совпадающее с именем поля до суффикса BackingField и т. д.:


private static string GenerateProperties(ITypeSymbol typeSymbol)
{
  var sb = new StringBuilder();
  var suffix = "BackingField";

  foreach (var fieldSymbol in typeSymbol.GetMembers().OfType<IFieldSymbol>()
    .Where(x=>x.Name.EndsWith(suffix)))
  {
    var propertyName = fieldSymbol.Name[..^suffix.Length];
    sb.AppendLine($@"
    public {fieldSymbol.Type} {propertyName}
    {{
    get => {fieldSymbol.Name};
    set
    {{
    {fieldSymbol.Name} = value;
    PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(nameof({propertyName})));
    }}
    }}");
  }

  return sb.ToString();
}

Всё, что мне осталось, — просто добавить этот сгенерированный файл в компиляцию. Для этого есть метод context.AddSource, которому нужно передать имя нового файла и сам исходный код. Я создам файл с таким же именем, как тип, который я расширяю с суффиксом Notify.cs.


foreach (var typeSymbol in immutableHashSet)
{
  var source = GeneratePropertyChanged(typeSymbol);
  context.AddSource($"{typeSymbol.Name}.Notify.cs", source);
}

Теперь этот генератор будет просто работать с моим проектом, а я у себя могу стереть весь бойлерплейт, который реализует NotifyPropertyChanged и сделать этот тип partial, чтобы часть созданная при помощи генератора была добавлена в этот же тип. Теперь CarModel тип в моем проекте выглядит вот так:


public partial class CarModel : INotifyPropertyChanged
{
    private double SpeedKmPerHourBackingField;
    private int NumberOfDoorsBackingField;
    private string ModelBackingField = "";

    public void SpeedUp() => SpeedKmPerHour *= 1.1;
}

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


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


О чем нужно помнить, когда вы написали свой первый генератор: Visual Studio и Roslyn увидят его только после того, как вы перекомпилируете проект, закроете IDE с ним и и откроете проект заново. То же самое относится и к случаю, когда вы скачали, например, это демо с гитхаба и открыли в IDE — при первом запуске вы увидите много ошибок, вызванных отсутствием в IDE информации о сгенерированном коде.


namespace NotifyPropertyChangedLiveDemo
{
  static class Program
  {
    static void Main()
    {
      var carModel = new CarModel
      {
        Model = "MyCar",
        NumberOfDoors = 4,
        SpeedKmPerHour = 200
      };

      Console.Write("Got ");
      PrintCarDetails();

      carModel.PropertyChanged += OnPropertyChanged();

      Console.WriteLine();
      Console.WriteLine("Updating to racing model...");
      carModel.Model = "Racing " + carModel.Model;
      carModel.NumberOfDoors = 2;

      while (carModel.SpeedKmPerHour <250)

Генератор загружается в память один раз в момент открытия проекта. Таким образом, IDE не увидит никаких изменений — добавления или редактирования генераторов, которые были сделаны в момент, когда проект был открыт. Предполагается, что генераторы будут один раз загружены с NuGet или написаны, затем часто меняться не будут. Необходимость переоткрытия проекта относится только к изменению кода самого генератора, если вы изменили что-то в вашем проекте, то генератор сразу же сможет создать новый код с учетом этого изменения.


Если вам нужно часто редактировать генератор и получать обратную связь в IDE без переоткрытия проекта, вы можете воспользоваться Rider, но думаю, что рано или поздно эта фича появится и в Visual Studio.


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


Когда мы слышим о метапрограммировании, помимо вопросов о том, как сделать, чтобы что-то заработало, возникает много вопросов о том, как потом его читать и поддерживать. Можно ли посмотреть на сгенерированный код, подебажить его, протестировать процесс генерации?


Генераторы были созданы в том числе для удобства поддержки кода, созданного ими, поэтому вы можете легко работать со сгенерированным кодом. Все файлы, созданные генераторами, видны в проектной модели, к типам и методам из сгенерированного кода можно снавигироваться, и в любой точке сгенерированного файла можно поставить брейкпоинт, который сработает при отладке приложения — всё как если бы вы написали этот код вручную!


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


Весь код примера с генератором можно найти на гитхабе.


Fody


Надеюсь, после предыдущего примера стало понятно, что в генераторах нет ничего сложного, и можно буквально за 10–15 минут написать простой генератор, например, для реализации интерфейса INotifyPropertyChanged. На проекте с большим количеством моделей с десятками это может позволить удалить огромное количество бойлерплейта, измеряемое десятками или даже сотнями тысяч строк кода.


Но на самом деле, здесь нет ничего совершенно нового — уже 5–7 лет назад я пользовался Fody и IL Weaving для того же самого INotifyPropertyChanged, и еще тогда с помощью этих тулов фактически создавал точно такую же реализацию интерфейса, как сейчас с помощью генератора. Отличие в том, что в Fody это делается не при помощи кода, а при помощи манипуляций с байт-кодом.


Покажу, как это выглядит.


public class Person : INotifyPropertyChanged
{
    public event PropertyChangedEventHandler PropertyChanged;

    public string GivenNames { get; set; }
    public string FamilyName { get; set; }
    public string FullName => $"{GivenNames} {FamilyName}";
}

Есть класс Person с NotifyPropertyChanged-интерфейсом, в котором есть сам ивент и несколько автосвойств. После того, как этот проект заканчивает компиляцию, модифицируется байт-код, и свойства становятся вычислимыми свойствами, у которых в сеттере есть какая-то логика, например, сравнение нового значения с тем, которое сейчас хранится в поле, и вызов события при изменении значения.


Возникает вопрос: а этот способ Microsoft чем-то лучше, или мы получили то же самое, но реализованное немного по-другому? Способов добавить что-то в компиляцию, на самом деле, было много. Это и IL Weaving в лице Fody и PostSharp, T4, ILGenerator, Codedom. Давайте разберемся, чем Source Generators лучше или хуже. Я сравню их с IL Weaving, как с наиболее популярным способом сделать то же самое, добавить что-то в проект. Все плюсы и минусы проистекают здесь из разницы в подходах.



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



C Fody мне приходится писать ивент PropertyChanged, который в своем демо я тоже перенес в генератор. Чтобы Fody начал работать, мне требуется готовая, уже скомпилированная сборка, ведь чтобы начать модифицировать IL, надо, чтобы компилятор его сначала создал. Если код проекта не скомпилируется, то до Fody дело просто не дойдет. Таким образом, при помощи IL Weaving будет гораздо труднее добавить методы, от которых зависит компиляция. Например, ивент PropertyChanged или методы, которые реализуют интерфейсы. С другой стороны, в Fody я могу просто написать автосвойство, и всё это магически будет работать.



С Source Generators, как вы помните, я могу только добавить новый файл. Это значит, что если у меня где-то есть автосвойство, то я не смогу подменить его реализацию — автосвойство не будет вызывать нужное мне событие. То есть этот подход для Source Generators не подойдет.


Вместо этого мы можем полагаться:


  • на конвенции, как в моём демо со схемой имен для полей;
  • на атрибуты, и указать в них имя свойства, дополнительные нотификации;
  • на конфигурационные файлы.

С атрибутами это может выглядеть следующим образом.



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



К сожалению, после компиляции проекта с генератором в коде в любом случае будет и свойство, и поле.


С одной стороны это хорошо, потому что у нас есть код, который можно подебажить.


С другой стороны, это не очень удобно, потому что в автодополнении у меня будет видно оба члена типа: и поле, и свойство. Это означает, что с генераторами я могу случайно напрямую использовать поле вместо свойства и потерять нотификацию об изменении.



Можно с этим бороться при помощи, например, атрибута [Obsolete] и #pragma warning disable в сгенерированном коде, но это тоже будет не очень удобно.



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


С генераторами у нас есть код, который можно посмотреть и подебажить, с IL Weaving у нас кода для дебага просто нет, т. к. все модификации были уже непосредственно в байткоде.


Генераторы можно легко протестировать, просто добавив тест на то, какой код генератор создает для конкретного кода на входе генератора. С ILWeaving вам скорее всего потребуется тестировать поведение уже обработанной сборки реального проекта.


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


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


Есть два небольших минуса: для расширения типа генератором требуется partial, то есть вы должны заранее подумать о том, какой класс вы хотите расширить. Кроме того, они не могут поменять существующий код, в то время как при помощи IL Weaving вы можете удалять или менять любой код, например, в целях оптимизации.


Так IL Weaving мертв?



В Fody и PostSharp есть огромное количество модификаций, которые легко заменить при помощи генераторов: PropertyChanged, реализация методов эквивалентности, ToString, структурная эквивалентность. Думаю, что в ближайшее время появятся генераторы, реализующие то же самое, просто на другой технологии, черпая из них вдохновение.


Но помимо этого, как я уже говорил, IL Weaving может модифицировать сам код методов, и это часто используется, чтобы добавить в него функциональные аспекты: кэширование, логирование, обработку исключений. В Fody, например, есть плагин, который позволяет выдавать запись в лог каждый раз, когда мы создаем disposable-объект, и каждый раз, когда он финализируется, таким образом позволяя по этим логам найти, где мы создали объект, на котором забыли вызвать Dispose(). Возникает вопрос: а можно ли то же самое сделать при помощи генераторов?


На самом деле, на официальном сайте Microsoft, когда представляли генераторы, сказали, что всё это — code rewriting, оптимизация, logging injection, IL Weaving, переписывание кода. Всё это — замечательные и полезные сценарии, но генераторам они официально не подходят. Поэтому я хочу рассказать о нескольких обходных путях, которыми можно реализовать многие из подобных сценариев.


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


Например, добавлять подобную диагностическую информацию из коробки умеет
PostSharp, при помощи атрибута Log.


[Log]
public Request(int id)
{
    Id = id;
}

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


public Request(int id) {
    if (localState.IsEnabled(LogLevel.Debug)) {
        logRecordInfo = new LogRecordInfo(MethodEntry, …);
        recordBuilder1.SetParameter<int>(..., id);
    }
    try {
        this.Id = id;
        logRecordInfo = new LogRecordInfo(MethodException, …);
    }
    catch(Exception ex) {
        logRecordInfo = new LogRecordInfo(MethodException, …);      
        recordBuilder1.SetException(ex);
        throw;
    }
}

Можно ли то же самое сделать при помощи генераторов и из старого доброго ООП?


Как заведено в ООП, любая проблема решается введением еще одного уровня абстракции.


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


В этом случае исходный код в вашем проекте будет содержать только бизнес-логику:


interface IAccountingService
{
    AccountsSet GetAccounts(Client client);
    decimal GetTotalBalance(AccountsSet accounts);
}

class AccountingServiceCore : IAccountingService
{
    public AccountsSet GetAccounts(Client client) => …
    public decimal GetTotalBalance(AccountsSet accounts) => …
}

А генератором вы создадите декоратор с поддержкой логирования:



На самом деле, с генераторами можно обойтись даже без интерфейса — сделать приватную реализацию, в которой будет только бизнес-логика, а затем генератором добавить в тот же самый тип публичный метод, в котором уже будет логирование.


Но все возможности IL Weaving заменить всё равно не выйдет, либо оно просто этого не стоит. Например, в Fody есть NullGuard, который позволяет добавить проверку на null всем аргументам каждого метода. Можно ли сделать это декораторами? Наверное, можно, но вряд ли это будет удобно.


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


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


Поскольку мы модифицируем код внутри метода, то генераторами это сделать уже не удастся, ведь генератор не может изменить существующий код, только добавить новый…


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


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



Если очень хочется сделать это именно при помощи генераторов, то с креативным подходом возможно всё. Но, скорее всего, это будет уже не очень удобно.


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


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


И есть еще один вид кодогенерации — это рантайм-генерация при помощи класса ILGenerator. В рантайме мы создаем генератор и просто пишем байткод. Возможно, напрямую вы им не пользовались, но, например, могли пользоваться Expression<T>.Compile().


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


Тем не менее, зачастую ILGenerator используется для обработки данных, которые доступны только в рантайме, например, если вам хочется создать код для интерпретации переданного пользователем регулярного выражения. В этом случае на этапе компиляции, когда отрабатывают новые генераторы, вы просто не знаете, какой код должны будете создавать, поэтому для таких сценариев вам по-прежнему потребуется ILGenerator.


Рефлексия


Еще один инструмент метапрограммирования, который хотелось бы упомянуть, — это рефлексия.


Казалось бы, какая здесь связь? Рефлексией мы никакого нового кода не создаем. Мы просто работаем с существующими объектами. Но на самом деле, часто рефлексия используется просто чтобы сократить код. Например, когда вы сериализуете объект, неважно чем — Newtonsoft.Json, System.Text.Json, DataContractSerializer — чтобы сериализовать объект, нужно посмотреть, какие в нём есть поля и свойства, разумеется, при помощи рефлексии.


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


Я нашел генератор JsonSrcGen, который как раз предоставляет методы сериализации для каждого типа в проекте, и согласно замерам автора, время первого старта приложения и первой сериализации уменьшилось на несколько порядков. Если это является узким местом производительности для вашего приложения, то здесь тоже можно использовать генераторы и сократить использование рефлексии до минимума.


Можно также оптимизировать Dependency Injection. Ниже представлен пример заполнения контейнера для AutoFac-фреймворка, который регистрирует все типы в сборке в качестве интерфейса, в котором они реализуются. Делает он это, разумеется, рефлексией.


var builder = new ContainerBuilder();
var asm = GetExecutingAssembly();
builder.RegisterAssemblyTypes(asm)
    .AsImplementedInterfaces();

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


Так же как и с ILGenerator, заменить рефлексию сгенерированным кодом будет возможно, только если все данные, нужные вам для генерации кода, доступны на этапе компиляции. Если вы используете рефлексию для того, чтобы исследовать разные объекты в рантайме или обойти ограничения компилятора, например, поработать с приватными методами или свойствами, то здесь, разумеется, заменить ее генераторами не выйдет.


Как поменяется мир метапрограммирования?


Подводя итог существующим технологиям, можно сказать, что IL Weaving в лице Fody и PostSharp, скорее всего, станет нишевым, но свои преимущества у него есть. ILGenerator и рефлексия — тут всё зависит от ваших сценариев.


Единственная технология, которая, скорее всего, уйдет, — это T4-шаблоны. Как и генераторы, они только создают новые файлы, у них свой синтаксис, у которого нет прямой поддержки в IDE. Например, Roslyn уже использует генераторы там, где мог бы быть T4. У команды компилятора есть XML-файл с описанием языка C#, из которого создаются классы синтаксических элементов. Раньше они пользовались самописным скриптом, который генерировал C#-файлы. Можно посмотреть пулл-реквест, в котором они заменили его на генератор. Единственное преимущество T4 — он может создавать не только C#-файлы, а вообще что угодно, и вам гораздо проще контролировать время запуска.


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


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


Проблемы при использовании генераторов


Возможно, вы не очень хотите писать генератор самостоятельно. Здесь хорошие новости: генератор можно подключить как обычный NuGet-пакет, и всё будет работать. Поделиться своим генератором не сложнее, чем Roslyn-анализатором. Только помните, что в этом случае вы пишете код для чужого проекта.


Первое, что рекомендуется сделать — проверить версию языка. Существует распространенное заблуждение, что генераторы — фича C# 9. Но на самом деле это просто фича компилятора. Подключить генератор вы можете к любому проекту, например, хоть на C# 4. Если вы при этом воспользуетесь в сгенерированном коде рекорд-типами или паттерн-матчингом, то они просто создадут потребителям генератора ошибки компиляции.


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


Думайте обо всех сценариях. Приведу пример:


           // begin building the generated source
            StringBuilder source = new StringBuilder($@"
namespace {namespaceName}
{{
    public partial class {classSymbol.Name} : {notifySymbol.ToDisplayString()}
    {{
");

Autonotify-реализация от Microsoft — генератор, создающий NotifyPropertyChanged-свойства. Вот так начинается генерация этого типа: создается namespace, в нём лежит public partial class, реализующий этот интерфейс, дальше добавляются свойства. Всё очень похоже на мое демо.


Я часто занимаюсь модификациями кода, которые предлагает ReSharper. Поэтому первое, что мне здесь бросается в глаза, — это то, что здесь использован class. Структуры, реализующей NotifyPropertyChanged, наверное, не будет, а record — почему бы и нет?


Почему public class, а не internal class? Почему он лежит напрямую в namespace, ведь тип, который вы хотите расширить, может быть вложен в другой? Почему здесь вообще всегда пишется namespace, ведь я могу оставить его в глобальном неймспейсе сборки? Тогда имя неймспейса будет пустым и этот код тоже не скомпилируется. Так что даже в таком простом деле, как объявить тип в чужом проекте, существует огромное количество подводных камней и, к сожалению, как только вы начинаете заниматься метапрограммированием, обо всём этом приходится думать.


В том числе вам придется подумать, какие зависимости есть у кода, который вы создаете. Например, если вы создаете методы сериализации объекта, и у вас есть какой-то fallback, например, для массивов примитивных типов вы хотите использовать Newtonsoft.Json, то вам потребуется указать, что тот, кто добавит пакет с генератором в ваш проект, должен также подключить и Newtonsoft.Json.


Как это сделать, можно посмотреть по ссылке.


Я также рекомендую проверить в самом генераторе, что этот пакет действительно есть, потому что через несколько лет человек может забыть, зачем ему Newtonsoft.Json, который никогда не используют, и просто удалить его.


Также для работы генератора может оказаться недостаточно информации о C#-коде, предоставленной ему компилятором, например, вам могут потребоваться дополнительные данные, скажем, XML-файл с настройками, конфигурацией или схемой генерируемых типов. Они не являются частью C#-проекта, поэтому по умолчанию вам компилятор их не предоставит.


Для этого есть дополнительный тег AdditionalFiles, который позволяет предоставить подключенным генераторам доступ к любым дополнительным файлам. На стороне генератора вы затем сможете прочитать их из одноименного свойства в контексте генератора. К таким файлам также можно прикрепить дополнительные свойства. Например, если это файл, из которого будет создан какой-то тип, то нелишним будет указать, в каком неймспейсе создавать этот тип, или с каким именем.


Сделать всё это можно при помощи еще одного тега — CompilerVisibleItemMetadata.


<CompilerVisibleItemMetadata Include="AdditionalFiles" MetadataName="NamespaceName" />
<CompilerVisibleItemMetadata Include="AdditionalFiles" MetadataName="ClassName" />

<ItemGroup>
  <AdditionalFiles Include="Assets/Sample.svg" NamespaceName="Assets" ClassName="Sample" />
</ItemGroup>

Также можно предоставить генераторам доступ к MSBuild-свойствам, сделать через них любую top level-конфигурацию, например, включить или отключить генератор, выдавать из генератора диагностическую информацию. Мне очень понравился пример, когда человек по MSBuild-флажку заходит в ветку с Debugger.Launch() и дебажит генератор, только указав, что хочет это сделать.


Примечание: со времени подготовки доклада появилась более простая возможность подключения дебаггера к генератору. Вы можете добавить в проект генератора файл launchSettings.json и указать что это генератор, и добавить команду DebugRoslynComponent с указанием, для какого конкретно проекта должен быть запущен генератор при исполнении этой команды. Теперь вы можете просто выбрать конфигурацию с генератором в качестве запускаемого проекта и дебажить его в один клик.


Пример конфигурации в launchSettings.json:


{
  "profiles": {
    "Generator": {
    "commandName": "DebugRoslynComponent",
    "targetProject": "..\\Target.csproj"
    }
  }
}

Можно также сконфигурировать код, который создается, например, указав через отдельное свойство неймспейс для всех сгенерированных типов. Если это logging generator, то выбрать фреймворк, с каким уровнем логировать отдельные элементы, или что вообще логировать.


Сделать это можно при помощи CompilerVisibleProperty, которое затем можно прочитать из генератора при помощи свойства GlobalOptions с префиксом build_property.


Например, если вы хотите передавать опцию, включающую/выключающую логирование, это можно сделать следующим кодом:


В .csproj:
<CompilerVisibleProperty Include="EnableLogging" />


В коде генератора:
context.AnalyzerConfigOptions.GlobalOptions .TryGetValue("build_property.EnableLogging" , out var emitLoggingSwitch);


Еще один интересный вопрос: что делать, если генераторов несколько? С чем будет работать второй генератор?


Допустим, нашли на NuGet генератор для логгинга и хотите сами написать кэширование. Сможет ли один из генераторов увидеть код, созданный другим генератором для того же проекта, и воспользоваться им?


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



Создав информацию о коде проекта один раз, компилятор передает иммутабельный объект с ней во все генераторы, таким образом, они абсолютно не зависят друг от друга и создают новые файлы, которые затем становятся частью целевого проекта одновременно, после того как все генераторы завершили работу.


Что это значит? Во-первых, генераторы только создают код в текстовом виде, поэтому вы можете легко из одного генератора использовать тип, который когда-нибудь создаст другой, не думая о том, какой из генераторов отработает раньше. Все файлы от всех генераторов будут компилироваться вместе, поэтому если вы используете тип, который должен будет когда-то позже создать другой генератор, то проблемы нет — вы легко можете это сделать.


Во-вторых, поскольку вы добавляете новые файлы, любой вызов можно абстрагировать за интерфейсом. Например, если вы хотите добавить одним генератором логирование, а другим — кэширование, вы в исходном коде можете объявить интерфейс и написать бизнес-логику. Затем одним генератором создать логирующий декоратор, другим — кэширующий.



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


var logsAllCalls = new LoggingLogic(new CachingLogic(new LogicImpl()));


var logsUniqueArguments = new CachingLogic(new LoggingLogic(new LogicImpl()));


Последнее, что хочу здесь посоветовать, — используйте новый partial. В C# 8 partial-методы могли быть только приватными методами, которые ничего не возвращают.


В C# 9 у нас появилась возможность делать их публичными, возвращать из них значения.


partial class MyType
{ 
  partial void OnModelCreating(string input); // C# 8

  public partial bool IsPetMatch(string input); // C# 9
}

Но если уж мы из этого метода возвращаем значение, то оно должно откуда-то взяться. А именно, из реализации этого partial-метода в другой части типа.


partial class MyType
{
    public partial bool IsPetMatch(string input)
        => input is "dog" or "cat" or "fish";
}

Поэтому такой partial-метод обязан иметь реализацию в другой части этого типа. У вас получается abstract в рамках одного типа. То есть если вы его объявили, то обязаны и предоставить реализацию.


Возникает закономерный вопрос: зачем тогда вообще нужен partial, если я все равно должен предоставить реализацию? Может быть, ее сразу же сюда и написать? И ответ на этот вопрос — для удобства использования генераторов. Вторая часть типа, содержащая реализацию такого partial-метода, может быть в сгенерированном коде. В этом случае у такой декларации есть две важные функции.


Во-вторых, когда через несколько лет вы (или ваш коллега) откроете файл с этим типом, вы сразу увидите в нем декларации методов и будете знать всё о том, что это за тип и какие методы в нем есть, без необходимости исследовать все его сгенерированные части. Простая partial-декларация позволит вам сразу узнать о функциях, доступных в этом типе, и предоставит точку для удобной навигации к реализации этого метода.


Best practices


Наконец, я хочу предложить несколько советов о том, как писать генераторы на еще одном примере. У меня есть готовый logging-генератор и простая программа, которая эмулирует работу банковского сервиса, возвращая тестовые данные. Когда я запускаю программу, мне выдается информация, с какими аргументами я вызываю эти интерфейсы, сколько времени занимают эти вызовы, что они вернули.



Я начинаю вызов GetTotalAccountBalanceRemainder для клиента Пети, запрашиваю счета этого клиента (GetClientAccounts). Возвращается несколько счетов (Return value : Accounts), сумма минус 5 000 рублей — Петя мне должен. Всё это заняло 216 мс.


А при запросе для клиента Васи у меня вообще случилось исключение. Сделано это как раз при помощи logging-декоратора. Если я поищу реализации использованных интерфейсов в программе, я увижу, что у меня есть две реализации: реализация с бизнес-логикой в моем исходном коде и вторая реализация, которая предоставлена генератором. Здесь тот же самый интерфейс реализуется методом, который добавляет запись в лог, а затем передает управление основному объекту бизнес-логики.



Сделано это генератором, который очень похож на тот, что в первоначальном демо. Мы точно так же обходим все файлы в проекте, находим все объявления типов, затем я смотрю, что эта декларация имеет атрибут Log. Если это интерфейс, то я создаю к нему какую-то partial-часть, которая реализует этот интерфейс, уже добавляя запись в лог.


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


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


Не полагайтесь на синтаксис для семантических проверок. Например, если вам нужно проверить, что тип отмечен атрибутом LogAttribute, вы можете просто проверить синтаксически наличие атрибута [Log] для типа, и это будет работать в простых ситуациях. Но в реальном коде он может быть записан и как [Log], и как [LogAttribute], и даже любым другим именем благодаря тайп-алиасам, например [Generate], если в файле или проекте есть using GenerateAttribute = LogAttribute;. Для того чтобы вам не пришлось обрабатывать все эти случаи самостоятельно, а пользователи вашего генератора не ломали голову над тем, почему что-то не работает, вы можете сразу перейти к семантической модели типа и посмотреть, какие у него есть атрибуты. В этом случае вам уже не придется думать ни о том, как именно они записаны, ни о том, что у типа может быть несколько деклараций и атрибут может быть на любой из них.


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


Чтобы избежать ненужной работы в таком сценарии, вы можете проверять, что компилятор всё еще ждет от вашего генератора результат, проверив context.CancellationToken.


Используйте SyntaxReceiver, чтобы сохранить интересующие вас синтаксические элементы до запуска генератора. Зачастую генератору потребуется исследовать код проекта и посмотреть, например, какие типы в нем объявлены. Вы можете сделать это, обойдя код всего проекта в методе ISourceGenerator.Execute, но в таком случае вы каждый раз будете тратить на это время, а результаты не могут быть переиспользованы другими генераторами.


Вместо того чтобы самостоятельно обходить все синтаксические деревья в проекте, вы можете в методе Initialize зарегистрировать ISyntaxReceiver. Это тип со всего одним методом OnVisitSyntaxNode, который будет вызван компилятором для того, чтобы оповестить генератор о наличии в проекте данного синтаксического элемента. Затем вы можете сохранить интересующие вас элементы, например декларации типов или методов, и в основной работе генератора работать с этой созданной заранее коллекцией, вместо того чтобы исследовать весь код проекта заново.


В этом случае вы фактически заменяете pull-модель. Когда вы сами ищете нужную вам информацию в том, что предоставил компилятор на push-модель, то он, один раз подготавливая модель кода для всех подключенных к проекту анализаторов и генераторов, один раз сообщит вам о наличии какого-то элемента. Эта информация будет переиспользована всеми генераторами, задействующими SyntaxReceiver.


Обратите внимание, что в SyntaxReceiver вы работаете исключительно с синтаксисом, на данном этапе вы не можете проверить никакую семантическую информацию — она просто еще не готова, поэтому здесь вам нужно будет ограничиться простыми синтаксическими проверками, например, типом синтаксического элемента, который вы хотите обработать.


Если вы пользуетесь генераторами, поднимите уровень компиляторного предупреждения CS8785 до ошибки.


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


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


К сожалению, это создает еще одну интересную проблему: откуда в пользовательском проекте появятся эти типы? Не распространять же генератор с инструкцией «а для запуска объявите, пожалуйста, вот эти атрибуты в вашем проекте»?


К сожалению, у каждого способа их предоставить есть свои недостатки.


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


Еще один способ — объявить их в проекте с генератором. К сожалению, это будет значить, что у проекта, обработанного вашим генератором, будет рантайм-зависимость от сборки с генератором, ведь теперь в этом проекте используются атрибуты из этой сборки. Это означает, что потребители вашего генератора обязаны будут поставлять его как часть своего продукта, что может быть неудобно.


Еще один способ — создать нужные вам атрибуты прямо в генераторе. Ведь вы же создаете код для пользовательского проекта, почему бы не создать еще и несколько типов, которые ему потребуются? Однако, здесь есть интересная проблема — если помните, весь код, добавленный всеми генераторами, появится в проекте одновременно, когда они все завершат работу. Это означает, что ваш генератор не сможет воспользоваться атрибутом, который сам же создал — атрибут будет добавлен лишь после того, как он завершит работу! Но здесь есть и обходной путь — вы можете модифицировать тот объект Compilation, который передал вам Roslyn. Сам объект иммутабелен, поэтому это никак не помешает остальным генераторам. Всё, что вам нужно — создать синтаксическое дерево нужного вам атрибута и добавить его при помощи метода AddSyntaxTrees.


var logSyntaxTree = CSharpSyntaxTree.ParseText(logSrc, options);

 compilation = compilation.AddSyntaxTrees(logSyntaxTree);

var options = (CSharpParseOptions) 
compilation.SyntaxTrees.First().Options;

Примечание: со времени подготовки доклада появилась возможность добавлять подобные статичные ресурсы в методе ISourceGenerator.Initialize с помощью вызова GeneratorInitializationContext.RegisterForPostInitialization. Это более эффективно, так как вам не требуется менять семантическую модель проекта, с которым вы работаете, добавлением в него новых типов; и делается однократно, а не на каждый запуск генератора.


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


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


Например, если ваш генератор может работать только с интерфейсами, а пользователь при помощи атрибута попросил его обработать класс, вы можете воспользоваться методом context.ReportDiagnostic(Diagnostic.Create(...)) и сообщить ему, что именно пошло не так. Также у каждого синтаксического элемента есть метод .GetLocation(), при помощи которого вы можете показать разработчику конкретную строчку, на которой случилась проблема, и позволить ему снавигироваться к ошибке.


Используйте CompilerVisibleProperty и AdditionalFiles, чтобы предоставить дополнительную информацию генератору. Иногда генератору недостаточно той информации, которую он может получить, исследовав C#-проект, к которому он подключен. В таких случаях вы можете предоставить дополнительную информацию из msbuild-свойств или дополнительных файлов, сделав их доступными генератору при помощи новых тегов в .csproj.


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


По ссылке вы можете посмотреть пример, в котором я при помощи отдельного свойства управляю тем, будут ли зашифрованы логи приложения, добавленные при помощи генератора. Я установил свойство LogEncryption в true только для релизных билдов, поэтому я смогу без проблем читать логи из дебаг-билдов, но шифровать в релизных безо всяких дополнительных действий. Также вы можете предоставить дополнительные данные в отдельных файлах, например, вы можете не хотеть указывать в коде генератора ключ шифрования, а использовать отдельный файл с ключом, который будет отличаться на машине разработчика и в продакшен-билдах.


Помните о том, что генераторы — это third-party код, и они могут быть использованы, чтобы добавить в ваше приложение уязвимость. С большими возможностями приходит и большая ответственность. Генераторы могут быть использованы в том числе и в злонамеренных целях, причем возможности для этого у них огромны.


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


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


Во-вторых, для того чтобы исполнить какой-то код, генератору даже не требуется, чтобы вы вызвали сгенерированный им метод. В C# 9 появились инициализаторы модулей, которые исполняются в момент загрузки вашей сборки. Генератор может добавить инициализатор модуля и запустить в нем какую-то асинхронную активность, например, майнинг криптовалюты или поиск файла с конфигурацией подключения к базе данных с персональной информацией ваших клиентов. Причем все эти действия будут исполняться как часть вашего приложения — на машинах ваших клиентов и с правами вашего приложения.


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


Примечание: со времени подготовки доклада команда компилятора предоставила и способ защититься от подобных уязвимостей. Вы можете попросить компилятор сохранять на диск все файлы, созданные генераторами, а затем проверить, что на билд-сервере генератор создал именно тот код, который вы видели при разработке, и не добавил в него никаких сюрпризов.
Сделать это вы можете при помощи двух новых тэгов в .csproj:


<EmitCompilerGeneratedFiles>true</EmitCompilerGeneratedFiles>
<CompilerGeneratedFilesOutputPath>..\GeneratedFiles</CompilerGeneratedFilesOutputPath>


Выводы


  • Проверяйте CancellationToken
  • Используйте ISyntaxReceiver
  • Поднимите CS8785 до ошибки
  • Выдавайте диагностики
  • Предоставьте необходимые атрибуты как часть генератора
  • Делайте генераторы конфигурируемыми
  • Проверяйте, что добавляете в проект

Что в итоге?


  • Генераторы делают создание шаблонного кода еще проще
  • Они решают многие типичные проблемы метапрограммирования, такие как навигация, дебаг, тестирование, порог вхождения
  • Возможности оптимизации старта приложения
  • Многие виды кодогенерации становятся более нишевыми
  • Генераторы только добавляют код

Примеры генераторов


  • JsonSrcGen — сериализация в JSON без рефлексии
  • ThisAssembly — константы текущей сборки: версия, название сборки, продукта
  • StringLiteralGeneratorReadonlySpan<byte> любой строки, заданной в атрибуте

Полезные ссылки



На этом всё, спасибо за внимание!


Следующий DotNext пройдёт в ноябре по хитрой схеме:
— 3-4 ноября: онлайн-часть
— 20 ноября: офлайн-часть в Москве с онлайн-трансляцией для тех, кто не готов добраться


Так что можно хоть посмотреть все доклады удалённо, хоть увидеть спикеров и других участников лично. Как обычно, будет много подобного технического контента про .NET — так что, если вы .NET-разработчик, обратите внимание.

Теги:
Хабы:
Всего голосов 25: ↑25 и ↓0+25
Комментарии7

Публикации

Информация

Сайт
jugru.org
Дата регистрации
Дата основания
Численность
51–100 человек
Местоположение
Россия
Представитель
Алексей Федоров