Patrón para delegar el comportamiento asíncrono en C #

9

Estoy tratando de diseñar una clase que exponga la capacidad de agregar problemas de procesamiento asincrónico. En la programación síncrona, esto podría verse así

   public class ProcessingArgs : EventArgs
   {
      public int Result { get; set; }
   } 

   public class Processor 
   {
        public event EventHandler<ProcessingArgs> Processing { get; }

        public int Process()
        {
            var args = new ProcessingArgs();
            Processing?.Invoke(args);
            return args.Result;
        }
   }


   var processor = new Processor();
   processor.Processing += args => args.Result = 10;
   processor.Processing += args => args.Result+=1;
   var result = processor.Process();

En un mundo asincrónico, donde cada preocupación puede necesitar devolver una tarea, esto no es tan simple. He visto esto de muchas maneras, pero tengo curiosidad por saber si hay alguna mejor práctica que la gente haya encontrado. Una posibilidad simple es

 public class Processor 
   {
        public IList<Func<ProcessingArgs, Task>> Processing { get; } =new List<Func<ProcessingArgs, Task>>();

        public async Task<int> ProcessAsync()
        {
            var args = new ProcessingArgs();
            foreach(var func in Processing) 
            {
                await func(args);
            }
            return args.Result
        }
   }

¿Hay algún "estándar" que la gente haya adoptado para esto? No parece haber un enfoque coherente que haya observado en las API populares.

Jeff
fuente
No estoy seguro de qué es lo que está tratando de hacer y por qué.
Nkosi
Estoy tratando de delegar las preocupaciones de implementación a un observador externo (similar al polimorfismo y un deseo de composición sobre la herencia). Principalmente para evitar una cadena de herencia problemática (y en realidad imposible porque requeriría herencia múltiple).
Jeff
¿Las preocupaciones están relacionadas de alguna manera y serán procesadas en secuencia o en paralelo?
Nkosi
Parecen compartir el acceso a la, ProcessingArgsasí que estaba confundido sobre eso.
Nkosi
1
Ese es precisamente el punto de la pregunta. Los eventos no pueden devolver una tarea. E incluso si uso un delegado que devuelve una Tarea de T, el resultado se perderá
Jeff

Respuestas:

2

El siguiente delegado se usará para manejar problemas de implementación asincrónica

public delegate Task PipelineStep<TContext>(TContext context);

De los comentarios se indicó

Un ejemplo específico es agregar múltiples pasos / tareas necesarios para completar una "transacción" (funcionalidad LOB)

La siguiente clase permite la creación de un delegado para manejar dichos pasos de manera fluida similar al middleware de .net core

public class PipelineBuilder<TContext> {
    private readonly Stack<Func<PipelineStep<TContext>, PipelineStep<TContext>>> steps =
        new Stack<Func<PipelineStep<TContext>, PipelineStep<TContext>>>();

    public PipelineBuilder<TContext> AddStep(Func<PipelineStep<TContext>, PipelineStep<TContext>> step) {
        steps.Push(step);
        return this;
    }

    public PipelineStep<TContext> Build() {
        var next = new PipelineStep<TContext>(context => Task.CompletedTask);
        while (steps.Any()) {
            var step = steps.Pop();
            next = step(next);
        }
        return next;
    }
}

La siguiente extensión permite una configuración en línea más simple utilizando envoltorios

public static class PipelineBuilderAddStepExtensions {

    public static PipelineBuilder<TContext> AddStep<TContext>
        (this PipelineBuilder<TContext> builder,
        Func<TContext, PipelineStep<TContext>, Task> middleware) {
        return builder.AddStep(next => {
            return context => {
                return middleware(context, next);
            };
        });
    }

    public static PipelineBuilder<TContext> AddStep<TContext>
        (this PipelineBuilder<TContext> builder, Func<TContext, Task> step) {
        return builder.AddStep(async (context, next) => {
            await step(context);
            await next(context);
        });
    }

    public static PipelineBuilder<TContext> AddStep<TContext>
        (this PipelineBuilder<TContext> builder, Action<TContext> step) {
        return builder.AddStep((context, next) => {
            step(context);
            return next(context);
        });
    }
}

Se puede extender aún más según sea necesario para envoltorios adicionales.

Un ejemplo de caso de uso del delegado en acción se demuestra en la siguiente prueba

[TestClass]
public class ProcessBuilderTests {
    [TestMethod]
    public async Task Should_Process_Steps_In_Sequence() {
        //Arrange
        var expected = 11;
        var builder = new ProcessBuilder()
            .AddStep(context => context.Result = 10)
            .AddStep(async (context, next) => {
                //do something before

                //pass context down stream
                await next(context);

                //do something after;
            })
            .AddStep(context => { context.Result += 1; return Task.CompletedTask; });

        var process = builder.Build();

        var args = new ProcessingArgs();

        //Act
        await process.Invoke(args);

        //Assert
        args.Result.Should().Be(expected);
    }

    public class ProcessBuilder : PipelineBuilder<ProcessingArgs> {

    }

    public class ProcessingArgs : EventArgs {
        public int Result { get; set; }
    }
}
Nkosi
fuente
Hermoso código
Jeff
¿No te gustaría esperar el próximo y luego esperar el paso? Supongo que depende si Agregar implica que agregue código para ejecutar antes que cualquier otro código que se haya agregado. La forma en que es es más como un "inserto"
Jeff
1
Los pasos de @Jeff se ejecutan de manera predeterminada en el orden en que se agregaron a la tubería. La configuración en línea predeterminada le permite cambiar eso manualmente si lo desea en caso de que se realicen acciones de publicación en el camino de regreso
Nkosi
¿Cómo diseñaría / alteraría esto si quisiera usar la Tarea de T como resultado en lugar de simplemente establecer el contexto. ¿Simplemente actualizaría las firmas y agregaría un método Insertar (en lugar de simplemente Agregar) para que un middleware pueda comunicar su resultado a otro middleware?
Jeff
1

Si desea mantenerlo como delegados, puede:

public class Processor
{
    public event Func<ProcessingArgs, Task> Processing;

    public async Task<int?> ProcessAsync()
    {
        if (Processing?.GetInvocationList() is Delegate[] processors)
        {
            var args = new ProcessingArgs();
            foreach (Func<ProcessingArgs, Task> processor in processors)
            {
                await processor(args);
            }
            return args.Result;
        }
        else return null;
    }
}
Paulo Morgado
fuente