前言
几乎所有的Web站点都需要存储文件资源,如图片、视频等。也有像百度网盘这样的平台专门做云存储,为用户提供了极大的便利。
基础知识
HTTP请求报文分为请求头、请求体。请求头中的Content-Type
字段,描述了请求体是什么类型的内容,它的字段值也叫做MIME
类型,也叫mimetype
,在这里进行查询:http://www.w3school.com.cn/media/media_mimeref.asp 。
有关Content-Type、媒体类型等详细知识可以参考《HTTP权威指南》,PDF版下载(密码:7u67)。
有关使用HTTP协议上传文件的原理,可以参考:https://www.cnblogs.com/cswuyg/p/3185164.html。
版本1
理解了上传文件的原理后,我们可以完成一个较为基础的文件保存函数,下面代码是基于Laravel5编写。
/**
* 保存上传的文件至服务器,并返回URL
* @param Illuminate\Http\UploadedFile $file 上传的文件
* @return string 文件在服务器中的URL,失败返回false
*/
function storeFile(UploadedFile $file) {
$store_path = 'uploads';
// 文件名称 xxx.yy
$name = time();
if ($extension = $file->extension()) {
$name .= $extension;
}
if (Storage::putFileAs($store_path, $file, $name)) {
return '/storage/uploads/' . $name;
} else {
return false;
}
}
storeFile
函数接受一个上传文件实例,保存成功后返回该文件对应的URL,失败返回false。
做法很简单,把上传的文件都放到一个HTTP可访问的目录下,得到URL,用户访问此URL即可访问上传的资源。
- 第10行。获取上传文件的扩展名。
- 第13行。保存上传的文件到指定的目录,并指定的文件名。
但是这种做法有几个缺点甚至有安全漏洞:
- 可上传自定义脚本。比如用户可以上传自己编写的PHP文件,这是非常危险的。就算使用Java等编译语言进行后台开发,也应该避免脚本文件的上传。
- 文件都放在同一个目录,有的文件系统会有数量限制。可参考:https://blog.csdn.net/leonwei/article/details/3980179
- 文件名可能冲突(并发量比较大时)。
- 可能有重复文件。比如两个人在不同时间点上传了一样的文件,系统会保存两份。
版本2:校验和分类
上代码:
/**
* 保存上传的文件至服务器,并返回URL
* @param Illuminate\Http\UploadedFile $file 上传的文件
* @return string 文件在服务器中的URL,失败返回false
*/
function storeFile(UploadedFile $file) {
$time = time();
// 文件名称 xxx.yy
$name = $time;
if ($extension = $file->extension()) {
$name .= $extension;
}
// 按日期将文件分类
$store_path = 'uploads' . DIRECTORY_SEPARATOR . date('Ymd', $time);
if (Storage::putFileAs($store_path, $file, $name)) {
return '/storage/' . str_replace('\\', '/', $store_path) . '/' . $name;
} else {
return false;
}
}
/**
* 检查上传的文件是否符合要求
* @param Illuminate\Http\UploadedFile $file 上传的文件
* @param array $rule 含mimetype, max_size(单位B)两个规则
* @return int 符合所有要求返回0,不符合mimetype返回1,不符合max_size返回2
*/
function checkFile(UploadedFile $file, $rule) {
if (! $rule) $rule = ['mimetype' => [], 'max_size' => 0];
if ($rule['mimtype'] && ! in_array($file->getMimeType(), $rule['mimetype'])) {
return 1;
} else if ($rule['max_size'] && $file->getSize() > $rule['max_size']) {
return 2;
} else {
return 0;
}
}
先调用 checkFile
检查上传文件的mimetype、文件大小是否符合要求,函数返回0表示没有不符合的。再调用 storeFile
保存文件。这样可以尽可能的避免版本1的第一、二个问题。
比如在上传头像时,可以规定只能上传jpg、bmp等图片格式,而且文件大小不得超过2M。
下面解释部分代码:
- 第14行。
DIRECTORY_SEPARATOR
是指当前操作系统的分隔字符,Linux是/
,windows是\
。 - 第16行。URL的分隔符都是
/
所以需要替换一下。
文件名生成函数可以替换成 microtime
,这是微秒级别的时间戳,所以就可以避免文件名冲突了(冲突可能性非常非常非常小,所以可以认为不会冲突)。
此版本仍然不能解决资源重复问题。
版本3:统一资源存储
上代码:
/**
* 保存文件和model
* @param Illuminate\Http\UploadedFile $file
* @return ResourceModel
*/
public function storeFile(UploadedFile $file) {
// 文件哈希值
$this->md = md5_file($file->getRealPath());
// 查找已存在的资源
if ($exist_rs = ResourceModel::where('md', $this->md)->first()) {
$exist_rs->from_db = true;
return $exist_rs;
}
// 文件名称 xxx.jpeg
$this->name = $file->getClientOriginalName();
// mimetype image/jpeg
$this->mime = $file->getMimeType();
// 后缀 jpeg
$this->suffix = $file->extension();
DB::beginTransaction();
// 保存数据库
if (! $this->save()) {
DB::rollBack();
return false;
}
// 文件保存路径 data/52/08/06
$store_path = $this->getStorePath();
// 520806eb60722ca0d10c89d3b20b370c
$store_name = $this->getStoreName();
// 保存文件
if (! Storage::exists($this->getFilename())) {
// 如果写入文件失败,则不存入数据库中
if (!Storage::putFileAs($store_path, $file, $store_name)) {
DB::rollBack();
return false;
}
}
DB::commit();
return $this;
}
/**
* 显示文件
* @param Illuminate\Http\Request $req
* @param $md
* @return mixed
*/
public function showResource(Request $req, $md) {
$rs = ResourceModel::where('md', $md)->first();
if (! $rs) {
return 'resource not found';
} else {
if ($rs->refer) return redirect($rs->refer);
// uploads/7d/s8/7ds87x...
$filename = $rs->getFilename();
// 获取文件物理路径
$full_filename = storage_path("app/${filename}");
if (!file_exists($full_filename)) {
return response('file not found', Response::HTTP_NOT_FOUND);
}
return response()->file($full_filename,[
'Content-Type' => $rs->mime
]);
}
}
storeFile
方法是 ResourceModel
里的,这里罗列一下 Resource
的表结构:
showResource
是controller里的方法,用于处理HTTP请求。
这个版本的核心思想是,先计算文件的MD5值,同一个文件具有一样的MD5值,不同的文件MD5值不一样。这个MD5值将作为文件名。文件的保存路径是取MD5值的前四位字符,两位分为一组,共两组,分别作为一级目录和二级目录名。
此版本仍然使用 checkFile
检查文件类型和大小。
代码解释:
- 第8行:计算文件的MD5值。
- 第10~13行:找到系统中已经存在的资源,直接返回该资源。这样可以避免资源重复。
- 第60行:
storage_path
返回文件的物理存储地址。 - 第64行:响应资源文件,并指定响应的Content-Type,让浏览器可以正常的显示资源。
这个版本已经可以满足绝大部分Web系统的需求了。并且还有可提升空间,可拓展性也比较强。
举个扩展的例子,资源文件越多时,可以通过目录划分来做分布式存储。
视频资源
视频资源在存放于Web系统之前,往往要进行调整分辨率、编码格式、加水印之类的操作。对于大视频文件,也应该采取分片存储的方式。
调整分辨率、编码格式、加水印等,可以使用 ffmpeg
完成。
文件分片包含两个话题:上传时分片,保存时分片。上传时分片,保存时就比较好操作,直接按片划分保存就行;上传不分片,保存如果要分片的话,只能能采取视频截取的方式(使用ffmpeg)把视频分为几个部分保存,一般来说取前1分钟为第一片,后续每4分钟一片,是比较好的分片方法。
视频文件分片后,播放时也是要分片播放的,如果采取1:4:4..:4的方法分片,前端需要先加载出第一片1分钟的视频,后面在依次加载4分钟的分片文件。这里解释下为什么第一片1分钟,因为用户一般看1分钟,没兴趣看就溜了,所以为了不浪费流量,第一片就1分钟比较合适。
分布式和同步
先看一个需要用到同步的场景,不同学校间共享教学视频,访问时又是自己内网的服务器。要实现这个功能有两个关键点:
- 学校的服务器至少有一台能和外部服务器通信。
- 采取分布式还是集中式同步。
集中式同步方案:选一个中心主机,视频都放在中心主机,并且通过文件共享手段,让服务器在读取资源时,直接从中心主机读取。文件共享手段比如NAS、rsync命令、基于xcopy的脚本等。需要注意的是,跨校区的同步速度是比较慢的。
分布式同步方案:没有中心主机,上传的时候先放在本校服务器,每一个服务器定期向其他服务器询问是否有新资源,和推送资源给其他服务器。这种方案,也可以选一个中心主机,本地服务器在保存完资源后,将视频推送到中心主机,再由中心主机下发到其他学校服务器(类似git机制)。
他们的核心区别是:集中式,不同服务器从同一台服务器上取视频资源;分布式,服务器从本地取视频资源。