.NET Core WebAPI fall-back API-version 以防缺少次要版本

.NET Core WebAPI fall-back API-version in case of missing minor version

经过多次尝试和阅读文章后,我决定将我的问题放在这里。我想要的是以下内容:我正在处理 api-versioning 的应用程序。 .NET Core(Microsoft.AspNetCore.Mvc.Versioning 包)支持的版本格式是 Major.Minor,这就是我想在我从事的项目中使用的格式。我想要的是 fall-back 版本,以防客户端未指定次要版本。 我正在使用 .NET core 2.2,并使用 header 中指定的 api-version。相应的 API 版本控制配置如下所示:

    services.AddApiVersioning(options => { 
        options.ReportApiVersions = true;
        options.ApiVersionReader = new HeaderApiVersionReader("api-version");
        options.ErrorResponses = new ApiVersioningErrorResponseProvider();
    });

每个版本我都有以下两个控制器:(为了这个 SO 问题,控制器被简化了):

[ApiVersion("1.0")]  
[Route("api/[controller]")]  
public class ValueControllerV10 : Controller  
{  
    [HttpGet(Name = "collect")]  
    public String Collect()  
    {  
        return "Version 1.0";  
    }  
} 


[ApiVersion("1.1")]  
[Route("api/[controller]")]  
public class ValueControllerV11 : Controller  
{  
    [HttpGet(Name = "collect")]  
    public String Collect()  
    {  
        return "Version 1.1";  
    }  
}  

如果客户端指定 api-version=1.0 则使用 ValueControllerV10。当然,如果客户端指定 api-version=1.1,则将按预期使用 ValueControllerV11。

现在我的问题来了。如果客户端指定 api-version=1(因此只有主要版本没有次要版本),则使用 ValueControllerV10。这是因为 ApiVersion.Parse("1") 等于 ApiVersion.Parse("1.0"),如果我没记错的话。但是在这种情况下我想要的是调用给定主要版本的最新版本,在我的示例中是 1.1。

我的尝试:

首先:ValueControllerV11

指定 [ApiVersion("1")]
    [ApiVersion("1")]  
    [ApiVersion("1.1")]  
    [Route("api/[controller]")]  
    public class ValueControllerV11 : Controller  
    {  
        [HttpGet(Name = "collect")]  
        public String Collect()  
        {  
            return "Version 1.1";  
        }  
    }  

不行,导致

AmbiguousMatchException: The request matched multiple endpoints

为了解决这个问题,我想到了第二种方法:

第二:使用自定义IActionConstraint。为此,我关注了这些文章:

然后我创建了以下 class:

[AttributeUsage(AttributeTargets.Method)]
public class HttpRequestPriority : Attribute, IActionConstraint
{
    public int Order
    {
        get
        {
            return 0;
        }
    }

    public bool Accept(ActionConstraintContext context)
    {
        var requestedApiVersion = context.RouteContext.HttpContext.GetRequestedApiVersion();

        if (requestedApiVersion.MajorVersion.Equals(1) && !requestedApiVersion.MinorVersion.HasValue)
        {
            return true;
        }

        return false;
    }
}

并用于 ValueControllerV11:

[ApiVersion("1")]  
[ApiVersion("1.1")]  
[Route("api/[controller]")]  
public class ValueControllerV11 : Controller  
{  
    [HttpGet(Name = "collect")]
    [HttpRequestPriority]  
    public String Collect()  
    {  
        return "Version 1.1";  
    }  
}

嗯,它解决了 AmbiguousMatchException,但覆盖了 Microsoft.AspNetCore.Mvc.Versioning 包的默认行为,所以如果客户端使用 api-version 1.1,那么她会得到 404 Not Found 返回,这是按照HttpRequestPriority

的实现可以理解

第三种:在Startup.cs中使用MapSpaFallbackRoute,有条件的:

        app.MapWhen(x => x.GetRequestedApiVersion().Equals("1") && x.GetRequestedApiVersion().MinorVersion == null, builder =>
        {
            builder.UseMvc(routes =>
            {
                routes.MapSpaFallbackRoute(
                    name: "spa-fallback",
                    defaults: new {controller = nameof(ValueControllerV11), action = "Collect"});
            });
        });

        app.UseMvc();

也不行,没有任何影响。 MapSpaFallbackRoute这个名字给我的感觉也不是我需要用的...

所以我的问题是:如何在 api-version 中未指定次要版本的情况下引入回退 'use latest' 行为?提前致谢!

这本质上不受支持out-of-the-box。浮动版本、范围等与 API 版本控制的原则背道而驰。 API 版本不会也不能暗示任何向后兼容性。除非你在一个封闭的系统中控制双方,否则假设客户可以处理任何合同变更,即使你只添加一个新成员,也是一个谬论。最终,如果客户端要求 1/1.0,那么这就是他们应该得到的,或者服务器应该说它不受支持。

抛开我的意见,有些人仍然想要这种行为。这不是特别简单,但您应该能够使用自定义 IApiVersionRoutePolicy 或自定义端点匹配器实现您的目标 - 这取决于您使用的路由样式。

如果您仍在使用 legacy 路由,这可能是最简单的,因为您只需创建一个新策略或扩展现有的 DefaultApiVersionRoutePolicy通过覆盖 OnSingleMatch 并将其注册到您的服务配置中。您会知道这就是您正在寻找的场景,因为传入的 API 版本将没有次要版本。 11.0 是一样的,你是对的,但是次要版本没有合并;因此,在这种情况下,ApiVersion.MinorVersion 将是 null

如果您使用的是端点路由,则需要更换ApiVersionMatcherPolicy。以下应该接近您想要实现的目标:

using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.Abstractions;
using Microsoft.AspNetCore.Mvc.Routing;
using Microsoft.AspNetCore.Mvc.Versioning;
using Microsoft.AspNetCore.Routing;
using Microsoft.AspNetCore.Routing.Matching;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
using static Microsoft.AspNetCore.Mvc.Versioning.ApiVersionMapping;

public sealed class MinorApiVersionMatcherPolicy : MatcherPolicy, IEndpointSelectorPolicy
{
    public MinorApiVersionMatcherPolicy(
        IOptions<ApiVersioningOptions> options,
        IReportApiVersions reportApiVersions,
        ILoggerFactory loggerFactory )
    {
        DefaultMatcherPolicy = new ApiVersionMatcherPolicy(
            options, 
            reportApiVersions, 
            loggerFactory );
        Order = DefaultMatcherPolicy.Order;
    }

    private ApiVersionMatcherPolicy DefaultMatcherPolicy { get; }

    public override int Order { get; }

    public bool AppliesToEndpoints( IReadOnlyList<Endpoint> endpoints ) =>
        DefaultMatcherPolicy.AppliesToEndpoints( endpoints );

    public async Task ApplyAsync(
        HttpContext httpContext,
        EndpointSelectorContext context,
        CandidateSet candidates )
    {
        var requestedApiVersion = httpContext.GetRequestedApiVersion();
        var highestApiVersion = default( ApiVersion );
        var explicitIndex = -1;
        var implicitIndex = -1;

        // evaluate the default policy
        await DefaultMatcherPolicy.ApplyAsync( httpContext, context, candidates );

        if ( requestedApiVersion.MinorVersion.HasValue )
        {
            // we're done because a minor version was specified
            return;
        }

        var majorVersion = requestedApiVersion.MajorVersion;

        for ( var i = 0; i < candidates.Count; i++ )
        {
            // make all candidates invalid by default
            candidates.SetValidity( i, false );

            var candidate = candidates[i];
            var action = candidate.Endpoint.Metadata?.GetMetadata<ActionDescriptor>();

            if ( action == null )
            {
                continue;
            }

            var model = action.GetApiVersionModel( Explicit | Implicit );
            var maxApiVersion = model.DeclaredApiVersions
                                        .Where( v => v.MajorVersion == majorVersion )
                                        .Max();

            // remember the candidate with the next highest api version
            if ( highestApiVersion == null || maxApiVersion >= highestApiVersion )
            {
                highestApiVersion = maxApiVersion;

                switch ( action.MappingTo( maxApiVersion ) )
                {
                    case Explicit:
                        explicitIndex = i;
                        break;
                    case Implicit:
                        implicitIndex = i;
                        break;
                }
            }
        }

        if ( explicitIndex < 0 && ( explicitIndex = implicitIndex ) < 0 )
        {
            return;
        }

        var feature = httpContext.Features.Get<IApiVersioningFeature>();

        // if there's a match:
        //
        // 1. make the candidate valid
        // 2. clear any existing endpoint (ex: 400 response)
        // 3. set the requested api version to the resolved value
        candidates.SetValidity( explicitIndex, true );
        context.Endpoint = null;
        feature.RequestedApiVersion = highestApiVersion;
    }
}

然后您需要像这样更新您的服务配置:

// IMPORTANT: must be configured after AddApiVersioning
services.Remove( services.Single( s => s.ImplementationType == typeof( ApiVersionMatcherPolicy ) ) );
services.TryAddEnumerable( ServiceDescriptor.Singleton<MatcherPolicy, MinorApiVersionMatcherPolicy>() );

如果我们考虑这样的控制器:

[ApiController]
[ApiVersion( "2.0" )]
[ApiVersion( "2.1" )]
[ApiVersion( "2.2" )]
[Route( "api/values" )]
public class Values2Controller : ControllerBase
{
    [HttpGet]
    public string Get( ApiVersion apiVersion ) =>
        $"Controller = {GetType().Name}\nVersion = {apiVersion}";

    [HttpGet]
    [MapToApiVersion( "2.1" )]
    public string Get2_1( ApiVersion apiVersion ) =>
        $"Controller = {GetType().Name}\nVersion = {apiVersion}";

    [HttpGet]
    [MapToApiVersion( "2.2" )]
    public string Get2_2( ApiVersion apiVersion ) =>
        $"Controller = {GetType().Name}\nVersion = {apiVersion}";
}

当您请求 api/values?api-version=2 时,您将匹配 2.2

我要重申,这通常不是一个好主意,因为客户应该能够依赖稳定版本。如果您想要 pre-release APIs(例如:2.0-beta1).

希望对您有所帮助。

嗯,回答这个问题的功劳归于@Chris Martinez,另一方面,我可以想出另一种方法来解决我的问题: 我已经为 RouteAttribute 创建了一个扩展,实现了 IActionConstraintFactory:

public class RouteWithVersionAttribute : RouteAttribute, IActionConstraintFactory
{
    private readonly IActionConstraint _constraint;

    public bool IsReusable => true;

    public RouteWithVersionAttribute(string template, params string[] apiVersions) : base(template)
    {
        Order = -10; //Minus value means that the api-version specific route to be processed before other routes
        _constraint = new ApiVersionHeaderConstraint(apiVersions);
    }

    public IActionConstraint CreateInstance(IServiceProvider services)
    {
        return _constraint;
    }
}

其中 IActionContraint 如下所示:

    public class ApiVersionHeaderConstraint : IActionConstraint
{
    private const bool AllowRouteToBeHit = true;
    private const bool NotAllowRouteToBeHit = false;

    private readonly string[] _allowedApiVersions;

    public ApiVersionHeaderConstraint(params string[] allowedApiVersions)
    {
        _allowedApiVersions = allowedApiVersions;
    }

    public int Order => 0;

    public bool Accept(ActionConstraintContext context)
    {
        var requestApiVersion = GetApiVersionFromRequest(context);

        if (_allowedApiVersions.Contains(requestApiVersion))
        {
            return AllowRouteToBeHit;
        }

        return NotAllowRouteToBeHit;
    }

    private static string GetApiVersionFromRequest(ActionConstraintContext context)
    {
        return context.RouteContext.HttpContext.Request.GetTypedHeaders().Headers[CollectApiVersion.HeaderKey];
    }
}

然后我就可以把ApiVersionAttribute和我自定义的RouteWithVersionAttribute一起使用了,如下:

[ApiVersion("1")]
[ApiVersion("1.1")]
[Route("collect", "1", "1.1")]
public class ValueControllerV11 : Controller
{
    [HttpRequestPriority]
    public String Collect()
    {
        return "Version 1.1";
    }
}

干杯!

注册服务时的CurrentImplementationApiVersionSelector选项呢?看这里:https://github.com/microsoft/aspnet-api-versioning/wiki/API-Version-Selector

The CurrentImplementationApiVersionSelector selects the maximum API version available which does not have a version status. If no match is found, it falls back to the configured DefaultApiVersion. For example, if the versions "1.0", "2.0", and "3.0-Alpha" are available, then "2.0" will be selected because it's the highest, implemented or released API version.

services.AddApiVersioning(
    options => options.ApiVersionSelector =
        new CurrentImplementationApiVersionSelector( options ) );