业务场景
需求
我们项目有一个文件上传需求,需要从客户端上传到七牛云的对象存储和自己的应用服务器上。这里使用七牛云主要是实现下载分发。应用服务器需要留一份是因为后续需要做文件分析(并且是上传后需要立马分析出结果展现给客户端)。另外,由于是初期项目,暂时没考虑用独立服务器来分析。
所用技术栈
服务器:Centos7 开发语言:PHP 框架:Laravel 前端上传组件:百度的 WebUploader
解决方案
准确的说我经过了三个阶段才真正完美的实现了需求(主要解决上传速度)。
一期解决方案及细节
初期面对需求很容易想到的思路是:客户端先上传文件到应用服务器(因为上传完成可以及时做分析),然后再上传到七牛云上。
所以我的解决方案是:前端用 webuploader,后端的七牛云文件处理方面使用了 Laravel 的一个插件:overtrue/flysystem-qiniu (https://github.com/overtrue/f…,该插件的接口很简洁好用(但是有坑,后面会说到)。
然后为了解决性能问题,我还做了以下工作:1,使用分片上传 2,后续上传七牛云使用异步的方式(因为文件上传到其他应用来下载这个文件,中间有许多时间来让上传任务的完成)
关于分片上传
这里讲下分片上传的实现思路,客户端主要是把大文件按一定 size 进行分片,然后上传到服务器,所以会有多个请求,并且每个请求还需带上关键的信息:当前 chunk(从 0 开始)和 chunks(总分片数)。由于我用的是 webuploader 组件,所以客户端不用自己做什么,只需配置下简单信息(是否分片及分片大小)。
服务端处理逻辑为:客户端一个请求过来,分两种情况:1,文件总 size 小于要分片的 size,这时候直接处理文件。2,处理分片情况。
具体逻辑是判断 chunk 和 chunks,如果相等说明为第一种情况,直接处理上传,其他走处理分片逻辑。
处理分片的逻辑为:保存当前分片到临时目录(按分片命名),然后判断当所有分片完成时,就合并文件。具体逻辑是判断 chunk + 1 是否等于 chunks。合并逻辑就是循环读取临时文件,然后写入到一个新的文件(合并后的),这里可以顺便删除临时文件。
所遇的坑:这里处理碎片文件时,当初图方便使用了 Laravel 的文件处理接口 Storage::append,但是这个接口有个坑就是它自作主张的文件结尾加入换行符。导致合并后的文件还原不成原始文件。解决办法是老老实实使用 php 的 fopen、fwrite、fclose 这一套。
关于 PHP 异步处理
关于 PHP 的异步实现可以参考鸟哥写的文章:http://www.laruence.com/2008/…
主要方法为:客户端 AJAX、popen 函数、curl、fsocketopen 等
不过这篇文章比较老了,局限性也大,现在有了协程等处理方案(现在 Swoole 也提供协程方案了,并且 client-server task 分发这种也可以用 swoole 的),而且往架构方面考虑可以使用队列等(感觉靠谱的还是队列)。
PS: 我这里前期用的是简单粗暴的 popen,后来使用的是 Laravel 提供的队列。
一期方案的问题
通过上述所说的方案,很容易就实现了一个版本。但是没高兴多久。。,在后续测试时遇到一个诡异 bug,当文件过大时,任务脚本上传到七牛云失败。
这里脚本是写在 Laravel 的 artisan 中的,当我把脚本命令直接在终端调试时也是没有任何异常(准确讲是看不了任何异常)
。前面我说过七牛这块 SDK 用的是 overtrue/flysystem-qiniu,并且为了考虑性能问题用的是他的 writeStream 接口。
$disk = Storage::disk(‘qiniu’);
$stream = fopen($localFileName, ‘r’);
$disk->writeStream($fileName, $stream);
if (is_resource($stream)) {
fclose($stream);
}
代码表面上看起来很理想,用的是文件流上传(怕吃内存)。但结果证明一切只是表面上的。。
当我遇到大文件无法上传到七牛云时,断点调试到 $disk->writeStream 这里,发现返回的是 false。继而调试到 overtrue/flysystem-qiniu 这个扩展的源代码。然后发现了一个大坑。。
主要是两个问题:1,writeStream 只是个假的流写入
具体源码在扩展的 QiniuAdapter.php 文件中,这里贴段代码:
public function writeStream($path, $resource, Config $config)
{
$contents = ”;
while (!feof($resource)) {
$contents .= fread($resource, 1024);
}
$response = $this->write($path, $contents, $config);
if (false === $response) {
return $response;
}
return compact(‘path’);
}
注意这里的 $contents 变量,最终还是等价于一个大文件内容的大小(服务器为此变量开辟的内存)。并且后续还要在方法间传递。所以这里是假的流!
2,接口对调试不友好
还有在 write 方法中,屏蔽了 $error,只返回 false,这样不便于我们查问题,最终我是断点打印这个 $error 才知道报的错误是:“invalid multipart format: multipart: message too large”,这个应该是七牛那边真正返回的,但这么重要的信息被这个扩展屏蔽了。
二期解决方案
知道了一期方案的具体问题所在,我就一直在思考(那个扩展就不提了。。我现在怀疑它的存在意义。。),甚至在想也许一开始整个思路就错了(通过 SDK 上传文件的方案)。后来还真被我找到了,七牛云官方提供一个脚本工具:Qshell(https://github.com/qiniu/qshell)。这个是命令行运行脚本,具体操作看文档就可以了。放到我的项目也是集成到七牛的任务脚本中。
后来测试可以了,整个流程可以跑通。
但是无意中发现二期的重要问题,这个上传走的是服务器的上行带宽!而我们平常付费买的带宽就是买的上行带宽!(下行是一般是免费的)。这还怎么搞!由于我们上传业务是商户端使用的,平时使用频次也不会太少,这会导致在上传时影响前端网站的访问速度。
这里具体讲下服务器带宽问题(网上查询后整理的):
首先对服务器带宽方向的描述一般是用上行和下行,上传和下载是指动作。
上行是指从服务器流出的带宽,如果是在其他机器下载服务器上的文件,用的主要是服务器的上行带宽(这里说下我们平时的网页浏览,其实也是不同客户端从服务器下数据, html 文件、css 等然后渲染,所以网页浏览占用的也是上行带宽)。
下行是指流入到服务器的带宽,如果是在其他机器上传文件到服务器,比如用 FTP 上传文件,用的主要是服务器的下行带宽(服务器上下载文件用的也是下行带宽)。
现在的云提供商比如阿里云不限制的是下行带宽,大部分服务器的使用环境,都是上行带宽用的多,下行带宽用的少。
通过对带宽的理解,再回到我们项目的上传实现思路,可以看到一开始就错了(不该用应用服务器作为中转)!
三期(最终)方案
当初为了节省时间,直接跳过官方文档,而使用第三方扩展。现在看来,不得不又回到官方文档了。
通过把七牛的文档过一遍,发现是有方案可以避开那个占用服务器上行带宽的问题的。
主体思路是要避开应用服务器上行带宽的使用,因为上行带宽很宝贵,尽量使用下行带宽(免费、速度很快!阿里的大概 60M 多每秒)。
具体实现是通过七牛的表单上传方案直接把客户端的文件先上传到七牛(这一步根本不关应用服务器什么事,所以避开了,而且直接上传到七牛的速度非常快,基本只取决于用户端的网速,而且对于一般需求,七牛提供了对于到我们应用服务器的回调方法)。然后由于我们应用服务器也需要文件,所以方案是直接在我们应用服务器直接下载七牛的文件(这里可以同步阻塞住,前端做个等待效果解决用户体验问题)。因为前面说到流入到服务器占用的是下行带宽。所以这里速度也会非常快(而且是免费的 ^_^)。
这种方案基本是完美的了。
总结
首先是对个人的反省,前期调研不充足,但是项目初期有点紧,这里也说明投入时间的重要性。
其次关于项目经验:上传第三方云存储,千万不要使用应用服务器做中转!可以直接上传到第三方云服务器,如果有后续处理逻辑的,可以使用他们的回调接口。