Anders
Anders

Reputation: 17564

Microsoft Dependency injection and contravariance

I'm porting a library to net core. We now have the built in DI and I want to use it has best as possible.

My library proxies events from backend to signalr clients, it contains a framework to validate event before sending it to specific client. My old library supported contravariance, so one Event handler can support all events if you want. Consider this

public class ContravariantHandler : IEventHandler<BaseEvent>
{
    public void Handle(BaseEvent message)
    {

    }
}

public abstract class BaseEvent { }

public class MyEvent : BaseEvent { }
public class MyEvent2 : BaseEvent { }

public interface IEventHandler<in T>
{
    void Handle(T message);
}

Doing new ContravariantHandler().Handle(new MyEvent()); and new ContravariantHandler().Handle(new MyEvent2()); are both fine here.

Can I get the net core DI to handle be the correct type here?

this does not work,

var provider = new ServiceCollection()
    .AddTransient<IEventHandler<BaseEvent>, ContravariantHandler>()
    .BuildServiceProvider();

var contravariance =  provider.GetService<IEventHandler<MyEvent>>();

Upvotes: 5

Views: 1365

Answers (3)

Leonid Salavatov
Leonid Salavatov

Reputation: 221

you just need to add two extra lines to your source code:

// + nuget package
using ContravarianceDependencyInjection; 


var provider = new ServiceCollection()
    .AddTransient<IEventHandler<BaseEvent>, ContravariantHandler>()
    
    // + Adds contravariance injection for IEventHandler
    .AddContravariance(typeof(IEventHandler<>))

    .BuildServiceProvider();


var contravariance = provider.GetService<IEventHandler<MyEvent>>();

Upvotes: 1

brads3290
brads3290

Reputation: 2075

I found myself wanting the same functionality for a service provider that would be used internally in a specific part of my library, and nowhere else.

My solution was to wrap my IServiceProvider in a class that could handle variance by searching the service collection for matching co/contra-variant service types, selecting one based on an (admittedly arbitrary) strategy, and passing it to the underlying service provider for creation.

A couple of notes that I think are important:

  • It is implemented as a separate method, GetVariantService, so the caller must be intentional about invoking this behaviour. The GetService method passes directly to the underlying IServiceProvider.GetService, so that there is no surprise behaviour if this class is being used "naively" as a regular service provider.
  • To use this, you must have control over the creation of the service provider (or at least have access to the source IServiceCollection, as the service collection is needed to find possible matching types)

NOTE: This solution will only work for resolving the top-level service. It will not work for resolving constructor-injected services (these will be resolved by 'normal' behaviour, so variance won't work)

NOTE 2: I dug into the framework code to find out how ServiceProvider resolves dependencies, and if we could hook in anywhere to modify the behaviour. The answer is, unfortunately, no. The lookup is performed inside the sealed class Microsoft.Extensions.DependencyInjection.ServiceLookup.CallSiteFactory, which maintains a private dictionary of Type -> (list of applicable service descriptors).

It all seems very tightly glued together with no real way to override behaviour (probably for the best..), so in order to achieve variance resolution on injected parameters, it seems one would need to re-implement ServiceProvider and it's dependencies from scratch.


Code below:

  1. The service provider and interface
public interface IVariantServiceProvider : IServiceProvider {

    object? GetVariantService(Type serviceType);

}

public class VariantServiceProvider : IVariantServiceProvider {

    private IServiceProvider _serviceProvider;
    private IServiceCollection _services;

    public VariantServiceProvider(IServiceProvider serviceProvider, IServiceCollection services) {
        this._serviceProvider = serviceProvider;
        this._services = services;
    }

    public object? GetService(Type serviceType) {
        return this._serviceProvider.GetService(serviceType);
    }

    public object? GetVariantService(Type serviceType) {
        // Variance only applies to interfaces..
        if (!serviceType.IsInterface) {
            return this.GetService(serviceType);
        }

        // .. with generics
        if (!serviceType.IsConstructedGenericType) {
            return this.GetService(serviceType);
        }

        //
        // 1. If serviceType has variant generic parameters, 
        // list all service descriptors that have compatible type and gen. params.
        //
        
        // Are any of our generic params variant?
        var genericDef = serviceType.GetGenericTypeDefinition();
        var genericParams = genericDef.GetGenericArguments();
        if (!genericParams.Any(gp => GetGenericParamVariance(gp) != GenericParameterAttributes.None)) {
            // No params have variance
            return this.GetService(serviceType);
        }

        // Find descriptors that match our serviceType
        var candidates = new List<ServiceDescriptor>();
        foreach (var service in this._services) {
            var candidateServiceType = service.ServiceType;
            if (!candidateServiceType.IsInterface) {
                continue;
            }

            if (!candidateServiceType.IsGenericType) {
                continue;
            }

            // If this is a catch-all generic definition (not a defined type),
            // we don't count it. If no other matches are found, the
            // underlying IServiceProvider should pick this up.
            if (candidateServiceType.IsGenericTypeDefinition) {
                continue;
            }

            // Check they have the same generic definition
            // --
            // To remain consistent with Microsoft's ServiceProvider, candidates must have the same
            // generic definition as our serviceType (i.e. be the same exact interface, not a derived one)
            if (candidateServiceType.GetGenericTypeDefinition() != genericDef) {
                continue;
            }

            // Check that our co/contra-variance matches
            if (!serviceType.IsAssignableFrom(candidateServiceType)) {
                continue;
            }

            candidates.Add(service);
        }

        // If no candidates, fall back on underlying provider
        if (!candidates.Any()) {
            return this.GetService(serviceType);
        }
        
        // If only one candidate, we don't need to try to reduce the
        // list
        if (candidates.Count == 1) {
            return this.GetService(candidates[0].ServiceType);
        }
        
        //
        // 2. We have multiple candidates. Prioritise them according to the following strategy:
        //      - Choose candidate whose 1st type arg is closest in the heirarchy to the serviceType's 1st arg
        //      - If more than one candidate, order by 2nd type arg, and so on.
        //      - If still more than one candidate after reaching end of type args, use the last service added
        //

        var serviceTypeParams = serviceType.GetGenericArguments();
        var genericParameterCount = genericDef.GenericTypeArguments.Length;
        var genericParamIdx = 0;
        while (genericParamIdx < genericParameterCount && candidates.Count > 1) {
            var serviceTypeParam = serviceTypeParams[genericParamIdx];

            var shortlist = new List<ServiceDescriptor>();
            var shortlistDistance = 0;
            foreach (var candidate in candidates) {
                var candidateType = candidate.ServiceType;
                var candidateTypeParam = candidateType.GetGenericArguments()[genericParamIdx];
                var distance = TypeDistance(serviceTypeParam, candidateTypeParam);

                if (distance == -1) {
                    
                    // This shouldn't happen, because we already ensured that
                    // one gen. param is assignable to the corresponding other when we selected candidates.
                    throw new Exception("Failed to get distance between types: " + candidateTypeParam.Name + " and " + serviceTypeParam.Name);
                }

                if (distance < shortlistDistance) {
                    shortlistDistance = distance;
                    
                    shortlist.Clear();
                    shortlist.Add(candidate);
                } else if (distance == shortlistDistance) {
                    shortlist.Add(candidate);
                }
            }

            // Have we reduced the list?
            if (shortlist.Any()) {
                candidates = shortlist;
            }
            
            genericParamIdx += 1;
        }

        // If there is still more than one candidate, use the one that was
        // added to _services most recently
        ServiceDescriptor match;
        if (candidates.Count > 1) {
            match = candidates.OrderBy(c => this._services.IndexOf(c)).Last();
        } else {
            match = candidates[0];
        }

        return this.GetService(match.ServiceType);
    }

    private static GenericParameterAttributes GetGenericParamVariance(Type genericParam) {
        var attributes = genericParam.GenericParameterAttributes;
        return attributes & GenericParameterAttributes.VarianceMask;
    }

    private static int TypeDistance(Type t1, Type t2) {
        Type ancestor;
        Type derived;

        if (t1.IsAssignableTo(t2)) {
            ancestor = t2;
            derived = t1;
        } else if (t2.IsAssignableTo(t1)) {
            ancestor = t1;
            derived = t2;
        } else {
            return -1;
        }

        var distance = 0;
        var current = derived;

        while (current != ancestor) {
            if (current == null) {
                return -1;
            }
            
            distance += 1;
            current = current.BaseType;
        }

        return distance;
    }

}
  1. The extension methods, similar to MS's built-in ones. These are not extensive and only contain the ones I needed.
public static class VariantServiceExtensions {

    public static VariantServiceProvider BuildVariantServiceProvider(this IServiceCollection services) {
        return new VariantServiceProvider(services.BuildServiceProvider(), services);
    }

    public static T? GetVariantService<T>(this IVariantServiceProvider provider) {
        return (T?) provider.GetVariantService(typeof(T));
    }

}
  1. Example usage:
var services = new ServiceCollection();
services.AddTransient<ITest<TypeParamB>, Test>();

var serviceProvider = services.BuildVariantServiceProvider();

// `Test` can be assigned to `ITest<TypeParamA>` via covariance
ITest<TypeParamA> test = new Test();

// Retrieve `Test` via the regular service provider
var regularResult = serviceProvider.GetService<ITest<TypeParamA>>();
Console.WriteLine(regularResult is null); // Output: True

// Retrieve `Test` via the variant service provider
var variantResult = serviceProvider.GetVariantService<ITest<TypeParamA>>();
Console.WriteLine(variantResult is null); // Output: False


//
// CLASS DEFINITIONS
//

public class TypeParamA { }

public class TypeParamB : TypeParamA { }

public interface ITest<out T> { }

public class Test : ITest<TypeParamB> { }

Upvotes: 0

Michael Petito
Michael Petito

Reputation: 13161

Can I get the net core DI to handle be the correct type here?

Not with any built-in mechanism and it's unlikely to be added.

We understand the scenario but adding support for this would make it difficult to work with other containers. https://github.com/aspnet/DependencyInjection/issues/453

You can however introduce another interface, i.e. IEventHandler, resolve IEnumerable<IEventHandler> and check each one for compatibility. The downside is that this results in activation of all of your event handlers.

Upvotes: 0

Related Questions