背景
在进行大文件上传的时候,例如: 一个 1G的文件,可能会被客户反复上传, 这样会造成以下的问题
- 客户在等待上传时间比较长
- 带宽被消耗(流量)
- 服务器资源被消耗(文件处理)
我们可以采用 文件分片的形式上传, 但是只解决了大文件上传中的等待时长问题, 我们还要判断文件是否已经上传过了,如果文件已经上传过了,就直接返回服务端的 url 即可
今天我们主要讨论的是,怎么知道 某个文件已经被用户上传了?
例如: 同一个视频文件,客户可能从 a 文件名改为 b文件名, 然后上传,服务器还需要从零开始处理这个文件的上传吗?
答: 不需要重复上传, 但是【服务器】怎么知道 a.mp4 文件 其实就是 b.mp4文件呢 ?
例如: 2个一样的视频文件, 一样的大小, 都叫 "真假美猴王.mp4"
, 由不同的用户上传, 它们真的是一样的吗 ?
答: 可能不是同一个东西, 要怎么甄别?
由此, 我们引出一个词: 文件摘要
文件摘要
【文件摘要】 是指通过特定的算法对文件内容进行计算,生成一个固定长度的摘要值,用于表示文件的完整性。常见的文件摘要算法包括MD5和SHA-256等。
MD5算法是一种被广泛使用的密码散列函数,可以产生出一个128位(16字节)的散列值(哈希值),用于确保信息传输完整一致。对于任意长度的消息,MD5算法可以将其压缩成固定长度的消息摘要,通常用一个长度为32的十六进制字符串来表示。
SHA-256算法也是一种常见的密码散列函数,它可以产生出一个256位(32字节)的散列值。与MD5相比,SHA-256具有更高的安全性,但它的输出长度也更长。
当一个文件被修改
时,其文件摘要值会发生相应的变化。因此,文件摘要可以用于检测文件的完整性,确保文件在传输或存储过程中没有被篡改。同时,文件摘要也可以用于验证文件的来源和身份,例如通过数字签名等技术实现。
从上面的文案上得出的结论: 文件摘要,可以验证文件身份, 由此解决上面的2个问题
代码实现
基础环境
json
window 10
node: 20.10.0
json
"vite": "^5.0.8"
"vue": "^3.3.11"
"@vitejs/plugin-vue": "^4.5.2"
"spark-md5": "^3.0.2"
截止 2024-02-05 ,以上环境均为最新包版本
使用 vite 创建一个空的 vue3项目做测试
本质与vue无任何关系, 所有实现均为js代码, 避免与框架捆绑, 可轻松移植到 react, ag, 原生环境等
spark-md5 实现
我们先使用 md5的方式实现
我们使用 spark-md5 github地址 这个库实现一下
css
npm i spark-md5 -S
src/useSparkMd5.js
js
import SparkMD5 from 'spark-md5'
/**
* 使用 spark-md5 库对文件计算 md5 hash
* @param {File} file
*/
export const useSparkMD5 = (file) => {
return new Promise((resolve, reject) => {
const spark = new SparkMD5()
// 以增量的方式对文件做hash处理
const chunkSize = 2 * 1024 * 1024
const chunks = Math.ceil(file.size / chunkSize)
const chunkList = []
let currentChunk = 0
let fileReader = new FileReader()
fileReader.addEventListener('load', (e) => {
// ArrayBuffer 读取到的是 这样一个数据类型
/**
* @type {ArrayBuffer}
*/
let chunk = e.target.result
spark.append(chunk)
chunkList.push(chunk)
currentChunk++
// 继续读取下一块
if (currentChunk < chunks) {
loadNext()
} else {
console.log('文件全部读取完成')
// 形成文件hash: 7ac2c2a9ff60a52d1552b347009dd51e
// 注意 spark.end 不要调用多次,调用几次会计算几次hash,导致结果非期望, console也不要输出 console.log(spark.end())
resolve({
// 调用end方法,返回十六进制计算结果
hash: spark.end(),
size: file.size,
originName: file.name,
type: file.type,
chunkList
})
}
})
fileReader.addEventListener('error', (e) => {
console.log('文件读取失败')
reject(e)
})
const loadNext = () => {
let start = currentChunk * chunkSize
let end = 0
if (start + chunkSize >= file.size) {
end = file.size
} else {
end = start + chunkSize
}
// 切片
let chunk = file.slice(start, end)
fileReader.readAsArrayBuffer(chunk)
}
loadNext()
})
}
App.vue
html
<template>
<input type="file" id="test-file" />
</template>
<script setup>
import { onMounted } from 'vue'
import { useSparkMD5 } from './useSparkMD5'
const init = () => {
let inputEl = document.querySelector('#test-file')
inputEl.addEventListener('change', async () => {
const file = inputEl.files[0]
console.time('t2')
let r = await useSparkMD5(file)
// 文件大小:342M 耗时 t1: 1074.14111328125 ms
console.timeEnd('t2')
console.log(r)
})
}
onMounted(() => {
init()
})
</script>
web Worker
现在我们把切片这块比较耗时的任务放下 web worker 里面做
具体是否使用 worker , 要取决于文件是否比较大(场景是否必须), 比如100M的文件,跟10G的文件, 那可能10G的文件有必要放在 worker 中, 因为新开 worker, 也是有资源损耗的, 通讯也有成本。
src/sliceFileWorker.js
js
import { useSparkMD5 } from './useSparkMd5.js'
self.onmessage = async (e) => {
let data = e.data
const file = data.file
let result = await useSparkMD5(file)
// 把计算内容返回给主线程
self.postMessage(result)
}
src/App.vue
js
const init = () => {
let inputEl = document.querySelector('#test-file')
inputEl.addEventListener('change', async () => {
const file = inputEl.files[0]
// 使用 Web Worker
useWorker(file)
})
}
/**
* 使用worker 实现计算,切片
* @param {File} file
*/
const useWorker = (file) => {
console.time('t2')
let url = new URL('./sliceFileWorker.js', import.meta.url)
console.log('worker 文件的地址')
console.log(url)
const worker = new Worker(url, {
type: 'module',
})
worker.postMessage({
file,
})
worker.onmessage = async (e) => {
console.log(e.data)
// t2: 2107.10107421875 ms
console.timeEnd('t2')
}
}
这里完全复用上面 spark-md5 实现中的 useSparkMD5
方法, 把 init
方法 里面的内容改造一下, 调用 useWorker
方法即可
SHA-256 浏览器原生实现
现在我们看看一个比较偏门的实现, 这个方法兼容性还可以, 但是不是经常使用的API crypto
js
const init = () => {
let inputEl = document.querySelector('#test-file')
inputEl.addEventListener('change', async () => {
const file = inputEl.files[0]
// 使用浏览器原生实现
useBrowserCrypto(file)
})
}
/**
* 使用浏览器原生实现
* @param {File} file
*/
const useBrowserCrypto = async (file) => {
console.time('t3')
const res = await culculateDigest(file)
// 文件大小:342M 耗时 984.02099609375 ms (多次测试,跟上面使用 spark-md5 在相同 342M文件大小下,速度差不多)
console.timeEnd('t3')
console.log(res)
}
/**
* 生成文件摘要
* @param {File} file
*
* 1. 只要文件本身内容没有发生变化,同一个文件计算出来的hash是一样的
* 2. 文件名从a变成b, hash也是一样的
*/
async function culculateDigest(file) {
// 读取文件buffer
const arrayBuffer = await file.arrayBuffer()
// https://developer.mozilla.org/zh-CN/docs/Web/API/SubtleCrypto/digest
// 计算摘要buffer
const digestBuffer = await crypto.subtle.digest('SHA-256', arrayBuffer)
// console.log(digestBuffer);
const digestArray = Array.from(new Uint8Array(digestBuffer))
// 转换为16进制字符串
const digestHex = digestArray
.map((b) => b.toString(16).padStart(2, '0'))
.join('')
return {
hash: digestHex,
size: file.size,
originName: file.name,
type: file.type,
}
}
兼容性
兼容性还不错, ✿✿ヽ(°▽°)ノ✿, 可以放心食用
完整代码
这里所有代码,跟上面的代码均为一模一样,就是下面有一个 type
判断用哪种方式, 我好分节介绍不同的用法
所有代码,均可以单独copy
, 拿走即可使用, 无耦合, ✿✿ヽ(°▽°)ノ✿ 😊😊😊
src/App.vue
html
<template>
<input type="file" id="test-file" />
</template>
<script setup>
import { onMounted } from 'vue'
import { useSparkMD5 } from './useSparkMD5'
const init = () => {
let inputEl = document.querySelector('#test-file')
inputEl.addEventListener('change', async () => {
const file = inputEl.files[0]
let type = 1
if (type === 0) {
console.time('t1')
let r = await useSparkMD5(file)
// 文件大小:342M 耗时 t1: 1074.14111328125 ms
console.timeEnd('t1')
console.log(r)
} else if (type === 1) {
// 使用 Web Worker
useWorker(file)
} else {
// 使用浏览器原生实现
useBrowserCrypto(file)
}
})
}
/**
* 使用浏览器原生实现
* @param {File} file
*/
const useBrowserCrypto = async (file) => {
console.time('t3')
const res = await culculateDigest(file)
// 文件大小:342M 耗时 984.02099609375 ms (多次测试,跟上面使用 spark-md5 在相同 342M文件大小下,速度差不多)
console.timeEnd('t3')
console.log(res)
}
/**
* 生成文件摘要
* @param {File} file
*
* 1. 只要文件本身内容没有发生变化,同一个文件计算出来的hash是一样的
* 2. 文件名从a变成b, hash也是一样的
*/
async function culculateDigest(file) {
// 读取文件buffer
const arrayBuffer = await file.arrayBuffer()
// https://developer.mozilla.org/zh-CN/docs/Web/API/SubtleCrypto/digest
// 计算摘要buffer
const digestBuffer = await crypto.subtle.digest('SHA-256', arrayBuffer)
// console.log(digestBuffer);
const digestArray = Array.from(new Uint8Array(digestBuffer))
// 转换为16进制字符串
const digestHex = digestArray
.map((b) => b.toString(16).padStart(2, '0'))
.join('')
return {
hash: digestHex,
size: file.size,
originName: file.name,
type: file.type,
}
}
/**
* 使用worker 实现计算,切片
* @param {File} file
*/
const useWorker = (file) => {
console.time('t2')
let url = new URL('./sliceFileWorker.js', import.meta.url)
console.log('worker 文件的地址')
console.log(url)
const worker = new Worker(url, {
type: 'module',
})
worker.postMessage({
file,
})
worker.onmessage = async (e) => {
console.log(e.data)
// t2: 2107.10107421875 ms
console.timeEnd('t2')
}
}
onMounted(() => {
init()
})
</script>
总结
从大文件上传, 我们学习了 文件摘要
这样一个概念, 从代码实现, 对文件身份
做了区分;
我们再来看看,最开始的 背景
中提到的
- 文件已经上传过了, 我们前端再次拿到原始文件, 计算一下hash, 把hash 通过一个验证接口传给后台,如果hash已经在后台,就不需要上传了,直接返回服务器的文件地址即可(
文件秒传
); 同时也节约了带宽,也节约了服务器的资源 - 当文件遇到:
真假美猴王.mp4
文件的时候, 由于 hash 不同, 已经知道是否要再次上传 - 另外, 所有我们上传到服务器的文件,都应该对文件名做重命名, 然后再返回给前端, 避免相同的文件,后上传的覆盖先上传的情况, 避免数据丢失
可以学到
- 文件摘要-概念, 实现
- 原生浏览器API对文件做文件摘要
- spark-md5 包的使用
- worker 使用
如果喜欢,希望 可以给一个点赞, 谢谢。
强烈推荐大家阅读 (大文件上传实现 - 小江大浪的专栏 - 掘金 (juejin.cn)