背景
网盘大家都不陌生,现在的网盘工具也很多,百度网盘,阿里网盘,和彩云盘等等,很多使用也都是有条件的,比如不开会员会限速,存储空间太小等等。百度网盘的资料也不安全,在互联网上会被人检索出来等等。
我日常工作需要存储的资料,文件,工具还是比较多的。用移动硬盘更加麻烦,需要拷贝来拷贝去,没有带移动硬盘的时候就很糟心了。用互联网产品不安全是首要问题,各种限速什么用的也很麻烦。
所以在经过一段时间的思考以后,决定自己做一个网盘工具
选型分析
做开始做网盘也是挺纠结的,要不要这么费尽力去搞这个事情,因为碰到的问题还不少。
- 存储的问题,我购买的云服务器只有100G的存储,如果扩充的话费用还是挺贵的。
- 网盘很重要的一块也是速度这块,如果上传下载速率很低,那其实没什么意义了。
- 可视化界面也很重要,文件的分类管理,上传下载管理,在线预览等等。
后面也是纠结、分析了好久,觉得还是利大于弊的,所以就选择做了。
- 存储问题,就直接考虑OSS对象存储了,这块费用会便宜很多,并且安全性也比较容易保证,开启跨域,防盗链,黑白名单等功能,很安全
- 云平台有5M的带宽,常规时间下载速度在600K左右,一般家里千兆带宽基本能在1.2M左右(云平台还是比较给力的)
技术介绍
界面基本上就是照着互联网的产品抄的,基本能满足我要求就行
文件上传
文件上传过程不是太复杂,但是体验过的小伙伴都知道,上传的体验要好,要知道当前上传的速度,上传的进度等等。上传过程可以挂起,大文件上传就后台任务在执行。
上传速度和进度的处理,是直接采用前端去计算的,也就是文件从上传开始到到达服务器的时间,因为后半部分无法计算,就是到达服务器以后,服务处理文件的进度(比如上传到OSS)。所以整个进度显示是 开始上传-->上传进度/速率-->上传成功-->服务器开始处理数据-->处理成功。
这里没有做断点续传的功能,只是简单做了分片上传,上面通过两段时间合并起来,就是完整的上传过程, 有点偷懒了。
断点续传原理也不是太复杂,后续有时间可以做上去
<template>
<a-modal
:title="'文件上传'"
:visible="visible"
@cancel="visible=false"
:maskClosable="false"
:footer="false"
>
<div style="display: flex;justify-content: center;align-items: center">
<!--suppress JSUnusedLocalSymbols -->
<a-progress type="circle" :percent="percent" :format="percent => `${percent} %`"/>
<div
style="margin-left: 20px;width:250px;display: flex;flex-direction:column;justify-content: center;align-items: center">
<div><span :style="{color: (currentSize===totalSize)?'green':'purple'}">{{
getFileSize(currentSize)
}}</span>/
<span style="color: green">{{ getFileSize(totalSize) }}</span></div>
<div style="color: darkgray">注:已下载/总共</div>
</div>
</div>
<div style="margin-top: 20px" v-for="item in fileList" :key="item.uid">
{{ item.name }}({{ getFileSize(item.size) }})
</div>
<div style="margin-top: 20px;text-align: center">
<a-button type="primary" :disabled="currentSize<totalSize" @click="visible=false">关闭</a-button>
</div>
</a-modal>
</template>
<script>
import {uploadFileBatch} from "xxxx/DistFolderFile";
import common from "../common";
export default {
name: "OnlineDownload",
data() {
return {
visible: false,
notify: {},
process: '',
title: '',
percent: 0,
currentSize: 0,
totalSize: 0,
fileList: [],
}
}, mounted() {
}
, methods: {
show(formData, lstFiles) {
this.visible = true;
this.fileList = lstFiles;
this.percent= 0;
this.currentSize= 0;
this.totalSize= 0;
this.uploadFiles(formData)
},
getFileSize(size) {
return common.getFileSize(size);
},
uploadFiles(formData) {
let totalSize = 0;
let uniSign = new Date().getTime() + ''; // 可能会连续点击下载多个文件,这里用时间戳来区分每一次下载的文件
uploadFileBatch(formData, this.callBackProgress, totalSize, uniSign).then(() => {
this.$message.info("上传成功!")
this.$emit("reload");
})
},
//回调函数,用于回显上传进度以及计算实时速率
callBackProgress(progress, totalSize, uniSign) {
let total = progress.total || totalSize;
let loaded = progress.loaded;
this.progressSign = uniSign;
this.currentSize = loaded;
this.totalSize = total;
// progress对象中的loaded表示已经下载的数量,total表示总数量,这里计算出百分比
this.percent = Math.round(100 * loaded / total);
// // 将此次下载的文件名和下载进度组成对象再用vuex状态管理
// this.$store.commit('downLoadProgress/SET_PROGRESS', {path: uniSign, 'progress': downProgress})
},
}
}
</script>
export function uploadFileBatch(data,callback,totalSize,uniSign) {
return request({
url: '/uploadFileBatch',
method: 'post',
headers: {
"Content-Type": "application/x-www-form-urlencoded"
},
data: data,
onUploadProgress (progress) {
callback(progress, totalSize, uniSign)
}
})
}
文件下载
文件下载和文件上传基本上就是一模一样的过程。
<!--suppress JSUnusedGlobalSymbols -->
<template>
<a-modal
:title="'文件下载(文件拉取到客户端)'"
:visible="visible"
@cancel="visible=false"
:maskClosable="false"
:footer="false"
>
<a-spin :spinning="spinning" tip="下载数据准备中,请稍后....">
<div style="display: flex;justify-content: center;align-items: center">
<!--suppress JSUnusedLocalSymbols -->
<a-progress type="circle" :percent="percent" :format="percent => `${percent} %`"/>
<div style="margin-left: 20px;width:250px;display: flex;flex-direction:column;justify-content: center;align-items: center">
<div><span :style="{color: (currentSize===totalSize)?'green':'purple'}">{{ getFileSize(currentSize) }}</span>/
<span style="color: green">{{ getFileSize(totalSize) }}</span></div>
<div style="color: darkgray">注:已下载/总共</div>
</div>
</div>
<div style="margin-top: 20px">{{title}}</div>
<div style="margin-top: 20px;text-align: center"><a-button type="primary" :disabled="currentSize<totalSize" @click="visible=false">关闭</a-button> </div>
</a-spin>
</a-modal>
</template>
<!--suppress JSCheckFunctionSignatures -->
<script>
import {downFileProgress,downFileBatchProgress} from "../../../api/DistFolderFile";
import common from "../common";
import moment from 'moment'
export default {
name: "OnlineDownload",
data() {
return {
visible: false,
notify: {},
process: '',
title: '',
percent: 0,
currentSize: 0,
totalSize: 0,
record: {},
batchRecord: {},
isPreview: false,
spinning: false
}
}, mounted() {
}
, methods: {
show(record, isPreview) {
this.record = record;
this.percent = 0;
this.currentSize =0 ;
this.totalSize = 0;
// this.visible = true;
if(isPreview){
this.isPreview = isPreview;
this.spinning = true;
}
else{
this.spinning = true;
this.visible = true;
}
this.dowOrgFile(false)
},
batchDowloadShow(batchRecord) {
this.batchRecord = batchRecord;
this.percent = 0;
this.currentSize =0 ;
this.totalSize = 0;
this.visible = true;
console.log("开始批量下载了!", moment(new Date()).format('YYYY/MM/DD HH:mm:ss'));
this.spinning = true;
this.dowOrgFile(true)
},
getFileSize(size) {
return common.getFileSize(size);
},
dowOrgFile(isBatchDownLoad) {
let totalSize = 0;
let uniSign = new Date().getTime() + ''; // 可能会连续点击下载多个文件,这里用时间戳来区分每一次下载的文件
if(!isBatchDownLoad){
downFileProgress(this.record, this.callBackProgress, totalSize, uniSign).then((data) => {
this.downloadCallBack(isBatchDownLoad,data);
});
}
else{
downFileBatchProgress(this.batchRecord, this.callBackProgress, totalSize, uniSign).then((data) => {
this.downloadCallBack(isBatchDownLoad,data);
});
}
},
downloadCallBack(isBatchDownLoad,data){
const that = this;
if (!data) {
this.$message.error("文件下载失败!");
this.spinning = false;
return;
}
//这里做了两段逻辑的处理,下载和预览下载
if(data.type==="application/json"){
const reader = new FileReader();
reader.onload = function(){
const content = reader.result;//内容就在这里
const jsonData = JSON.parse(content);
that.$message.error("下载文件失败!失败原因:" + jsonData.msg);
};
reader.readAsText(data);
this.visible = false;
this.spinning = false;
return;
}
if(this.isPreview){
const url = window.URL.createObjectURL(new Blob([data]));
this.visible =false;
this.$emit("preview",url)
this.spinning = false;
}
else{
let fileName="";
if(!isBatchDownLoad){
fileName = this.record.fileName;
}
else{
fileName = moment().format('YYYYMMDDHHmmss')+ ".zip";
}
//文件下载
if (typeof window.navigator.msSaveBlob !== "undefined") {
window.navigator.msSaveBlob(new Blob([data]), fileName);
} else {
const url = window.URL.createObjectURL(new Blob([data]));
const link = document.createElement("a");
link.style.display = "none";
link.href = url;
if(this.record.type==="Folder"){
fileName = fileName + ".zip";
}
console.log(fileName)
link.setAttribute("download", fileName);
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
window.URL.revokeObjectURL(url);
this.visible =false;
this.spinning = false;
}
}
},
callBackProgress(progress, totalSize, uniSign) {
this.spinning = false;
let total = progress.total || totalSize;
let loaded = progress.loaded;
this.progressSign = uniSign;
this.currentSize = loaded;
this.totalSize = total;
this.percent = Math.round(100 * loaded / total);
},
}
}
</script>
文件存储
文件最终都是会存储在OSS上的,本地服务器建立了几张关系表(建立目录、文件索引,方便查询和检索)。
关于文件上传还有一个内容需要注意,你在很多网盘上传文件的时候,尤其是一些互联网上的安装包什么的,他会提供一个极速秒传的功能,几个G的文件一下子就上传上去了。按我的经验来分析的话,其实就是每个文件都是有独立的md5(你网上下载文档会让你比对md5,看有没有被篡改过),如果服务器上已经存在这个文件了,也就是MD5值一样,那网盘就不需要你上传实际文件了,它只要后台把文件和你的账号已关联,就相当于是上传了。
我自己做的管理体系,也是借鉴这个原理的,通过MD5值比对是否有上传过。
/**
* 上传的MultipartFile[]处理,
*/
public void uploadFileBatch(MultipartFile[] files, DistFolderFileEntity distFolderFileEntityRequest) throws IOException {
String prefix = "dist-oss";
for (MultipartFile multipartFile : files) {
String fileName = multipartFile.getOriginalFilename();
assert fileName != null;
//获取传入的md5值,去服务器文件表匹配,有没有上传过
String md5 = com.assassin.utils.FileUtils.getMd5(multipartFile);
//......
DistFileEntity distFileEntity = distFileDao.getFileByMd5(md5);
String url = "";
String fileId = "";
if(distFileEntity!=null){
url = distFileEntity.getFilePath();
//如果文件名和MD5完全一致,就直接创建记录就行,不需要额外生成了
if(StringUtils.equals(fileName,distFileEntity.getFileName())){
fileId = distFileEntity.getId();
}
}
else{
//如果文件不存在,则把文件上传并写入服务器, 并建立文件记录
url = writeFile(multipartFile, distFolderFileEntityRequest.getUser());
//.... 建立文件记录
}
//写入文件夹和文件的关系,每个用户下文件夹都是不同的,文件是共享的
if(!distFileEntityList.isEmpty()){
//批量写入文件记录
distFileDao.insertDistFileBatch(distFileEntityList);
}
if(!distFolderFileEntityList.isEmpty()){
//批量写入文件夹和文件关联记录
distFolderFileDao.insertDistFolderFileBatch(distFolderFileEntityList);
}
}
/**
oss分片上传,返回上传后的地址
*/
public static String uploadMulti(String prefix, String name, MultipartFile multipartFile){
name = prefix + "/" + name;
OSS ossClient = new OSSClientBuilder().build(endpoint, accessKeyId, accessKeySecret);
try {
// 创建InitiateMultipartUploadRequest对象。
InitiateMultipartUploadRequest request = new InitiateMultipartUploadRequest(bucketName, name);
// 初始化分片。
InitiateMultipartUploadResult upresult = ossClient.initiateMultipartUpload(request);
// 返回uploadId,它是分片上传事件的唯一标识。您可以根据该uploadId发起相关的操作,例如取消分片上传、查询分片上传等。
String uploadId = upresult.getUploadId();
// partETags是PartETag的集合。PartETag由分片的ETag和分片号组成。
List<PartETag> partETags = new ArrayList<PartETag>();
// 每个分片的大小,用于计算文件有多少个分片。单位为字节。
final long partSize = 1 * 1024 * 1024L; //1 MB。
// 填写本地文件的完整路径。如果未指定本地路径,则默认从示例程序所属项目对应本地路径中上传文件。
long fileLength = multipartFile.getSize();
int partCount = (int) (fileLength / partSize);
if (fileLength % partSize != 0) {
partCount++;
}
InputStream inputStream = multipartFile.getInputStream();
ByteArrayInputStream stream = new ByteArrayInputStream(InputStreamToByte(inputStream));
// 遍历分片上传。
for (int i = 0; i < partCount; i++) {
long startPos = i * partSize;
long curPartSize = (i + 1 == partCount) ? (fileLength - startPos) : partSize;
// 跳过已经上传的分片。
stream.skip(startPos);
UploadPartRequest uploadPartRequest = new UploadPartRequest();
uploadPartRequest.setBucketName(bucketName);
uploadPartRequest.setKey(name);
uploadPartRequest.setUploadId(uploadId);
uploadPartRequest.setInputStream(stream);
// 设置分片大小。除了最后一个分片没有大小限制,其他的分片最小为100 KB。
uploadPartRequest.setPartSize(curPartSize);
// 设置分片号。每一个上传的分片都有一个分片号,取值范围是1~10000,如果超出此范围,OSS将返回InvalidArgument错误码。
uploadPartRequest.setPartNumber( i + 1);
// 每个分片不需要按顺序上传,甚至可以在不同客户端上传,OSS会按照分片号排序组成完整的文件。
UploadPartResult uploadPartResult = ossClient.uploadPart(uploadPartRequest);
// 每次上传分片之后,OSS的返回结果包含PartETag。PartETag将被保存在partETags中。
partETags.add(uploadPartResult.getPartETag());
}
// 创建CompleteMultipartUploadRequest对象。
// 在执行完成分片上传操作时,需要提供所有有效的partETags。OSS收到提交的partETags后,会逐一验证每个分片的有效性。当所有的数据分片验证通过后,OSS将把这些分片组合成一个完整的文件。
CompleteMultipartUploadRequest completeMultipartUploadRequest =
new CompleteMultipartUploadRequest(bucketName, name, uploadId, partETags);
// 完成分片上传。
CompleteMultipartUploadResult completeMultipartUploadResult = ossClient.completeMultipartUpload(completeMultipartUploadRequest);
System.out.println(completeMultipartUploadResult.getETag());
}catch(OSSException ce) {
System.out.println("Caught an OSSException, which means your request made it to OSS, "
+ "but was rejected with an error response for some reason.");
System.out.println("Error Message:" + ce.getErrorMessage());
System.out.println("Error Code:" + ce.getErrorCode());
System.out.println("Request ID:" + ce.getRequestId());
System.out.println("Host ID:" + ce.getHostId());
} catch (ClientException | IOException ce) {
throw new GlobalException("上传失败!" + ce.getMessage());
} finally {
if (ossClient != null) {
ossClient.shutdown();
}
}
return String.format("http://%s.%s/%s",bucketName,endpoint,name);
}
回收站
回收站的原理也是比较简单,就是所有的数据库都增加了 delete_flag字段,默认是0表示正常,第一次删除的时候状态改为1,表示被标记删除了。这时候回收站就可以查找数据了。当然删除文件夹的时候,会同步把文件夹下的所有子文件夹和文件都标记删除。
回收站支持彻底删除和还原。还原逻辑就是把标记改回来就行。彻底删除就要分情况了,文件夹记录就彻底删掉了,文件记录和文件内容会判断,如果这个文件还有别的用户有引用关系(极速秒传),那就只删除当前这个用户和文件的关系就行,如果没有任何引用关系了,那除了删除关系以后,还会把OSS上的文件也彻底删除。
后面自己重复上传大文件的时候,发现删除文件有点麻烦,重新上传有点慢,所以我就改了下代码,不删除实体文件了。OSS目前存储比较充分,后续不够了再说。
文件预览
这块在后面文件预览的专题里介绍。
总结
云盘最大的工作量是在上传、下载和存储的设计上,如何能做到又快又好。到目前为止使用场景都满足要求,除了断点续传的功能,因为我很少传大文件上去,所以目前来说也没什么影响。