Web总结:资源存储

前言

几乎所有的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编写。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
/**
* 保存上传的文件至服务器,并返回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:校验和分类

上代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
/**
* 保存上传的文件至服务器,并返回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:统一资源存储

上代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
/**
* 保存文件和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 的表结构:

mark

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分钟比较合适。

分布式和同步

先看一个需要用到同步的场景,不同学校间共享教学视频,访问时又是自己内网的服务器。要实现这个功能有两个关键点:

  1. 学校的服务器至少有一台能和外部服务器通信。
  2. 采取分布式还是集中式同步。

集中式同步方案:选一个中心主机,视频都放在中心主机,并且通过文件共享手段,让服务器在读取资源时,直接从中心主机读取。文件共享手段比如NAS、rsync命令、基于xcopy的脚本等。需要注意的是,跨校区的同步速度是比较慢的。

分布式同步方案:没有中心主机,上传的时候先放在本校服务器,每一个服务器定期向其他服务器询问是否有新资源,和推送资源给其他服务器。这种方案,也可以选一个中心主机,本地服务器在保存完资源后,将视频推送到中心主机,再由中心主机下发到其他学校服务器(类似git机制)。

他们的核心区别是:集中式,不同服务器从同一台服务器上取视频资源;分布式,服务器从本地取视频资源。

坚持原创文章分享,您的支持将鼓励我继续创作!