背景
效果和结果,通常需要在接口内部执行业务操作前检查状态;而防重可以认为是一个业务无关的通用功能,在ASP.NET Core中我们可以借助过Filter和redis实现。
关于Filter
编码实现
作为一个通用组件,我们需要能让使用者自定义作为标识符的字段以及过期时间,下面开始实现。
PreventDuplicateRequestsActionFilter
public class PreventDuplicateRequestsActionFilter : IAsyncActionFilter
{
public string[] FactorNames { get; set; }
public TimeSpan? AbsoluteExpirationRelativeToNow { get; set; }
private readonly IDistributedCache _cache;
private readonly ILogger<PreventDuplicateRequestsActionFilter> _logger;
public PreventDuplicateRequestsActionFilter(IDistributedCache cache, ILogger<PreventDuplicateRequestsActionFilter> logger
{
_cache = cache;
_logger = logger;
}
public async Task OnActionExecutionAsync(ActionExecutingContext context, ActionExecutionDelegate next
{
var factorValues = new string?[FactorNames.Length];
var isFromBody =
context.ActionDescriptor.Parameters.Any(r => r.BindingInfo?.BindingSource == BindingSource.Body;
if (isFromBody
{
var parameterValue = context.ActionArguments.FirstOrDefault(.Value;
factorValues = FactorNames.Select(name =>
parameterValue?.GetType(.GetProperty(name?.GetValue(parameterValue?.ToString(.ToArray(;
}
else
{
for (var index = 0; index < FactorNames.Length; index++
{
if (context.ActionArguments.TryGetValue(FactorNames[index], out var factorValue
{
factorValues[index] = factorValue?.ToString(;
}
}
}
if (factorValues.All(string.IsNullOrEmpty
{
_logger.LogWarning("Please config FactorNames.";
await next(;
return;
}
var idempotentKey = $"{context.HttpContext.Request.Path.Value}:{string.Join("-", factorValues}";
var idempotentValue = await _cache.GetStringAsync(idempotentKey;
if (idempotentValue != null
{
_logger.LogWarning("Received duplicate request({},{}, short-circuiting...", idempotentKey, idempotentValue;
context.Result = new AcceptedResult(;
}
else
{
await _cache.SetStringAsync(idempotentKey, DateTimeOffset.UtcNow.ToString(,
new DistributedCacheEntryOptions {AbsoluteExpirationRelativeToNow = AbsoluteExpirationRelativeToNow};
await next(;
}
}
}
PreventDuplicateRequestsActionFilter里,我们首先从通过反射从 ActionArguments
拿到指定参数字段的值,由于从request body取值略有不同,我们需要放开处理;接下来开始拼接key并检查redis,如果key已经存在,我们需要短路请求,这里直接返回的是 Accepted (202
而不是Conflict (409
或者其它错误状态,是为了避免上游已经调用失败而继续重试。
PreventDuplicateRequestsAttribute
PreventDuplicateRequestsActionFilter中已经实现,由于它需要注入 IDistributedCache
和ILogger
对象,我们使用IFilterFactory
实现一个自定义属性,方便使用。
[AttributeUsage(AttributeTargets.Method]
public class PreventDuplicateRequestsAttribute : Attribute, IFilterFactory
{
private readonly string[] _factorNames;
private readonly int _expiredMinutes;
public PreventDuplicateRequestsAttribute(int expiredMinutes, params string[] factorNames
{
_expiredMinutes = expiredMinutes;
_factorNames = factorNames;
}
public IFilterMetadata CreateInstance(IServiceProvider serviceProvider
{
var filter = serviceProvider.GetService<PreventDuplicateRequestsActionFilter>(;
filter.FactorNames = _factorNames;
filter.AbsoluteExpirationRelativeToNow = TimeSpan.FromMinutes(_expiredMinutes;
return filter;
}
public bool IsReusable => false;
}
注册
为了简单,操作redis,直接使用微软Microsoft.Extensions.Caching.StackExchangeRedis包;注册PreventDuplicateRequestsActionFilter
,PreventDuplicateRequestsAttribute
无需注册。
builder.Services.AddStackExchangeRedisCache(options =>
{
options.Configuration = "127.0.0.1:6379,DefaultDatabase=1";
};
builder.Services.AddScoped<PreventDuplicateRequestsActionFilter>(;
使用
假设我们有一个接口CancelOrder
,我们指定入参中的OrderId为防重因子。
namespace PreventDuplicateRequestDemo.Controllers
{
[Route("api/[controller]"]
[ApiController]
public class OrderController : ControllerBase
{
[HttpPost(nameof(CancelOrder]
[PreventDuplicateRequests(5, "OrderId"]
public async Task<IActionResult> CancelOrder([FromBody] CancelOrderRequest request
{
await Task.Delay(1000;
return new OkResult(;
}
}
public class CancelOrderRequest
{
public Guid OrderId { get; set; }
public string reason { get; set; }
}
}
启动程序,多次调用api,除第一次调用成功,其余请求皆被短路
参考链接
https://learn.microsoft.com/en-us/aspnet/core/performance/caching/distributed?view=aspnetcore-7.0