admin管理员组

文章数量:1134576

I started implementing something like "strict" mode for IServiceCollection which will throw an Exception (or even AggregateException if possible) when an AddXXX extension method would overwrite an already registered dependency. Main motivation is to be sure that with all the AddXXX methods in my codebase which others contribute to, some depedency doesn't get accidentally overwritten.

What I have so far is an extension method AsStrict() on IServiceCollection which returns an instance of a custom IStrictServiceCollection interface (extending IServiceCollection for chaining) which has its only method AsNormal(). The purpose of the method is to turn off the validation logic. It uses the captured IServiceCollection instance to forward to for the ICollection methods with the exception of Add where I base the exception logic on TryAddEnumerable with a couple of additional if clauses in the body of the loop:

int count = services.Count;
for (int i = 0; i < count; i++) {
    ServiceDescriptor service = services[i];
    if (service.ServiceType == descriptor.ServiceType &&
        service.GetImplementationType() == implementationType &&
        object.Equals(service.ServiceKey, descriptor.ServiceKey)) {
        // Already added
        // no return but
        throw new InvalidOperationException(...)
    }
}

services.Add(descriptor); 

The use would be:

services.AddX()
        .AsStrict()
        .AddY()
        .AddZ()
        .AsNormal()
        .AddXXX

so I could capture exceptions from part of the service registration chain or the whole. I've already come across some "normal" edge cases that made me post this question in the hopes that somebody has encountered most/if not all and has a more robust implementation.

I started implementing something like "strict" mode for IServiceCollection which will throw an Exception (or even AggregateException if possible) when an AddXXX extension method would overwrite an already registered dependency. Main motivation is to be sure that with all the AddXXX methods in my codebase which others contribute to, some depedency doesn't get accidentally overwritten.

What I have so far is an extension method AsStrict() on IServiceCollection which returns an instance of a custom IStrictServiceCollection interface (extending IServiceCollection for chaining) which has its only method AsNormal(). The purpose of the method is to turn off the validation logic. It uses the captured IServiceCollection instance to forward to for the ICollection methods with the exception of Add where I base the exception logic on TryAddEnumerable with a couple of additional if clauses in the body of the loop:

int count = services.Count;
for (int i = 0; i < count; i++) {
    ServiceDescriptor service = services[i];
    if (service.ServiceType == descriptor.ServiceType &&
        service.GetImplementationType() == implementationType &&
        object.Equals(service.ServiceKey, descriptor.ServiceKey)) {
        // Already added
        // no return but
        throw new InvalidOperationException(...)
    }
}

services.Add(descriptor); 

The use would be:

services.AddX()
        .AsStrict()
        .AddY()
        .AddZ()
        .AsNormal()
        .AddXXX

so I could capture exceptions from part of the service registration chain or the whole. I've already come across some "normal" edge cases that made me post this question in the hopes that somebody has encountered most/if not all and has a more robust implementation.

Share Improve this question edited Jan 8 at 11:50 Ivan Petrov asked Jan 7 at 21:10 Ivan PetrovIvan Petrov 3,9162 gold badges11 silver badges23 bronze badges 11
  • Do you really need the exceptions or would be preventing overrides by using .TryAdd... enough? – monty Commented Jan 7 at 21:23
  • @monty I'd really prefer AggregateException or some info where things fail – Ivan Petrov Commented Jan 7 at 22:17
  • I wonder if this could be implemented with an analyzer. This way you could get warnings/errors in compilation. And you could get squiggly lines in VS. – Carles Company Commented Jan 8 at 6:25
  • @CarlesCompany maybe, but for now I am happy with a single Exception thrown when a registration (think TryAddEnumerable and not TryAdd) overwrites an existing one. – Ivan Petrov Commented Jan 8 at 7:43
  • What's the real problem you want to solve? .Add and .TryAdd don't overwrite registrations and the code you provided proves this. What you seem to ask for is enforcing a single implementation per type, not "strict" mode. To what actual problem would that be a solution? some "normal" edge cases that includes keyed services which isn't edge. You can't modify the built-in extension methods anyway, so you can't make clients throw if eg they register two different repository implementations for the same interface. Interfaces don't have code anyway so IStrictServiceCollection won't help – Panagiotis Kanavos Commented Jan 8 at 8:08
 |  Show 6 more comments

2 Answers 2

Reset to default 1

Not so quite sure what your definition of invalid registration is (so I left it out).

Here is some executable code sample (Visual Studio 2022 .NET8 Web API template).

Feel free to edit the answer to provide the some reproducable error scenario.

using System.Collections;

namespace WebApplication1
{
    public class Program
    {
        public static void Main(string[] args)
        {
            var builder = WebApplication.CreateBuilder(args);

            // Add services to the container.

            var normal = builder.Services;
            var strict = builder.Services.AsStrict();

            normal.AddControllers();
            normal.AddEndpointsApiExplorer();
            normal.AddSwaggerGen();

            normal.AddX().AddY().AddZ();
            // so far only fails on strict.AddZ()
            strict.AddX().AddY().AddZ();
            
            var app = builder.Build();

            // Configure the HTTP request pipeline.
            if (app.Environment.IsDevelopment())
            {
                app.UseSwagger();
                app.UseSwaggerUI();
            }

            app.UseAuthorization();


            app.MapControllers();

            app.Run();
        }
    }
    public class ServiceX { }
    public class ServiceY { }
    public class ServiceZ { }

    public static class ServiceCollectionExtensions
    {
        public static IServiceCollection AddX(this IServiceCollection services)
        {
            services.AddSingleton<ServiceX>();
            return services;
        }
        public static IServiceCollection AddY(this IServiceCollection services)
        {
            services.AddScoped<ServiceY>();
            return services;
        }
        public static IServiceCollection AddZ(this IServiceCollection services)
        {
            services.AddTransient<ServiceZ>();
            return services;
        }
        public static IServiceCollection AddXXX(this IServiceCollection services)
        {
            services.AddSingleton<ServiceX>();
            services.AddScoped<ServiceY>();
            services.AddTransient<ServiceZ>();
            return services;
        }
    }

    public static class StrictServiceCollectionExtensions
    {
        public static IStrictServiceCollection AsStrict(this IServiceCollection services)
        {
            if (services == null) throw new ArgumentNullException();
            if (services is IStrictServiceCollection) return (IStrictServiceCollection)services;

            return new StrictServiceCollection(services);
        }

        public static IServiceCollection AsNormal(this IServiceCollection services)
        {
            if (services == null) throw new ArgumentNullException();
            if (services is not IStrictServiceCollection strictServiceCollection) return services;

            return strictServiceCollection.AsNormal();
        }
    }

    public interface IStrictServiceCollection : IServiceCollection
    {
        IServiceCollection AsNormal();
    }

    public class StrictServiceCollection : IStrictServiceCollection
    {

        protected readonly IServiceCollection NormalServiceCollection;

        public StrictServiceCollection(IServiceCollection normalServiceCollection)
        {
            if (normalServiceCollection == null) throw new ArgumentNullException(nameof(normalServiceCollection));
            if (normalServiceCollection is IStrictServiceCollection) throw new ArgumentException("nesting strict service collections.");
            NormalServiceCollection = normalServiceCollection;
        }

        public IServiceCollection AsNormal()
        {
            return NormalServiceCollection;
        }

        public void CheckInvalidRegistration(ServiceDescriptor item)
        {
            int count = NormalServiceCollection.Count;
            for (int i = 0; i < count; i++)
            {
                ServiceDescriptor service = NormalServiceCollection[i];
                if (service.ServiceType == item.ServiceType &&
                    service.ImplementationType == item.ImplementationType &&
                    object.Equals(service.ServiceKey, item.ServiceKey))
                {
                    // Already added
                    // no return but
                    throw new InvalidOperationException($"service {service.ServiceType.FullName} already added as {service.Lifetime}");
                }
            }
        }

        // add your checks in this methods

        public void Insert(int index, ServiceDescriptor item)
        {
            CheckInvalidRegistration(item);
            NormalServiceCollection.Insert(index, item);
        }

        public void Add(ServiceDescriptor item)
        {
            CheckInvalidRegistration(item);
            NormalServiceCollection.Add(item);
        }

        public ServiceDescriptor this[int index]
        {
            get { return NormalServiceCollection[index]; }
            set
            {
                CheckInvalidRegistration(value); // edge case if value is already at index
                NormalServiceCollection[index] = value;
            }
        }

        // the other methods are just passed

        public int Count => NormalServiceCollection.Count;
        public bool IsReadOnly => NormalServiceCollection.IsReadOnly;
        public IEnumerator<ServiceDescriptor> GetEnumerator() => NormalServiceCollection.GetEnumerator();
        IEnumerator IEnumerable.GetEnumerator() => NormalServiceCollection.GetEnumerator();
        public void Clear() => NormalServiceCollection.Clear();
        public bool Contains(ServiceDescriptor item) => NormalServiceCollection.Contains(item);
        public void CopyTo(ServiceDescriptor[] array, int arrayIndex) => NormalServiceCollection.CopyTo(array, arrayIndex);
        public bool Remove(ServiceDescriptor item) => NormalServiceCollection.Remove(item);
        public int IndexOf(ServiceDescriptor item) => NormalServiceCollection.IndexOf(item);
        public void RemoveAt(int index) => NormalServiceCollection.RemoveAt(index);
    }

}

The actual registration implementation is handled by the ServiceCollection class, not the interfaces or extension methods. The only way to intercept all registrations is to WRAP that class and override its IList<ServiceDescriptor>.Add and maybe IList<ServiceDescriptor>.Insert methods. The IServiceCollection interface itself is just public interface IServiceCollection : IList<ServiceDescriptor>{}.

The Microsoft.Extension.Http package uses a custom service collection to ensure the order of configuration option registration, in DefaultHttpClientBuilderServiceCollection class by providing its own IList<>.Add while delegating all other methods to the outer ServiceCollection :

internal sealed class DefaultHttpClientBuilderServiceCollection : IServiceCollection
{
    private readonly IServiceCollection _services;
    private readonly bool _isDefault;
    private readonly DefaultHttpClientConfigurationTracker _tracker;

    public DefaultHttpClientBuilderServiceCollection(IServiceCollection services, bool isDefault, DefaultHttpClientConfigurationTracker tracker)
    {
        _services = services;
        _isDefault = isDefault;
        _tracker = tracker;
    }

    public void Add(ServiceDescriptor item)
    {
       //Custom registration code
       ...
    }

    public ServiceDescriptor this[int index]
    {
        get => _services[index];
        set => _services[index] = value;
    }
    public int Count => _services.Count;
    public bool IsReadOnly => _services.IsReadOnly;
    public void Clear() => _services.Clear();
    public bool Contains(ServiceDescriptor item) => _services.Contains(item);
    public void CopyTo(ServiceDescriptor[] array, int arrayIndex) => _services.CopyTo(array, arrayIndex);
    public IEnumerator<ServiceDescriptor> GetEnumerator() => _services.GetEnumerator();
    public int IndexOf(ServiceDescriptor item) => _services.IndexOf(item);
    public void Insert(int index, ServiceDescriptor item) => _services.Insert(index, item);
    public bool Remove(ServiceDescriptor item) => _services.Remove(item);
    public void RemoveAt(int index) => _services.RemoveAt(index);
    IEnumerator IEnumerable.GetEnumerator() => GetEnumerator();
}

Adapted to the question, this would be something like :

    internal sealed class SingleRegistrationServiceCollection : IServiceCollection
    {
    ...

        void ValidateRegistration(ServiceDescriptor item)
        {
            for (int i = 0; i < Count; i++) {
                var service = services[i];
                if (service.ServiceType == item.ServiceType &&
                    service.GetImplementationType() == item.ImplementationType 
                    &&
                   object.Equals(service.ServiceKey, item.ServiceKey)) {
                   // Already added
                   // no return but
                   throw new InvalidOperationException(...)
                }
             }
         }

        public void Add(ServiceDescriptor item)
        {
            ValidateRegistration(item);
            _services.Add(item);
        }

        public void Insert(int index, ServiceDescriptor item) 
        {
            ValidateRegistration(item);
            _services.Insert(index, item);
        }
...

The class is internal and can only be accessed indirectly through the AddHttpClient extension method, through the DefaultHttpClientBuilder. This avoids leaking the custom service collection or mixing up registrations :

    public static IHttpClientBuilder AddHttpClient(this IServiceCollection services, string name, Action<HttpClient> configureClient)
    {
        ThrowHelper.ThrowIfNull(services);
        ThrowHelper.ThrowIfNull(name);
        ThrowHelper.ThrowIfNull(configureClient);

        AddHttpClient(services);

        var builder = new DefaultHttpClientBuilder(services, name);
        builder.ConfigureHttpClient(configureClient);
        return builder;
    }

Adapting this to the question :

    public static IServiceCollection AddStrict(this IServiceCollection outerServices, Action<IServiceCollection> configureStrict)
    {
    ....
        var services=new SingleRegistrationServiceCollection(outerServices);
        configureStrict(services);
        return outerServices;
    }

This will result in registration code like this :

services.AddX()
        .AddStrict(svc=>
            .AddY()
            .AddZ()
         )
        .AddXXX()

The configuration action pattern allows more complex validation too. Eg some validations may be possible only once all "strict" services are added. This can by done by adding a Validate method to the custom service collection and calling it after the configuration action:

    public static IServiceCollection AddStrict(this IServiceCollection outerServices, Action<IServiceCollection> configureStrict)
    {
    ....
        var services=new SingleRegistrationServiceCollection(outerServices);
        configureStrict(services);
        services.Validate();
        return outerServices;
    }

本文标签: cHow do I implement a quotstrictquot mode for adding to IServiceCollectionStack Overflow