什麼是重新整理/重新載入
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.
View Code
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,或者都不加。
View Code
[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即可。
View Code
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來存放伺服器端的標誌位,所以當使用者在同一瀏覽器中不同標籤頁開啟同一個頁面時會有些小問題。後面開啟的標籤頁一切正常,但如果在上面做了一些操作後又回到前面開啟的標籤操作,會引起先開啟的標籤重新導向。