Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Pipeline discussion #1096

Closed
RaymondHuy opened this issue Mar 27, 2020 · 20 comments · Fixed by #1121
Closed

Pipeline discussion #1096

RaymondHuy opened this issue Mar 27, 2020 · 20 comments · Fixed by #1121
Labels
Milestone

Comments

@RaymondHuy
Copy link
Member

Hi @tillig , @alexmg , @alsami , @alistairjevans I created this issue to discuss more in-depth about pipeline approach to see whether it is suitable for the version 6.0 and how it can take advantage from .NET 5

@tillig
Copy link
Member

tillig commented Mar 30, 2020

For context:

We have run into some challenging issues involving ordering and event handling that we're unsure how best to address. #758 is one example, however, I've seen in the past issues with things like ACTNARS behaving differently based on the order in which it's registered.

Further, there have been Twitter threads and questions about Autofac being more "linker safe" for things like Mono; we've added some docs about configuring linker settings for native applications but with a different resolution process it may be possible to do more build-time work to aid in this.

I mentioned in #970 that a pipeline sort of mechanism might address some challenges we've seen. @alistairjevans brought up the same thing in #758, so it's at least worth discussing whether it'd be worth pursuing, what it would take, and so on.

It's acknowledged that the outcome of this would likely be something fairly similar in the front, with the ContainerBuilder and all that; but internally there's probably a lot of shifting around. So... a 6.0 (or later) full-version breaking-change sort of release.

@alistairjevans
Copy link
Member

Additional consideration from #1102, should consider how pipeline event behaviour might apply for the start and end of scopes when there hasn't necessarily been a resolve event.

@alistairjevans
Copy link
Member

Did some thinking/looking at this today. Stream of consciousness follows, but thought I'd record it here.

Let me know if you have any thoughts.

Overview

Pipelines as a concept will be something of a replacement for the current resolution process. To be precise, it will replace the process of resolving a service, from ResolutionExtensions.TryResolveService down.

The 'picking the registration' is going to be the first stage in the pipeline. This then selects the concrete resolution pipeline and invokes it. My reasoning for making picking the registration an actual pipeline stage is because this means the entire resolve operation tree including all dependencies can be represented in a chain of pipeline stages, with no need to break out of that style.

The activation of the final component (after all dependencies have been loaded) will be the last stage in the pipeline.

I'm hoping to minimise allocations during resolve, by putting mutable state in the a new pipeline context object, and allocating pipeline stages at Container Build time.

Current Call Chain (very approximate)

ResolutionExtensions.TryResolveService
-> ComponentRegistry.TryGetRegistration
-> IComponentContext.ResolveComponent
    -> ResolveOperation.Execute
        -> ResolveOperation.GetOrCreateInstance
            -> InstanceLookup.Execute
            -> InstanceLookup.CreateInstance
                -> Activator.ActivateInstance
                -> InstanceDecorator.TryDecorateRegistration

New Possible Pipeline Proposal


ResolutionExtensions.TryResolveService()

// Invoke any ResolveOperationBeginning event handlers. 
// Instantiate context objects for the pipeline.
// Invokes Complete events at the end. Registration-Agnostic.
⬇ ResolveOperationStage ⬆                

// Retrieve registration, get concrete pipeline from registration. Registration-Agnostic.
⬇ LocateRegistrationStage ⬆

// Detects circular references, pushes and pops from the application stack. Registration-Agnostic.
⬇ DependencyChainStage ⬆

// Ideally decoration stages would be added to the concrete registration pipeline 
// in a Container Build post-processing stage, rather than
// done dynamically at run-time. Registration-Specific Stage.
⬇ DecorationStage(s) ⬆

// Registration-Specific Stage - Only added to the registration pipeline if the InstanceSharing.Shared.
// Does not call any further pipeline stages if there is a shared instance available.
⬇ SharingStage ⬆

// Handles registering instances with the disposer (after next()). Only added if component implements IDisposable/IAsyncDisposable.
⬇ DisposalStage ⬆

// Handles auto-start for components (after next()).
⬇ StartableStage ⬆

// Registration-Specific Stage - The appropriate stage is added depending on registration type.
// Can invoke the LocateRegistrationStage for another service, which will function as the form of recursion
// through the dependency graph.
⬇ Delegate/Provided/ReflectionActivatorStage ⬆

What is a Pipeline Stage?

A pipeline stage will be a class that implements IPipelineStage. Rough API might look something like the following:

public class PipelineContext
{
    public Stack<InstanceLookup> ActivationStack { get; }

    public List<InstanceLookup> SuccessfulActivations { get; }

    public ResolveRequest Request { get; }

    public object Instance { get; }
}

public interface IPipelineStage
{
    void Execute(PipelineContext context, Action<PipelineContext> next);
}

A PipelineBuilder class would be responsible for constructing pipelines, and represented by a built IPipeline instance when the pipeline is fixed.

I mulled over whether to support the concept of asynchronous operations being involved in the resolve operation, but am not in favour of it right now. People shouldn't be doing async operations as part of a resolve, and should be discouraged from doing things like that in OnActivating-style handlers. In addition, setting up async contexts and the like does have an overhead, even if it isn't always a massive one.

Even if we want to end up implementing async-friendly callbacks for events, as requested in #1069, I imagine that these would be shims of a sort that actually end up blocking.

Registration

It would be good to be able to build a pipeline per-registration at container build time, and reduce the amount of work done at resolve time.

We want to minimise as much as possible the importance of ordering when registering services (i.e. avoiding the ordering issues of #758).

Currently, my thinking is that the current IRegistrationSource behaviour stays relatively 'as-is'. A lot of that behaviour is about locating registrations in the component registry, and is complex. It would be an extremely breaking change if we update how they work too much.

RegistrationData will have new property, Pipeline (or something like that), backed by a new IPipelineBuilder. This pipeline builder will contain custom pipeline stages added during the registration builder. It will also replace the set of Preparing/Activating/ActivatedHandlers

Responsibility for creating the 'final' pipeline probably sits inside RegistrationBuilder.CreateRegistration. That can take account of both custom pipeline stages added in RegistrationData, and the built-in ones. In my brain, the 'last' stage of a pipeline is the Activator.

Events

So, this will be the biggest breaking change from an external viewpoint.

The existing Preparing/Activating/Activated handlers will be removed, and instead we will allow the addition of a pipeline stage that can be inserted before any named stage in the pipeline. So, an event stage could insert itself before the DecorationStage to handle pre- and post-decoration, or before the ActivatorStage to handle pre- and post-activation for the concrete component.

Externally, we will probably add BeforeActivation and AfterActivation handlers which run at a default position and give some compatibility with the old event handlers, but more advanced use-cases can add custom middleware for specific ordering needs.

Decorators

From what I can tell, decorators are all available in the component registry. However, decorators can be added in a nested scope which affects registrations in the outer scope.

I'm envisaging some sort of copy-on-write pipeline that allows pipelines to have single stages inserted without copying the entire pipeline. Could use this when adding decorators in a lower scope.

Would it be viable to have some sort of post-process step in our container build that goes through the new registrations and 'optimises' them, generating the most concrete pipeline we can and inserting decorator stages? This could cause issues with BeginLifetimeScope performance though.

Generating Concrete Pipelines for Component Constructor Parameters at Container Build Times

In my brain, it feels like it would be good to determine the set of component dependencies at Container Build time (post-processing step of some form?).

With this behaviour, when a registration is 'built', it will scan the dependencies that each IInstanceActivator knows about for a service, and will do a direct reference to the pipeline for each dependency.

This opens up the possibility of eventually generating entirely static resolve trees for dependencies (possibly even using source generators to create it).

I can imagine that each pipeline stage can have an implementation of the IStageCodeGenerator interface; if all the stages implement the interface, the entire pipeline can be turned into a single block of code.

@tillig
Copy link
Member

tillig commented Apr 27, 2020

This is some great stuff. I'm glad to see some thoughts here.

I can see where various stages in the pipeline would be sort of this iterative/re-entrant thing, like:

  • Component A starts the pipeline
  • Component A gets down to the activator stage, needs component B
    • Component B starts the pipeline
    • Component B, on the way back out, gets decorated
      • Decorator for component B starts the pipeline
      • Decorator for component B finishes the pipeline
    • Component B finishes the pipeline
  • Component A finishes activating and continues the pipeline
  • Component A, on the way back out, gets decorated
    • Decorator for component A starts the pipeline
    • Decorator for component A finishes the pipeline
  • Component A finishes the pipeline

Like a stack of pipelines, sorta.

Moving on, I'm curious how parameters might affect the ability to generate an optimal pipeline. Like, Microsoft.Extensions.DependencyInjection has no equivalent for

scope.Resolve<T>().WithParameter("foo");

That means the pipeline itself, for MEDI is somewhat fixed; it doesn't really change based on parameter vs. available dependency. Maybe it's not that big of a deal.

I've never really gotten into the whole build-time code generation thing that might be required for being more linker-friendly. Has anyone else? I'd be interested in looking at that a bit more so I can understand better and maybe contribute in a more substantive way in that area.

@johneking
Copy link

Apologies all, I've been out of the loop for quite a while but thought I'd chime in here on this proposal since I spent some time pondering some of it previously. Looks good as a start!

Regarding composites, assuming they should be supported here - that structure @tillig outlined is basically where my thinking ended up previously (#970), with a couple of modifications which seem possible.

Firstly, the root of each pipeline could actually be associated to a requested service instead of the registration or component. That can then chain into pipelines for individual components, but it allows customisation of the way components are selected or composed for each request - that does seem to be a requirement for composites, and that runs against the idea of the first step in each pipeline being to find a single registration. To me that makes sense generally, but is also a somewhat substantial change in thinking for the way things like ResolveRequests work.

Decorator and composite registrations would modify that first part of the pre-calculated service pipeline, and that could be compiled into a lookup at container build-time from the existing types of decorator registrations, along with a default.

The other slight shift in thinking related to that is the conceptual inversion of ordering which actually gives rise to something like the nested structure @tillig's described. Composites in particular need to decide what to try to resolve downstream, and the composition of the resolved components is the last step in activating the composite, so you can't just add it as the last step in resolving a particular component.

An analogy for this approach is also in the existing example: if Component A depends on Component B, that's essentially the same as Decorator B's dependency on Component B, so it may make sense to resolve them in a similar fashion. Apologies for the increased nesting... but if we make Component A implement Service A and the same for B, and chain 2 decorators just for example, it would make the new flow look something like:

  • Service A requested
  • Pipeline for Service A starts
  • Default pipeline with no decoration jumps straight to first selected component
    • Component A starts pipeline
    • Component A gets down to the activator stage, needs Service B
      • Service B pipeline starts
      • Service B requires decoration with 2 decorators (in a fixed order)
        • Decorator D2 starts pipeline (with dependency on D1)
          • Decorator D1 starts pipeline (with dependency on first component for Service B)
            • Component B starts pipeline
            • Component B finishes pipeline
          • Decorator D1 finishes pipeline (activated using Component B)
        • Decorator D2 finishes pipeline (activated using Decorator D1)
      • Service B pipeline finishes
    • Component A pipeline finishes
  • Service A pipeline finishes

That approach appears to add some complexity but it opens it up for composites and chaining decorators with them. In a similar way, the service pipeline section is responsible for arranging a sequence of dependencies. Say we have a Composite C targeting Service A, and Service A has two implementations, Component A and Component B. We get:

  • Service A requested
  • Service A pipeline requires a single composite
    • Composite C pipeline starts (with dependency on all components for Service A, potentially including filtering etc.)
      • Component A pipeline starts
      • Component A pipeline finishes
      • Component B pipeline starts
      • Component B pipeline finishes
    • Composite C pipeline finishes
  • Service A pipeline finishes

An implication of that is that a bunch of existing logic would now need to pass around an IEnumerable of component registrations for a service, which could be problematic. But as in my previous example, this approach does seem to get decorators and composites playing nicely and could (?) help control some of the other issues out there.

Not sure if I've missed the mark on any of this, I'm really not across the broader set of considerations @alistairjevans outlined, so there could be a stack of reasons why this won't work - happy to hear them if so! But in any case, IMO it does seem worth considering how composites would fit into the new structure and any alternatives - their requirements could draw out more considerations for the overall process.

@alistairjevans
Copy link
Member

@tillig,

I'm curious how parameters might affect the ability to generate an optimal pipeline.

Not sure to be honest. The parameters are just passed to the activator for construction, no? Is there something more complex than that? The parameters would probably be a mutable set during pipeline execution, to be used by decorators, activators on 'the way down'.

Like a stack of pipelines, sorta.

Sort of; we shouldn't actually need to maintain a literal stack structure though. The use of the next() function and the way the pipeline is constructed should allow the actual call stack to provide all the stack we need.

I've never really gotten into the whole build-time code generation thing

Yeah, this is all going to be very new stuff; the source generation functionality isn't even in .NET yet, it's just been heralded for a while and is (I believe) planned for .NET 5.0. As far as I'm aware source generators will be an extension of the existing analyzer functionality, but can execute during the build process and output new source trees to be included in the assembly compilation.

The way I see it working is something like:

  • The pipeline has a 'what-if' mode, that follows the entire pipeline and dependency chains, but without actually instantiating any objects.
  • Developers consuming Autofac would need to mark some method with an attribute that says, here is where I run my container set up (it will obviously have a variety of restrictions).
  • At build time, we build that container, and execute all the container's pipelines in what-if mode, watching the pipeline for instructions to translate into source structures.
  • The output of that process should be a static look-up table of Service -> Pipeline, with little or no registry look-ups.
  • When we look for a pipeline for a registration, if there is a static one, we use it.

Something like that anyway.

Regardless, if the pipeline changes are a 6.0 change, then the source generation behaviour will probably be a 7.0.

@alistairjevans
Copy link
Member

@johneking,

Thanks for the detailed commentary and ideas! This is good stuff.

I'm going to think this through; so if we consider a composite service to be one that is registered As<IService>(), but the component has a dependency of IEnumerable<IService>, then we need to modify the pipeline to:

  • Locate all the other dependencies of IService (except itself), prior to activation.
  • Tell the activator to use the already-resolved collection of IService to provide that dependency (instead of falling back to the default collection behaviour).

This sort of feeds into my idea of a registration post-processor, which can go through all the registrations at build time, and modify the pipeline as needed.

Let's imagine a CompositeRegistrationPostProcessor. This checks the dependencies of all registrations, and if it injects an IEnumerable, ICollection, or IList of one of it's own registered services, then it:

  • Removes any added decorator stages (added by a different post-processor).
  • Adds a new CompositeDependencyLocatorStage, just before the Activator stage.

This need to understand the dependencies of a registration at pipeline construction is going to be a key component of the new pipeline approach (I think).

Let's also imagine that the pipeline context contains the set of Parameters to use during Activation, as opposed to having a default set of parameters in the activator. (@tillig, this may feed into your earlier point about parameters).

When the CompositeDependencyLocatorStage pipeline stage executes it would:

  • Resolve all other registrations of IService except the requested one.
  • Invoke each of the pipelines for those services.
  • Insert a new CompositeDependencyParameter (at the start of the parameter list) that will provide any collection dependencies of IService, overriding the normal AutoWiringParameter behaviour.

The pipeline for that component should then look like:

⬇ SharingStage   ⬆
⬇ DisposalStage  ⬆
⬇ StartableStage ⬆

// This stage locates all **other** registrations of the service apart from the requested registration,
// and resolves their pipeline (including any decorators).
// Adds a new Parameter to the pipeline's parameter set, which contains an IEnumerable<IService>.
⬇ CompositeDependencyLocatorStage ⬆

// Normal activator runs, any collection dependencies of IEnumerable<IService> would be met by the parameter added in the previous stage, preventing normal collection resolution.
⬇ Delegate/Provided/ReflectionActivatorStage ⬆

If we use the example from #970:

public interface IService { }

public class ServiceA : IService { }

public class ServiceB : IService { }

public class ServiceC : IService { }

public class Decorator : IService
{
    public IService Decorated { get; }

    public Decorator(IService decorated)
    {
        Decorated = decorated;
    }
}

public class CompositeService : IService
{
    public IEnumerable<IService> Services { get; set; }

    public CompositeService(IEnumerable<IService> services)
    {
        Services = services;
    }
}

[Fact]
public void CanCreateCompositeComponent()
{
    var builder = new ContainerBuilder();
    builder.RegisterType<ServiceA>().As<IService>();
    builder.RegisterType<ServiceB>().As<IService>();
    builder.RegisterType<ServiceC>().As<IService>();
    builder.RegisterDecorator<DecoratorService, IService>();
    builder.RegisterType<CompositeService>().As<IService>();

    var container = builder.Build();

    var service = container.Resolve<IService>();

    Assert.IsType<CompositeService>(service);
    var composite = (CompositeService)service;

    var services = composite.Services.OfType<DecoratorService>().ToArray();
    Assert.Equal(3, services.Length);
    Assert.IsType<ServiceA>(services[0].Decorated);
    Assert.IsType<ServiceB>(services[1].Decorated);
    Assert.IsType<ServiceC>(services[2].Decorated);
}

This makes the nested chain look something like:

  • IService requested
  • Registration CompositeService found (because it was the last registration).
  • Start CompositeService pipeline
    • CompositeDependencyLocatorStage Runs
      • Locates all Registrations for IService except for self (CompositeService).
      • Starts ServiceA pipeline
        • DecoratorStage
          • ReflectionActivatorStage - Activates ServiceA.
          • Start Decorator pipeline.
            • ReflectionActivatorStage - Activates Decorator with ServiceA.
          • End Decorator pipeline
          • Update Activated Instance with decorated.
      • End ServiceB pipeline.
      • Starts ServiceB pipeline
        • DecoratorStage
          • ReflectionActivatorStage - Activates ServiceB.
          • Start Decorator pipeline.
            • ReflectionActivatorStage - Activates Decorator with ServiceB.
          • End Decorator pipeline
          • Update Activated Instance with decorated.
      • End ServiceB pipeline.
      • Now have all IService implementations.
      • Adds CompositeParameter containing those implementations.
      • ReflectionActivatorStage - Activates CompositeService, uses the CompositeParameter to
        provide IEnumerable<IService>.
  • End CompositeService pipeline.

I think that's pretty close to the chain you originally proposed, but without attaching service-specific behaviour.

Thoughts?

@johneking
Copy link

Just on

without attaching service-specific behaviour

I kind of think that philosophically it is the nature of decorators and composites that they are modifiers for specific services, as opposed to fixed layers on top of components, so not sure that's necessarily something to avoid... You'd imagine that there would be a low number of services which have a pre-calculated non-default behaviour anyway.

This changes the solution a lot too (should have mentioned earlier), but I actually ran with the latter suggestion from #970 regarding syntax: I think it makes more sense to have an explicit registration for composites, very similar to decorators.

builder.RegisterComposite<CompositeService, IService>();

There are a few reasons:

  • generally in the context of DI it seems to me to make sense to have something to say "replace the entire group of components registered for IService with a single composite which also exposes IService", as opposed to just taking a dependency on IEnumerable<IService> in a somewhat unordered way and still exposing the underlying components. The latter is possible in any case via registration filtering as @alexmg says, which may actually fit that particular use-case.
  • the ordering of decoration and composition really could be critical and there are likely valid use-cases for both composite -> decorator and decorator -> composite. I'm not sure how that would be possible (let alone other more complex, valid structures) by trying to infer the application order from a "normal" type registration
  • I'm not sure the composite itself should appear in any normal requests for IEnumerable<IService> - again the same as decorators

Again, a few assertions there up for some scrutiny!

The CompositeDependencyParameter actually overlaps with my proposal but isn't quite the same. My idea is that for each decorator or composite stage in the pipeline you have something akin to that parameter, but its scope is limited to only that single stage in the chain. By definition, each section of this part of the pipeline iteratively resolves parameters from the "next" part of the pipeline which are then used to activate the decorators/composites as required. Here's a modified/cut-down version of my example - it has a slightly different syntax to the proposed pipelining but that might not be unresolvable:

public class ResolutionResult
{
  public object RootInstance { get; set; }
  public object FinalInstance { get; set; }
}
public interface IResolutionPipelineSection
{
  IEnumerable<ResolutionResult> Resolve<TService>(IResolutionContext context);
}
public interface IResolutionContext
{
  ILifetimeScope LifetimeScope { get; }
}

// each pipeline section can be constructed and chained from registrations at container build-time
public class DecoratorPipelineSection : IResolutionPipelineSection
{
  private readonly IResolutionPipelineSection _nextSection;   // this is "next" structurally but gets resolved before
  private readonly Type _decoratorType;
  public DecoratorPipelineSection(IResolutionPipelineSection nextSection, Type decoratorType)
  {
    _nextSection = nextSection;
    _decoratorType = decoratorType;
  }

  public IEnumerable<ResolutionResult> Resolve<TService>(IResolutionContext context)
  {
    var inner = _nextSection.Resolve<TService>(context);
    return inner.Select(r => ApplyDecorator<TService>(r, context));
  }

  private ResolutionResult ApplyDecorator<TService>(ResolutionResult innerResult, IResolutionContext context)
  {
    // use the result of the child to activate the decorator, for this decorator only
    var newInstance = context.LifetimeScope.Resolve(_decoratorType, new TypedParameter(typeof(TService), innerResult.FinalInstance));
    innerResult.FinalInstance = newInstance;
    return innerResult;
  }
}

public class ComponentPipelineSection : IResolutionPipelineSection  // this is always the most deeply nested part of the pipeline
{
  public IEnumerable<ResolutionResult> Resolve<TService>(IResolutionContext context)
  {
    // this is where the rest of the normal component resolution happens, along with any nested dependency resolution pipelines
    // an alternative would be to pass the full list of relevant registrations in the resolution context for iteration
    foreach (var component in context.LifetimeScope.Resolve<IEnumerable<TService>>())    
    {
      var result = new ResolutionResult
      {
        RootInstance = component,
        FinalInstance = component
      };
      yield return result;
    }
  }
}

The composites section is a little more verbose so I won't include it, but hopefully it's apparent that under that structure it works in a similar way to the decorator without issue. The emergent behaviour of the above is a resolution sequence like those previously outlined, but with a few differences like activation of registered components represented as a child of the decorator, but that actually may mirror the real chain of dependencies accurately.

The IEnumerable dependency chain also means you don't need to fully resolve all IServices up-front - it's possible not all are required if filtering is applied. Again, this could add complexity in other ways and I'm really not sure how much performance could take a hit.

Apologies for the composites rabbit hole, obviously it's just one small aspect of many so don't want to hijack the thread too much. Also conscious I'm pushing this one particular approach which might ultimately be unworkable, but hopefully at least there are some useful ideas here to work through.

@alistairjevans
Copy link
Member

On the service-specific behaviour, I see your point; decorators are on the service, not the component, so if we want to build as static a pipeline as we can at container build, we need the service to have pipeline behaviour attached to it.

What I propose, to permit that, is that while the registration has the bulk of the pipeline (to avoid unnecessary duplication), each service can define a pipeline transformation, that injects stages into the main registration pipeline.

So, decorator pipeline stages would be defined by each service that is decorated; these stages are then inserted into the main pipeline during execution so decoration can take place.

As for composites, I see your point on RegisterComposite; I think we can probably classify them under 'Adapters' the same as decorators, and we'll need a single pattern of how those will work.

I think we have enough content on composites right now that we won't forget about them, but we'll have to see how it fits in when we start prototyping some of this stuff. It can be one of the tests to make sure the design is suitable.

One last general note on pipelines, based on looking at your code; we want to avoid any invocations directly against an ILifetimeScope.Resolve inside a pipeline stage, because it will cause the resolve process to step outside of the current resolution pipeline and create another one.

Any need to resolve items within a pipeline should be done via an IComponentContext local to the pipeline that allows the pipeline to continue within the same overall pipeline context, even if we need to resolve something different and execute a new pipeline.

Not sure how that will look exactly, but I know we want to be able to define a single pipeline from start to end, without restarting a new resolve operation (and losing pipeline context) at any point.

@tillig
Copy link
Member

tillig commented Apr 29, 2020

Something that also might be interesting to consider - the builder syntax we have is good, but behind the scenes doing the callbacks has thrown a bit of a wrench in the works for use cases where folks want to register X only if Y is already registered, or retroactively remove a registration they don't want, or something like that.

Would any of this pipeline work/pipeline generation be made easier if there was a collection-based registration mechanism like the MEDI IServiceCollection?

@alistairjevans
Copy link
Member

The only issue with changing it is that with Autofac properties of a registration can be modified any time after it is added to the collection.

var registration = builder.RegisterType<MyService>();

// Do other stuff.

registration.As<IService>();

At what point do we add to the collection?

MEDI has it easy, because it is so simple. It doesn't even permit multiple service types against the same registration.

@tillig
Copy link
Member

tillig commented Apr 29, 2020

I've seen some things where it's like this:

public ITypeRegistration<T> RegisterType<T>(this Registrar builder)
{
  var registration = new TypeRegistration<T>();
  builder.AllRegistrations.Add(registration);
  return registration;
}

Total pseudocode, but basically, it gets added to the collection on the first call. It's not too different from what we do now, except right now the thing getting built is a series of callbacks that have to execute in order to create the collection of registrations (the IComponentRegistry). Instead of deferring the creation of the individual registrations and generating the collection on Build(), the component registry would be built up interactively and Build() could do stuff like...

  • Execute any additional callbacks that we may need to do.
  • Post-process registrations to do stuff like build the decorator pipelines.
  • Auto-start stuff like we already do.

I just happened to see the notes about having a separate registration for composites, generating pipelines for decorators, that sort of thing and was like, "Hmmm... so we'd iterate through the collection of registrations and... wait, collection of registrations... maybe switching from callbacks to just a straight collection could help out some challenging use cases."

I dunno. Maybe it doesn't make a difference.

I think RegistrationSource is going to throw a wrench in the works. ACTNARS has always been a pain in the ass, trying to do any sort of pipeline generation for... anything not already registered?... is going to be rough. RegistrationSource is also how the default relationships like Lazy<T> and Func<T> work.

I wonder if registration sources instead become middleware in the pipeline.

@alistairjevans
Copy link
Member

alistairjevans commented Apr 29, 2020

I suppose the collection approach would improve simplicity by reducing the need for the deferred callbacks. It might also make static analysis easier, because the collection is updated as we go.

I have given the Registration Sources some consideration. In my thinking, registration sources output pipelines, rather than being a single stage.

In the design, I already have a pipeline stage for locating registrations:

// Retrieve registration, get concrete pipeline from registration. Registration-Agnostic.
⬇ LocateRegistrationStage ⬆

That stage would locate the registration and retrieve its pipeline. That entry point would either be the normal registration, or perhaps a more complex pipeline generated by a registration source.

Instead of yielding additional registrations, registration sources yield custom pipelines.

For example, if we consider the collection source:

ResolutionExtensions.TryResolveService()

⬇ ResolveOperationStage ⬆                
// Consult possible sources, find CollectionRegistrationSource, which returns
// it's desired pipeline stage, CollectionActivationStage.
⬇ LocateRegistrationStage ⬆
      // Iterates over the registrations and invokes the pipeline for each.
     ⬇ CollectionActivationStage ⬆
          // Service A first - new nested pipeline.        
         ⬇ LocateRegistrationStage  ⬆
         ⬇ DependencyChainStage     ⬆
         ⬇ SharingStage             ⬆
         ⬇ ActivatorStage           ⬆
         // Service B next - new nested pipeline.
         ⬇ LocateRegistrationStage  ⬆
         ⬇ DependencyChainStage     ⬆
         ⬇ SharingStage             ⬆
         ⬇ ActivatorStage           ⬆

@tillig
Copy link
Member

tillig commented Apr 30, 2020

Here's some info on code generators. Interesting stuff.

@alistairjevans
Copy link
Member

That's good timing! I'm excited by the possibilities; source generators could range from pre-calculating type dependencies and constructor selection, all the way to generating complete static pipelines.

@alistairjevans
Copy link
Member

First major problem I've encountered that is going to cause problems pre-calculating dependencies at container build; dependencies that are only registered in nested scopes.

So, let's say I have a set of components/services like so:

interface IService1 { }
interface IService2 {}

class ComponentA : IService1
{
   public ComponentA(IService2 service2) { }
}

class ComponentB : IService2 { }

Registered and used like so:

var builder = new ContainerBuilder();
builder.RegisterType<ComponentA>().As<IService1>();

var root = builder.Build();
var scope = root.BeginLifetimeScope(cfg => cfg.RegisterType<ComponentB>().As<IService2>());
scope.Resolve<IService1>();

This is perfectly acceptable right now, and will inject IService2 into ComponentA.

However, it means that we cannot determine a valid constructor at container build time, even if an activator has access to the whole component registry.

Possible ideas that I'm considering:

  • Pipelines are 'late-built', in that they are only built the first time a component is resolved. They will then have access to the IComponentRegistry from which they were retrieved. One problem with that is two different nested scopes could have two completely different service registrations.
  • Providing a way to indicate to the root container that there will be a service available, something like:
    // This creates a blank service registration that effectively doesn't point at anything (yet).
    builder.RegisterService<IService1>();
    Problem with that though is it's a major breaking change to everyone who is used to it.
  • If all the constructors of a type can be met from the component registry at container build, then go down an optimised path. If not, then fall back to a less-optimised one, where we cannot determine the concrete set of dependencies at container build time. This might be my favourite option, because it makes the most common case the most performant.

Obviously, this all really complicates the statically built pipeline idea, because the actual component providing a dependency may not be known until runtime.

If anyone has any thoughts on the above, it would be appreciated...

@alistairjevans
Copy link
Member

Similar problem to the above, what happens if someone registers a new decorator in a nested scope? Service pipelines that were previously determined are no longer correct within that scope.

@tillig
Copy link
Member

tillig commented May 13, 2020

I think there are going to be a lot of things that throw a wrench in the works for precalculated paths.

Parameters: Let's say you have a class with two constructors.

public class Consumer
{
  public Consumer() {}
  public Consumer(Dependency d) { this.Dependency = d; }
  public Dependency Dependency { get; }
}

and the container is built without Dependency in it. It can be provided by a parameter:

var builder = new ContainerBuilder();
builder.RegisterType<Consumer>();
var container = builder.Build();
var first = container.Resolve<Consumer>();
var second = container.Resolve<Consumer>(new TypedParameter(typeof(Dependency), new Dependency()));

// first.Dependency is null
// second.Dependency is populated

Registration sources: You can do some fancy stuff with registration sources that'll hose pipelines, like dynamically adding registrations based on arbitrary runtime environment values.

Factory relationships: Func<T> or (worse?) Func<X, Y, T> with parameterizations might hose things up.

Owned items: Right now Owned<T> basically creates its own nested lifetime scope so it can be manually disposed. I haven't thought this one through entirely, but I imagine the "resolve a thing where there's a child scope that has its own set of dependencies and pipelines" could make things tricky.

It might be helpful to split the notions of "resolve via pipeline" and "try to optimize and pre-build all the pipelines." Even if we can't pre-build every pipeline, having the concept of pipelines in here to control ordering of operations, add a bit more traceability, and so on... that's still valuable.

@alistairjevans
Copy link
Member

Thanks for the thoughts @tillig.

The overall goal for this really is to build the resolve pipeline for each registration at container build time, but not necessarily determine the dependencies of each registration at container build time (yet).

I've given this another day of work and state is as follows:

  • The pipeline for each registration (not including nested dependencies), is generated at registry build time.
  • The Activator adds its stages to the pipeline after the component registry has been built (and has the registry available to it at that point).
    This means that 'if' we were able to do any pre-calculation regarding the available services/dependencies, we can do that when the Activator adds to the pipeline (I'm not doing that right now, but I can imagine a selection of ways that could come in handy).
  • 'Common' pipeline stages, scoped to the component registry, are added to all component pipelines at the same time as the Activator adds it's stage(s). This is how circular dependency detection and scope selection get added to all pipelines.
  • The ResolveOperation class now holds the state for a single pipeline operation (and nested pipelines) in a similar manner to how it used to work.
  • When a dependency is required, a new ResolveRequest is issued (via ResolveOperation), which locates the appropriate pipeline, and invokes it, within the scope of the existing pipeline (not dissimilar to the current behaviour of using another InstanceLookup).

I'm currently mulling over how I want per-service pipelines (to power decorators) to work. I've currently got them attached to the ServiceRegistrationInfo, but that won't support decorators declared in nested scopes.

@alistairjevans alistairjevans linked a pull request May 27, 2020 that will close this issue
@alistairjevans alistairjevans added this to the v6.0 milestone May 27, 2020
@alistairjevans
Copy link
Member

I'm going to close this issue out now; the overall design is settled, although there may be subsequent discussions on some following functionality.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
Projects
None yet
Development

Successfully merging a pull request may close this issue.

4 participants