Discovering Generic Controllers in ASP.NET Core

2020-01-31 02:08发布

I am trying to create a generic controller like this:

[Route("api/[controller]")]
public class OrdersController<T> : Controller where T : IOrder
{
    [HttpPost("{orderType}")]
    public async Task<IActionResult> Create(
        [FromBody] Order<T> order)
    {
       //....
    }
}

I intend for the {orderType} URI segment variable to control the generic type of the controller. I'm experimenting with both a custom IControllerFactory and IControllerActivator, but nothing is working. Every time I try to send a request, I get a 404 response. The code for my custom controller factory (and activator) is never executed.

Evidently the problem is that ASP.NET Core expects valid controllers to end with the suffix "Controller", but my generic controller instead has the (reflection based) suffix "Controller`1". Thus the attribute-based routes it declares are going unnoticed.

In ASP.NET MVC, at least in its early days, the DefaultControllerFactory was responsible for discovering all the available controllers. It tested for the "Controller" suffix:

The MVC framework provides a default controller factory (aptly named DefaultControllerFactory) that will search through all the assemblies in an appdomain looking for all types that implement IController and whose name ends with "Controller."

Apparently, in ASP.NET Core, the controller factory no longer has this responsibility. As I stated earlier, my custom controller factory executes for "normal" controllers, but is never invoked for generic controllers. So there is something else, earlier in the evaluation process, which governs the discovery of controllers.

Does anyone know what "service" interface is responsible for that discovery? I don't know the customization interface or "hook" point.

And does anyone know of a way to make ASP.NET Core "dump" the names of all the controllers it discovered? It would be great to write a unit test that verifies that any custom controller discovery I expect is indeed working.

Incidentally, if there is a "hook" which allows generic controller names to be discovered, it implies that route substitutions must also be normalized:

[Route("api/[controller]")]
public class OrdersController<T> : Controller { }

Regardless of what value for T is given, the [controller] name must remain a simple base-generic name. Using the above code as an example, the [controller] value would be "Orders". It would not be "Orders`1" or "OrdersOfSomething".

Note

This problem could also be solved by explicitly declaring the closed-generic types, instead of generating them at run time:

public class VanityOrdersController : OrdersController<Vanity> { }
public class ExistingOrdersController : OrdersController<Existing> { }

The above works, but it produces URI paths that I don't like:

~/api/VanityOrders
~/api/ExistingOrders

What I had actually wanted was this:

~/api/Orders/Vanity
~/api/Orders/Existing

Another adjustment gets me the URI's I'm looking for:

[Route("api/Orders/Vanity", Name ="VanityLink")]
public class VanityOrdersController : OrdersController<Vanity> { }
[Route("api/Orders/Existing", Name = "ExistingLink")]
public class ExistingOrdersController : OrdersController<Existing> { }

However, although this appears to work, it does not really answer my question. I would like to use my generic controller directly at run-time, rather than indirectly (via manual coding) at compile-time. Fundamentally, this means I need ASP.NET Core to be able to "see" or "discover" my generic controller, despite the fact that its run-time reflection name does not end with the expected "Controller" suffix.

4条回答
Rolldiameter
2楼-- · 2020-01-31 02:08

To get a list of controllers in RC2, just get ApplicationPartManager from DependencyInjection and do this:

    ApplicationPartManager appManager = <FROM DI>;

    var controllerFeature = new ControllerFeature();
    appManager.PopulateFeature(controllerFeature);

    foreach(var controller in controllerFeature.Controllers)
    {
        ...
    }
查看更多
姐就是有狂的资本
3楼-- · 2020-01-31 02:19

Short Answer

Implement IApplicationFeatureProvider<ControllerFeature>.

Question and Answer

Does anyone know what "service" interface is responsible for [discovering all available controllers]?

The ControllerFeatureProvider is responsible for that.

And does anyone know of a way to make ASP.NET Core "dump" the names of all the controllers it discovered?

Do that within ControllerFeatureProvider.IsController(TypeInfo typeInfo).

Example

MyControllerFeatureProvider.cs

using System;
using System.Linq;
using System.Reflection;
using Microsoft.AspNetCore.Mvc.Controllers;

namespace CustomControllerNames 
{
    public class MyControllerFeatureProvider : ControllerFeatureProvider 
    {
        protected override bool IsController(TypeInfo typeInfo)
        {
            var isController = base.IsController(typeInfo);

            if (!isController)
            {
                string[] validEndings = new[] { "Foobar", "Controller`1" };

                isController = validEndings.Any(x => 
                    typeInfo.Name.EndsWith(x, StringComparison.OrdinalIgnoreCase));
            }

            Console.WriteLine($"{typeInfo.Name} IsController: {isController}.");

            return isController;
        }
    }
}

Register it during startup.

public void ConfigureServices(IServiceCollection services)
{
    services
        .AddMvcCore()
        .ConfigureApplicationPartManager(manager => 
        {
            manager.FeatureProviders.Add(new MyControllerFeatureProvider());
        });
}

Here is some example output.

MyControllerFeatureProvider IsController: False.
OrdersFoobar IsController: True.
OrdersFoobarController`1 IsController: True.
Program IsController: False.
<>c__DisplayClass0_0 IsController: False.
<>c IsController: False.

And here is a demo on GitHub. Best of luck.

Edit - Adding Versions

.NET Version

> dnvm install "1.0.0-rc2-20221" -runtime coreclr -architecture x64 -os win -unstable

NuGet.Config

<?xml version="1.0" encoding="utf-8"?>
<configuration>
  <packageSources>
    <clear/>
    <add key="AspNetCore" 
         value="https://www.myget.org/F/aspnetvnext/api/v3/index.json" />  
  </packageSources>
</configuration>

.NET CLI

> dotnet --info
.NET Command Line Tools (1.0.0-rc2-002429)

Product Information:
 Version:     1.0.0-rc2-002429
 Commit Sha:  612088cfa8

Runtime Environment:
 OS Name:     Windows
 OS Version:  10.0.10586
 OS Platform: Windows
 RID:         win10-x64

Restore, Build, and Run

> dotnet restore
> dotnet build
> dotnet run

Edit - Notes on RC1 vs RC2

This might not be possible is RC1, because DefaultControllerTypeProvider.IsController() is marked as internal.

查看更多
够拽才男人
4楼-- · 2020-01-31 02:27

What happens by default

During the controller discovery process, your open generic Controller<T> class will be among the candidate types. But the default implementation of the IApplicationFeatureProvider<ControllerFeature> interface, DefaultControllerTypeProvider, will eliminate your Controller<T> because it rules out any class with open generic parameters.

Why overriding IsController() doesn't work

Replacing the default implementation of the IApplicationFeatureProvider<ControllerFeature> interface, in order to override DefaultControllerTypeProvider.IsController(), will not work. Because you don't actually want the discovery process to accept your open generic controller (Controller<T>) as a valid controller. It is not a valid controller per se, and the controller factory wouldn't know how to instantiate it anyway, because it wouldn't know what T is supposed to be.

What needs to be done

1. Generate closed controller types

Before the controller discovery process even starts, you need to generate closed generic types from your open generic controller, using reflection. Here, with two sample entity types, named Account and Contact:

Type[] entityTypes = new[] { typeof(Account), typeof(Contact) };
TypeInfo[] closedControllerTypes = entityTypes
    .Select(et => typeof(Controller<>).MakeGenericType(et))
    .Select(cct => cct.GetTypeInfo())
    .ToArray();

We now have closed TypeInfos for Controller<Account> and Controller<Contact>.

2. Add them to an application part and register it

Application parts are usually wrapped around CLR assemblies, but we can implement a custom application part providing a collection of types generated at runtime. We simply need to have it implement the IApplicationPartTypeProvider interface. Therefore, our runtime-generated controller types will enter the controller discovery process like any other built-in type would.

The custom application part:

public class GenericControllerApplicationPart : ApplicationPart, IApplicationPartTypeProvider
{
    public GenericControllerApplicationPart(IEnumerable<TypeInfo> typeInfos)
    {
        Types = typeInfos;
    }

    public override string Name => "GenericController";
    public IEnumerable<TypeInfo> Types { get; }
}

Registration in MVC services (Startup.cs):

services.AddMvc()
    .ConfigureApplicationPartManager(apm =>
        apm.ApplicationParts.Add(new GenericControllerApplicationPart(closedControllerTypes)));

As long as your controller derives from the built-in Controller class, there is no actual need to override the IsController method of the ControllerFeatureProvider. Because your generic controller inherits the [Controller] attribute from ControllerBase, it will be accepted as a controller in the discovery process regardless of its somewhat bizarre name ("Controller`1").

3. Override the controller name in the application model

Nevertheless, "Controller`1" is not a good name for routing purposes. You want each of your closed generic controllers to have independent RouteValues. Here, we will replace the name of the controller with that of the entity type, to match what would happen with two independent "AccountController" and "ContactController" types.

The model convention attribute:

public class GenericControllerAttribute : Attribute, IControllerModelConvention
{
    public void Apply(ControllerModel controller)
    {
        Type entityType = controller.ControllerType.GetGenericArguments()[0];

        controller.ControllerName = entityType.Name;
    }
}

Applied to the controller class:

[GenericController]
public class Controller<T> : Controller
{
}

Conclusion

This solution stays close to the overall ASP.NET Core architecture and, among other things, you will keep full visibility of your controllers through the API Explorer (think "Swagger").

It has been tested successfully with both conventional and attribute-based routing.

查看更多
smile是对你的礼貌
5楼-- · 2020-01-31 02:35

Application Feature Providers examine application parts and provide features for those parts. There are built-in feature providers for the following MVC features:

  • Controllers
  • Metadata Reference
  • Tag Helpers
  • View Components

Feature providers inherit from IApplicationFeatureProvider, where T is the type of the feature. You can implement your own feature providers for any of MVC's feature types listed above. The order of feature providers in the ApplicationPartManager.FeatureProviders collection can be important, since later providers can react to actions taken by previous providers.

By default, ASP.NET Core MVC ignores generic controllers (for example, SomeController). This sample uses a controller feature provider that runs after the default provider and adds generic controller instances for a specified list of types (defined in EntityTypes.Types):

public class GenericControllerFeatureProvider : IApplicationFeatureProvider<ControllerFeature>
{
    public void PopulateFeature(IEnumerable<ApplicationPart> parts, ControllerFeature feature)
    {
        // This is designed to run after the default ControllerTypeProvider, 
        // so the list of 'real' controllers has already been populated.
        foreach (var entityType in EntityTypes.Types)
        {
            var typeName = entityType.Name + "Controller";
            if (!feature.Controllers.Any(t => t.Name == typeName))
            {
                // There's no 'real' controller for this entity, so add the generic version.
                var controllerType = typeof(GenericController<>)
                    .MakeGenericType(entityType.AsType()).GetTypeInfo();
                feature.Controllers.Add(controllerType);
            }
        }
    }
}

The entity types:

public static class EntityTypes
{
    public static IReadOnlyList<TypeInfo> Types => new List<TypeInfo>()
    {
        typeof(Sprocket).GetTypeInfo(),
        typeof(Widget).GetTypeInfo(),
    };

    public class Sprocket { }
    public class Widget { }
}

The feature provider is added in Startup:

services.AddMvc()
    .ConfigureApplicationPartManager(p => 
        p.FeatureProviders.Add(new GenericControllerFeatureProvider()));

By default, the generic controller names used for routing would be of the form GenericController`1[Widget] instead of Widget. The following attribute is used to modify the name to correspond to the generic type used by the controller:

using Microsoft.AspNetCore.Mvc.ApplicationModels; using System;

namespace AppPartsSample
{
    // Used to set the controller name for routing purposes. Without this convention the
    // names would be like 'GenericController`1[Widget]' instead of 'Widget'.
    //
    // Conventions can be applied as attributes or added to MvcOptions.Conventions.
    [AttributeUsage(AttributeTargets.Class, AllowMultiple = false, Inherited = true)]
    public class GenericControllerNameConvention : Attribute, IControllerModelConvention
    {
        public void Apply(ControllerModel controller)
        {
            if (controller.ControllerType.GetGenericTypeDefinition() != 
                typeof(GenericController<>))
            {
                // Not a GenericController, ignore.
                return;
            }

            var entityType = controller.ControllerType.GenericTypeArguments[0];
            controller.ControllerName = entityType.Name;
        }
    }
}

The GenericController class:

using Microsoft.AspNetCore.Mvc;

namespace AppPartsSample
{
    [GenericControllerNameConvention] // Sets the controller name based on typeof(T).Name
    public class GenericController<T> : Controller
    {
        public IActionResult Index()
        {
            return Content($"Hello from a generic {typeof(T).Name} controller.");
        }
    }
}

Sample: Generic controller feature

查看更多
登录 后发表回答