simpleinjector / SimpleInjector

An easy, flexible, and fast Dependency Injection library that promotes best practice to steer developers towards the pit of success.
https://simpleinjector.org
MIT License
1.22k stars 152 forks source link

Can Injection collection be based on more than just a type (eg. class attributes)? #1009

Open wizfromoz opened 1 day ago

wizfromoz commented 1 day ago

I have an interface that specifies a particular functionality, let's say a parser, that has one method, Parse, that takes a string and returns an object which is the result of parsing.

My application has parsing activity in several places, and not all parsers are applicable to each situation. Is there a way for me to specify additional criteria for each situation that will inject only those parser instances that match not just IParser type, but these additional criteria as well? The particular thing I had in mind is to use attributes on different parser classes, that will provide this additional selection criteria.

Some applications declare new interfaces, which are identical to the originals ones (eg. IParser2) solely for the purpose of helping DI group parsers correctly, but to me this looks like a very wrong reason to introduce new interface, just to compensate for the lack of additional selection criteria from DI framework.

dotnetjunkie commented 1 day ago

Hi @wizfromoz,

Can you demonstrate this with some code examples? I'd like to see the signature of the parser interface, and some examples of how consumers of the interface are intended to use the parser.

wizfromoz commented 14 hours ago

Hi @dotnetjunkie ,

Imagine an interface like this:

interface IParser {
Object Parse(byte[] message);
}

My application needs to parse information about apples and oranges. Each apple is identified by some numeric id, unique among apples, and similarly, each orange is identified by a numeric id, unique among oranges, but may overlap with ids from some apples.

Now, processing of apples and oranges is happening independently, in different parts of my application. In one part, I need to collect all apple parsers, and create some kind of a parser map, based on the unique apple identifying key (mentioned before), that will allow me to select a correct apple parser to process information on that apple. The same situation with oranges.

So, my problem is that when I ask the container to inject a collection of IParser instances that parse only apples, I get also instances that parse oranges, which is not what I want. I want only IParser instances that parse apple only. Clearly, I need some kind of a distinguishing attribute on those instances, but I don't want that attribute to be a part of IParser interface, as I want to keep that interface clean of any DI related artifacts. I was thinking that, perhaps, I could use attributes for that, ie. apply different attribute to apple parsers as opposed to orange parsers, and use that to select only one or the other type of IParser instances, but I couldn't see anything that supports that sort of selection. I think attributes would be perfect, because they contain metadata and don't pollute interfaces with extra stuff required just for DI.

wizfromoz commented 14 hours ago

PS. now, to make things just a little bit more complicated, for completeness:

Parsing of apples is a 2 stage process: for the first part, I need to select a correct apple parser, based on the apple code. But, sometimes, there are message extensions, each identified by their own code, for which I need to invoke 2nd stage parser, from within my original parser. The extension types are shared for all apple information messages.

So, the new problem here is that extension parsers need to be injected into normal apple parsers and these extension parser also implement the same IParser interface. How will SimpleInjector "know" to create extension parser instances first in order to inject them into apple parsers?

dotnetjunkie commented 14 hours ago

When faced with design challenges like these, I often try to cross-check my design with the SOLID principles, as they often give me hints on whether I'm on the right path or not. In your case the Liskov Substitution Principle (LSP) (the L in SOLID) might be appropriate.

The LSP states that you should be able to substitute any used sub type for another sub type within the same hierarchy without breaking the consumer. Or translated to your specific case it means that a consumer it should be able to work when injected with any IParser implementation. It it breaks while being injected with some, it is a strong indication that the LSP violation is broken.

I do believe that you are violating the LSP with your design, especially because your IParser interface returns object, while some consumers can only handle Apple and others can only handle Orange sub types.

It might be good to first trying to fix that design issue before trying forcing the current design onto your DI Container. Even though Simple Injector is very versatile and you can do a lot with it, my experience is that fixing SOLID principle violations most of the time also simplifies DI Container registrations a lot.

In general, when some consumers only support half of the implementations and other consumers support the other half, this is a strong indication that you actually need two interfaces. This could mean you define an IAppleParser and IOrangeParser. Or, alternatively, one generic IParser<TFruit> interface, as from runtime and type system's perspective, IParser<Apple> is a completely separate interface from IParser<Orange>.

In other words, consider defining the following interface:

public interface IParser<TFruit>
{
    TFruit Parse(byte[] message);
}

This interface, for instance, allows consumers to take a dependency on it as follows:

public class AppleController
{
    public AppleController(IEnumerable<IParser<Apple>> parsers) ...
}

public class OrangeController
{
    public OrangeController(IEnumerable<IParser<Orange>> parsers) ...
}

This might already solve part of the issue for you and prevents having to fallback to attributes.

Note that in many cases, generic typing and attributes both allow you to enrich types with extra metadata. The advantage, however, of generic typing, is that it gives extra compile-time support, which attributes don't give.

When it comes to wiring everything together using Simple Injector, you can now register all your parsers in one line:

Assembly[] applicationAssemblies = GetApplicationAssemblies();
container.Collection.Register(typeof(IParser<>), applicationAssemblies);

Even better would it be to hide the use of the enumerable behind a composite implementation, e.g.:

public class AppleController
{
    public AppleController(IParser<Apple> parser) ... <- injects a composite here
}

Simple Injector has great support for handling composites. Take a look, for instance, at the CompositeValidator<T> example in the documentation.

Such composite might even also solve your "invoke 2nd stage parser" problem. Your composite implementation can be the 1ste stage and going through the list of injected parsers to select the proper parser to use.

I can give you more ideas later on, but perhaps it's good to play with these ideas first. If you want more alternative approaches, let me know.

wizfromoz commented 13 hours ago

Thanks for your prompt reply @dotnetjunkie ! I think in my case, LSP is not really applicable, because different parser instances are not equivalent. The context is more of a factory method: any returned object implements the same interface, but do slightly different things. So, in this case, I'm trying to use DI to inject applicable IParser instances into a factory method, that will later pick the correct IParser instance based on some code. I wonder if maybe this is a wrong use of DI framework to begin with? I mean, your ILogger examples are all substitutable and you may select the "correct" one based on some contextual info (eg. some target property), but my parsers are not substitutable... It's not really my design, someone came up with this pattern and I'm wondering if this is just plain wrong use of DI container facilty.

wizfromoz commented 13 hours ago

PS. if the key idea is about injectable services is that various providers are equally OK to be injected, what is the idea behind allowing injection of a collection of instances that provide some interface? Like, what particular principles or use cases were the inspiration for that?

If injection of a collection of IParser providers is allowed, then surely some sort of filtering may be supported by DI framework? I know I can do the filtering myself after receiving all of IParser instances, but can it be done on DI level? And, to me, more importantly, is this a supported use case, or just an unintentional use of what started as a good idea?

dotnetjunkie commented 54 minutes ago

what is the idea behind allowing injection of a collection of instances that provide some interface? Like, what particular principles or use cases were the inspiration for that?

I don't think there's really a principle -or at least not a SOLID principle- behind the injection of collections, but in general you'll find many situations where you need to work with collections of services. You have many IParser implementations, you already mentioned ILogger, but there are of course many other examples I can think of. But while working with a set of instances of an abstraction is common, it also causes extra complexity to the application if you would inject such IEnumerable<IMyAbstraction> into many consumers. This is what the earlier mentioned Composite pattern is discovered for, as it allows you to hide the complexity of a collection as if it is just a single instance, just like you can implement a class CompositeLogger : ILogger, wrapping an IEnumerable<ILogger> and forwarding the log request to all wrapped loggers.

If injection of a collection of IParser providers is allowed, then surely some sort of filtering may be supported by DI framework?

Simple Injector supports context-based injection, allowing to choose which implementation to inject in what consumer. This mechanism, however, can't be applied to elements of a collection. Still, it might be useful to achieve your needs, because Simple Injector allows the definition of multiple collections for the same service type, and you can mix this with conditional registrations. For instance:

var container = new Container();

// Some consumer depending on IEnumerable<IParser>
container.Register<ParserFactory<Apple>>();
container.Register<ParserFactory<Orange>>();

Assembly[] applicationAssemblies = new[] { typeof(IParser).Assembly };

// Load all parser types from the application's assemblies
Type[] parserTypes =
    container.GetTypesToRegister(typeof(IParser), applicationAssemblies).ToArray();

// Filter the parsers based on some condition.
Type[] appleParserTypes = parserTypes.Where(t => t.Name.StartsWith("Apple")).ToArray();
Type[] orangeParserTypes = parserTypes.Where(t => t.Name.StartsWith("Orange")).ToArray();

// Create the registration object for apple parsers. After this call, the Registration is not
// yet part of the container. It must be added: see below.
Registration applesRegistration =
    container.Collection.CreateRegistration<IParser>(appleParserTypes);

// Do the same for orange parsers.
Registration orangesRegistration =
    container.Collection.CreateRegistration<IParser>(orangeParserTypes);

// Now we add the Registrations using a conditional registration.
// Apple parsers will be injected into the ParserFactory<Apple>.
container.RegisterConditional(
    serviceType: typeof(IEnumerable<IParser>),
    registration: applesRegistration,
    c => c.Consumer?.ImplementationType == typeof(ParserFactory<Apple>));

// Orange parsers will be injected into the ParserFactory<Orange>.
// You can make these conditional registrations as suffisticated as you need.
container.RegisterConditional(
    serviceType: typeof(IEnumerable<IParser>),
    registration: orangesRegistration,
    c => c.Consumer?.ImplementationType == typeof(ParserFactory<Orange>));

// HACK: Currently needed to prevent verification errors; might be fixed in the future. See #1010
applesRegistration.SuppressDiagnosticWarning(DiagnosticType.TornLifestyle, "False possitive");
orangesRegistration.SuppressDiagnosticWarning(DiagnosticType.TornLifestyle, "False possitive");

The above registration ensures a collection of apple parsers is injected into ParserFactory<Appple>, while the ParserFactory<Orange> gets a collection of orange parsers.