簡單實作 ASP.NET Core 6 的 Idempotent Key / Idempotency Key

HttpMethod 的幂等性(Idempotent),指的是相同的請求參數,不論調用幾次結果都是相同的,不會影響結果。例如,PUT 要求必須具有等冪性。 若用戶端多次送出相同的 PUT 要求,結果應該永遠保持不變 (使用相同的值會修改相同的資源)。為什麼需要冪等,假設,在店商平台購物,付款時,連續點選了兩次支付,如果平台沒有做好保護、驗證,就會發生扣款兩次,我們會有幾種手段來避免這樣的事發生:前端攔截(PRG 防止表單重送)、平台語言鎖、分散式鎖、資料庫主鍵、唯一鍵 / 唯一索引、資料庫樂觀鎖定(搭配版號)、Token 令牌…等。

這裡我想要演練『Token 令牌實現冪等』,將會使用 .NET Core 之後新增的 IDistributedCache,還需要一台 Cache Server ,為了方便演練,我只會使用 Memory。

mikechen 解釋得很好,我就直接引用了,流程如下:

调用接口的时候先向后端请求一个全局ID(Token),后面请求的时候带上这个全局ID(Token最好放在Headers中),后端需要对这个Token作为Key,用户信息作为Value到Redis中进行键值内容校验,如果Key存在且Value值和Token值一致就执行删除命令,然后正常执行后面的业务逻辑。內容出處:什么是幂等性?四种接口幂等性方案详解! - mikechen的互联网架构 - 博客园 (cnblogs.com)

 

開發環境

  • .NET6
  • Rider 2022.2.3

實作

先新增一個 ASP.NET Core WebAPI專案

dotnet new webapi -o Lab.Idempotent.WebApi --framework net6.0

 

IdempotentAttribute

從 DI Container 取出 IDistributedCache 實例,注入給 IdempotentAttributeFilter

[AttributeUsage(AttributeTargets.Class | AttributeTargets.Method, Inherited = false)]
public class IdempotentAttribute : Attribute, IFilterFactory
{
    public bool IsReusable => false;

    public IFilterMetadata CreateInstance(IServiceProvider serviceProvider)
    {
        var distributedCache = (IDistributedCache)serviceProvider.GetService(typeof(IDistributedCache));

        var filter = new IdempotentAttributeFilter(distributedCache);
        return filter;
    }
}

.NET Core  之後出現的 IFilterFactory 可以讓我們的 Filter 不直接依賴  HttpContext.RequestServices.GetService,而是依賴抽象,這不是這一篇的重點,就不多說了,有機會的話會在專門寫一篇解釋。

 

新增一個  IdempotentAttributeFilter 類別並實作 ActionFilterAttribute,建構函數注入 IDistributedCache

public class IdempotentAttributeFilter : ActionFilterAttribute
{
    public const string HeaderName = "IdempotencyKey";
    public static readonly TimeSpan Expiration = new(0, 0, 60);

    private readonly IDistributedCache _distributedCache;
    private string _idempotencyKey;
    private bool _hasIdempotencyKey;

    public IdempotentAttributeFilter(IDistributedCache distributedCache)
    {
        this._distributedCache = distributedCache;
    }
}

為了演練所以我把快取的時間縮短到 60s,實務上你可以拉長一點

 

在 OnActionExecuting 方法

也就是進入 Actione 之前會執行的動作

  1. 檢查 Header 有沒有 IdempotencyKey,沒有的話則回傳 Bad Request
  2. 從快取得有沒有相同的內容,快取沒有內容就離開,有的話就回傳給調用端
public override void OnActionExecuting(ActionExecutingContext context)
{
    // 檢查 Header 有沒有 IdempotencyKey
    if (context.HttpContext.Request.Headers.TryGetValue(HeaderName, out var idempotencyKey) == false)
    {
        // 沒有的話則回傳 Bad Request
        context.Result = Failure.Results[FailureCode.NotFoundIdempotentKey];
        return;
    }

    this._idempotencyKey = idempotencyKey;
    
    var cacheData = this._distributedCache.GetString(this.GetDistributedCacheKey());
    if (cacheData == null)
    {
        // 沒有快取則進入 Action
        return;
    }

    // 從快取取出內容回傳給調用端 
    var jsonObject = JsonObject.Parse(cacheData);
    context.Result = new ObjectResult(jsonObject["Data"])
    {
        StatusCode = jsonObject["StatusCode"].GetValue<int>()
    };
    this._hasIdempotencyKey = true;
}

 

在 OnResultExecuted 方法

也就是 Action 執行完畢,這裡的動作就只有把回傳結果放到快取裡面

public override void OnResultExecuted(ResultExecutedContext context)
{
    if (this._hasIdempotencyKey)
    {
        return;
    }

    var contextResult = (ObjectResult)context.Result;
    if (contextResult.StatusCode != (int)HttpStatusCode.OK)
    {
        return;
    }

    var cacheOptions = new DistributedCacheEntryOptions
    {
        AbsoluteExpirationRelativeToNow = Expiration
    };
    var json = JsonSerializer.Serialize(new
    {
        Data = contextResult.Value,
        contextResult.StatusCode
    });
    this._distributedCache.SetString(this.GetDistributedCacheKey(),
        json,
        cacheOptions);
}

完整代碼位置:sample.dotblog/IdempotentAttributeFilter.cs at master · yaochangyu/sample.dotblog (github.com)

 

註冊 DistributedCache

這裡為了方便演練,我使用的是 Memory

builder.Services.AddDistributedMemoryCache(p =>
{
    p.ExpirationScanFrequency = TimeSpan.FromSeconds(60);
});

 

套用 IdempotentAttribute

在 Action 上面 [Idempotent] 就可以了

[ApiController]
[Route("[controller]")]
public class WeatherForecastController : ControllerBase
{
    private static readonly string[] Summaries = new[]
    {
        "Freezing", "Bracing", "Chilly", "Cool", "Mild", "Warm", "Balmy", "Hot", "Sweltering", "Scorching"
    };

    private static readonly List<WeatherForecast> s_repository = new();

    private readonly ILogger<WeatherForecastController> _logger;

    public WeatherForecastController(ILogger<WeatherForecastController> logger)
    {
        _logger = logger;
    }

    [HttpPost("{temperature}")]
    [Idempotent]
    public async Task<ActionResult<WeatherForecast>> Post(int temperature, CancellationToken cancel = default)
    {
        var rng = new Random();
        var data = new WeatherForecast
        {
            TemperatureC = temperature,
            Summary =  Summaries[rng.Next(Summaries.Length)],
            Date = DateTime.UtcNow
        };
        s_repository.Add(data);

        return data;
    }

    [HttpGet]
    public async Task<ActionResult<IEnumerable<WeatherForecast>>> Get()
    {
        return s_repository;
    }
}

 

運行結果

運行站台後可以用 curl 或是 Postman 調用,由下圖可見,我 Post 了兩次都會回傳相同的結果。

IdempotencyKey,實務上你可以採用集中式的 UUID Server 頒發,這裡我隨便帶入一個值。

至於,全域的 UUID 我就不演練了 XDD

 

專案位置

sample.dotblog/WebAPI/Idempotent at master · yaochangyu/sample.dotblog (github.com)

若有謬誤,煩請告知,新手發帖請多包涵


Microsoft MVP Award 2010~2017 C# 第四季
Microsoft MVP Award 2018~2022 .NET

Image result for microsoft+mvp+logo