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 之前會執行的動作
- 檢查 Header 有沒有 IdempotencyKey,沒有的話則回傳 Bad Request
- 從快取得有沒有相同的內容,快取沒有內容就離開,有的話就回傳給調用端
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