Reemplazo de eventos de C # con extensiones reactivas mediante la generación de código

Hola, mi nombre es Ivan y soy desarrollador.





La conferencia .NETConf 2020 se celebró recientemente, programada para coincidir con el lanzamiento de .NET 5, donde uno de los oradores habló sobre C # Source Generators . Después de buscar en youtube, encontré otro buen video sobre este tema . Te aconsejo que los mires. Muestran cómo, mientras el desarrollador escribe el código, el código se genera, e InteliSense inmediatamente recoge el código generado, ofrece los métodos y propiedades generados, y el compilador no jura por su ausencia. En mi opinión, esta es una buena oportunidad para ampliar las capacidades del idioma y trataré de demostrarlo.





Idea

¿ Alguien conoce LINQ ? Entonces, para los eventos, existe una biblioteca similar Reactive Extensions , que le permite procesar eventos de la misma manera que LINQ .





El problema es que para utilizar las extensiones reactivas, es necesario organizar los eventos en forma de extensiones reactivas y, dado que todos los eventos de las bibliotecas estándar están escritos en una forma estándar, no es conveniente utilizar extensiones reactivas. Hay una muleta que convierte los eventos estándar de C # en extensiones reactivas. Se parece a esto. Digamos que hay una clase con algún evento:





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



Para usar este evento en el estilo Extensiones reactivas , debe escribir un método de extensión de vista:





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);
}
      
      



Y después de eso, puedes aprovechar todas las ventajas de Reactive Extensions , por ejemplo, así:





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



Entonces, la idea es que esta muleta se genere por sí misma, y ​​los métodos se pueden utilizar desde InteliSense durante el desarrollo.





Una tarea

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", firstText);
    }
    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)>
    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);
    }
}
      
      



:





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();
    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) 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( @"        {");

        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. GitHub Visual Studio. Rider ReSharper 2020.3. ReSharper , 2020.3.





, . WPF , GitHub Roslyn.





CompletionProvider Vsix . NuGet . . using , NuGet.





   

Initialize Debugger.Launch();



VS





public void Initialize(GeneratorInitializationContext context)
{
    #if (DEBUG)
    Debugger.Launch();
    #endif
    context.RegisterForSyntaxNotifications(() => new SyntaxReceiver());
}
      
      



. - VS, .





CompletionProvider VS «Analyzer with code Fix». , Vsix. CompletionProvider , .





 

El código del generador encaja en 140 líneas. Para estas 140 líneas, resultó cambiar la sintaxis del lenguaje, deshacerse de los eventos reemplazándolos con Extensiones Reactivas con un enfoque más conveniente, en mi opinión. Creo que la tecnología de los generadores de código fuente cambiará en gran medida el enfoque para desarrollar bibliotecas y extensiones.





Enlaces

NuGet





Github








All Articles