什么是刷新/重新载入
IE中的刷新(Refresh),在FF和Chrome中称为重新载入(Reload),与正常进入页面的区别在于以下两点:
1. 缓存控制
如果文件(比如图片)在本地缓存中已经存在,正常进入页面会不访问服务器而直接从本地加载。而对于刷新操作,即使存在本地缓存,也会强制访问服务器检查更新,在Request Header中会带有“If-Modified-Since”标签。如果文件没有更新,服务器会返回304, 否则返回200及更新后的文件。
2. 重复提交
刷新会重复提交上一次的请求。比方说新建一张订单然后保存,而系统操作成功后停留在当前页面。此时进行刷新,浏览器会弹出一个对话框询问是否继续,点确定后浏览器会重复提交上一次的POST请求(点击保存的请求),系统可能会产生一张重复的订单。当然,上次操作如果只是Get请求而非POST,重复提交并不会导致这种问题。
如何防止重复提交
防止重复提交的方法与PRG(POST-Redirect-GET)模式有一些相似之处,具体来说,当Web服务器识别到一个重复提交的POST请求的时候,重定向到当前页面,然后浏览器以GET的方式请求该页面。如果使用HttpWatch之类的工具查看网络请求,可以看到会有两个请求,302 & 200。
现在问题在于如何识别一个请求是由刷新引起的。我们利用刷新操作会重复提交上一次请求,而不是当前请求的特性,可以有以下方案:在服务器端(Session)和页面(可以是页面上一个hidden field)上都设置一个整型标志位,在每次POST请求中都比较两者的值,然后自增。在正常提交情况下,浏览器会提交当前页面上hidden field的值,所以每次该值与Session中的都相等;在刷新情况下,提交的值是上次请求的值,就会与Session的不相等,由此得知是刷新操作引起的重复提交。
这类似于Struts中的Token验证。
在这里我们把页面hidden field称为Client Flag,具体的流程如下
在最后一步刷新页面的时候,hidden field的值为3,但是浏览器会重复提交上一次的请求(2),造成与Session中的值(3)不一致。
在ASP.NET MVC中的实现
添加一个自定义的ActionFilter.
public class NoResubmitAttribute : ActionFilterAttribute { private static readonly string HttpMehotdPost = "POST"; private static readonly string prefix = "postFlag"; private string nameWithRoute; public override void OnActionExecuting(ActionExecutingContext filterContext) { var controllerContext = filterContext.Controller.ControllerContext; if (!controllerContext.IsChildAction) { var request = controllerContext.HttpContext.Request; var session = controllerContext.HttpContext.Session; nameWithRoute = generateNameWithRoute(controllerContext); int sessionFlag = session[nameWithRoute] == null ? 0 : (int)session[nameWithRoute]; int requestFlag = string.IsNullOrEmpty(request.Form[nameWithRoute]) ? 0 : int.Parse(request.Form[nameWithRoute]); // get or normal post: true; bool isValid = !IsPost(filterContext) || sessionFlag == requestFlag; if (sessionFlag == int.MaxValue) { sessionFlag = -1; } session[nameWithRoute] = ++sessionFlag; if (!isValid) { filterContext.Result = new RedirectResult(GenerateUrlWithTimeStamp(request.RawUrl)); return; } } base.OnActionExecuting(filterContext); } private string GenerateUrlWithTimeStamp(string url) { return string.Format("{0}{1}timeStamp={2}", url, url.Contains("?") ? "&" : "?", (DateTime.Now - DateTime.Parse("2010/01/01")).Ticks); } private bool IsPost(ActionExecutingContext filterContext) { return filterContext.HttpContext.Request.HttpMethod == HttpMehotdPost; } private string generateNameWithRoute(ControllerContext controllerContext) { StringBuilder sb = new StringBuilder(prefix); foreach (object routeValue in controllerContext.RouteData.Values.Values) { sb.AppendFormat("_{0}", routeValue); } return sb.ToString(); } public override void OnResultExecuted(ResultExecutedContext filterContext) { base.OnResultExecuted(filterContext); if (!filterContext.IsChildAction && !(filterContext.Result is RedirectResult)) { //string format = "<script type='text/javascript'>$(function () [[ $('form').each(function()[[$('<input type=hidden id={0} name={0} value={1} />').appendTo($(this));]])]]); </script>"; string format = "<script type='text/javascript'> var forms = document.getElementsByTagName('form'); for(var i = 0; i<forms.length; i++)[[var ele = document.createElement('input'); ele.type='hidden'; ele.id=ele.name='{0}'; ele.value='{1}'; forms[i].appendChild(ele);]] </script>"; string script = string.Format(format, nameWithRoute, filterContext.HttpContext.Session[nameWithRoute]).Replace("[[", "{").Replace("]]", "}"); filterContext.HttpContext.Response.Write(script); } } }
为了提交易用性,这里将通过javascript创建HiddenField的部分也封装在ActionFilter里,当然你也可以在母板页中加此hiddenfield。
然后将此Attribute加在需要防止重复提交的Action之上,需要提醒的是,Get和Post的Action都需要加此Attribute,或者都不加。
[HttpGet] [NoResubmit] public ActionResult Index() { return View(); } [HttpPost] [NoResubmit] public ActionResult Index(int id) { return View(); } [NoResubmit] public ActionResult About() { return View(); }
在ASP.NET Web Form中的实现
跟MVC一样,在Web Form中我们仍然可以使用加Hidden Field的方法,但是我更愿意使用ViewState(视图状态)来储存该标志位,这样就不许动态创建hidden field。
实现会放在一个继承自Web.UI.Page的BasePage类中,只需将页面类继承该BasePage即可。
public class BasePage : System.Web.UI.Page { private static readonly string prefix = "postFlag"; private string nameWithRoute; public BasePage() { nameWithRoute = generateNameWithRoute(); } protected override void LoadViewState(object savedState) { object[] allStates = (object[])savedState; base.LoadViewState(allStates[0]); int requestFlag = string.IsNullOrEmpty(allStates[1].ToString()) ? 0 : int.Parse(allStates[1].ToString()); int sessionFlag = Session[nameWithRoute] == null ? 0 : (int)Session[nameWithRoute]; // get or normal post: true; bool isValid = !IsPostBack || sessionFlag == requestFlag; if (sessionFlag == int.MaxValue) { sessionFlag = -1; } Session[nameWithRoute] = ++sessionFlag; if (!isValid) { Response.Redirect(GenerateUrlWithTimeStamp(Request.RawUrl), false); Response.End(); return; } } protected override object SaveViewState() { object[] allStates = new object[2]; allStates[0] = base.SaveViewState(); allStates[1] = Session[nameWithRoute]; return allStates; } private string generateNameWithRoute() { string fileSubfix = ".aspx"; return prefix + Request.FilePath.Replace(fileSubfix, "").Replace("/", "_"); } private string GenerateUrlWithTimeStamp(string url) { return string.Format("{0}{1}timeStamp={2}", url, url.Contains("?") ? "&" : "?", (DateTime.Now - DateTime.Parse("2010/01/01")).Ticks); } }
避免IE 8及之前版本的Bug
再此过程中发现IE8及之前IE版本中的一个Bug。在刷新操作中进行重定向到当前路径,IE9/Chrome/Firefox中会产生两个请求,302和200,但是在IE8中,在302后没有进行重定向,页面则变成空白页。
IE9/Chrome/Firefox: (正常)
IE8 and ealier: (异常)
并不清楚这是IE8的缺陷还是所谓by-designed的行为。为了避免这种情况,在上述两种实现中都在重定向的URL中加了一个时间戳,使之与原路径不一样来避免此问题。
该方案的限制性
由于我们使用Session来存放服务器端的标志位,所以当用户在同一浏览器中不同标签页打开同一个页面时会有些小问题。后面打开的标签页一切正常,但如果在上面做了一些操作后又回到前面打开的标签操作,会引起先打开的标签重定向。