ASP.NET Core uses filter and redis to implement interface anti-duplication

Background

In daily development, it is often necessary to add an anti-duplication function to some key business interfaces that do not respond quickly, that is, to process multiple identical requests received in a short period of time, and only process one, and not process the rest to avoid dirty data. This is slightly different from idempotency. Idempotency requires the same effect and result for repeated requests, and usually needs to perform business operations inside the interface. Pre-check status; and anti-duplication can be considered as a general-purpose function that has nothing to do with business. In ASP.NET Core, we can use Filter and redis to achieve it.

About Filter

The origin of Filter can be traced back to ActionFilter in ASP.NET MVC and ActionFilterAttribute in ASP.NET Web API. ASP.NET Core unifies these different types of Filter into one type, called Filter, to simplify API and improve flexibility. Filter in ASP.NET Core can be used to implement various functions, such as authentication, logging, exception handling, performance monitoring, etc.

image

By using Filter, we can run custom code before or after a specific stage of the request processing pipeline to achieve the effect of AOP.

image

Coding implementation

The idea of the anti-heavy component is very simple. Some parameters of the first request are stored in redis as identifiers, and the expiration time is set. When the next request comes, first check whether the same request of redis has been processed;
As a general component, we need to allow users to customize the fields used as identifiers and the expiration time. Let’s implement it below.

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(IDisttributedCache 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();
        }
    }
}

In the PreventDuplicateRequestsActionFilter, we first get the value of the specified parameter field from ActionArguments through reflection. Since the value from the request body is slightly different, we need to process it separately; then start splicing the key and check redis, if the key Already exists, we need to short-circuit the request, here directly return Accepted (202) instead of Conflict (409) or other error status, in order to avoid the failure of the upstream call Go ahead and try again.

PreventDuplicateRequestsAttribute

All the logic of the anti-duplicate component has been implemented in PreventDuplicateRequestsActionFilter, because it needs to inject IDisttributedCache and ILogger objects, we use IFilterFactory code> Implement a custom attribute for easy use.

[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;
}

Sign up

For simplicity, to operate redis, directly use the Microsoft.Extensions.Caching.StackExchangeRedis package; register PreventDuplicateRequestsActionFilter, PreventDuplicateRequestsAttribute does not need to register.

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

Use

Suppose we have an interface CancelOrder, and we specify the OrderId and Reason in the input parameters as factors.

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; }
    }
}

Start the program, call the api multiple times, except for the first successful call, all other requests are short-circuited
image

View redis, there are records
image

Reference link

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