在 ASP .NET Core 中實現冪等 REST API

banq發表於2024-10-27

冪等性是 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. 伺服器檢查之前是否見過此金鑰:
    • 對於新金鑰,處理請求並儲存結果。
    • 對於已知鍵,返回儲存的結果而不進行重新處理。
    <ul>
    這可確保重試的請求(例如由於網路問題)在伺服器上僅處理一次。

    我們可以透過組合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)
        {
            <font>// Parse the Idempotence-Key header from the request<i>
            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)<i>
            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<i>
            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(<font>"api/[controller]")]
    public class OrdersController : ControllerBase
    {
        [HttpPost]
        [Idempotent(cacheTimeInMinutes: 60)]
        public IActionResult CreateOrder([FromBody] CreateOrderRequest request)
        {
           
    // Process the order...<i>

            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)
        {
            <font>// Parse the Idempotence-Key header from the request<i>
            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)<i>
            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<i>
            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<i>
    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(<font>"/api/orders", CreateOrder)
        .RequireAuthorization()
        .WithOpenApi()
        .AddEndpointFilter<IdempotencyFilter>();


    前兩種實現的替代方法是在自定義中介軟體中實現冪等邏輯。

    最佳實踐和注意事項
    實現冪等性時始終牢記的關鍵事項。

    快取持續時間是個棘手的問題。我的目標是覆蓋合理的重試視窗,而不會保留過時的資料。合理的快取時間通常從幾分鐘到 24-48 小時不等,具體取決於您的具體用例。

    併發性可能很麻煩,尤其是在高流量 API 中。使用分散式鎖和“嘗試一次”方法的執行緒安全實現效果很好。當多個請求同時發生時,它可以控制一切。但這種情況應該很少發生。

    對於分散式設定,Redis 是我的首選。它非常適合用作共享快取,可在所有 API 例項中保持冪等性一致。此外,它還可以處理分散式鎖定。

    如果客戶端在不同的請求主體中重複使用冪等性金鑰,該怎麼辦?在這種情況下,我會返回錯誤。我的方法是對請求主體進行雜湊處理,並將其與冪等性金鑰一起儲存。當收到請求時,我會比較請求主體雜湊。如果它們不同,我會返回錯誤。這可以防止濫用冪等性金鑰並維護 API 的完整性。

    概括
    在 REST API 中實現冪等性可增強服務可靠性和一致性。它可確保相同的請求產生相同的結果,防止意外重複並妥善處理網路問題。

    雖然我們的實施提供了基礎,但我建議根據您的需求進行調整。重點關注 API 中的關鍵操作,尤其是那些修改系統狀態或觸發重要業務流程的操作。

    透過採用冪等性,您可以構建更加健壯、使用者友好的 API。

    相關文章