Padrão para delegar comportamento assíncrono em C #

9

Estou tentando criar uma classe que expõe a capacidade de adicionar preocupações de processamento assíncrono. Na programação síncrona, isso pode parecer

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

em um mundo assíncrono, onde cada preocupação pode precisar retornar uma tarefa, isso não é tão simples. Eu já vi isso de várias maneiras, mas estou curioso para saber se existem práticas recomendadas que as pessoas encontraram. Uma possibilidade simples é

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

Existe algum "padrão" que as pessoas adotaram para isso? Não parece haver uma abordagem consistente que observei nas APIs populares.

Jeff
fonte
Não tenho certeza do que você está tentando fazer e por quê.
Nkosi
Estou tentando delegar preocupações de implementação a um observador externo (semelhante ao polimorfismo e um desejo de composição sobre a herança). Principalmente para evitar uma cadeia de herança problemática (e realmente impossível porque exigiria herança múltipla).
Jeff
As preocupações estão relacionadas de alguma forma e serão processadas em sequência ou em paralelo?
Nkosi
Eles parecem compartilhar o acesso ao ProcessingArgsque eu estava confuso sobre isso.
Nkosi
11
Esse é precisamente o ponto da questão. Eventos não podem retornar uma tarefa. E mesmo se eu usar um delegado que retorne uma tarefa de T, o resultado será perdido
Jeff

Respostas:

2

O delegado a seguir será usado para lidar com problemas de implementação assíncrona

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

Dos comentários foi indicado

Um exemplo específico é adicionar várias etapas / tarefas necessárias para concluir uma "transação" (funcionalidade LOB)

A classe a seguir permite a criação de um delegado para lidar com essas etapas de maneira fluente, semelhante ao middleware .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;
    }
}

A extensão a seguir permite uma configuração mais simples em linha usando wrappers

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

Pode ser estendido ainda mais, conforme necessário, para invólucros adicionais.

Um exemplo de caso de uso do delegado em ação é demonstrado no seguinte teste

[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
fonte
Código bonito.
30519 Jeff Jeff
Você não gostaria de aguardar o próximo passo e aguardar o passo? Eu acho que depende se Add implica que você adicione código para executar antes de qualquer outro código que foi adicionado. Do jeito que é, é mais como uma "inserção"
Jeff
11
As etapas @Jeff são executadas por padrão na ordem em que foram adicionadas ao pipeline. A configuração embutida padrão permite que você altere isso manualmente, se desejar, caso haja ações pós-executadas no caminho de backup
Nkosi
Como você projetaria / alteraria isso se eu quisesse usar a Tarefa de T como resultado, em vez de apenas definir o contexto. Você apenas atualiza as assinaturas e adiciona um método Insert (em vez de apenas Add) para que um middleware possa comunicar seu resultado a outro middleware?
Jeff
1

Se você deseja mantê-lo como delegados, pode:

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
fonte