0.Chrome扩展开发(Gmail附件管理助手)系列之〇——概述
1.Chrome扩展开发之一——Chrome扩展的文件结构
2.Chrome扩展开发之二——Chrome扩展中脚本的运行机制和通信方式
3.Chrome扩展开发之三——Chrome扩展中的数据本地存储和下载
4.Chrome扩展开发之四——核心功能的实现思路
5.Chrome扩展开发之五——采用指数退避算法实现ajax请求的重发,全部完成时触发回调函数
如果你对GmailAssist感兴趣,可以在chrome商店中搜索“Gmail助手”,或点击这里直接访问商店来安装试用;
如果你对GmailAssist的源码感兴趣,可以在我的GitHub上查看它的源码。
在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}中选一个数。以此类推。
若连续多次发生冲突,就表明可能有较多的站参与争用信道。但使用退避算法可使重传需要推迟的平均时间随重传次数而增大(这也称为动态退避),因而减小发生碰撞的概率,有利于整个系统的稳定。
这个问题其实是在远远早于问题1被提出时遇到的,问题来源于获取附件列表的方式:先获取邮件 list,然后针对list中的每个 messageId 去get相应的message。其中list方法可能有不止一页的返回值,每次获取下一页都得等上一页返回后,拿着其中的 nextPageToken 去获取下一页。故list实际上是被迫地只能“同步”地请求(当然并不是真正的同步操作,同步(sync)是很不推荐的,因为它会锁死浏览器的相应,很影响用户体验),而messages.get 则可以完全地异步(async)去请求。
我们希望在所有邮件获取到之后,把其中的附件信息提取出来,形成列表,显示出来。那么这就需要在所有附件信息被提取出来后能触发一个回调函数。或者至少我们应该实现一个机制来定期检测是否所有的附件信息已经全部准备好。
jQuery 的 ajax 相关方法封装了普通的xhr请求,deferred也是一大神器。网上有很多介绍 jQuery 的中文文档,看了几个感觉比较水,就是互相抄抄,简单翻译翻译官方文档,同时官方文档有的地方也说得不是很好理解(至少对于新手而言)。看到的文档中就这个很不错,至少在我学习jQuery ajax时给了很大帮助。但这个文档也不是面面俱到,有些东西还是得去 stackoverflow 一顿淘,再加上自己的摸索,才能明白。
基础的东西不多说了,看文档就明白。只说 指数退避重发 和 多个请求完成后回调 的实现。灵感基本都来源于 stackoverflow 上大神们的答案,文章底部我给出了相关问题的链接。
//参数中这个匿名函数的三个参数分别是:当前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和问题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的源码)
收到上面两个问题的解决方案的启发,之前有点困扰的插入多个附件的实现,也可以用自定义 deferred 对象结合着 when 函数来解决。
具体是把每个获取附件部分给封装为一个dfd对象,并在所有获取附件的ajax请求发出时,用when开始“监听”全部的附件的获取过程。基本和上面的类似,都是通过自己封装的deferred 对象的状态来达成目的的。代码就不再贴了,感兴趣可以去github上看源码。
给出几个对我帮助很大的 stackoverflow 上的问题的链接,感谢那些把经验和思路分享出来的大神们!其他的如阮一峰的博客等,也有非常大的帮助,链接我已经在正文中给出了。
至此,围绕着GmailAssist的开发展开的系列博文就告一段落了。有一些细节上的技巧,我没有再专门写博文来介绍,比如弄i18n时HTML页面的内容怎么处理等。我当时也是从网上学习的技巧,重写一遍有点拾人牙慧的意思,就不再啰嗦一遍了。