2
\$\begingroup\$

I created a simple middleware pipeline builder that I want to use for my frameworks. It is similar to how ASP.NET-Core middleware works and uses the same conventions:

  • A public constructor with a parameter of type RequestDelegate.
  • A public method named Invoke or InvokeAsync. This method must:
    • Return a Task.
    • Accept a first parameter of type HttpContext.

The only difference is that I use a generic delegate for next that I renamed:

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

API

The whole thing is quite simple. It uses a Stack to store registered middleware types (currently without constructor parameters) and so that I can process them in the reverse order. I need this to inject the last middleware's Invoke as next of the one that is going to be created aftewards and so on. The last one, or the first added one, is the starting point.

I use Autofac to resolve dependencies.

public class PipelineBuilder
{
    private readonly Stack<Type> _middlewareTypes = new Stack<Type>();
    private readonly ILifetimeScope _lifetimeScope;

    public PipelineBuilder(ILifetimeScope lifetimeScope)
    {
        _lifetimeScope = lifetimeScope;
    }

    public PipelineBuilder Add<T>()
    {
        _middlewareTypes.Push(typeof(T));
        return this;
    }

    public RequestCallback<TContext> Build<TContext>()
    {
        var previous = default(object);
        while (_middlewareTypes.Any())
        {
            var middlewareType = _middlewareTypes.Pop();
            var next = CreateNext<TContext>(previous);
            previous = _lifetimeScope.Resolve(middlewareType, new TypedParameter(typeof(RequestCallback<TContext>), next));
        }

        return CreateNext<TContext>(previous);
    }

    // Using this helper to "catch" the "previous" middleware before it goes out of scope and is overwritten by the loop.
    private RequestCallback<TContext> CreateNext<TContext>(object previous)
    {
        // Doing it here to reflection next time.
        var nextInvoke = previous?.GetType().GetMethod("Invoke") ?? previous?.GetType().GetMethod("InvokeAsync");

        return (RequestCallback<TContext>)(context =>
        {
            if (previous is null)
            {
                return Task.CompletedTask;
            }
            else
            {
                return (Task)nextInvoke.Invoke(previous, new object[] { context });
            }
        });
    }
}

Demo

I tested it with four dummy middlewares that build a message. Here's the complete code:

void Main()
{
    var containerBuilder = new ContainerBuilder();
    containerBuilder.RegisterType<M1>();
    containerBuilder.RegisterType<M2>();
    containerBuilder.RegisterType<M3>();
    containerBuilder.RegisterType<M4>();
    var container = containerBuilder.Build();
    var lifetimeScope = container.BeginLifetimeScope();

    var pipelineBuilder = new PipelineBuilder(lifetimeScope);
    pipelineBuilder.Add<M1>();
    pipelineBuilder.Add<M2>();
    pipelineBuilder.Add<M3>();
    pipelineBuilder.Add<M4>();
    var next = pipelineBuilder.Build<TestContext>();
    var context = new TestContext { Message = "" };
    next(context);
    context.Message.Dump(); // --> "Spongebob Squarepants"
}

public class TestContext
{
    public string Message { get; set; }
}

class M1
{
    private readonly RequestCallback<TestContext> _next;
    public M1(RequestCallback<TestContext> next) => _next = next;
    public Task Invoke(TestContext context)
    {
        context.Message = "Sponge";
        return _next(context);
    }
}

class M2
{
    private readonly RequestCallback<TestContext> _next;
    public M2(RequestCallback<TestContext> next) => _next = next;
    public Task Invoke(TestContext context)
    {
        context.Message += "bob";
        return _next(context);
    }
}

class M3
{
    private readonly RequestCallback<TestContext> _next;
    public M3(RequestCallback<TestContext> next) => _next = next;
    public Task Invoke(TestContext context)
    {
        context.Message += " Square";
        return _next(context);
    }
}

class M4
{
    private readonly RequestCallback<TestContext> _next;
    public M4(RequestCallback<TestContext> next) => _next = next;
    public Task Invoke(TestContext context)
    {
        context.Message += "pants";
        return _next(context);
    }
}

Questions

What do you think? Can we make it better? Please ignore the missing null-checks... it's an early proof-of-concept.

\$\endgroup\$
  • \$\begingroup\$ I take it that Invoke and InvokeAsync are methods by convention, not by interface? \$\endgroup\$ – dfhwze Aug 12 at 14:26
  • \$\begingroup\$ @dfhwze exactly. There is no interface. \$\endgroup\$ – t3chb0t Aug 12 at 14:26
  • \$\begingroup\$ And preferring Invoke over InvokeAsync is according to some spec or personal preference? \$\endgroup\$ – dfhwze Aug 12 at 14:27
  • \$\begingroup\$ @dfhwze haha, just a coincidence. I have no preference towards either of them... although I think InvokeAsyc would be more conventional and I didn't cheat by looking at the original source-code :-P so I actually don't know which one of them asp.net-core favors. \$\endgroup\$ – t3chb0t Aug 12 at 14:29
3
\$\begingroup\$

After running through a few ideas in my first pass at the current implementation, I ended up with this refactored approach to simplify your build process

public RequestCallback<TContext> Build<TContext>() {
    var next = new RequestCallback<TContext>(context => Task.CompletedTask);
    while (_middlewareTypes.Any()) {
        var middlewareType = _middlewareTypes.Pop();            
        var middlewareInstance = _lifetimeScope.Resolve(middlewareType, new TypedParameter(typeof(RequestCallback<TContext>), next));
        var nextInvoke = getNextInvoke(middlewareType);
        next = (RequestCallback<TContext>)nextInvoke.CreateDelegate(typeof(RequestCallback<TContext>), middlewareInstance);
    }
    return next;
}

MethodInfo getNextInvoke(Type type) {
    return type.GetMethod("Invoke") ?? type.GetMethod("InvokeAsync");
}

Since you are already starting at the end of the pipeline, I figured you could have the dummy delegate to begin with

var next = new RequestCallback<TContext>(context => Task.CompletedTask);

and use that as the next in the pipeline.

From there it was a matter of passing the delegate on to the next in line when resolving the middleware.

Your CreateNext seemed a little over complicated at first glance, then I remembered that you can create a delegate directly from a MethodInfo using the instance.

next = (RequestCallback<TContext>)nextInvoke.CreateDelegate(typeof(RequestCallback<TContext>), middlewareInstance);

Using a quick unit test I was able to reproduce your demo to prove my refactor did not break the expected behavior

[TestClass]
public class PipelineBuilderTests {
    [TestMethod]
    public async Task PipelineBuilder_Should_Build_Delegate() {
        //Arrange
        var pipelineBuilder = new PipelineBuilder();
        pipelineBuilder.Add<M1>();
        pipelineBuilder.Add<M2>();
        pipelineBuilder.Add<M3>();
        pipelineBuilder.Add<M4>();
        var next = pipelineBuilder.Build<TestContext>();
        var context = new TestContext { Message = "" };
        string expected = "Spongebob Squarepants";

        //Act
        await next(context);

        //Assert (FluentAssertions)
        context.Message.Should().Be(expected);
    }
}
\$\endgroup\$
  • 1
    \$\begingroup\$ oh, this is a nice trick! :-] and with a new generic extension for CreateDelegate<T> I can even suppress the repeated types. \$\endgroup\$ – t3chb0t Aug 13 at 6:08
  • \$\begingroup\$ @t3chb0t I had the same thought as well for it to be more DRY \$\endgroup\$ – Nkosi Aug 13 at 6:29
  • 1
    \$\begingroup\$ I mark your answer because I learned the new CreateDelegate method... although after all I wasn't able to use it with the new scenario where the actual Invoke has other parameters but the request. \$\endgroup\$ – t3chb0t Aug 14 at 15:08
2
\$\begingroup\$

Conventions

If you want similar behavior as ASP.NET Core, you should check against both methods Invoke and InvokeAsync. If not, I would prefer to change the order to seek the methods. Postfix *Async is a convention to return Task.

View component '...' must have exactly one public method named 'InvokeAsync' or 'Invoke'.

Dependencies

You have included a depedency on a third-party library Autofac. I would try to prevent this. Specially, since there is an easy way of making this class dependency-free. Use a callback Func<..> instead, or an interface ITypeResolver if the func is bad for readability.

\$\endgroup\$
  • 1
    \$\begingroup\$ I'm kind of married to Autofac lol ;-] it's baked into everthing in my libs :-\ \$\endgroup\$ – t3chb0t Aug 12 at 14:42
  • \$\begingroup\$ Does Autofac know you are cheating on her with DynamicException.Create? :p \$\endgroup\$ – dfhwze Aug 12 at 14:46
  • 2
    \$\begingroup\$ haha, she's powerfull, but not almighty so she sometimes needs a little help ;-] I think she even likes having another compiler around. \$\endgroup\$ – t3chb0t Aug 12 at 14:48
  • 1
    \$\begingroup\$ I use this builder now to create a new IO-layer pipeline. I had decorators before (ugly) That's so cool now where I can log every file/config/email/http or other resource operation and statistics. Next, I can throw away a lot of logging elsewhere ;-] I turned every resource access into a REST-controller. Basiacally it's like asp.net-core for applictaion-io lol \$\endgroup\$ – t3chb0t Aug 14 at 16:14
  • 1
    \$\begingroup\$ Here's an example \$\endgroup\$ – t3chb0t Aug 14 at 16:33
2
\$\begingroup\$

(self-answer)


This thing has grown a little bit since I have posted it. I followed most of the suggestions and added some new features: dynamic parameter resolution for middleware constructor and for the Invoke methods. I now throw exceptions when contract conventions have not been met like invalid middleware constructor, too few or too many invokes or having invalid signatures.


Something for my fans of the DynamicException: it saved me from creating five unnecessary exception classes here.


public class MiddlewareBuilder
{
    private readonly Stack<(Type MiddlewareType, object[] Parameters)> _middleware = new Stack<(Type MiddlewareType, object[] Parameters)>();

    public Func<Type, object> Resolve { get; set; } = _ => throw new InvalidOperationException("No service for resolving middleware dependencies has been registered.");

    public MiddlewareBuilder Add<T>(params object[] parameters)
    {
        _middleware.Push((typeof(T), parameters));
        return this;
    }

    public RequestCallback<TContext> Build<TContext>()
    {
        var previous = default(object);
        while (_middleware.Any())
        {
            var current = _middleware.Pop();
            var nextCallback = CreateNext<TContext>(previous);
            var parameters = new object[] { nextCallback };
            if (current.Parameters.Any())
            {
                parameters = parameters.Concat(current.Parameters).ToArray();
            }

            var middlewareCtor = current.MiddlewareType.GetConstructor(parameters.Select(p => p.GetType()).ToArray());
            if (middlewareCtor is null)
            {
                throw DynamicException.Create
                (
                    "ConstructorNotFound",
                    $"Type '{current.MiddlewareType.ToPrettyString()}' does not have a constructor with these parameters: [{parameters.Select(p => p.GetType().ToPrettyString()).Join(", ")}]"
                );
            }

            previous = middlewareCtor.Invoke(parameters);
        }

        return CreateNext<TContext>(previous);
    }


    // Using this helper to "catch" the "previous" middleware before it goes out of scope and is overwritten by the loop.
    private RequestCallback<TContext> CreateNext<TContext>(object middleware)
    {
        // This is the last last middleware and there is nowhere to go from here.
        if (middleware is null)
        {
            return _ => Task.CompletedTask;
        }

        var invokeMethods = new[]
        {
            middleware.GetType().GetMethod("InvokeAsync"),
            middleware.GetType().GetMethod("Invoke")
        };

        var nextInvokeMethod = invokeMethods.Where(Conditional.IsNotNull).SingleOrThrow
        (
            onEmpty: () => DynamicException.Create("InvokeNotFound", $"{middleware.GetType().ToPrettyString()} must implement either 'InvokeAsync' or 'Invoke'."),
            onMany: () => DynamicException.Create("AmbiguousInvoke", $"{middleware.GetType().ToPrettyString()} must implement either 'InvokeAsync' or 'Invoke' but not both.")
        );

        var parameters = nextInvokeMethod.GetParameters();

        if (parameters.First().ParameterType != typeof(TContext))
        {
            throw DynamicException.Create
            (
                "InvokeSignature",
                $"{middleware.GetType().ToPrettyString()} Invoke(Async)'s first parameters must be of type '{typeof(RequestCallback<TContext>).ToPrettyString()}'."
            );
        }

        return context =>
        {
            var parameterValues =
                parameters
                    .Skip(1) // TContext is always there.
                    .Select(parameter => Resolve(parameter.ParameterType)) // Resolve other Invoke(Async) parameters.
                    .Prepend(context);

            // Call the actual invoke with its parameters.
            return (Task)nextInvokeMethod.Invoke(middleware, parameterValues.ToArray());
        };

        // I leave this here in case I need it later...
        //return next.CreateDelegate<RequestCallback<TContext>>(middleware);
    }
}

I also moved the Autofac dependency into a new class. This way I can easily test the main class and use this one with more complex scenarios in production.

public class MiddlewareBuilderWithAutofac : MiddlewareBuilder
{
    public MiddlewareBuilderWithAutofac(IComponentContext componentContext)
    {
        Resolve =
            type =>
                componentContext.IsRegistered(type)
                    ? componentContext.Resolve(type)
                    : throw DynamicException.Create("TypeNotFound", $"Could not resolve '{type.ToPrettyString()}'.");
    }
}
\$\endgroup\$
  • \$\begingroup\$ If only one parameter then you can use create delegate else what you have there. \$\endgroup\$ – Nkosi Aug 14 at 16:41
  • 1
    \$\begingroup\$ I am away from my machine at the moment, will review this again later. \$\endgroup\$ – Nkosi Aug 14 at 16:45
  • \$\begingroup\$ @Nkosi maybe OP should ask follow-up, or we'll get a review of a self-answer, which will get very confusing :p \$\endgroup\$ – dfhwze Aug 14 at 16:46

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service, privacy policy and cookie policy

Not the answer you're looking for? Browse other questions tagged or ask your own question.