在ASP.Net中進行Web程序開發時會遇到經常會遇到重復提交問題。例如:點擊某個按鈕添加一條數據,這個按鈕事件就是向數據庫中插入一條數據。添加一條數據後如果按f5,那麼ASP.net應用應用服務器是無法區別這是正常點擊按鈕添加還是f5刷新添加,那麼這樣就會導致在數據庫裡會存在n條一莫一樣的數據。 為什麼在原Asp開發程序中不會碰到這樣的問題呢?我覺得是因為Asp程序主要都是將表單提交給另外一個頁面處理,並且,這個頁面處理之後,將跳轉到另外一個提示頁面。那麼在Asp程序中,只需要在回退時將頁面設置為過期那麼就可以有效的避免重復提交的問題。但是在ASP.Net中,基本上所有的操作都是基於事件操作,而事件的本質上就是頁面自己提交給自己,並且頁面無法識別提交時正常操作還是重復刷新。
在我開發的第一個Web應用程序中是在項目後期,也就是基本上快完成時發現了這個問題,項目後期整個的頁面框架都已經非常穩定,這時候來修改整個框架是得不償失的,只能采取變通的方式解決這個問題。幸好整個系統的數據訪問層全部都是通過存儲過程來處理數據的,那麼我們根據幾個基本的關鍵字段判斷這條數據是否在表裡存在,如果存在那麼就拋出異常(主要是Insert的存儲過程),通過存儲過程的判斷來判斷這條數據是否是重復提交的數據。這個只是權宜方案,並不是一個很好的方案,因為這樣只對實體表有用,但是對關系表的插入可能很難判斷,關系表主要存儲的是別的表的主鍵,如果多個他表主鍵在邏輯上形成了唯一性,這樣還比較好判斷,但是如果多個他表主鍵不能在邏輯上形成唯一性,那麼對唯一性的判斷將十分的困難。並且實體表在某些情況下也無法適用,如果某個實體表允許除主鍵字段,其他字段都允許相同,那麼這個也是無法判斷是否是重復提交的數據。
那麼如何解決這個問題呢?在微軟Msdn上提供了一套解決方案,這個方案是意大利的Dino Esposito提供的。他的思想就是在客戶端保存一個標志,在服務端也保存一個標志,在提交時對比兩個標志的值,來判斷是否是重復提交。
先看下面代碼,首先是一個RefreshAction靜態類,這個類主要是用來初始化服務端Session保存上一次票證的值並且對比客戶端和服務端票證的值,當檢測到刷新不是重復刷新時,將要把客戶端的票證值更新到服務端
using System;
using System.Web;
namespace Msdn
...{
public static class RefreshAction
...{
// ***********************************************************
// 常量
//服務端票證key
public const string LastRefreshTicketEntry = "__LASTREFRESHTICKET";
//客戶端票證key
public const string CurrentRefreshTicketEntry = "__CURRENTREFRESHTICKET";
//用來保存是否是重復刷新的屬性的key
public const string PageRefreshEntry = "IsPageRefresh";
// ***********************************************************
// ***********************************************************
// 檢測F5按鈕是否被按下
public static void Check(HttpContext ctx)
...{
//初始化服務端票證
EnsureRefreshTicket(ctx);
//從Session裡讀取上一次提供的票證
int lastTicket = GetLastRefreshTicket(ctx);
//從請求裡的隱藏域裡讀取當前頁面的票證
int thisTicket = GetCurrentRefreshTicket(ctx);
// 對比兩個票證
if (thisTicket > lastTicket ||
(thisTicket == lastTicket && thisTicket == 0))
...{
//如果當前的票證值大於上一次的票證值 或者
//當前票證值等於上一次票證值,並且當前票證值為0(這是第一次刷新)
//那麼更新Session裡上一次的票證值為當前票證值
UpdateLastRefreshTicket(ctx, thisTicket);
//設置當前頁是否重復刷新屬性為false
ctx.Items[PageRefreshEntry] = false;
}
else
...{
//設置當前頁是否重復刷新屬性為true;
ctx.Items[PageRefreshEntry] = true;
}
}
// ***********************************************************
// ***********************************************************
//確認上一次的票證不為空值
private static void EnsureRefreshTicket(HttpContext ctx)
...{
// Initialize the session slots for the page (Ticket) and the module (LastTicketServed)
//初始化Session的最後一次票證值
if (ctx.Session[LastRefreshTicketEntry] == null)
ctx.Session[LastRefreshTicketEntry] = 0;
}
// ***********************************************************
// ***********************************************************
//從Session裡得到上一次請求的票證值
private static int GetLastRefreshTicket(HttpContext ctx)
...{
//返回Session裡保存的上一次票證值
return Convert.ToInt32(ctx.Session[LastRefreshTicketEntry]);
}
// ***********************************************************
// ***********************************************************
//從當前請求裡的到隱藏域裡保存的當前票證值
private static int GetCurrentRefreshTicket(HttpContext ctx)
...{
return Convert.ToInt32(ctx.Request[CurrentRefreshTicketEntry]);
}
// ***********************************************************
// ***********************************************************
// 將當前的票證值保存到Session裡的上一次刷新的票證值
private static void UpdateLastRefreshTicket(HttpContext ctx, int ticket)
...{
ctx.Session[LastRefreshTicketEntry] = ticket;
}
// ***********************************************************
}
}
下面是一個HttpModule類,在請求開始時就來檢測雙方的票證值
using System;
using System.Web;
using System.Web.SessionState;
namespace Msdn
...{
public class RefreshModule : IHttpModule
...{
// ***********************************************************
//初始化模塊
public void Init(HttpApplication app)
...{
// Register for pipeline events
//注冊請求關聯狀態時的事件處理器,就是說當一個請求到達服務器,
//那麼首先觸發這個事件,由OnAcquireRqeustState事件處理
app.AcquireRequestState += new EventHandler(this.OnAcquireRequestState);
}
// ***********************************************************
// ***********************************************************
// IHttpModule::Dispose
public void Dispose()
...{
}
// ***********************************************************
// ***********************************************************
//判斷是否是F5或前進/後退操作
private void OnAcquireRequestState(object sender, EventArgs e)
...{
//得到訪問的HTTP上下文
HttpApplication app = (HttpApplication)sender;
HttpContext ctx = app.Context;
//檢查是否是F5操作
RefreshAction.Check(ctx);
return;
}
// ***********************************************************
}
}
下面是繼承於Page頁面的基類,它主要用來保存刷新的次數和客戶端票證的值,並且提供一個屬性來標志此頁面是否是重復提交的頁面
using System;
using System.Web.UI;
using System.Web;
using System.Text;
namespace Msdn
...{
public class MsdnPage : System.Web.UI.Page
...{
Constants#region Constants
// ***********************************************************
// 常量
public const string RefreshTicketCounter = "RefreshTicketCounter";
// ***********************************************************
#endregion
// ***********************************************************
// Ctor
public MsdnPage()
...{
// Register a PreRender handler
//注冊頁面呈現前的事件處理器
this.PreRender += new EventHandler(RefreshPage_PreRender);
}
// ***********************************************************
// **************************************************************
//標志頁面是否按F5進行重復刷新的標志屬性
public bool IsPageRefresh
...{
get
...{
object o = HttpContext.Current.Items[RefreshAction.PageRefreshEntry];
if (o == null)
return false;
return (bool)o;
}
}
// **************************************************************
// **************************************************************
//增加刷新票證的內部計數器
public void TrackRefreshState()
...{
//初始化刷新計數器
InitRefreshState();
//將刷新計數器加1,然後放進Session
int ticket = Convert.ToInt32(Session[RefreshTicketCounter]) + 1;
Session[RefreshTicketCounter] = ticket;
}
// **************************************************************
Private Members#region Private Members
// **************************************************************
// Create the hidden fIEld to store the current request ticket
//創建隱藏域來保存當前請求的票證值
private void SaveRefreshState()
...{
//將票證計數器的值加1,然後將此值注冊到當前票證隱藏域中
int ticket = Convert.ToInt32(Session[RefreshTicketCounter]) + 1;
this.ClientScript.RegisterHiddenFIEld(RefreshAction.CurrentRefreshTicketEntry, ticket.ToString());
}
// **************************************************************
// **************************************************************
//初始化刷新計數器
private void InitRefreshState()
...{
if (Session[RefreshTicketCounter] == null)
Session[RefreshTicketCounter] = 0;
}
// **************************************************************
// **************************************************************
// PreRender事件處理器
private void RefreshPage_PreRender(object sender, EventArgs e)
...{
//在頁面呈現之前就保存票證值到隱藏域
SaveRefreshState();
}
// **************************************************************
#endregion
}
}
下面是一個繼承於MsdnPage類的頁面,它通過判斷是否是重復刷新屬性來顯示相應的值
using System;
using System.Data;
using System.Configuration;
using System.Collections;
using System.Web;
using System.Web.Security;
using System.Web.UI;
using System.Web.UI.WebControls;
using System.Web.UI.WebControls.WebParts;
using System.Web.UI.HtmlControls;
using Msdn;
public partial class _Default : MsdnPage
...{
protected void Page_Load(object sender, EventArgs e)
...{
}
protected void Button1_Click(object sender, EventArgs e)
...{
if (this.IsPageRefresh)
...{
this.Label1.Text = "這是重復刷新的頁面";
}
else
...{
this.TrackRefreshState();
this.Label1.Text = "這是正常提交的頁面";
}
}
}
在WebConfig的system.web節點下加入處理請求的HttpModule
<httpModules>
<add name="MsdnModule" type="Msdn.RefreshModule"/>
</httpModules>
上面的代碼就是解決重復刷新的代碼,那麼我們來分析這個代碼,當我們進入頁面然後點擊Button1是怎麼來處理這些刷新的信息的。
當我們進入頁面時按照下面的順序來執行
1、當第一次進入頁面,首先由系統自動調用RefreshModule的Init事件,在此事件裡,我們給Application對象的請求關聯狀態事件(AcquireRequestState)注冊了一個事件處理器(OnAcquireRequestState),那麼當我們請求關聯狀態時會自動調用OnAcquireRequestState函數。
2、調用MsdnPage的構造函數,在此函數裡注冊了PreRender的事件處理器。
3、第一次進入頁面也是一個關聯請求,現在會自動調用OnAcquireRequestState事件處理器
4、在OnAcquireRequestState事件處理器中我們調用靜態類RefreshAction靜態類的Check方法,HttpContext作為參數傳入
- 在Check方法裡,我們首先初始化服務端票證(保存在Session裡),讓服務端票證的值為0。
然後我們得到上一次刷新的票證值也就是服務端票證值,它為0。
- 我們得到這次請求保存在隱藏域裡的當前的票證值,因為這是第一次請求,那麼這個值為空,轉換為整數,為0
- 對比兩個票證,如果當前的票證值大於上一次的票證值或者當前票證值等於上一次票證值,並且兩者都為0(這表明是第一次刷新),那麼我們將當前的票證值保存為上一次票證值。這時候,客戶端和服務端的票證值都為0。將標志頁面是否是重復刷新的值設置為false。如果對比條件為假,那麼設置重復刷新的值為true。
- OnAcquireRequestState事件處理完畢
5、現在觸發了PreRender事件,在頁面呈現之前觸發,此事件調用SaveRefreshState用來保存客戶端當前票證的值。
- 首先將刷新次數的值得到並加1,此時刷新次數為0。
- 將刷新次數的加1的值保存到客戶端當前票證的隱藏域中,那麼現在當前票證的值為1,上一次票證值為0,刷新次數的值為0。
6、當我們點擊Button1按鈕的時候首先調用MsdnPage的構造函數注冊PreRender的事件處理器
7、然後系統自動調用AcquireRequestState事件處理器,調用RefreshAction的Check方法
- 初始化服務端票證函數無用,因為服務端票證已經存在值
- 得到上一此票證刷新的值為0
- 得到當前票證刷新的值為1
- 判斷票證,這時當前票證值是大於上次票證的值,將當前票證的值更新到上一次票證值,此時上一次票證值為1
- 設置是否重刷新標志為false,這時候當前票證為1,上一次票證為1。
8、這時候不是調用PreRender事件,而是調用Button1的Click事件。
- 判斷MsdnPage的IsPageRefresh屬性是否為真,很顯然,現在這個值為假
- 那麼調用MsdnPage的TrackRefreshState方法,在這個方法裡將刷新次數加1,保存在Session裡。注意此時當前票證為1,上一次票證為1,刷新次數為1。
9、那麼這時候調用PreRender事件處理器的SaveRefreshState方法
- 將刷新次數加1,並且保存到當前票證裡,那麼這時候當前票證為2,上一次票證為1,刷新次數為1。
那麼我們可以觀察到正常的提交服務端(上一次)票證始終小於客戶端(當前)票證,刷新次數也小於當前票證,那麼如果是按F5刷新呢?我們觀察一下代碼
1、調用MsdnPage的構造函數注冊PreRender事件
2、調用AcquireRequestState事件處理器裡的Check方法
- 初始化服務端票證,此時無效
- 得到上一次刷新的票證為1,得到當前的票證也為1
- 判斷兩個票證,此時肯定為假,那麼設置重復刷新標志為false
3、處理button1的Click事件
判斷IsPageRefresh屬性,顯然此時重復刷新標志為true,表明此次刷新是按F5刷新的。
在這裡很奇怪,在正常點擊時,當前票證(客戶端)為2,上一次票證(服務端)為1,刷新次數為1,那麼為什麼按F5刷新以後,當前票證為1了?
我剛開始也很奇怪,然後我做了一個實驗,使一個按鈕點擊時增加隱藏域的值,讓他加1,在Page_Load的時候去讀取這個隱藏域,我點擊button讓隱藏域的值增加,但是當我按F5時,隱藏域的值始終保持不變,那麼我猜測,按F5時,不是將當前頁面的數據提交給服務端,是將緩存的數據提交給服務端,所以我們捕獲到的數據值就是上一次正常提交的數據,此時隱藏域的值仍然保存最新的票證值,但是按F5,這個值不會提交給服務器。,直到正常的點擊Button1提交數據。
那麼回退/前進可以說更好理解,我回退之後再點擊Button1,此時提交的是上一個頁面的隱藏域的值,但是存在Session裡上一個票證的值已經增加了,那麼對比的時候就可以知道這是重復刷新提交的操作。
上面是這個解決方案件的原理已經闡述完畢。但是這個解決方案仍然有一定的缺點。如回退之後第一次點擊可以探明是重復提交,但是第二次點擊仍然會說明是正常提交。還有一個缺點,服務端票證保存在Session裡,Session是會過期的,這時候應該加一個Session超時的判斷。還有一個最大的缺點,此解決方案不能配合IFrame使用,因為在IFrame中,客戶端頁面會加載兩次(即IFrame外的父窗口和IFrame導向的子窗口),導致客戶端票證與服務端票證相同,那麼在IFrame中,提交始終是重復提交。
在實際應用中,我們肯定不能像示例那樣使用這個解決方案。因為我們在項目中經常會使用用戶控件,一般我們是將Button和文本框包裝成一個用戶控件,點擊Button拋出一個事件,由頁面處理。這樣還比較好判斷頁面是否是重復提交的。但是如果在Button在不拋出事件,就在用戶控件裡自行解決,那麼這樣比較難以實現在事件中處理和判斷頁面是否重復提及。我認為這個判斷最好放在Page_Load事件裡,如果是重復刷新的就跳轉到另外一個提示頁面(中斷button的處理器),然後在跳轉回來,作為第一次進入這個頁面。這樣就可以避免在每次提交事件來做頁面是否是刷新頁面的判斷。
在這裡我覺得需要回顧一下Page的加載順序。
- Page的構造函數
- protected void Page_PreInit(object sender, EventArgs e)
- protected void Page_Init(object sender, EventArgs e)
- protected void Page_InitCompleted(object send, EventArgs e)
- protected void Page_PreLoad(object sender, EventArgs e)
- protected void Page_Load(object sender, EventArgs e)
- 處理完Page_Load事件,如果有提交事件就開始處理提交事件,在處理完提交事件之後在處理剩下的Page事件
- protected void Page_LoadComplete(object sender, EventArgs e)
- protected void Page_PreRender(object sender, EventArgs e)
- protected void Page_PreRenderComplete(object sender, EventArgs e)
- protected void Page_SaveStateComplete(object sender, EventArgs e)