Русский
Русский
English
Статистика
Реклама

Заменяем события 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

Источник: habr.com
К списку статей
Опубликовано: 15.12.2020 12:20:26
0

Сейчас читают

Комментариев (0)
Имя
Электронная почта

Net

Visual studio

C

Разработка под linux

Разработка под windows

Reactivex

Reactive extensions

Events

C# source generators

Completionprovider

Категории

Последние комментарии

  • Имя: Макс
    24.08.2022 | 11:28
    Я разраб в IT компании, работаю на арбитражную команду. Мы работаем с приламы и сайтами, при работе замечаются постоянные баны и лаги. Пацаны посоветовали сервис по анализу исходного кода,https://app Подробнее..
  • Имя: 9055410337
    20.08.2022 | 17:41
    поможем пишите в телеграм Подробнее..
  • Имя: sabbat
    17.08.2022 | 20:42
    Охренеть.. это просто шикарная статья, феноменально круто. Большое спасибо за разбор! Надеюсь как-нибудь с тобой связаться для обсуждений чего-либо) Подробнее..
  • Имя: Мария
    09.08.2022 | 14:44
    Добрый день. Если обладаете такой информацией, то подскажите, пожалуйста, где можно найти много-много материала по Yggdrasil и его уязвимостях для написания диплома? Благодарю. Подробнее..
© 2006-2024, personeltest.ru