通常在普通的操作當(dāng)中,我們不需要處理重復(fù)提交的,而且有很多方法來(lái)防止重復(fù)提交。比如在登陸過(guò)程中,通過(guò)使用redirect,可以讓用戶登陸之上重定向到后臺(tái)首頁(yè)界面,當(dāng)用戶刷新界面時(shí)就不會(huì)觸發(fā)重復(fù)提交了?;蛘呤褂胻oken,隱藏在表單中,當(dāng)提交時(shí)進(jìn)行token驗(yàn)證,驗(yàn)證失敗也不讓提交。這都是一般的做法。
我們這次碰到的問(wèn)題是重復(fù)提交本身就是一個(gè)錯(cuò)誤,重復(fù)提交會(huì)導(dǎo)致一些相關(guān)數(shù)據(jù)的邏輯不再正確。而這些重復(fù)提交并不是通過(guò)普通的刷新界面,或者兩次點(diǎn)擊按鈕來(lái)進(jìn)行的。在普通的操作當(dāng)中,我們可以通過(guò)一系列的手段,使得相應(yīng)參數(shù)被清零,從而防止數(shù)據(jù)上的不正確。但是,在一種情況下,這些手段都不再有效,那就是并發(fā)的重復(fù)提交。
并發(fā)重復(fù)提交,那就是在同一時(shí)間內(nèi)(時(shí)間間隔可以縮短到0.X秒之內(nèi)),在這種情況下,所有的常規(guī)邏輯都不再有效,因?yàn)槎鄠€(gè)請(qǐng)求,同時(shí)進(jìn)入系統(tǒng),系統(tǒng)已不能判斷出這些請(qǐng)求是否是無(wú)效的,它們同時(shí)通過(guò)常規(guī)的重復(fù)邏輯判斷,并最終在同一時(shí)間內(nèi)將數(shù)據(jù)寫(xiě)入到數(shù)據(jù)庫(kù)中,引起數(shù)據(jù)錯(cuò)誤。
舉一個(gè)簡(jiǎn)單的例子,在系統(tǒng)中銷售一個(gè)商品,首先通過(guò)該商品id進(jìn)入到系統(tǒng)邏輯判斷,判斷此商品是否已售出,如果未售出,就進(jìn)行數(shù)據(jù)存取操作。商品是否售出,是一個(gè)邏輯判斷,是驗(yàn)證數(shù)據(jù)存儲(chǔ)到數(shù)據(jù)庫(kù)的一道門。在常規(guī)的判斷當(dāng)中,前一請(qǐng)求通過(guò)這道門之后,后一請(qǐng)求就不能通過(guò)了,因?yàn)轵?yàn)證為false。但在并發(fā)請(qǐng)求中,兩個(gè)或多個(gè)請(qǐng)求同時(shí)通過(guò)了這道門,因?yàn)槎际峭瑫r(shí)進(jìn)入到判斷,在判斷之前都驗(yàn)證商品沒(méi)有被售出,所以就同時(shí)進(jìn)入到數(shù)據(jù)的存儲(chǔ)當(dāng)中。
在常規(guī)的java開(kāi)發(fā)中,對(duì)于這種情況,臨界資源,通常是使用加鎖來(lái)保證這種情況的先后順序。但是加鎖有一個(gè)問(wèn)題即是,它是對(duì)于全局信息的加鎖,即對(duì)整個(gè)將要銷售的商品進(jìn)行加鎖了。對(duì)于BS應(yīng)用來(lái)說(shuō),我們必須保證另一個(gè)操作人員的同一種商品的銷售請(qǐng)求通過(guò),即只限制同一個(gè)操作人員銷售的并發(fā)請(qǐng)求,不限制多個(gè)操作人員不同請(qǐng)求的處理。
在這種情況下,我們的加鎖就不能簡(jiǎn)單的鎖定在商品上,而是要鎖定在與操作人員有關(guān)的信息上,這就是session。
session是一個(gè)在單個(gè)操作人員整個(gè)操作過(guò)程中,與服務(wù)器端保持通信的惟一識(shí)別信息。在同一操作人員的多次請(qǐng)求當(dāng)中,session始終保證是同一個(gè)對(duì)象,而不是多個(gè)對(duì)象,因?yàn)榭梢詫?duì)其加鎖。當(dāng)同一操作人員多個(gè)請(qǐng)求進(jìn)入時(shí),可以通過(guò)session限制只能單向通行。
本文正是通過(guò)使用session以及在session中加入token,來(lái)驗(yàn)證同一個(gè)操作人員是否進(jìn)行了并發(fā)重復(fù)的請(qǐng)求,在后一個(gè)請(qǐng)求到來(lái)時(shí),使用session中的token驗(yàn)證請(qǐng)求中的token是否一致,當(dāng)不一致時(shí),被認(rèn)為是重復(fù)提交,將不準(zhǔn)許通過(guò)。
整個(gè)流程可以由如下流程來(lái)表述:
- 客戶端申請(qǐng)token
- 服務(wù)器端生成token,并存放在session中,同時(shí)將token發(fā)送到客戶端
- 客戶端存儲(chǔ)token,在請(qǐng)求提交時(shí),同時(shí)發(fā)送token信息
- 服務(wù)器端統(tǒng)一攔截同一個(gè)用戶的所有請(qǐng)求,驗(yàn)證當(dāng)前請(qǐng)求是否需要被驗(yàn)證(不是所有請(qǐng)求都驗(yàn)證重復(fù)提交)
- 驗(yàn)證session中token是否和用戶請(qǐng)求中的token一致,如果一致則放行
- session清除會(huì)話中的token,為下一次的token生成作準(zhǔn)備
- 并發(fā)重復(fù)請(qǐng)求到來(lái),驗(yàn)證token和請(qǐng)求token不一致,請(qǐng)求被拒絕
由以上的流程,我們整個(gè)實(shí)現(xiàn)需要以下幾個(gè)東西
- token生成器,負(fù)責(zé)生成token
- 客戶token請(qǐng)求處理action,負(fù)責(zé)處理客戶請(qǐng)求,并返回token信息
- token攔截器,用于攔截指定的請(qǐng)求是否需要驗(yàn)證token
- token請(qǐng)求攔截標(biāo)識(shí),用于標(biāo)識(shí)哪些請(qǐng)求是需要被攔截的
- 客戶端token請(qǐng)求處理方法,用于請(qǐng)求token,并存放于特定操作中,并在提交時(shí)發(fā)送到請(qǐng)求中
token生成器
token生成器在這里使用了一個(gè)隨機(jī)數(shù)來(lái)實(shí)現(xiàn),即隨機(jī)生成一個(gè)數(shù)字,即實(shí)現(xiàn)token生成,如下所示:
1 2 3 4 5 6 7 8 9 | private static final Random random = new Random(System.currentTimeMillis()); public static final String TOKENPARAM = "session-token" ; /** 生成一個(gè)token */ public static synchronized String generateToken(HttpSession session) { String s = String.valueOf(random.nextLong()); session.setAttribute(TOKENPARAM, s); return s; } |
token請(qǐng)求處理action
請(qǐng)求處理action,即接收相應(yīng)的請(qǐng)求,然后直接返回相對(duì)應(yīng)的token即可,如下即為一個(gè)為ajax請(qǐng)求生成token的處理action:
1 2 3 4 5 | public String generateTokenAjax() { String token = SessionTokenGenerator.generateToken(ServletActionContext.getRequest().getSession()); AjaxSupport.sendSuccessText(token); return NONE; } |
token請(qǐng)求攔截標(biāo)識(shí)
攔截標(biāo)識(shí),即表示哪些方法需要被攔截,這里可以使用注解來(lái)實(shí)現(xiàn),即在要攔截的方法上追加類似@TokenNeed的注解,或者使用配置文件,將需要攔截的方法列表記錄在配置文件中,在本文中,使用了一個(gè)配置文件來(lái)記錄
token攔截器
token攔截器實(shí)現(xiàn)了我們所需要的攔截處理,在當(dāng)碰到需要攔截的方法請(qǐng)求中,將同步進(jìn)行token的判斷和處理,并根據(jù)處理結(jié)果判斷是否該繼續(xù)放行或攔截之:
01 02 03 04 05 06 07 08 09 10 11 12 13 14 15 | public String intercept(ActionInvocation invocation) throws Exception { String action = invocation.getProxy().getAction().getClass().getName(); String method = invocation.getProxy().getMethod(); final HttpSession session = ServletActionContext.getRequest().getSession(); if (includeMethodSet.contains(action + "." + method)) { synchronized (session) { String paramSessionToken = ServletActionContext.getRequest().getParameter(SessionTokenGenerator.TOKENPARAM); String sessionSessionToken = (String) session.getAttribute(SessionTokenGenerator.TOKENPARAM); if (sessionSessionToken == null || paramSessionToken == null || !paramSessionToken.equals(sessionSessionToken)) return fail(); session.removeAttribute(SessionTokenGenerator.TOKENPARAM); } } return invocation.invoke(); } |
如上即是判斷處理的方法是否在攔截列表中,如果是,則取得參數(shù)中的token,再將其與session中的token相比,如果不一致,則直接返回fail,隨后將其從session中移除。
客戶端token實(shí)現(xiàn)
作為客戶端,只需要在進(jìn)行請(qǐng)求提交之前申請(qǐng)一個(gè)token,在請(qǐng)求時(shí),將此token加到請(qǐng)求中即可。在本文中,有一個(gè)jquery的ajax方法來(lái)處理token請(qǐng)求,隨后在進(jìn)行ajax請(qǐng)求時(shí)將此token一起加入到param。如下即為token的jquery請(qǐng)求
1 2 3 4 5 6 | m_ylf.token = function() { m_ylf.invoke( "/token/generateToken" ,{}, function(re) { re = re[ "result" ]; window[ "session-token" ] = re; }); } |
即在處理時(shí)將接收到的token放到window中,要提交請(qǐng)求時(shí)再將其從window中取出,一并提交即可,如下的統(tǒng)一ajax處理方法:
1 2 3 | //追加session-token if (window[ "session-token" ]) param[ "session-token" ] = window[ "session-token" ]; |
至此,整個(gè)防session Token請(qǐng)求即完成。如果在客戶端模擬多個(gè)請(qǐng)求中,首先會(huì)有一個(gè)請(qǐng)求被成功處理,其它的請(qǐng)求即直接返回類似“不能重復(fù)提交”的錯(cuò)誤警告(對(duì)于ajax請(qǐng)求)。