效率提升-网盘

94 阅读6分钟

背景

  网盘大家都不陌生,现在的网盘工具也很多,百度网盘,阿里网盘,和彩云盘等等,很多使用也都是有条件的,比如不开会员会限速,存储空间太小等等。百度网盘的资料也不安全,在互联网上会被人检索出来等等。

  我日常工作需要存储的资料,文件,工具还是比较多的。用移动硬盘更加麻烦,需要拷贝来拷贝去,没有带移动硬盘的时候就很糟心了。用互联网产品不安全是首要问题,各种限速什么用的也很麻烦。

所以在经过一段时间的思考以后,决定自己做一个网盘工具

选型分析

  做开始做网盘也是挺纠结的,要不要这么费尽力去搞这个事情,因为碰到的问题还不少。

  1. 存储的问题,我购买的云服务器只有100G的存储,如果扩充的话费用还是挺贵的。
  2. 网盘很重要的一块也是速度这块,如果上传下载速率很低,那其实没什么意义了。
  3. 可视化界面也很重要,文件的分类管理,上传下载管理,在线预览等等。

后面也是纠结、分析了好久,觉得还是利大于弊的,所以就选择做了。

  1. 存储问题,就直接考虑OSS对象存储了,这块费用会便宜很多,并且安全性也比较容易保证,开启跨域,防盗链,黑白名单等功能,很安全
  2. 云平台有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目前存储比较充分,后续不够了再说。

文件预览

  这块在后面文件预览的专题里介绍。

总结

  云盘最大的工作量是在上传、下载和存储的设计上,如何能做到又快又好。到目前为止使用场景都满足要求,除了断点续传的功能,因为我很少传大文件上去,所以目前来说也没什么影响。