-
-
Notifications
You must be signed in to change notification settings - Fork 838
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
Comments
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 |
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. |
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. OverviewPipelines 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 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)
New Possible Pipeline Proposal
What is a Pipeline Stage?A pipeline stage will be a class that implements 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 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. RegistrationIt 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
Responsibility for creating the 'final' pipeline probably sits inside EventsSo, 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 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. DecoratorsFrom 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 Generating Concrete Pipelines for Component Constructor Parameters at Container Build TimesIn 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 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 |
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:
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. |
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 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:
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:
An implication of that is that a bunch of existing logic would now need to pass around an 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. |
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'.
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.
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:
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. |
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
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
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 When the
The pipeline for that component should then look like:
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:
I think that's pretty close to the chain you originally proposed, but without attaching service-specific behaviour. Thoughts? |
Just on
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.
There are a few reasons:
Again, a few assertions there up for some scrutiny! The 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 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. |
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 Any need to resolve items within a pipeline should be done via an 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. |
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 |
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. |
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
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 I wonder if registration sources instead become middleware in the pipeline. |
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:
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:
|
Here's some info on code generators. Interesting stuff. |
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. |
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 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:
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... |
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. |
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 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: Owned items: Right now 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. |
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:
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. |
I'm going to close this issue out now; the overall design is settled, although there may be subsequent discussions on some following functionality. |
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
The text was updated successfully, but these errors were encountered: