ASP.NET Core使用filter和redis實現介面防重

2023-03-15 18:01:04

背景

日常開發中,經常需要對一些響應不是很快的關鍵業務介面增加防重功能,即短時間內收到的多個相同的請求,只處理一個,其餘不處理,避免產生髒資料。這和冪等性(idempotency)稍微有點區別,冪等性要求的是對重複請求有相同的效果結果,通常需要在介面內部執行業務操作前檢查狀態;而防重可以認為是一個業務無關的通用功能,在ASP.NET Core中我們可以藉助過Filter和redis實現。

關於Filter

Filter的由來可以追溯到ASP.NET MVC中的ActionFilter和ASP.NET Web API中的ActionFilterAttribute。ASP.NET Core將這些不同型別的Filter統一為一種型別,稱為Filter,以簡化API和提高靈活性。ASP.NET Core中Filter可以用於實現各種功能,例如身份驗證、紀錄檔記錄、例外處理、效能監控等。

通過使用Filter,我們可以在請求處理管道的特定階段之前或者之後執行自定義程式碼,達到AOP的效果。

編碼實現

防重元件的思路很簡單,將第一次請求的某些引數作為識別符號存入redis中,並設定過期時間,下次請求過來,先檢查redis相同的請求是否已被處理;
作為一個通用元件,我們需要能讓使用者自定義作為識別符號的欄位以及過期時間,下面開始實現。

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中已經實現,由於它需要注入 IDistributedCacheILogger物件,我們使用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包;註冊PreventDuplicateRequestsActionFilterPreventDuplicateRequestsAttribute無需註冊。

builder.Services.AddStackExchangeRedisCache(options =>
{
    options.Configuration = "127.0.0.1:6379,DefaultDatabase=1";
});
builder.Services.AddScoped<PreventDuplicateRequestsActionFilter>();

使用

假設我們有一個介面CancelOrder,我們指定入參中的OrderId和Reason為因子。

namespace PreventDuplicateRequestDemo.Controllers
{
    [Route("api/[controller]")]
    [ApiController]
    public class OrderController : ControllerBase
    {
        [HttpPost(nameof(CancelOrder))]
        [PreventDuplicateRequests(5, "OrderId", "Reason")]
        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,除第一次呼叫成功,其餘請求皆被短路

檢視redis,已有記錄

參考連結

https://learn.microsoft.com/en-us/aspnet/core/mvc/controllers/filters?view=aspnetcore-7.0
https://learn.microsoft.com/en-us/aspnet/core/performance/caching/distributed?view=aspnetcore-7.0