目錄:
如果你對GmailAssist感興趣,可以在chrome商店中搜索“Gmail助手”,或點擊這里直接訪問商店來安裝試用;
如果你對GmailAssist的源碼感興趣,可以在我的GitHub上查看它的源碼。
一、問題的提出
問題1. 如何實現指數退避重發請求
在GmailAssist最初的版本完成后,用郵件數量很小的郵箱進行測試時,功能都很正常。然而用郵件數目較大的郵箱測試時,獲取附件列表始終無法成功,通過在源碼中留下的一系列console.log,找出了問題所在:get messages時,收到的都是失敗的結果——403錯誤。即服務器拒絕了我的請求。進一步查官方文檔才了解到,請求速率過高了(即單位時間內請求的數目過高時,就會觸發這個錯誤,關於這個限制,我在上一篇博文中有說)。
官方文檔對於這種請求速率達到上限后請求失敗的情況,給出的建議是:采用指數退避算法來重試,這也是一個針對這種請求失敗的通用解決方案。(當時我還考慮是否可以不采用指數退避,而是固定一個較長的時間間隔來重發,從而降低請求速率。當然是可以的,但這和指數退避在實現上也沒有什么太大的區別,最終還是實現了指數退避。)
補充一下指數退避是怎么個事。最初接觸這個詞是在計算機網絡的課程中,CSMA/CD協議解決信道沖突時,就采用了“截斷二進制指數退避算法”來重發數據:
確定基本退避時間,它就是爭用期。以太網把爭用期定為51.2us。對於10Mb/s以太網,在爭用期內可發送512bit,即64字節。也可以說爭用期是512比特時間。1比特時間就是發送1比特所需要的時間。所以這種時間單位與數據率密切相關。
從離散的整數集合[0,1,…,]中隨機取出一個數,記為r。重傳應推后的時間就是r倍的爭用期。上面的參數k按下面的公式計算:
k=Min[重傳次數,10]
可見當重傳次數不超過10時,參數k等於重傳次數;但當重傳次數超過10時,k就不在增大而一直等於10。
當重傳達16次仍不能成功時(這表明同時打算發送的數據站太多,以致連續發生沖突),則丟棄該,並向高層報告。
例如,在第1次重傳時,k=1,隨機數r從整數{0,1}中選一個數。因此重傳推遲的時間是0或爭用期,在這兩個時間中隨機選擇一個。
若再發生碰撞,則重傳時,k=2,隨機數r就從整數{0,1,2,3}中選一個數。因此重傳推遲的時間是在0,2
,4
和6
這4個時間中隨機抽取一個。
同樣,若在發生碰撞,則重傳時k=3,隨機數r就從整數{0,1,2,3,4,5,6,7}中選一個數。以此類推。
若連續多次發生沖突,就表明可能有較多的站參與爭用信道。但使用退避算法可使重傳需要推遲的平均時間隨重傳次數而增大(這也稱為動態退避),因而減小發生碰撞的概率,有利於整個系統的穩定。
問題2. “所有請求都成功了”怎么判斷?或者說怎么在這個條件滿足時執行某函數?
這個問題其實是在遠遠早於問題1被提出時遇到的,問題來源於獲取附件列表的方式:先獲取郵件 list,然后針對list中的每個 messageId 去get相應的message。其中list方法可能有不止一頁的返回值,每次獲取下一頁都得等上一頁返回后,拿着其中的 nextPageToken 去獲取下一頁。故list實際上是被迫地只能“同步”地請求(當然並不是真正的同步操作,同步(sync)是很不推薦的,因為它會鎖死瀏覽器的相應,很影響用戶體驗),而messages.get 則可以完全地異步(async)去請求。
我們希望在所有郵件獲取到之后,把其中的附件信息提取出來,形成列表,顯示出來。那么這就需要在所有附件信息被提取出來后能觸發一個回調函數。或者至少我們應該實現一個機制來定期檢測是否所有的附件信息已經全部准備好。
二、解決方案——神器 jQuery
jQuery 的 ajax 相關方法封裝了普通的xhr請求,deferred也是一大神器。網上有很多介紹 jQuery 的中文文檔,看了幾個感覺比較水,就是互相抄抄,簡單翻譯翻譯官方文檔,同時官方文檔有的地方也說得不是很好理解(至少對於新手而言)。看到的文檔中就這個很不錯,至少在我學習jQuery ajax時給了很大幫助。但這個文檔也不是面面俱到,有些東西還是得去 stackoverflow 一頓淘,再加上自己的摸索,才能明白。
基礎的東西不多說了,看文檔就明白。只說 指數退避重發 和 多個請求完成后回調 的實現。靈感基本都來源於 stackoverflow 上大神們的答案,文章底部我給出了相關問題的鏈接。
1. 指數退避重發
//參數中這個匿名函數的三個參數分別是:當前AJAX請求的所有參數選項、傳遞給$.ajax()方法的未經修改的參數選項、當前請求的jqXHR對象 $.ajaxPrefilter(function(opts, originalOptions, jqXHR) { // 自己再封裝一個dfd,用它的成功和失敗狀態來完成相應回調函數的觸發(或者說相應事件的觸發) var dfd = $.Deferred(); // 請求成功則直接把dfd的狀態置為成功 jqXHR.done(dfd.resolve); // 請求失敗則根據情況采取重發等策略 jqXHR.fail(function() { console.log(jqXHR.status + '錯誤 已重試次數:' + originalOptions.retryCount); //重試次數+1,到7不再加 originalOptions.retryCount++; if (originalOptions.retryCount > 7) { originalOptions.retryCount = 7; } //jqXHR.status表明當前HTTP請求返回的狀態碼。我在這里幾乎針對所有的不成功請求都進行重試了。 //然而存在一種情況ERR_CONNECTION_CLOSED,測試中在更新草稿時出現的,這種情況重發無效,於是直接提示用戶。 if(!jqXHR.status){ document.getElementById('status_span').innerHTML = chrome.i18n.getMessage("errorOfConnection");//'網絡錯誤,請刷新頁面重試!'; document.getElementById('load1').style.display = 'none'; return; } //若錯誤是由於授權失敗,則額外多做點處理再重發 if(jqXHR.status == 401){ //重新授權,通過向后台腳本發消息(咱現在是在content script中),讓后台腳本重新authorize chrome.runtime.sendMessage({reAuth: '401'}, function (response) { token = response.token; }); originalOptions.headers.Authorization = 'OAuth '+token; } //准備重發了,構造新的ajax settings參數 var newOpts = $.extend({},originalOptions,{ error: function() { dfd.rejectWith(jqXHR); } }); setTimeout(function () { $.ajax(newOpts); }, nextDelayTime(originalOptions.retryCount));//把當前已重試次數隨着請求對象傳下去,用這個次數去計算接下來等待多久后重發 //設為失敗狀態 dfd.rejectWith(jqXHR); }); //覆蓋jqHXR本來的done和fail方法 return dfd.promise(jqXHR); });
其中 jqXHR 是一個把普通的 xhr 給封裝了的 deferred 類型的對象。而 deferred 對象不僅可以用在ajax中,還可以是任何普通的 js 操作(本地、ajax都可以),而其成功或失敗或未結束的狀態可以在程序中由你指定,這就給了我們很大的發揮空間,也提供了很多強大功能實現的基礎。關於 deferred 對象的理解,可以看這個博客。
上面用到的 nextDelayTime(originalOptions.retryCount) 函數如下:
function nextDelayTime(attempts) { return (Math.pow(2, attempts) * 1000) + Math.floor(Math.random() * 1000);//random() 方法可返回介於 0 ~ 1 之間的一個隨機數。 }
重傳的實現思路就如上面代碼中注釋所言。如果覺得不好理解,下面這張圖大概可以提供一些幫助:
用一個咱們自己的 dfd 對象封裝初次的請求,根據 dfd 的狀態是成功還是失敗,來判斷當前這個請求是否已經成功(若失敗或重傳失敗,則dfd都是失敗,只有當第一次或某次重傳成功后,才將dfd置為成功)。dfd 的失敗所對應的回調函數,正是重新構造一個請求並發送之。
其中,ajax1是原始的請求,ajax2是重新構造並發送的一個新請求,只是參數除 retryCount 外和 ajax1 一樣。(每次重傳都可以看做當前失敗的是ajax1,重新構造的是ajax2。也就是說每次重傳都是重新構造了一個ajax請求並發送的。)
2. 判斷一批請求全部完成,並執行回調函數
問題2和問題1並不是完全獨立的,互相之間影響着對方的解決方案。舉例來說,我希望在獲取附件列表時,每個附件信息都被獲取好之后,觸發一個函數,這個函數把這些信息按表格顯示出來。
但問題在於,這些附件信息的獲取過程,是異步的。我當時還錯誤地認為在確定每個 ajax 請求都返回后,執行回調函數顯示附件列表即可(即我忽略了ajax之后,在本地處理請求結果也是需要時間的)。
先考慮如何判斷所有的 messages.get 請求都成功返回吧。
查jQuery的文檔后發現,似乎可以通過 ajaxStop 函數來監聽當前是否仍有未完成的ajax請求。當當前沒有正在進行中的 ajax 請求時,觸發 ajaxStop 所綁定的回調函數。
但問題又來了,我這有重傳的,那等待重傳期間,當那個時間間隔足夠長時,就會觸發 ajaxStop事件。所以這個方案不行。
繼續查文檔和翻 stackoverflow 得知,jQuery 中有神器 $.when() 。可以傳入任意個參數,每個參數都是一個 deferred 對象,當所有參數的狀態都為成功時,將觸發綁定的回調函數!
接下來的問題是,看了幾個 when() 方法的示例,傳入的參數都是已知個數的,但我每次list時,並不事先知道我要傳入多少個 deferred 對象啊。
stackoverflow上有大神給出了解決方案,用 apply 方法可以傳入一個數組。那么我只需要把要監聽的對象都放進一個數組里,把它傳入 when 方法就可以達到目的了。
還有一個小問題是,上面提過的,在ajax請求們都成功后,本地還可能需要花時間處理,故應該是當這個本地處理成功時,才讓對應的 deferred 對象狀態置為成功。
Talk is cheap, show me the code.
function fetchNextList(pagetoken) { //這里我省略了一部分拼url的代碼 var url = XXXXXXXXXXX; var settings = { retryCount: 0, url: url, /** * ajax請求成功對應的回調函數 * @param list 即解析過(JSON.parse)之后的xhr.responseText * @param textStatus 描述該ajax請求的狀態的字符串 * @param xhr jqXHR對象 */ success: function (list, textStatus, xhr) { for (i = 0; i < list.messages.length; i++) { msgFinished[i + msgNow] = false; dfdsGettingMsg.push(getMessage(list.messages[i].id, i + msgNow));//放進數組里,准備傳給$.when.apply } msgNow += i; if (list.nextPageToken) { fetchNextList(list.nextPageToken); } else { //msg.list到最后一頁了,之后沒有了,這時候就可以開始調用when了!等待全部ajax的jqXHR(即deferred對象們)的done了! $.when.apply($,dfdsGettingMsg).done(function(){ console.info('全部message get請求已完成'); showTable(); }); } } } $.ajax(settings); }
function getMessage(MessageId) { //用個dfd來表明Message到底有沒有完成get。這里所謂完成,是指完成一封msg里的所有part添加進字符串數組allContent的步驟。 var dfd = $.Deferred(); url = MESSAGE_FETCH_URL_prefix + MessageId; var settings = { retryCount: 0, url: url, success: function (messageObj, textStatus, xhr) { var parts = messageObj.payload.parts; var headers = messageObj.payload.headers; var sender; var subject = '-'; var labels = messageObj.labelIds; var date; if (parts) { for (i in headers) { var header = headers[i]; if (header.name == 'From') { sender = header.value; } else if (header.name == 'Subject') { if (header.value) { subject = header.value; } } else if (header.name == 'Date') { date = header.value; } } for (i in parts) { var part = parts[i]; //當part.filename字段存在時,說明這是一個附件 if (part.filename) { for (i in part.headers) { var partheader = part.headers[i]; if (partheader.name == 'Content-ID') { var in_content = true; } } if (in_content && not_include_content_pics) { break; } part.body.size = Math.ceil(part.body.size * 0.75 / 1024); var d = new Date(Date.parse(date)); //用一個字符串數組保存全部的附件信息(只是文件名之類的信息,不含附件內容),每個附件的信息占數組中的一項 allContent[id] = part.filename + '|-|' + part.body.size + '|-|' + sender + '|-|' + labels + '|-|' + subject + '|-|' + d.toLocaleDateString() + '|-|' + MessageId + '|-|' + part.partId; id++; } } } //一封郵件中的附件處理完畢了,就把這個郵件對應的 dfd 對象置為成功,從而讓 when 函數可以判斷 dfd.resolve(); } } return dfd.promise($.ajax(settings)); }
when 函數可以完成監聽所有參數的狀態是否都為成功,具體實現機制我沒有看jQuery的源碼,但我猜測是通過不斷遍歷所有參數對應的deferred 對象,檢測是否都為成功狀態。其中為了提高效率,還可以采用一定程度上的“累計確認滑動窗口”,即按順序,在一遍中已經確定連續的已完成了的deferred對象就不再在下一遍中重新遍歷,從序號最小的未完成的或已失敗的deferred 對象開始往后遍歷。(優化的思路還很多,具體還是找時間應該學習一下jQuery的源碼)
3. 大殺器 deferred 對象的其他用途
收到上面兩個問題的解決方案的啟發,之前有點困擾的插入多個附件的實現,也可以用自定義 deferred 對象結合着 when 函數來解決。
具體是把每個獲取附件部分給封裝為一個dfd對象,並在所有獲取附件的ajax請求發出時,用when開始“監聽”全部的附件的獲取過程。基本和上面的類似,都是通過自己封裝的deferred 對象的狀態來達成目的的。代碼就不再貼了,感興趣可以去github上看源碼。
三、補充鏈接
給出幾個對我幫助很大的 stackoverflow 上的問題的鏈接,感謝那些把經驗和思路分享出來的大神們!其他的如阮一峰的博客等,也有非常大的幫助,鏈接我已經在正文中給出了。
Retry a jquery ajax request which has callbacks attached to its deferred
Automatically try AJAX request again on Fail
Wait until all jQuery Ajax requests are done?
至此,圍繞着GmailAssist的開發展開的系列博文就告一段落了。有一些細節上的技巧,我沒有再專門寫博文來介紹,比如弄i18n時HTML頁面的內容怎么處理等。我當時也是從網上學習的技巧,重寫一遍有點拾人牙慧的意思,就不再啰嗦一遍了。