【问题标题】:.NET Core WebAPI fall-back API-version in case of missing minor version.NET Core WebAPI 备用 API 版本以防缺少次要版本
【发布时间】:2019-11-06 09:36:27
【问题描述】:

经过多次尝试和阅读文章后,我决定将我的问题放在这里。我想要的是以下内容:我正在研究应用程序的 api 版本。 .NET Core(Microsoft.AspNetCore.Mvc.Versioning 包)支持的版本格式是 Major.Minor,这是我想在我从事的项目中使用的版本。我想要的是一个备用版本,以防客户端未指定次要版本。 我正在使用 .NET core 2.2,并使用标题中指定的 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。为此,我关注了这些文章:

然后我创建了以下类:

[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,根据@987654343的实现这是可以理解的@

第三:在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 中未指定次要版本的情况下,如何引入后备“使用最新”行为?提前致谢!

【问题讨论】:

    标签: asp.net-mvc asp.net-web-api .net-core api-versioning


    【解决方案1】:

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

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

    如果您仍在使用 legacy 路由,这可能是最简单的,因为您只需创建新策略或通过覆盖 OnSingleMatch 扩展现有 DefaultApiVersionRoutePolicy 并注册它在您的服务配置中。您会知道这是您正在寻找的场景,因为传入的 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 API(例如:2.0-beta1),则在版本中使用 status 可能更合适。

    希望对你有帮助。

    【讨论】:

    • 非常感谢您的详细解答,我从中学到了很多! (对于迟到的答案感到抱歉,最近有一些非常忙碌的日子......)。好吧,这似乎是正确的方法,但是请您检查一下我对同一问题的回答吗?我刚刚发布了它..我可以想出另一种方法来解决我的问题,我想知道是否这也是一个正确的解决方案,否则我可能会用它击中自己的脚。再次非常感谢!
    • OP 已经迁移到 .NET Core 3.1 了吗?我很想看看上面的更新实现,因为我很难让我们的版本控制在 3.1 中工作
    • 很抱歉不知道 OP 是什么,但 .NET Core 3.1 支持 API 版本控制 对于这种特定场景,设置大致相同。使用 Endpoint Routing,您可能可以使用如上所示的匹配器,并在 API 版本控制后注册它。您实际上会将无效的候选人再次标记为有效;但是,替换已注册的实现可能是最安全的。
    【解决方案2】:

    嗯,回答这个问题的功劳归于@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";
        }
    }
    

    干杯!

    【讨论】:

    • 这似乎可以工作。这种方法的最大缺点是您必须将其应用于所有控制器,这可能是不可取的。您不应该必须重新列出允许的 API 版本,因为您已经准备好通过[ApiVersion] 声明它们。在您的路由约束中,您可以通过context.CurrentCandidate.Action.GetApiVersionModel() 访问它们,这是在应用程序启动时计算的。然后您可以通过context.RouteContext.HttpContext.GetRequestedApiVersion() 获取当前请求的 API 版本。
    • 现在只需将请求的 API 版本与实现的集合进行比较即可。这可能可以通过以下方式完成:var max = model.ImplementedApiVersions.Where(v =&gt; v.MajorVersion == requestedVersion.MajorVersion).Max(),然后是context.CurrentCandidate.Action.MappingTo(max) != ApiVersioningMapping.None。我还应该提到,如果您允许任何类型的隐式版本控制,这将会失败,因为从请求管道中没有任何价值。希望对您有所帮助,并为您提供更多想法。
    • @ChrisMartinez 可爱的,好点,我会处理它们!再次感谢您的详细帮助,祝您一切顺利! ;)
    【解决方案3】:

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

    CurrentImplementationApiVersionSelector 选择没有版本状态的最大可用 API 版本。如果未找到匹配项,则回退到配置的 DefaultApiVersion。例如,如果版本“1.0”、“2.0”和“3.0-Alpha”可用,则将选择“2.0”,因为它是最高、已实施或已发布的 API 版本。

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

    【讨论】:

    • 欢迎来到stackoverflow。带有外部链接的简短答案被认为是低质量的,因为外部链接的内容可能会发生变化。最佳做法是在答案中包含关键细节。
    • 我刚刚注意到这一点,但我想我会回复的。虽然这是一个值得质疑的问题,但它是一个很好的问题。你不能在这里使用 CurrentImplementationApiVersionSelector 的原因是因为如果你有2.02.23.0,那么希望只是去2.2,但是选择器会选择@987654328 @。您还可以实现自定义 IApiVersionSelector,但是当客户端指定显式版本时,它不会按照设计被调用。在不更改某些核心路由位的情况下,无法简单地覆盖此行为。
    猜你喜欢
    • 1970-01-01
    • 1970-01-01
    • 1970-01-01
    • 1970-01-01
    • 1970-01-01
    • 2011-12-21
    • 1970-01-01
    • 2018-11-11
    • 1970-01-01
    相关资源
    最近更新 更多