问题背景
在一个使用MongoDB GridFS实现文件存储和分片读取的项目中,同事遇到了一个令人困惑的问题:音频文件总是丢失最后几秒,视频文件也出现类似情况。更奇怪的是,播放器显示的总时长为无限大。这个问题困扰了团队成员几天,都没有解决,因为架构这块我负责的,最后当然就需要我来深入调查了(本来是想蒙混过去的,毕竟影响其实不大,但因为刚好有点空闲)。
- 在应用中,音频时长为无限,可以不停的快进也就是时长无限增长,大部分手机都如此,除了一部分手机正常
- 在浏览器中单独打开音频文件,播放正常
- 文件使用Mongodb的GridFS存储在数据库,通过http接口对外提供读取接口
HTTP分片传输简介
在深入问题之前,让我们先了解一下HTTP分片传输的基本概念,这对理解后续的调试过程至关重要。这部分内容很久之前看网络相关文档的时候,有初略的了解,大概就是前端请求时,后端支持分片可返回部分数据+总数据长度,然后前端就可以按需读取了。
HTTP分片传输(HTTP Range Requests)允许客户端请求资源的部分内容,而不是整个资源。这在处理大文件,特别是音频和视频流时非常有用。主要涉及以下HTTP头:
-
Range:客户端在请求中使用此头来指定所需的字节范围。
例如:Range: bytes=500-999
表示请求从第500字节到第999字节的内容。 -
Content-Range:服务器在响应中使用此头来指明实际返回的字节范围。
例如:Content-Range: bytes 500-999/1234
表示返回的是500-999字节,总文件大小为1234字节。 -
Accept-Ranges:服务器使用此头来告诉客户端它支持范围请求。
重要的是,HTTP规范中定义的Range是包含性的(inclusive),这意味着Range: bytes=0-499
实际上请求了500个字节。这里是一个非常容易出错的点,因为在大多数的编程语言中,对类似数组数据的操作,结尾位置的数据都是不包含的,这也是一个坑点,刚入行不久的小伙伴很可能就跳进去了,毕竟随能够记得那么细呢?而且是一个反常见模式的知识点!
调试过程
1. 初步分析
首先,我注意到播放器的duration显示为无穷大。为了进一步测试,我尝试将currentTime设置为一个非常大的值,但问题依然存在。但是在单独的页面播放和oppo的某手机播放没问题,就猜测是某个特殊点的兼容性问题,但部分尝试做了兼容,所以正常,但大部分的设备或者webview都没有兼容,导致这个问题没被处理,从而出现时长无限。这时候测试得到一个比较重要的信息:
- 特别小的文件,时长在5秒内的不会出现无限时长,视频文件大概率都会出现
于是就进一步猜测,肯定和网络传输有关,而且和文件体积有关系,这时候想起了大文件分片,因为上传的时候就是分片上传的
2. 网络请求分析
观察到网络请求一直处于pending状态,我怀疑可能是由于socket没有正确关闭导致无限加载。我尝试在数据流关闭后结束请求,但这并没有解决问题。
- 而且发现浏览器在不同的发送包内容基本一样的请求
3. 请求头分析
仔细查看请求头,我发现了一些奇怪的Range请求,如Range=11333-11333/11334
。起初,我认为可能是因为起始值和结束值相同导致的问题。我尝试在这种情况下重写Range,对于Range=11333-
这样的请求则返回416状态码。但这些措施都没有解决问题。
也就是这里引起了我的警觉,因为是不停的在请求,一开始的时候,还以为是触发了某个浏览器的bug,但是分析请求头,有一点引起可我的注意,就是11333-11333/11334 ,为什么会请求这样的数据,第一反应是一个0字节的请求
然后我就开始读HTTP1.1-rfc2616规范文档,发现这了这个反直觉的坑,那就是范围的结尾值是包含的。
这里基本上我猜测到了问题出现的原因,下一步就是验证了
附rfc的说明 - inclusive,开始还不确定这个单词的意思
4. 深入源码
分析第三方库的源码后,我发现了问题的根源:
- HTTP的Content-Range头中,start和end都是包含的(inclusive)。
- 而在JavaScript和MongoDB中,类似slice()这样的操作,end是不包含的(exclusive)。
这种不一致性导致了在处理最后一个字节时出现问题。具体来说:
- 当客户端请求最后一个字节时(例如
Range: bytes=11333-11333
),服务器正确地解释了这个请求。 - 但是,当服务器使用JavaScript或MongoDB的API来获取这个范围的数据时,由于end是exclusive的,实际上没有返回任何数据。
- 这导致客户端认为还有更多数据需要获取,因此会继续发送请求,造成无限加载的情况。
解决方案
为了解决这个问题,便对getDownloadStream方法进行了修改-重写,类似于下面的方式-先缓存老方法,再修改请求参数,再调用老方法:
const oldFun = obj.a;
obj.a = function(a1, a2) {if (a2.end) {a2.end += 1;}return oldFun(a1, a2);
}
这个简单的修改确保了JavaScript/MongoDB的操作范围与HTTP请求的Range一致,解决了缺少最后一个字节的问题。通过将end值增加1,确保了包含了请求范围的最后一个字节。
经验总结
-
HTTP协议的深入理解很重要:尤其是请求头参数的精确含义,对于解决复杂问题至关重要。在这个案例中,理解Range请求的包含性本质是解决问题的关键。
-
框架设计的重要性:这次调试过程让我意识到了Koa等框架设计的优势。能够在一个中间件中同时处理请求和响应,大大方便了调试过程。
-
细节决定成败:在这个案例中,仅仅一个字节的差异就导致了严重的用户体验问题。这提醒我们在处理底层数据时必须格外小心,特别是在处理不同系统间的数据交换时。
-
开源贡献的价值:通过这次经历,我不仅解决了自己的问题,还有机会通过PR为开源社区做出贡献,提升了项目的整体质量。这展示了开源社区如何通过集体智慧来解决复杂问题。
-
跨系统集成的挑战:这个问题突出了在集成不同系统(如HTTP服务器、JavaScript运行时和MongoDB)时可能遇到的细微差异。在设计跨系统解决方案时,需要特别注意这些潜在的不一致性。
结语
这个看似简单的问题背后隐藏着HTTP协议、JavaScript特性和数据库操作之间的微妙差异。通过耐心的调试和深入的分析,我不仅解决了问题,还加深了对各种技术细节的理解。这再次证明,在软件开发中,“魔鬼藏在细节里”。
对于其他可能遇到类似问题的开发者,建议深入理解所使用的每个组件的特性,特别是在处理底层数据传输时。同时,这个案例也强调了全面测试的重要性,尤其是在处理边界情况时。通过分享这种经验,可以共同提高整个开发社区的技术水平和问题解决能力。
本文主要是复盘之前工作中遇到的一个奇怪的前端问题,并且是我个人的第一次开源代码贡献,还提交了两次,第一次因为格式不对没写注释-个人仓库管理员没接受,第二次说明了情况并详细说明和备注了解释等才通过