Source Generators в действии

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

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

epdgbgd9jjjvbebwyg1fdgrpbk8.jpeg

Что вообще такое эти 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;
}

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

nxyfpzmqa2bmiqvxkbj3jfz9zom.png

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

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

aiogp4h6de0ql67jknbtd8lx-cm.png


Что такое Source Generators?

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

gpcb6r6eqd2dqa1xhku1ioy-bzq.png

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 этой модели, меняю какие-то свойства, получаю от них нотификации, то есть просто тестирую, что всё это работает.

2ntwpcaejym2hkjkvariszlvfeu.png

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

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

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

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



  
    netstandard2.0
    preview
  

  
    
    
    
  

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

Теперь пришло время написать сам генератор — он должен реализовать интерфейс 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()

    }
  }
}

Есть и второй способ, если не хочется возиться с поиском нужного типа или угадать его название не получается — можно зайти на сайт 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()
            .Select(x => semanticModel.GetDeclaredSymbol(x))
        .OfType()
            .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()
    .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, как с наиболее популярным способом сделать то же самое, добавить что-то в проект. Все плюсы и минусы проистекают здесь из разницы в подходах.

2udk51pn79utkrjvq8agv0f0d_g.jpeg

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

qtqcapmzibrgozdluvndagmx0gq.png

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

yvdxi0smez5wfniq6-qccipxk2g.png

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

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


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

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

opysho1mow4uc-d0foxayayxwiu.png

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

tchryhhica5cqakaavsyq1oq21s.png

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

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

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

dwjvfthh-ljbucanpxpxewoeygy.png

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

d08av3qb6ymt4do1egwresskxh0.png

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

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

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

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

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

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


Так IL Weaving мертв?

ctxvp3srm82owdlcqzcrajq1zdg.png

В 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(..., 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) => …
}

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

qlzfo4h3cxgdguiozrqrvu0iodc.png

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

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

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

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

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

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

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

gaxsj2rvno99fmg_vlp4czcj8ee.png

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

© Habrahabr.ru