boycot 搜索
avatar

boycot

断点续传与个人网盘系统的前后端设计

功能设计

  1. 登录鉴权:进入系统必须先登录,未登录无法访问到后端接口与网盘的静态资源
  2. 上传:断点续传、文件秒传
  3. 文件分享:生成一个随机密钥字符串与一个资源访问地址,输入密钥验证成功即可访问到该资源,密钥会在一定时间内过期
  4. 回收站:删除后的文件默认先保留在回收站,7 天后自动删除
  5. 文件操作: 新建文件夹、重命名、移动、删除、批量删除

技术选型

  • 前端:使用 Vue 构建,使用 ElementUI 构建 UI,使用 vue-simple-uploader 插件实现上传的断点续传、文件秒传功能。
  • 后端:使用 Koa 实现,直接使用 Koa 搭建静态资源服务器(即个人网盘资源目录),加入静态资源鉴权,使用原生 Nodejs 处理文件管理与上传功能。

问题与思考

Q:是否需要使用数据库,将文件信息保存到数据库中?

原则上,文件的增删查改都将使用原生 nodejs 进行操作,这些都不需要使用到数据库。但是原生 nodejs 并不能直接读取到文件的 MD5 值,在断点续传与秒传功能中就无法通过传来的 MD5 标识跟本地的文件进行匹配。所以还是需要建立一个含文件 MD5、文件路径等信息的数据表记录本地文件的 MD5。

Q:若使用了数据库记录文件 MD5 信息,怎么保证数据表的数据与本地物理存储是同步的?

如果进行文件操作并不是通过该文件管理系统,而是直接在 windows 上进入到网盘目录进行文件增删改,这时我们的应用是无法监听到文件的变更的,数据表数据并不会更新。这样就会出现我把某个文件删除了,但是数据表仍然记录了该文件是已经上传的情况。

原本是想采用使用定时器定时对本地文件与数据表进行数据同步,但是发现这样在后期文件多或嵌套深的情况下性能会很差,这种方式并不合适。

由于这些信息只是在文件断点续传与秒传功能中需要用到,后面采用的方案为:直接在预探请求中先判断数据库信息是否与本地物理存储相符,如果不相符则认为本地已不存在,需要重新上传。(原则上是不推荐直接使用 windows 进入目录进行文件操作,而是都通过这个文件管理系统进行文件操作)

Q: 同一个文件,但存在于网盘不同目录下,同时在不同目录删除该文件,回收站中是否会冲突?

删除文件时,使用原文件名+时间(yyyy-MM-dd HH:mm

)进行重命名后再移动文件到回收站。同时需要往数据库记录文件删除的信息,删除前的文件路径与删除时间等,以便实现文件还原与回收站定时清理的功能。

Q:文件夹并无 MD5 值,删除文件夹如何确保可以还原?

删除文件夹与删除文件属于同样的操作,也是通过文件夹名+时间重命名后移动到回收站目录。但是数据库中需要使用一个新的数据表记录文件夹的删除信息。

实现

文件鉴权

登录时保留 session, 然后使用一个中间件鉴权,如果没有 session 则不允许访问系统除登录接口外的其他任何请求,包括静态资源。使用 koa-static 构建静态资源服务器,并将 defer 属性设置为 true,让它允许通过鉴权中间件。

// ...
app.use(async (ctx, next) => {
  if (ctx.url.includes("/storage") && ctx.url !== "/storage/login") {
    if (!ctx.session.isLogin) {
      ctx.body = r.loginError();
      return;
    }
  }
  await next();
});

// ...
app.use(
  static(__dirname + "/public", {
    defer: true,
  })
);

// ...
const router = new Router({
  prefix: "/storage",
});

// ...
router.post("/login", async (ctx) => {
  const { access } = ctx.request.body;
  if (!access) {
    ctx.body = r.parameterError();
    return;
  }
  try {
    const base64Decode = new Buffer.from(access, "base64");
    const genAccess = base64Decode.toString();
    if (storageRootKey !== genAccess) {
      ctx.body = r.error(311, "密码错误");
      return;
    }
    ctx.session.isLogin = true;
    logger("登入Storage");
    ctx.body = r.success();
  } catch (e) {
    ctx.body = r.error(310, "登录失败");
  }
});

这里设置的文件系统接口为 storage/*,静态资源服务器为 public/storage,登录时前后端会把密码进行简单 base64 转码。

若未登录直接访问静态资源,则回返回错误信息。

未登录直接访问 未登录.jpg

登录后在访问 已登录.jpg

断点续传与文件秒传

文件 md5 计算

实现断点续传与文件秒传的前提是需要确定出文件的唯一标识,最好的方式是计算出文件的 md5 值。

由于选择的 vue-simple-uploader 没有直接提供文件 md5 计算的 api,因此需要手动实现。这里采用 spark-md5 插件计算文件的 md5,在 file-added 事件中,直接用 fileReader 读取文件,根据每个切片循环算出 md5。

注意尽量不要直接一次读取整个文件的 md5,直接读取大文件在 IE 浏览器中有可能会出现卡死的情况,遍历读取每个切片可以减轻浏览器计算压力。

methods: {
  hanldeFileAdd (file) {
    const fileList = this.$refs.uploader.files
    const index = fileList.findIndex(item => item.name === file.name)
    if (~index) {
      file.removeFile(file)
    } else {
      file.targetPath = this.currentPath
      this.computeMD5(file)
    }
  },
  computeMD5 (file) {
    const fileReader = new FileReader()
    const blobSlice = File.prototype.slice || File.prototype.mozSlice || File.prototype.webkitSlice
    let currentChunk = 0
    const chunkSize = CHUNK_SIZE
    const chunks = Math.ceil(file.size / chunkSize)
    const spark = new SparkMD5.ArrayBuffer()
    this.$nextTick(() => {
      this.createMD5Element(file)
    })
    loadNext()
    fileReader.onload = e => {
      spark.append(e.target.result)
      if (currentChunk < chunks) {
        currentChunk++
        loadNext()
        this.$nextTick(() => {
          this.setMD5ElementText(file, `校验MD5 ${((currentChunk / chunks) * 100).toFixed(0)}%`)
          document.querySelector(`.uploader-list .file-${file.id} .uploader-file-actions`).style.display = 'none'
        })
      } else {
        const md5 = spark.end()
        file.uniqueIdentifier = md5
        file.resume()
        this.destoryMD5Element(file)
        document.querySelector(`.uploader-list .file-${file.id} .uploader-file-actions`).style.display = 'block'
      }
    }
    fileReader.onerror = function () {
      this.$nextTick(() => {
        this.setMD5ElementText(file, '校验MD5失败')
      })
      file.cancel()
    }
    function loadNext () {
      const start = currentChunk * chunkSize
      const end = ((start + chunkSize) >= file.size) ? file.size : start + chunkSize
      fileReader.readAsArrayBuffer(blobSlice.call(file.file, start, end))
    }
  },
  createMD5Element (file) {
    this.$nextTick(() => {
      const el = document.querySelector(`.uploader-list .file-${file.id} .uploader-file-status`)
      const MD5Status = document.createElement('div')
      MD5Status.setAttribute('class', 'md5-status')
      el.appendChild(MD5Status)
    })
  },
  destoryMD5Element (file) {
    this.$nextTick(() => {
      const el = document.querySelector(`.uploader-list .file-${file.id} .uploader-file-status .md5-status`)
      if (el) {
        el.parentNode.removeChild(el)
      }
    })
  },
  setMD5ElementText (file, text) {
    const el = document.querySelector(`.uploader-list .file-${file.id} .uploader-file-status .md5-status`)
    if (el) {
      el.innerText = text
    }
  }
}

将计算完的 MD5 直接替换到 file 对象的 uniqueIdentifier 属性上,最终发送的请求中的 identifier 将是文件的 MD5,后端通过该字段进行识别。

Vue-simple-uploader 文件列表状态需要加入计算 MD5 相关状态,可以通过 css 为原文件列表增加多一层 md5 状态层,然后通过相关事件进行显隐。

md5-status.jpg

断点续传

默认 Vue-simple-uploader 提供了文件上传时的暂停/开始操作,你可以在上传过程中随时暂停。但是这个并不是真正的断点续传,因为页面刷新后,上传状态并没有保存下来,仍会重新从第一片重新上传。若将状态保留到 localstorage 中,仍是不太现实的,最好的方式是由后端返回是否需要当前这个切片,因为后端能知道当前该文件已上传的切片。

testChunks 属性设为 true(默认)时,每个切片会先发送一个不含文件流的预探 get 请求给后端,通过后端返回的 http 状态码(可更改)判断该切片是否需要发送。

默认每个切片都会发送一个预探请求,这样假如一个 10 个切片的文件就会产生 20 个请求,造成浪费。最理想的情况是预探请求只发送一个。新版 simple-uplder 也考虑到这点,并提供了 checkChunkUploadedByResponse 属性,可以将预探请求设置为一个,后端为这个预探请求直接返回当前已经有的切片数组,然后前端直接判断切片请求是否需要发送。

例:文件上传到一半,点了暂停,然后刷新网页,再重新上传。文件校验完 Md5 后,预探请求返回已存在的切片数组[1~25],然后真正切片请求会直接从第 26 片开始上传。

续传.jpg

前端处理

// 前端vue-simple-uploader配置项
options: {
  target: (instance, chunk, isTest) => isTest ? '/api/storage/testUpload' : '/api/storage/upload',
  query: () => {
    return {
      targetPath: this.currentPath
    }
  },
  chunkSize: CHUNK_SIZE,
  allowDuplicateUploads: false,
  checkChunkUploadedByResponse: (chunk, message) => {
    const response = JSON.parse(message)
    const existChunk = response.data.map(item => ~~item)
    return existChunk.includes(chunk.offset + 1)
  }
}

其中/storage/testUpload 为预探请求(get),storage/upload 为真正切片上传请求(post)。checkChunkUploadedByResponse 控制只上传后端不存在的切片。

后端处理

router.get("/testUpload", async (ctx) => {
  const { identifier, filename, targetPath = "$Root", totalChunks } = ctx.query;
  const chunkFolderURL = `${storageChunkPath}/${identifier}`;
  try {
    const checkExistResult = await query(
      `select * from storage where id = ? and isComplete = 1 and isDel = 0`,
      identifier
    );
    // 检查是否已经完整上传过该文件
    if (checkExistResult.length > 0) {
      let { fullPath } = checkExistResult[0];
      let realPath = fullPath.replace("$Root", storageRootPath);
      // 检查当前DB信息是否与物理存储相符
      if (fs.existsSync(realPath)) {
        // 检查目标位置是否与之前上传的位置一样,不一致则复制过去
        let targetFilePath = `${targetPath}/${filename}`;
        if (fullPath !== targetFilePath) {
          targetFilePath = targetFilePath.replace("$Root", storageRootPath);
          fs.copyFileSync(realPath, targetFilePath);
        }
        // 返回全部分片数组
        const chunksArr = Array.from(
          { length: totalChunks },
          (item, index) => index + 1
        );
        ctx.body = r.successData(chunksArr);
        return;
      }
    }
    if (!fs.existsSync(chunkFolderURL)) {
      fs.mkdirSync(chunkFolderURL, { recursive: true });
      const now = DateFormat(new Date(), "yyyy-MM-dd HH:mm:ss");
      const sql = `replace into storage(id, fullPath, updatedTime, isComplete, isDel) values(?, ?, ?, 0, 0)`;
      await query(sql, [identifier, `${targetPath}/${filename}`, now]);
      ctx.body = r.successData([]);
    } else {
      const ls = fs.readdirSync(chunkFolderURL);
      ctx.body = r.successData(ls);
    }
  } catch (e) {
    ctx.status = 501;
    ctx.body = r.error(306, e);
  }
});

router.post("/upload", async (ctx) => {
  const {
    chunkNumber,
    identifier,
    filename,
    totalChunks,
    targetPath = "$Root",
  } = ctx.request.body;
  const { file } = ctx.request.files;
  const chunkFolderURL = `./public/storage-chunk/${identifier}`;
  const chunkFileURL = `${chunkFolderURL}/${chunkNumber}`;
  if (chunkNumber !== totalChunks) {
    const reader = fs.createReadStream(file.path);
    const upStream = fs.createWriteStream(chunkFileURL);
    reader.pipe(upStream);
    ctx.body = r.success();
  } else {
    const targetFile = `${targetPath}/${filename}`.replace(
      "$Root",
      storageRootPath
    );
    fs.writeFileSync(targetFile, "");
    try {
      for (let i = 1; i <= totalChunks; i++) {
        const url = i == totalChunks ? file.path : `${chunkFolderURL}/${i}`;
        const buffer = fs.readFileSync(url);
        fs.appendFileSync(targetFile, buffer);
      }
      const now = DateFormat(new Date(), "yyyy-MM-dd HH:mm:ss");
      const sql = `update storage set isComplete = 1, updatedTime = ? where id = ?`;
      await query(sql, [now, identifier]);
      ctx.body = r.success();
      deleteFolder(chunkFolderURL);
      logger(
        "文件上传成功",
        1,
        `targetFile: ${targetFile}, MD5:${identifier}, 切片源删除成功`
      );
    } catch (e) {
      ctx.status = 501;
      ctx.body = r.error(501, e);
      logger("文件合并失败", 0, `分片丢失 => ${e}`);
      fs.unlinkSync(targetFile);
    }
  }
});

在 testUpload 请求中,通过数据库与本地切片生成已存在的切片数组给前端,若从未传过还需要更新数据库记录。

在 upload 请求中,对每个切片使用 nodejs 管道流进行读写,将文件保留在 chunk 文件夹中,并以 md5 值为文件名,存放目标文件的切片。当遇到最后一个切片时,执行合并文件操作(需要注意,最后一个切片由于流未关闭,这个时刻最后一个切片文件是还没保存到本地,只是可以直接读取临时文件)。合并文件完成后,删除切片文件夹,并更新数据库信息,记录该文件已经完成。

切片存放.jpg

当上传一个本地已经存在的文件时,由于数据库记录了该 md5 文件是已经完成的,所以预探请求会返回全部切片数组,前端就不会再发送 upload 请求从而实现了文件秒传。即使上传的目标目录与本地已存在文件处在不同目录,在预探请求时识别到时,也会进行复制操作,前端也不需要再传。

断点续传演示 断点续传.gif

上传过程暂停,然后刷新页面,重新上传同一个文件,可以发现文件是从上传暂停的地方重新开始。

文件秒传演示 文件秒传.gif

上传上面演示的同一个文件,由于发现是已经存在的文件,则会直接返回成功。

至此,一个断点续传、秒传功能的前后端都实现完了。

另外该系统还有一些对文件进行移动、删除、下载的功能都是比较简单的,基本都是使用 nodejs 的 fs 模块就能实现,这里就不细说了。

该系统前端 Git: https://github.com/leon-kfd/FileSystem

由于目前该后端是嵌入到了本人的其他系统里面,还未能开源,等有空会整理出一份。同时系统部分功能由于时间问题也还没有空去完善,望见谅。

乳白
杏仁黄
茉莉黄
麦秆黄
油菜花黄
佛手黄
篾黄
葵扇黄
柠檬黄
金瓜黄
藤黄
酪黄
香水玫瑰黄
淡密黄
大豆黄
素馨黄
向日葵黄
雅梨黄
黄连黄
金盏黄
蛋壳黄
肉色
鹅掌黄
鸡蛋黄
鼬黄
榴萼黄
淡橘橙
枇杷黄
橙皮黄
北瓜黄
杏黄
雄黄
万寿菊黄
菊蕾白
秋葵黄
硫华黄
柚黄
芒果黄
蒿黄
姜黄
香蕉黄
草黄
新禾绿
月灰
淡灰绿
草灰绿
苔绿
碧螺春绿
燕羽灰
蟹壳灰
潭水绿
橄榄绿
蚌肉白
豆汁黄
淡茧黄
乳鸭黄
荔肉白
象牙黄
炒米黄
鹦鹉冠黄
木瓜黄
浅烙黄
莲子白
谷黄
栀子黄
芥黄
银鼠灰
尘灰
枯绿
鲛青
粽叶绿
灰绿
鹤灰
淡松烟
暗海水绿
棕榈绿
米色
淡肉色
麦芽糖黄
琥珀黄
甘草黄
初熟杏黄
浅驼色
沙石黄
虎皮黄
土黄
百灵鸟灰
山鸡黄
龟背黄
苍黄
莱阳梨黄
蜴蜊绿
松鼠灰
橄榄灰
蟹壳绿
古铜绿
焦茶绿
粉白
落英淡粉
瓜瓤粉
蜜黄
金叶黄
金莺黄
鹿角棕
凋叶棕
玳瑁黄
软木黄
风帆黄
桂皮淡棕
猴毛灰
山鸡褐
驼色
茶褐
古铜褐
荷花白
玫瑰粉
橘橙
美人焦橙
润红
淡桃红
海螺橙
桃红
颊红
淡罂粟红
晨曦红
蟹壳红
金莲花橙
草莓红
龙睛鱼红
蜻蜓红
大红
柿红
榴花红
银朱
朱红
鲑鱼红
金黄
鹿皮褐
醉瓜肉
麂棕
淡银灰
淡赭
槟榔综
银灰
海鸥灰
淡咖啡
岩石棕
芒果棕
石板灰
珠母灰
丁香棕
咖啡
筍皮棕
燕颔红
玉粉红
金驼
铁棕
蛛网灰
淡可可棕
中红灰
淡土黄
淡豆沙
椰壳棕
淡铁灰
中灰驼
淡栗棕
可可棕
柞叶棕
野蔷薇红
菠萝红
藕荷
陶瓷红
晓灰
余烬红
火砖红
火泥棕
绀红
橡树棕
海报灰
玫瑰灰
火山棕
豆沙
淡米粉
初桃粉红
介壳淡粉红
淡藏花红
瓜瓤红
芙蓉红
莓酱红
法螺红
落霞红
淡玫瑰灰
蟹蝥红
火岩棕
赭石
暗驼棕
酱棕
栗棕
洋水仙红
谷鞘红
苹果红
铁水红
桂红
极光红
粉红
舌红
曲红
红汞红
淡绯
无花果红
榴子红
胭脂红
合欢红
春梅红
香叶红
珊瑚红
萝卜红
淡茜红
艳红
淡菽红
鱼鳃红
樱桃红
淡蕊香红
石竹红
草茉莉红
茶花红
枸枢红
秋海棠红
丽春红
夕阳红
鹤顶红
鹅血石红
覆盆子红
貂紫
暗玉紫
栗紫
葡萄酱紫
牡丹粉红
山茶红
海棠红
玉红
高粱红
满江红
枣红
葡萄紫
酱紫
淡曙红
唐菖蒲红
鹅冠红
莓红
枫叶红
苋菜红
烟红
暗紫苑红
殷红
猪肝紫
金鱼紫
草珠红
淡绛红
品红
凤仙花红
粉团花红
夹竹桃红
榲桲红
姜红
莲瓣红
水红
报春红
月季红
豇豆红
霞光红
松叶牡丹红
喜蛋红
鼠鼻红
尖晶玉红
山黎豆红
锦葵红
鼠背灰
甘蔗紫
石竹紫
苍蝇灰
卵石紫
李紫
茄皮紫
吊钟花红
兔眼红
紫荆红
菜头紫
鹞冠紫
葡萄酒红
磨石紫
檀紫
火鹅紫
墨紫
晶红
扁豆花红
白芨红
嫩菱红
菠根红
酢酱草红
洋葱紫
海象紫
绀紫
古铜紫
石蕊红
芍药耕红
藏花红
初荷红
马鞭草紫
丁香淡紫
丹紫红
玫瑰红
淡牵牛紫
凤信紫
萝兰紫
玫瑰紫
藤萝紫
槿紫
蕈紫
桔梗紫
魏紫
芝兰紫
菱锰红
龙须红
蓟粉红
电气石红
樱草紫
芦穗灰
隐红灰
苋菜紫
芦灰
暮云灰
斑鸠灰
淡藤萝紫
淡青紫
青蛤壳紫
豆蔻紫
扁豆紫
芥花紫
青莲
芓紫
葛巾紫
牵牛紫
紫灰
龙睛鱼紫
荸荠紫
古鼎灰
乌梅紫
深牵牛紫
银白
芡食白
远山紫
淡蓝紫
山梗紫
螺甸紫
玛瑙灰
野菊紫
满天星紫
锌灰
野葡萄紫
剑锋紫
龙葵紫
暗龙胆紫
晶石紫
暗蓝紫
景泰蓝
尼罗蓝
远天蓝
星蓝
羽扇豆蓝
花青
睛蓝
虹蓝
湖水蓝
秋波蓝
涧石蓝
潮蓝
群青
霁青
碧青
宝石蓝
天蓝
柏林蓝
海青
钴蓝
鸢尾蓝
牵牛花蓝
飞燕草蓝
品蓝
银鱼白
安安蓝
鱼尾灰
鲸鱼灰
海参灰
沙鱼灰
钢蓝
云水蓝
晴山蓝
靛青
大理石灰
海涛蓝
蝶翅蓝
海军蓝
水牛灰
牛角灰
燕颔蓝
云峰白
井天蓝
云山蓝
釉蓝
鸥蓝
搪磁蓝
月影白
星灰
淡蓝灰
鷃蓝
嫩灰
战舰灰
瓦罐灰
青灰
鸽蓝
钢青
暗蓝
月白
海天蓝
清水蓝
瀑布蓝
蔚蓝
孔雀蓝
甸子蓝
石绿
竹篁绿
粉绿
美蝶绿
毛绿
蔻梢绿
麦苗绿
蛙绿
铜绿
竹绿
蓝绿
穹灰
翠蓝
胆矾蓝
樫鸟蓝
闪蓝
冰山蓝
虾壳青
晚波蓝
蜻蜓蓝
玉鈫蓝
垩灰
夏云灰
苍蓝
黄昏灰
灰蓝
深灰蓝
玉簪绿
青矾绿
草原远绿
梧枝绿
浪花绿
海王绿
亚丁绿
镍灰
明灰
淡绿灰
飞泉绿
狼烟灰
绿灰
苍绿
深海绿
长石灰
苷蓝绿
莽丛绿
淡翠绿
明绿
田园绿
翠绿
淡绿
葱绿
孔雀绿
艾绿
蟾绿
宫殿绿
松霜绿
蛋白石绿
薄荷绿
瓦松绿
荷叶绿
田螺绿
白屈菜绿
河豚灰
蒽油绿
槲寄生绿
云杉绿
嫩菊绿
艾背绿
嘉陵水绿
玉髓绿
鲜绿
宝石绿
海沬绿
姚黄
橄榄石绿
水绿
芦苇绿
槐花黄绿
苹果绿
芽绿
蝶黄
橄榄黄绿
鹦鹉绿
油绿
象牙白
汉白玉
雪白
鱼肚白
珍珠灰
浅灰
铅灰
中灰
瓦灰
夜灰
雁灰
深灰