在 ASP .NET Core 中实现幂等 REST API

幂等性是 REST API 的一个关键概念,可确保系统的可靠性和一致性。幂等操作可以重复多次,而不会改变初始 API 请求之外的结果。此属性在分布式系统中尤为重要,因为网络故障或超时可能会导致重复请求。

在 API 中实现幂等性可以带来以下几个好处:

  • 它可以防止意外的重复操作
  • 它提高了分布式系统的可靠性
  • 它有助于处理网络问题并正常重试
我们将探讨如何在 ASP .NET Core API 中实现幂等性,以确保您的系统保持稳健和可靠。

什么是幂等性?
在 Web API 中,幂等性是指发出多个相同的请求应具有与发出单个请求相同的效果。换句话说,无论客户端发送相同请求多少次,服务器端效果都应仅发生一次。

关于 HTTP 语义的 RFC 9110 标准提供了我们可以采用的定义。它对幂等方法的定义如下:
如果使用该方法的多个相同请求对服务器的预期效果与单个此类请求的效果相同,则认为该请求方法具有“幂等性”。

在本规范定义的请求方法中,PUT、DELETE 和安全请求方法 [(GET、HEAD、OPTIONS 和 TRACE) - 作者注] 是幂等的。
— RFC 9110(HTTP 语义),第 9.2.2 节,第 1 段

但是,下面这段话很有意思。它澄清了服务器可以实现不适用于资源的“其他非幂等副作用”。

...幂等性仅适用于用户所请求的内容;服务器可以自由地分别记录每个请求、保留修订控制历史记录或为每个幂等请求实现其他非幂等副作用。
— RFC 9110(HTTP 语义),第 9.2.2 节,第 2 段

实现幂等性的好处不仅仅是遵守 HTTP 方法语义。它显著提高了 API 的可靠性,尤其是在网络问题可能导致重试请求的分布式系统中。通过实现幂等性,您可以防止由于客户端重试而发生的重复操作。

哪些 HTTP 方法是幂等的?
有几种 HTTP 方法本质上是幂等的:

  • GET,HEAD:在不修改服务器状态的情况下检索数据。
  • PUT:更新某个资源,无论是否重复,结果都是相同的状态。
  • DELETE:删除多个请求中结果相同的资源。
  • OPTIONS:检索通信选项信息。
POST本质上不是幂等的,因为它通常会创建资源或处理数据。重复的POST请求可能会创建多个资源或触发多个操作。

但是,我们可以POST使用自定义逻辑来实现方法的幂等性。

注意:虽然POST请求并非天生幂等,但我们可以将其设计为幂等。例如,在创建之前检查现有资源可确保重复的POST请求不会导致重复的操作或资源。

在 ASP .NET Core 中实现幂等性
为了实现幂等性,我们将使用涉及幂等性键的策略:

  1. 客户端为每个操作生成一个唯一的密钥,并在自定义标头中发送它。
  2. 服务器检查之前是否见过此密钥:
    • 对于新密钥,处理请求并存储结果。
    • 对于已知键,返回存储的结果而不进行重新处理。
这可确保重试的请求(例如由于网络问题)在服务器上仅处理一次。

我们可以通过组合Attribute和来实现控制器的幂等性IAsyncActionFilter。现在,我们可以指定IdempotentAttribute将幂等性应用于控制器端点。

注意:当请求失败(返回 4xx/5xx)时,我们不会缓存响应。这允许客户端使用相同的幂等性密钥重试。但是,这意味着失败的请求后跟使用相同密钥的成功请求将会成功 - 确保这符合您的业务需求。

[AttributeUsage(AttributeTargets.Method)]
internal sealed class IdempotentAttribute : Attribute, IAsyncActionFilter
{
    private const int DefaultCacheTimeInMinutes = 60;
    private readonly TimeSpan _cacheDuration;

    public IdempotentAttribute(int cacheTimeInMinutes = DefaultCacheTimeInMinutes)
    {
        _cacheDuration = TimeSpan.FromMinutes(minutes);
    }

    public async Task OnActionExecutionAsync(
        ActionExecutingContext context,
        ActionExecutionDelegate next)
    {
        // Parse the Idempotence-Key header from the request
        if (!context.HttpContext.Request.Headers.TryGetValue(
               
"Idempotence-Key",
                out StringValues idempotenceKeyValue) ||
            !Guid.TryParse(idempotenceKeyValue, out Guid idempotenceKey))
        {
            context.Result = new BadRequestObjectResult(
"Invalid or missing Idempotence-Key header");
            return;
        }

        IDistributedCache cache = context.HttpContext
            .RequestServices.GetRequiredService<IDistributedCache>();

       
// Check if we already processed this request and return a cached response (if it exists)
        string cacheKey = $
"Idempotent_{idempotenceKey}";
        string? cachedResult = await cache.GetStringAsync(cacheKey);
        if (cachedResult is not null)
        {
            IdempotentResponse response = JsonSerializer.Deserialize<IdempotentResponse>(cachedResult)!;

            var result = new ObjectResult(response.Value) { StatusCode = response.StatusCode };
            context.Result = result;

            return;
        }

       
// Execute the request and cache the response for the specified duration
        ActionExecutedContext executedContext = await next();

        if (executedContext.Result is ObjectResult { StatusCode: >= 200 and < 300 } objectResult)
        {
            int statusCode = objectResult.StatusCode ?? StatusCodes.Status200OK;
            IdempotentResponse response = new(statusCode, objectResult.Value);

            await cache.SetStringAsync(
                cacheKey,
                JsonSerializer.Serialize(response),
                new DistributedCacheEntryOptions { AbsoluteExpirationRelativeToNow = _cacheDuration }
            );
        }
    }
}

internal sealed class IdempotentResponse
{
    [JsonConstructor]
    public IdempotentResponse(int statusCode, object? value)
    {
        StatusCode = statusCode;
        Value = value;
    }

    public int StatusCode { get; }
    public object? Value { get; }
}

注意:检查和设置缓存之间存在一个小的竞争条件窗口。为了绝对的一致性,我们应该考虑使用分布式锁定模式,尽管这会增加复杂性和延迟。

现在,我们可以将此属性应用到我们的控制器操作中:

[ApiController]
[Route("api/[controller]")]
public class OrdersController : ControllerBase
{
    [HttpPost]
    [Idempotent(cacheTimeInMinutes: 60)]
    public IActionResult CreateOrder([FromBody] CreateOrderRequest request)
    {
       
// Process the order...

        return CreatedAtAction(nameof(GetOrder), new { id = orderDto.Id }, orderDto);
    }
}


最少 API 实现幂等性
为了使用最少的 API 实现幂等性,我们可以使用IEndpointFilter。

internal sealed class IdempotencyFilter(int cacheTimeInMinutes = 60)
    : IEndpointFilter
{
    public async ValueTask<object?> InvokeAsync(
        EndpointFilterInvocationContext context,
        EndpointFilterDelegate next)
    {
        // Parse the Idempotence-Key header from the request
        if (TryGetIdempotenceKey(out Guid idempotenceKey))
        {
            return Results.BadRequest(
"Invalid or missing Idempotence-Key header");
        }

        IDistributedCache cache = context.HttpContext
            .RequestServices.GetRequiredService<IDistributedCache>();

       
// Check if we already processed this request and return a cached response (if it exists)
        string cacheKey = $
"Idempotent_{idempotenceKey}";
        string? cachedResult = await cache.GetStringAsync(cacheKey);
        if (cachedResult is not null)
        {
            IdempotentResponse response = JsonSerializer.Deserialize<IdempotentResponse>(cachedResult)!;
            return new IdempotentResult(response.StatusCode, response.Value);
        }

        object? result = await next(context);

       
// Execute the request and cache the response for the specified duration
        if (result is IStatusCodeHttpResult { StatusCode: >= 200 and < 300 } statusCodeResult
            and IValueHttpResult valueResult)
        {
            int statusCode = statusCodeResult.StatusCode ?? StatusCodes.Status200OK;
            IdempotentResponse response = new(statusCode, valueResult.Value);

            await cache.SetStringAsync(
                cacheKey,
                JsonSerializer.Serialize(response),
                new DistributedCacheEntryOptions
                {
                    AbsoluteExpirationRelativeToNow = TimeSpan.FromMinutes(cacheTimeInMinutes)
                }
            );
        }

        return result;
    }
}

// We have to implement a custom result to write the status code
internal sealed class IdempotentResult : IResult
{
    private readonly int _statusCode;
    private readonly object? _value;

    public IdempotentResult(int statusCode, object? value)
    {
        _statusCode = statusCode;
        _value = value;
    }

    public Task ExecuteAsync(HttpContext httpContext)
    {
        httpContext.Response.StatusCode = _statusCode;

        return httpContext.Response.WriteAsJsonAsync(_value);
    }
}

现在,我们可以将此端点过滤器应用到我们的最小 API 端点:

app.MapPost("/api/orders", CreateOrder)
    .RequireAuthorization()
    .WithOpenApi()
    .AddEndpointFilter<IdempotencyFilter>();


前两种实现的替代方法是在自定义中间件中实现幂等逻辑。

最佳实践和注意事项
实现幂等性时始终牢记的关键事项。

缓存持续时间是个棘手的问题。我的目标是覆盖合理的重试窗口,而不会保留过时的数据。合理的缓存时间通常从几分钟到 24-48 小时不等,具体取决于您的具体用例。

并发性可能很麻烦,尤其是在高流量 API 中。使用分布式锁和“尝试一次”方法的线程安全实现效果很好。当多个请求同时发生时,它可以控制一切。但这种情况应该很少发生。

对于分布式设置,Redis 是我的首选。它非常适合用作共享缓存,可在所有 API 实例中保持幂等性一致。此外,它还可以处理分布式锁定。

如果客户端在不同的请求主体中重复使用幂等性密钥,该怎么办?在这种情况下,我会返回错误。我的方法是对请求主体进行哈希处理,并将其与幂等性密钥一起存储。当收到请求时,我会比较请求主体哈希。如果它们不同,我会返回错误。这可以防止滥用幂等性密钥并维护 API 的完整性。

概括
在 REST API 中实现幂等性可增强服务可靠性和一致性。它可确保相同的请求产生相同的结果,防止意外重复并妥善处理网络问题。

虽然我们的实施提供了基础,但我建议根据您的需求进行调整。重点关注 API 中的关键操作,尤其是那些修改系统状态或触发重要业务流程的操作。

通过采用幂等性,您可以构建更加健壮、用户友好的 API。