.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
。为此,我关注了这些文章:
- https://stevenknox.net/aspnet-core-mvc-action-priority-using-actionconstraints/
- https://www.strathweb.com/2017/06/using-iactionconstraints-in-asp-net-core-mvc/
然后我创建了以下 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 版本将没有次要版本。 1
和 1.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 ) );
经过多次尝试和阅读文章后,我决定将我的问题放在这里。我想要的是以下内容:我正在处理 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
。为此,我关注了这些文章:
- https://stevenknox.net/aspnet-core-mvc-action-priority-using-actionconstraints/
- https://www.strathweb.com/2017/06/using-iactionconstraints-in-asp-net-core-mvc/
然后我创建了以下 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 版本将没有次要版本。 1
和 1.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 ) );