Заменяем события C# на Reactive Extensions с помощью кодогенерации

Моя цель - предложение широкого ассортимента товаров и услуг на постоянно высоком качестве обслуживания по самым выгодным ценам.

Здравствуйте, меня зовут Иван и я разработчик.

Недавно прошла приуроченная к выходу .NET 5 конференция .NETConf 2020. На которой один из докладчиков рассказывал про C# Source Generators. Поискав на youtube нашел еще неплохое видео по этой теме. Советую их посмотреть. В них показывается как во время написания кода разработчиком, генерируется код, а InteliSense тут же подхватывает сгенерированный код, предлагает сгенерированные методы и свойства, а компилятор не ругается на их отсутствие. На мой взгляд, это хорошая возможность для расширения возможностей языка и я попробую это продемонстрировать.

Идея

Все же знают LINQ? Так вот для событий есть аналогичная библиотека Reactive Extensions, которая позволяет в том же виде, что и LINQ обрабатывать события.

Проблема в том, что чтобы пользоваться Reactive Extensions надо и события оформить в виде Reactive Extensions, а так как все события, в стандартных библиотеках, написаны в стандартном виде то и Reactive Extensions использовать не удобно. Есть костыль, который преобразует стандартные события C# в вид Reactive Extensions. Выглядит он так. Допустим есть класс с каким-то событием:

public partial class Example
{
    public event Action<int, string, bool> ActionEvent;
}

Чтобы этим событием можно было пользоваться в стиле Reactive Extensions необходимо написать метод расширения вида:

public static IObservable<(int, string, bool)> RxActionEvent(this TestConsoleApp.Example obj)
{
    if (obj == null) throw new ArgumentNullException(nameof(obj));
    return Observable.FromEvent<System.Action<int, string, bool>, (int, string, bool)>(
    conversion => (obj0, obj1, obj2) => conversion((obj0, obj1, obj2)),
    h => obj.ActionEvent += h,
    h => obj.ActionEvent -= h);
}

И после этого можно воспользоваться всеми плюсами Reactive Extensions, например, вот так:

var example = new  Example();
example.RxActionEvent().Where(obj => obj.Item1 > 10).Take(1).Subscribe((obj)=> { /* some action  */});

Так вот, идея состоит в том, чтобы костыль этот генерировался сам, а методами можно было пользоваться из InteliSense при разработке.

Задача

1) Если в коде после установленного маркера «.» использующегося для обращения к члену класса идет полноценное обращение к методу начинающемуся на «Rx», например, example.RxActionEvent(), а имя метода совпадает с именем одного из событий класса, например, у класса есть событие Action ActionEvent, а в коде написано .RxActionEvent(), должен сгенерироваться следующий код:

public static IObservable<(System.Int32 Item1Int32, System.String Item2String, System.Boolean Item3Boolean)> RxActionEvent(this TestConsoleApp.Example obj)
{
    if (obj == null) throw new ArgumentNullException(nameof(obj));
    return Observable.FromEvent<System.Action<System.Int32, System.String, System.Boolean>, (System.Int32 Item1Int32, System.String Item2String, System.Boolean 
Item3Boolean)>(
    conversion => (obj0, obj1, obj2) => conversion((obj0, obj1, obj2)),
    h => obj.ActionEvent += h,
    h => obj.ActionEvent -= h);
}

2) InteliSense должен подсказывать имя метода до его генерации.

Настройка проектов

Для начала надо создать 2 проекта первый для самого генератора второй для тестов и отладки.

Проект генератора выглядит следующим образом:

<Project Sdk="Microsoft.NET.Sdk">
  <PropertyGroup>
    <TargetFramework>netstandard2.0</TargetFramework>
    <LangVersion>preview</LangVersion>
  </PropertyGroup>
  <ItemGroup>
    <PackageReference Include="Microsoft.CodeAnalysis.Analyzers" Version="3.3.1">
      <PrivateAssets>all</PrivateAssets>
      <IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
    </PackageReference>
    <PackageReference Include="Microsoft.CodeAnalysis.CSharp.Workspaces" Version="3.8.0" />
  </ItemGroup>
</Project>

Обратите внимание, что проект должен быть netstandard2.0 и включать 2 пакета Microsoft.CodeAnalysis.Analyzers и Microsoft.CodeAnalysis.CSharp.Workspaces.

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

<Project Sdk="Microsoft.NET.Sdk">
  <PropertyGroup>
    <OutputType>Exe</OutputType>
    <TargetFramework>netcoreapp3.1</TargetFramework>
    <LangVersion>preview</LangVersion>
  </PropertyGroup>
  <ItemGroup>
    <PackageReference Include="System.Reactive" Version="5.0.0" />
  </ItemGroup>
  <ItemGroup>
    <ProjectReference Include="..\SourceGenerator\RxSourceGenerator.csproj" ReferenceOutputAssembly="false" OutputItemType="Analyzer" />
  </ItemGroup>
</Project>

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

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

Разработка генератора

Сам генератор должен быть помечен атрибутом [Generator] и реализовывать ISourceGenerator:

[Generator]
public class RxGenerator : ISourceGenerator
{
    public void Initialize(GeneratorInitializationContext context)  {  }
    public void Execute(GeneratorExecutionContext context)  {  }
}

Mетод Initialize используется для инициализации генератора, а Execute для генерации исходного кода.

В методе Initialize мы можем зарегистрировать ISyntaxReceiver.

Логика, здесь следующая:

  • файл парсится на синтаксис->

  • каждый синтаксис в файле передается в ISyntaxReceiver->

  • в ISyntaxReceiver надо отобрать тот синтаксис, который нужен для генерации кода->

  • в методе Execute ждем когда придет ISyntaxReceiver, и на его базе генерируем код.

Если это звучит сложно, то код выглядит просто:

[Generator]
public class RxGenerator : ISourceGenerator
{
    private const string firstText = @"using System; using System.Reactive.Linq; namespace RxGenerator{}";
    public void Initialize(GeneratorInitializationContext context)
    {
        // Регистрируем ISyntaxReceiver
        context.RegisterForSyntaxNotifications(() => new SyntaxReceiver());
    }
    public void Execute(GeneratorExecutionContext context)
    {
        if (context.SyntaxReceiver is not SyntaxReceiver receiver) return;
        // Добавляем новый файл с именем "RxGenerator.cs" и текстом, что в firstText
        context.AddSource("RxGenerator.cs", fitstText);
    }
    class SyntaxReceiver : ISyntaxReceiver
    {
        public void OnVisitSyntaxNode(SyntaxNode syntaxNode)
        {
        // здесь надо отобрать тот синтаксис, который нужен для генерации кода.
        }
    }
}

Если на данной стадии скомпилировать проект генератора и перезагрузить VS, то в код тестового проекта можно добавить using RxGenerator; и на него не будет ругаться VS.

Отбор синтаксиса в ISyntaxReceiver

В методе OnVisitSyntaxNode находим синтаксис MemberAccessExpressionSyntax.

private class SyntaxReceiver : ISyntaxReceiver
{
    public List<MemberAccessExpressionSyntax> GenerateCandidates { get; } =
        new List<MemberAccessExpressionSyntax>();

    public void OnVisitSyntaxNode(SyntaxNode syntaxNode)
    {
        if (!(syntaxNode is MemberAccessExpressionSyntax syntax)) return;
        if (syntax.HasTrailingTrivia || syntax.Name.IsMissing) return;
        if (!syntax.Name.ToString().StartsWith("Rx")) return;
        GenerateCandidates.Add(syntax);

    }
}

Здесь:

  • syntax.Name.IsMissing это случай когда поставили точку и ничего не написали

  • syntax.HasTrailingTrivia это случай когда поставили точку и что-то начали печатать

  • !syntax.Name.ToString().StartsWith("Rx") это случай когда поставили точку написали метод но метод не начинается с "Rx"

Эти случаи надо исключить, остальное попадает в список кандидатов на генерацию кода.

Получение всей необходимой информации для генерации

Чтобы сгенерировать метод расширения необходима следующая информация:

  • Тип класса, для которого генерируются методы

  • Полный тип события. Например, 

    System.Action<System.Int32, System.String, System.Boolean,xSouceGeneratorXUnitTests.SomeEventArgs>

  • Список всех аргументов делегата события

Получения этой информации рассмотрим на коде:

private static IEnumerable<(string ClassType, string EventName, string EventType, List<string> ArgumentTypes, bool IsStub)>
    GetExtensionMethodInfo(GeneratorExecutionContext context, SyntaxReceiver receiver)
{
    HashSet<(string ClassType, string EventName)>
        hashSet = new HashSet<(string ClassType, string EventName)>();
    foreach (MemberAccessExpressionSyntax syntax in receiver.GenerateCandidates)
    {
        SemanticModel model = context.Compilation.GetSemanticModel(syntax.SyntaxTree);
        ITypeSymbol? typeSymbol = model.GetSymbolInfo(syntax.Expression).Symbol switch
        {
            IMethodSymbol s => s.ReturnType,
            ILocalSymbol s => s.Type,
            IPropertySymbol s => s.Type,
            IFieldSymbol s => s.Type,
            IParameterSymbol s => s.Type,
            _ => null
        };
        if (typeSymbol == null) continue;

...

Для того чтобы получить тип класса необходимо сначала получить SemanticModel. Из неё получить информацию о объекте для которого генерируются методы. И вот оттуда получаем тип ITypeSymbol. А из ITypeSymbol можно получить остальную информацию.

...
        string eventName = syntax.Name.ToString().Substring(2);

        if (!(typeSymbol.GetMembersOfType<IEventSymbol>().FirstOrDefault(m => m.Name == eventName) is { } ev)
        ) continue;

        if (!(ev.Type is INamedTypeSymbol namedTypeSymbol)) continue;
        if (namedTypeSymbol.DelegateInvokeMethod == null) continue;
        if (!hashSet.Add((typeSymbol.ToString(), ev.Name))) continue;

        string fullType = namedTypeSymbol.ToDisplayString(SymbolDisplayFormat);
        List<string> typeArguments = namedTypeSymbol.DelegateInvokeMethod.Parameters
            .Select(m => m.Type.ToDisplayString(SymbolDisplayFormat)).ToList();
        yield return (typeSymbol.ToString(), ev.Name, fullType, typeArguments, false);
    }
}

Здесь стоит отдельно обратить внимание на:

string fullType = namedTypeSymbol.ToDisplayString(symbolDisplayFormat);

SymbolDisplayFormat это такой хитрый класс SymbolDisplayFormat который объясняет методу ToDisplayString() в каком виде необходимо выдать информацию. Без него метод ToDisplayString() вместо:

System.Action<System.Int32, System.String, System.Boolean, RxSouceGeneratorXUnitTests.SomeEventArgs>

вернёт

Action<int, string, bool, SomeEventArgs>

То есть в сокращенном виде.

Также интересно место:

List<string> typeArguments = namedTypeSymbol.DelegateInvokeMethod.Parameters.Select(m => m.Type.ToDisplayString(SymbolDisplayFormat)).ToList();

Здесь получаются типы аргументов делегата события.

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

Полный код метода Execute:

Spoiler
public void Execute(GeneratorExecutionContext context)
{
    if (!(context.SyntaxReceiver is SyntaxReceiver receiver)) return;

    if (!(receiver.GenerateCandidates.Any()))
    {
        context.AddSource("RxGenerator.cs", startText);
        return;
    }

    StringBuilder sb = new StringBuilder();
    sb.AppendLine("using System;");
    sb.AppendLine("using System.Reactive.Linq;");
    sb.AppendLine("namespace RxMethodGenerator{");
    sb.AppendLine("    public static class RxGeneratedMethods{");

    foreach ((string classType, string eventName, string eventType, List<string> argumentTypes, bool isStub) in GetExtensionMethodInfo(context,
        receiver))
    {
        string tupleTypeStr;
        string conversionStr;

        switch (argumentTypes.Count)
        {
            case 0:
                tupleTypeStr = classType;
                conversionStr = "conversion => () => conversion(obj),";
                break;
            case 1:
                tupleTypeStr = argumentTypes.First();
                conversionStr = "conversion => obj1 => conversion(obj1),";
                break;
            default:
                tupleTypeStr =
                    $"({string.Join(", ", argumentTypes.Select((x, i) => $"{x} Item{i + 1}{x.Split('.').Last()}"))})";
                string objStr = string.Join(", ", argumentTypes.Select((x, i) => $"obj{i}"));
                conversionStr = $"conversion => ({objStr}) => conversion(({objStr})),";
                break;
        }

        sb.AppendLine(
            @$"        public static IObservable<{tupleTypeStr}> Rx{eventName}(this {classType} obj)");
        sb.AppendLine(@"        {");
        if (isStub)
        {
            sb.AppendLine("            throw new Exception('RxGenerator stub');");
        }
        else
        {
            sb.AppendLine("            if (obj == null) throw new ArgumentNullException(nameof(obj));");
            sb.AppendLine(@$"            return Observable.FromEvent<{eventType}, {tupleTypeStr}>(");
            sb.AppendLine(@$"            {conversionStr}");
            sb.AppendLine(@$"            h => obj.{eventName} += h,");
            sb.AppendLine(@$"            h => obj.{eventName} -= h);");
        }
        sb.AppendLine("        }");
    }
    sb.AppendLine("    }");
    sb.AppendLine("}");

    context.AddSource("RxGenerator.cs", sb.ToString());
}

Добавление в InteliSense метода расширение до его генерации

На текущей стадии после установленного маркера «.» InteliSense нам буде подсказывать имя метода расширения только если генератор уже его сгенерировал. Но хотелось бы чтобы подсказка была всегда. Я пробовал при установки маркера «.» получать все события из объекта и для них генерировать методы расширения. Это работает, но разработчики MS советуют так не делать и обещают добавить функционал обработки редактируемого кода в будущем. Поэтому я пошел другим путем.

На самом деле можно написать CompletionProvider это как раз действия InteliSense после установленного маркера «.». С недавних пор его можно поставлять через NuGet, так что его можно положить рядом с генератором.

Итак по порядку.

В CompletionProvider есть метод, который отбирает триггеры, на которые отработает CompletionProvider:

public override bool ShouldTriggerCompletion(SourceText text, int caretPosition, CompletionTrigger trigger, OptionSet options)
{
    switch (trigger.Kind)
    {
        case CompletionTriggerKind.Insertion:
            int insertedCharacterPosition = caretPosition - 1;
            if (insertedCharacterPosition <= 0) return false;
            char ch = text[insertedCharacterPosition];
            char previousCh = text[insertedCharacterPosition - 1];
            return ch == '.' && !char.IsWhiteSpace(previousCh) && previousCh != '\t' && previousCh != '\r' && previousCh != '\n';
        default:
            return false;
    }
}

В данном случае отбирается установленный маркер «.» если перед ним есть какой-то символ.

Если метод вернет True то сработает следующий метод, в котором подготавливаются элементы InteliSense:

public override async Task ProvideCompletionsAsync(CompletionContext context)
{
    SyntaxNode? syntaxNode = await context.Document.GetSyntaxRootAsync(context.CancellationToken).ConfigureAwait(false);
    if (!(syntaxNode?.FindNode(context.CompletionListSpan) is ExpressionStatementSyntax
        expressionStatementSyntax)) return;
    if (!(expressionStatementSyntax.Expression is MemberAccessExpressionSyntax syntax)) return;
    if (!(await context.Document.GetSemanticModelAsync(context.CancellationToken).ConfigureAwait(false) is { }
        model)) return;

    ITypeSymbol? typeSymbol = model.GetSymbolInfo(syntax.Expression).Symbol switch
    {
        IMethodSymbol s => s.ReturnType,
        ILocalSymbol s => s.Type,
        IPropertySymbol s => s.Type,
        IFieldSymbol s => s.Type,
        IParameterSymbol s => s.Type,
        _ => null
    };
    if (typeSymbol == null) return;

    foreach (IEventSymbol ev in typeSymbol.GetMembersOfType<IEventSymbol>())
    {
        ...        
        // Создаем и добавляем элемент InteliSense
        CompletionItem item = CompletionItem.Create($"Rx{ev.Name}");
        context.AddItem(item);
    }
}

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

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

public override Task<CompletionDescription> GetDescriptionAsync(Document document, CompletionItem item, CancellationToken cancellationToken)
{
    return Task.FromResult(CompletionDescription.FromText("Описание метода"));
}

Если в InteliSense выбрать созданный элемент сработает следующий метод, который непосредственно заменяет все, что было набрано после маркера «.» на выбранный метод:

public override async Task<CompletionChange> GetChangeAsync(Document document, CompletionItem item,
    char? commitKey, CancellationToken cancellationToken)
{
    string newText = $".{item.DisplayText}()";
    TextSpan newSpan = new TextSpan(item.Span.Start - 1, 1);

    TextChange textChange = new TextChange(newSpan, newText);
    return await Task.FromResult(CompletionChange.Create(textChange));
}

Всё!

Где и как это работает

Все это работает в Visual Studio №16.8.3. На GitHab есть гифка демонстрирующая как это выглядит в Visual Studio. В Rider и ReSharper пока не работает. Так что не забудьте выключить ReSharper перед экспериментами.

Сами генераторы исходного кода работают на проектах простой консольки или библиотеках, это я проверял. На WPF не работает, этот баг описан на GitHab Roslyn.

Для CompletionProvider все работает если его собрать как Vsix расширение. Если как NuGet работает только само добавление метода. Описание метода не работает. Я сделал чтобы автоматом еще using добавлялись, но это тоже пока не работает для NuGet.

Как это все отлаживать

Генератор отлаживать можно добавив в метод Initialize строчку Debugger.Launch(); и перезапустить VS

public override async Task<CompletionChange> GetChangeAsync(Document document, CompletionItem item,
    char? commitKey, CancellationToken cancellationToken)
{
public void Initialize(GeneratorInitializationContext context)
{
    #if (DEBUG)
    Debugger.Launch();
    #endif
    context.RegisterForSyntaxNotifications(() => new SyntaxReceiver());
}

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

Для отладки CompletionProvider проще всего использовать шаблон в VS «Analyzer with code Fix». Создать проекты по шаблону, после чего запускать проект Vsix. Он буде загружать новую студию с подключенным CompletionProvider как расширение, в котором можно нормально отлаживать.

Краткий вывод

Код генератора уместился в 140 строк. За эти 140 строк получилось изменить синтаксис языка, избавится от событий заменив их на Reactive Extensions с более удобным, на мой взгляд, подходом. Я думаю, что технология генераторов исходного кода сильно изменит подход к разработке библиотек и расширений.

Ссылки

NuGet

GitHab

Источник: https://habr.com/ru/post/533128/


Интересные статьи

Интересные статьи

Недавно на проекте интегрировал модуль CRM Битрикса c виртуальной АТС Ростелеком. Делал по стандартной инструкции, где пошагово показано, какие поля заполнять. Оказалось, следование ей не гаран...
Здравствуйте! Меня зовут Игорь Шамаев, я главный инженер по разработке в команде SmartData. Занимаюсь fullstack-разработкой внутренней аналитической BI-системы. В нашей компании React...
Вы наверняка слышали это знаменитое высказывание от GoF: «Предпочитайте композицию наследованию класса». И дальше, как правило, шли длинные размышления на тему того, как статически определяемое н...
Компании растут и меняются. Если для небольшого бизнеса легко прогнозировать последствия любых изменений, то у крупного для такого предвидения — необходимо изучение деталей.
Привет, Хабр! Меня зовут Саша и я backend разработчик. В свободное от работы время я изучаю ML и развлекаюсь с данными hh.ru. Эта статья о том, как мы с помощью машинного обучения автоматизиро...