这实际上是 MVC(可能还有其他 Web 框架)中相当普遍的问题,所以我将对其进行一些解释,然后提供解决方案。
问题
假设您在一个带有表单的网页上。你点击提交。服务器需要一段时间才能响应,因此您再次单击它。然后再次。此时,您已经触发了三个单独的请求,服务器将同时处理所有这些请求。但是在浏览器中只会执行一个响应 - 第一个。
这种情况可以用下面的折线图来表示。
┌────────────────────┐
Request 1 │ │ Response 1: completes, browser executes response
└────────────────────┘
┌────────────────┐
Request 2 │ │ Response 2: also completes!
└────────────────┘
┌───────────────────┐
Request 3 │ │ Response 3: also completes!
└───────────────────┘
横轴代表时间(不按比例)。也就是说,三个请求是按顺序触发的,但只有第一个响应返回给浏览器;其他的都被丢弃了。
这是个问题。并非总是如此,但通常足以令人讨厌,这样的请求会产生副作用。这些副作用可能会有所不同,如计数器递增、创建重复记录,甚至是多次处理信用卡付款。
解决方案
现在,在 MVC 中,大多数 POST 请求(尤其是具有副作用的请求)应该使用内置的 AntiForgeryToken 逻辑来生成和验证每个表单的随机令牌。以下解决方案利用了这一点。
计划:我们丢弃所有重复的请求。这里的逻辑是:缓存每个请求的令牌。如果它已经在缓存中,则返回一些虚拟重定向响应,可能带有错误消息。
就我们的折线图而言,这看起来像...
┌────────────────────┐
Request 1 │ │ Response 1: completes, browser executes the response [*]
└────────────────────┘
┌───┐
Request 2 │ x │ Response 2: rejected by overwriting the response with a redirect
└───┘
┌───┐
Request 3 │ x │ Response 3: rejected by overwriting the response with a redirect
└───┘
[*] 浏览器执行了 错误 响应,因为它已经被请求 2 和 3 替换。
请注意这里的一些事情:因为我们根本不处理重复请求,所以它们会快速执行结果。太快了——他们实际上通过先进入来代替第一个请求的响应。
因为我们实际上并没有处理这些重复的请求,所以我们不知道将浏览器重定向到哪里。如果我们使用虚拟重定向(如/SameController/Index),那么当第一个响应返回到浏览器时,它将执行该重定向而不是它应该执行的任何操作。这让用户忘记了他们的请求是否真正成功完成,因为第一个请求的结果丢失了。
显然这不太理想。
因此,我们修改后的计划:不仅缓存每个请求的令牌,还缓存响应。这样,我们就可以分配实际应该返回给浏览器的响应,而不是为重复的请求分配任意重定向。
这是使用过滤器属性在代码中的外观。
/// <summary>
/// When applied to a controller or action method, this attribute checks if a POST request with a matching
/// AntiForgeryToken has already been submitted recently (in the last minute), and redirects the request if so.
/// If no AntiForgeryToken was included in the request, this filter does nothing.
/// </summary>
[AttributeUsage(AttributeTargets.Class | AttributeTargets.Method, AllowMultiple = false, Inherited = true)]
public class PreventDuplicateRequestsAttribute : ActionFilterAttribute {
/// <summary>
/// The number of minutes that the results of POST requests will be kept in cache.
/// </summary>
private const int MinutesInCache = 1;
/// <summary>
/// Checks the cache for an existing __RequestVerificationToken, and updates the result object for duplicate requests.
/// Executes for every request.
/// </summary>
public override void OnActionExecuting(ActionExecutingContext filterContext) {
base.OnActionExecuting(filterContext);
// Check if this request has already been performed recently
string token = filterContext?.HttpContext?.Request?.Form["__RequestVerificationToken"];
if (!string.IsNullOrEmpty(token)) {
var cache = filterContext.HttpContext.Cache[token];
if (cache != null) {
// Optionally, assign an error message to discourage users from clicking submit multiple times (retrieve in the view using TempData["ErrorMessage"])
filterContext.Controller.TempData["ErrorMessage"] =
"Duplicate request detected. Please don't mash the submit buttons, they're fragile.";
if (cache is ActionResult actionResult) {
filterContext.Result = actionResult;
} else {
// Provide a fallback in case the actual result is unavailable (redirects to controller index, assuming default routing behaviour)
string controller = filterContext.ActionDescriptor.ControllerDescriptor.ControllerName;
filterContext.Result = new RedirectResult("~/" + controller);
}
} else {
// Put the token in the cache, along with an arbitrary value (here, a timestamp)
filterContext.HttpContext.Cache.Add(token, DateTime.UtcNow.ToString("s"),
null, Cache.NoAbsoluteExpiration, new TimeSpan(0, MinutesInCache, 0), CacheItemPriority.Default, null);
}
}
}
/// <summary>
/// Adds the result of a completed request to the cache.
/// Executes only for the first completed request.
/// </summary>
public override void OnActionExecuted(ActionExecutedContext filterContext) {
base.OnActionExecuted(filterContext);
string token = filterContext?.HttpContext?.Request?.Form["__RequestVerificationToken"];
if (!string.IsNullOrEmpty(token)) {
// Cache the result of this request - this is the one we want!
filterContext.HttpContext.Cache.Insert(token, filterContext.Result,
null, Cache.NoAbsoluteExpiration, new TimeSpan(0, MinutesInCache, 0), CacheItemPriority.Default, null);
}
}
}
要使用此属性,只需将其粘贴在 [HttpPost] 和 [ValidateAntiForgeryToken] 旁边的方法上:
[HttpPost]
[ValidateAntiForgeryToken]
[PreventDuplicateRequests]
public ActionResult MySubmitMethod() {
// Do stuff here
return RedirectToAction("MySuccessPage");
}
...然后随心所欲地向那些提交按钮发送垃圾邮件。我一直在几个操作方法上使用它,到目前为止没有任何问题 - 并且没有更多重复记录,无论我向提交按钮发送多少垃圾邮件。
如果有人对 MVC 实际处理请求的方式有更准确的描述(因为这纯粹是根据观察和堆栈跟踪编写的),请成为我的客人,我会相应地更新这个答案。
最后,感谢@CShark,我将其suggestions用作我的解决方案的基础。