如何在 ASP.Net Core 中实现自定义控制器动作选择?

How to implement custom controller action selection in ASP.Net Core?

我有一个 ASP.Net 核心 API 项目。我希望能够编写自定义路由逻辑,以便能够根据 HTTP Body 参数选择不同的控制器操作。为了说明我的问题,这是我的 Controller class:

[ApiController]
[Route("api/[controller]")]
public class TestController
{
    // should be called when HTTP Body contains json: '{ method: "SetValue1" }'
    public void SetValue1()
    {
        // some logic
    }

    // should be called when HTTP Body contains json: '{ method: "SetValue2" }'
    public void SetValue2()
    {
        // some logic
    }
}

正如您从我的评论中看到的那样,我想根据 HTTP 正文选择不同的操作方法。 这是我的 Startup class:

public class Startup
{
    public void ConfigureServices(IServiceCollection services)
    {
        services.AddControllers();
    }

    public void Configure(IApplicationBuilder app, IWebHostEnvironment env)
    {
        if (env.IsDevelopment())
        {
            app.UseDeveloperExceptionPage();
        }

        // I assume instead of this built in routing middleware, 
        // I will have to implement custom middleware to choose the correct endpoints, from HTTP body,
        // any ideas how I can go about this?
        app.UseRouting();

        app.UseEndpoints(endpoints =>
        {
            endpoints.MapControllers();
        });
    }
}

我可以使用的选项之一是使用一个入口 Action 方法,该方法将根据 HTTP 正文内容调用不同的方法,但我想避免这种情况并将此逻辑封装在自定义路由中的某个位置。

在旧的 APS.Net Web API 中,有一个方便的 class ApiControllerActionSelector 可以扩展,并定义我选择 Action 方法的自定义逻辑,但是这在新的 ASP.Net Core。我想我将不得不实现我自己的 app.UseRouting 中间件版本。关于我该怎么做的任何想法?

在旧的asp.net core3.0之前),我们可以实现一个自定义的IActionSelector,当ActionSelector仍然是[=57=时特别方便].但是随着新的端点路由,它变成了所谓的EndpointSelector。实现是完全相同的,关键是我们如何提取 ActionDescriptor 放在 Endpoint 中作为元数据。以下实现需要默认的 EndpointSelector(即 DefaultEndpointSelector),但不幸的是,它是内部实现的。所以我们需要使用一个技巧来获取该默认实现的实例以在我们的自定义实现中使用。

public class RequestBodyEndpointSelector : EndpointSelector
{
    readonly IEnumerable<Endpoint> _controllerEndPoints;
    readonly EndpointSelector _defaultSelector;
    public RequestBodyEndpointSelector(EndpointSelector defaultSelector, EndpointDataSource endpointDataSource)
    {
        _defaultSelector = defaultSelector;
        _controllerEndPoints = endpointDataSource.Endpoints
                                                 .Where(e => e.Metadata.GetMetadata<ControllerActionDescriptor>() != null).ToList();
    }
    public override async Task SelectAsync(HttpContext httpContext, CandidateSet candidates)
    {
        var request = httpContext.Request;
        request.EnableBuffering();
        //don't use "using" here, otherwise the request.Body will be disposed and cannot be used later in the pipeline (an exception will be thrown).
        var sr = new StreamReader(request.Body);
        try
        {
            var body = sr.ReadToEnd();
            if (!string.IsNullOrEmpty(body))
            {
                try
                {
                    var actionInfo = Newtonsoft.Json.JsonConvert.DeserializeObject<ActionInfo>(body);
                    var controllerActions = new HashSet<(MethodInfo method, Endpoint endpoint, RouteValueDictionary routeValues, int score)>();
                    var constrainedControllerTypes = new HashSet<Type>();
                    var routeValues = new List<RouteValueDictionary>();
                    var validIndices = new HashSet<int>();
                    for (var i = 0; i < candidates.Count; i++)
                    {
                        var candidate = candidates[i];
                        var endpoint = candidates[i].Endpoint;
                        var actionDescriptor = endpoint.Metadata.GetMetadata<ControllerActionDescriptor>();
                        if (actionDescriptor == null) continue;
                        routeValues.Add(candidate.Values);
                        constrainedControllerTypes.Add(actionDescriptor.MethodInfo.DeclaringType);
                        if (!string.Equals(actionInfo.MethodName, actionDescriptor.MethodInfo.Name,
                                           StringComparison.OrdinalIgnoreCase)) continue;
                        if (!controllerActions.Add((actionDescriptor.MethodInfo, endpoint, candidate.Values, candidate.Score))) continue;
                        validIndices.Add(i);
                    }
                    if (controllerActions.Count == 0)
                    {
                        var bestCandidates = _controllerEndPoints.Where(e => string.Equals(actionInfo.MethodName,
                                                                                           e.Metadata.GetMetadata<ControllerActionDescriptor>().MethodInfo.Name,
                                                                                           StringComparison.OrdinalIgnoreCase)).ToArray();
                        var routeValuesArray = request.RouteValues == null ? routeValues.ToArray() : new[] { request.RouteValues };
                        candidates = new CandidateSet(bestCandidates, routeValuesArray, new[] { 0 });
                    }
                    else
                    {
                        for(var i = 0; i < candidates.Count; i++)
                        {
                            candidates.SetValidity(i, validIndices.Contains(i));                                
                        }                            
                    }
                    //call the default selector after narrowing down the candidates
                    await _defaultSelector.SelectAsync(httpContext, candidates);
                    //if some endpoint found
                    var selectedEndpoint = httpContext.GetEndpoint();
                    if (selectedEndpoint != null)
                    {
                        //update the action in the RouteData to found endpoint                            
                        request.RouteValues["action"] = selectedEndpoint.Metadata.GetMetadata<ControllerActionDescriptor>().ActionName;
                    }
                    return;
                }
                catch { }
            }
        }
        finally
        {
            request.Body.Position = 0;
        }
        await _defaultSelector.SelectAsync(httpContext, candidates);
    }
}

这样的注册码有点棘手:

//define an extension method for registering conveniently
public static class EndpointSelectorServiceCollectionExtensions
{
    public static IServiceCollection AddRequestBodyEndpointSelector(this IServiceCollection services)
    {
        //build a dummy service container to get an instance of 
        //the DefaultEndpointSelector
        var sc = new ServiceCollection();
        sc.AddMvc();
        var defaultEndpointSelector = sc.BuildServiceProvider().GetRequiredService<EndpointSelector>();            
        return services.Replace(new ServiceDescriptor(typeof(EndpointSelector),
                                sp => new RequestBodyEndpointSelector(defaultEndpointSelector, 
                                                                      sp.GetRequiredService<EndpointDataSource>()),
                                ServiceLifetime.Singleton));
    }
}

//inside the Startup.ConfigureServices
services.AddRequestBodyEndpointSelector();

asp.net core 2.2

中使用的旧常规路由的旧解决方案

您的要求有点奇怪,您可能不得不为此做出一些权衡。首先,该要求可能要求您阅读 Request.Body 两次(当 selected 操作方法有一些模型绑定参数时)。即使框架在 HttpRequest 上支持所谓的 EnableBuffering,它仍然有点权衡取舍。其次在 select 最佳操作(在 IActionSelector 上定义)的方法中,我们不能使用 async 因此读取请求主体当然不能使用 async.

对于高性能网络应用程序,绝对应该避免。但是如果你能接受这种权衡,我们这里有一个解决方案,通过实现自定义 IActionSelector,最好让它继承默认 ActionSelector。我们可以覆盖的方法是ActionSelector.SelectBestActions。但是该方法不提供 RouteContext(我们需要访问它来更新 RouteData),因此我们将重新实现 IActionSelector 的另一个名为 IActionSelector.SelectBestCandidate 的方法提供 RouteContext.

详细代码如下:

//first we define a base model for parsing the request body
public class ActionInfo
{
    [JsonProperty("method")]
    public string MethodName { get; set; }
}

//here's our custom ActionSelector
public class RequestBodyActionSelector : ActionSelector, IActionSelector
{        
    readonly IEnumerable<ActionDescriptor> _actions;
    public RequestBodyActionSelector(IActionDescriptorCollectionProvider actionDescriptorCollectionProvider, 
        ActionConstraintCache actionConstraintCache, ILoggerFactory loggerFactory) 
        : base(actionDescriptorCollectionProvider, actionConstraintCache, loggerFactory)
    {            
        _actions = actionDescriptorCollectionProvider.ActionDescriptors.Items;            
    }
    ActionDescriptor IActionSelector.SelectBestCandidate(RouteContext context, IReadOnlyList<ActionDescriptor> candidates)
    {
        var request = context.HttpContext.Request;
        //supports reading the request body multiple times
        request.EnableBuffering();
        var sr = new StreamReader(request.Body);
        try
        {
            var body = sr.ReadToEnd();
            if (!string.IsNullOrEmpty(body))
            {
                try
                {
                    //here I use the old Newtonsoft.Json
                    var actionInfo = JsonConvert.DeserializeObject<ActionInfo>(body);
                    //the best actions should be on these controller types.
                    var controllerTypes = new HashSet<TypeInfo>(candidates.OfType<ControllerActionDescriptor>().Select(e => e.ControllerTypeInfo));
                    //filter for the best by matching the controller types and 
                    //the method name from the request body
                    var bestActions = _actions.Where(e => e is ControllerActionDescriptor ca &&
                                                          controllerTypes.Contains(ca.ControllerTypeInfo) &&
                                                         string.Equals(actionInfo.MethodName, ca.MethodInfo.Name, StringComparison.OrdinalIgnoreCase)).ToList();
                    //only override the default if any method name matched 
                    if (bestActions.Count > 0)
                    {
                        //before reaching here, 
                        //the RouteData has already been populated with 
                        //what from the request's URL
                        //By overriding this way, that RouteData's action
                        //may be changed, so we need to update it here.
                        var newActionName = (bestActions[0] as ControllerActionDescriptor).ActionName;                            
                        context.RouteData.PushState(null, new RouteValueDictionary(new { action = newActionName }), null);

                        return SelectBestCandidate(context, bestActions);
                    }
                }
                catch { }
            }
        }
        finally
        {
            request.Body.Position = 0;
        }
        return SelectBestCandidate(context, candidates);
    }        
}

要在 Startup.ConfigureServices 中注册自定义 IActionSelector:

services.AddSingleton<IActionSelector, RequestBodyActionSelector>();