找回密码
 会员注册
查看: 14|回复: 0

文件上传杂谈

[复制链接]

4

主题

0

回帖

13

积分

新手上路

积分
13
发表于 2024-10-7 22:40:51 | 显示全部楼层 |阅读模式
点击关注“有赞coder”获取更多技术干货哦~????作者:陈晓斌部门:增长中心一、写在前面文件上传是前端很常见的一类场景。图片、视频和文档等等都属于文件范畴,每个文件则是通过 File.Type 进行更细的划分。本文将针对文件上传的一些通用维度场景做简单的剖析和尝试,抛砖引玉,希望共同学习,共同成长。本文案例里使用的组件来源于组件库 zent@7.4.4二、常见的上传场景及实现上传的形式或场景各式各样,除了业务级别的封装外,常遇到的通用场景有如下:重复上传上传预览拖拽上传上传裁剪上传进度可视化文件压缩上传前置校验切片上传上传加密暂停&断网续传 ...我们抽取部分场景进行实现:2.1 上传前置校验在文件上传前,经常会需要对文件格式进行校验,我们需要在文件上传/展示预览图前提示用户图片是否完成校验。常用的格式校验:文件类型、文件大小、上传的尺寸我们先看看和文件相关的两个对象的定义:Blob 和 File/** A file-like object of immutable, raw data.Blobs represent data that isn't necessarily in a JavaScript-native format. The File interface is based on Blob, inheriting blob functionality and expanding it to support files on the user's system. */interface Blob { readonly size: number; readonly type: string; arrayBuffer(): Promise; slice(start?: number, end?: number, contentType?: string): Blob; stream(): ReadableStream; text(): Promise;}/** Provides information about files and allows JavaScript in a web page to access their content. */interface File extends Blob { readonly lastModified: number; readonly name: string;}通过定义我们知道, Blob是一个不可变、存储文件原数据的一个类文件,但其并非是JS的原生数据,而 File继承于 Blob,使得 Blob信息扩展为用户操作系统可支持的文件,并使得页面里可以使用 Javascript访问其文件信息。除了继承与原有的 size 和 type 属性, File对象还额外返回 lastModified (返回文件最后修改日期)和 name (文件名)属性。以下是某个文件的 File实例信息{ lastModified: 1581424451211 lastModifiedDate: Tue Feb 11 2020 20:34:11 GMT+0800 (中国标准时间) name: "计算机网络.pdf" size: 70809807 type: "application/pdf" webkitRelativePath: ""}通过上面信息,我们可以很轻松地校验文件类型和文件大小。具体的实现我们接着看下去。2.1.1 限制文件上传类型1.使用 input 自带属性 accept Mime 类型列表属性描述值例子accept期望文件类型image/* , audio/* , video/* ...image/jpeg ...图1 Input限制上传类型2.使用文件后缀或 MIME-TYPE// ...const acceptTypes = ['image/png', 'image/jpeg'];const picSlipt = name.split('.');// 切割文件名后缀const picSuffix = `image/${picSlipt[picSlipt.length - 1]}`;// 直接使用解析的文件信息const fileType = file.type;if (acceptTypes.includes(picSuffix) || acceptTypes.includes(fileType)) { console.log('通过文件类型校验!');};//...3.使用二进制文件信息流读取但我们知道直接更改文件后缀并不会改变文件类型的本质。比如以下我直接更改一张 png 图片后缀为 jpg,那么它就很有可能绕过了我们的规则 image/jpeg(虽然想要绕过前端的规则校验有非常多的方法)图2 通过更改png图片后缀绕过前端上传规则但实际上它还是png图片,我们可以通过图像信息查询网站可以得出该图片信息实际如下:图3 后缀和类型不一致上传校验的绕过会给服务器带来很多潜在危险,因此我们可能需要通过更严格的类型校验:文件头信息进行格式鉴别??文件类型的信息一般是头文件里前8个字节我们看一下上面那张图avatar.jpg,第一行头信息里有什么?图4 文件的16进制信息通过vscode的插件hexdump可以查看到该文件头部信息前8个字节为:89 50 4E 47 0D 0A 1A 0A。这其实是 png 图片的头部信息,前8个字节属于 png 图片的头标识,后4个字节为数据域长度,最后4个字节为 png 的 IHDR 标识,是图片宽高等数据流前的第一个数据块。通过前8个字节证明了即使图片后缀为 jpg,但文件类型仍然为 png以下列举一些常见的文件格式前字节标识信息(可从网上查阅或用 vscode 读取)文件类型规则hex(十六进制)png前8个字节89 50 4E 47 0D 0A 1A 0Ajpg前2个字节FF D8gif前6个字节47 49 46 38 39 61bmp前2个字节42 4DES6已经支持我们我们直接通过 ArrayBuffer对象存储文件的二进制数据,并通过 DataView去读取。const reader = new FileReader();reader.onload = function () { // 这里从0开始获取文件二进制数据的前8个字节 const dataView = new DataView(this.result, 0, 8); for (let i = 0; i { const files = e.target.files; const isPNG = await checkType(files[0]);}// utils.jsexport const checkType = file => { return new Promise(resolve => { const reader = new FileReader(); reader.onload = function () { // PNG文件头标识(16进制) const PNG_HEADER_HEX = [0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A]; const dataView = new DataView(this.result); const bufferUint8Array = new Array(dataView.byteLength).fill('').map((_, index) => dataView.getUint8(index)) console.log(`文件: ${file.name} 的前8个字节十进制为, ${bufferUint8Array}`); // 用获取到的字节和图片头信息进行对比 const isPNG = PNG_HEADER_HEX.every((hex, index) => { return hex === bufferUint8Array[index]; }); resolve(isPNG); } reader.readAsArrayBuffer(file.slice(0, 8)); })}现在我们分别上传一张标准 png 图片、一张更改后缀为 jpg 的 png 图片和一张标准 jpg 判断其是否符合标准的 png 头部信息。图5 判断上传文件是否为png格式可以看到:avatar.png:十进制: [137, 80, 78, 71, 13, 10, 26, 10]十六进制为:0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0Aavatar.jpg:十进制: [137, 80, 78, 71, 13, 10, 26, 10]十六进制为:0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0Abanner.jpg:十进制: [255, 216, 255, 224, 0, 16, 74, 70]十六进制: 0xFF, 0xD8, 0xFF, 0xE1, 0x00, 0x18, 0x45, 0x782.1.2 文件尺寸的校验因为文件的尺寸无法通过 File对象直接获得,我们可以使用以下方法1.使用 Image获取上传图片尺寸const reader = new FileReader();const widthLimit = 100;const heightLimit = 100;console.log('限制图片的宽度 & 高度', `${widthLimit}px`, `${heightLimit}px`);reader.readAsDataURL(file);reader.onload = async function () { // 加载图片获取图片真实高度和上传 const src = reader.result; const image = new Image(); image.onload = await function () { const width = image.width; const height = image.height; console.log('上传图片的宽度 & 高度', `${width}px`, `${height}px`); if (Number(widthLimit) !== width || Number(heightLimit) !== height) { console.log(` %c x 校验不通过 ,请上传${widthLimit}*${heightLimit}的尺寸图片`, 'color: #ed6a0c'); resolve(false) } else { console.log('%c y 校验通过!', 'color: #2da641'); resolve(true); } } // 放置onload后 image.src = src;}2.使用二进制文件信息流读取通过下面两种方式可以利用文件的头信息找到宽高:通过找到图片信息的前置标志,然后再进行字节偏移直接进行字节偏移(仅适用于信息在固定位的格式,例如 png、gif 等。jpg 的前置标志没有固定的位置,只能通过第一种方式)文件类型前置标志读取方式pngIHDR(13-16字节)宽度:17-20字节(4 bytes)高度: 21-24字节(4 bytes)gifGIF89(1-6字节)GIF尺寸是反着存储宽度:第8字节+第7字节(2 bytes)高度: 第10字节+第9字节(2 bytes)bmp-宽度:18-21字节(4 bytes) 高度: 22-25字节(4 bytes)jpgSOF0、SOF1...偏移3个字节后(n)开始计算高度n, n+1)(2 bytes)宽度n+2,n+3)(2 bytes)完整代码export const checkPxByHeader = file => { console.log('文件信息', file); const reader = new FileReader(); reader.onload = function () { const dataView = new DataView(this.result); isPNG(dataView); } // 如果是判断jpg图片需要遍历整个Buffer,不能切割 // png的前置标志固定在13-16字节 reader.readAsArrayBuffer(file.slice(0, 50));}// png文件信息第一块数据表示 IHDR(49 48 44 52)const isPNG = dataView => { const IHDR_HEX = [0x49, 0x48, 0x44, 0x52]; // 方法一 查找数据块标志 new Array(dataView.byteLength - 4).fill('').map((_, index) => { const fourBytesArr = [index, index + 1, index + 2, index + 3].map(num => dataView.getUint8(num)); // 通过提取的4位无符号的8-bit整数与标准的PNG-IHDR16进制对比,判断是否遍历到了IHDR位置 const isTouchIHDR = fourBytesArr.every((hex, index) => { return hex === IHDR_HEX[index]; }); if (isTouchIHDR) { // 找到IHDR位置,偏移4个字节后获取4个字节的32位整数即可获取宽度 const width = dataView.getInt32(index + 4); const height = dataView.getInt32(index + 8); console.log('方法一获取 width', width); console.log('方法一获取 height', height); } if (!isTouchIHDR & index === dataView.byteLength - 4) { console.log('方法一获取 上传文件并非png'); } }) // 方法二 直接偏移字节 // 从第17个字节开始读取 const width = dataView.getInt32(16); const height = dataView.getInt32(20); console.log('方法二获取 width', width); console.log('方法二获取 height', height);}图6 通过文件信息获取宽高2.2 大文件上传之切片上传接口超时、上传失败后又从零开始上传等是大文件上传经常要面临的问题,通过切片上传、断点续传等方式可以很好地解决以上痛点,改善交互体验。我们先来看一下基础的大文件上传最终效果:图7 切片上传完整演示图其实切片上传和单文件上传没有很大的区别,切片上传实际上就是一个个小切片的单文件上传。可以归纳成以下几步操作,我们一一实现:获取上传文件信息。前端根据实际情况进行切片。如果是断点续传,则需要从已上传的切片数后面开始切割。(注:需要给每个切片的名字带上唯一标志,一般为索引值)上传切片至服务端。通过 ajax 的?ProgressEvent读取上传进度,前端展示。(注:ProgressEvent返回的是每个切片上传的进度,总进度应该是所有切片上传的进度)服务器接收切片。切片上传完毕后,前端发送请求通知服务器端合并切片,最后清除切片缓存。返回上传结果 & 文件路径。2.2.1 获取上传文件信息通过调用 input的实例,打开选择文件弹窗并获取上传文件信息。图8 获取上传文件信息完整代码/** html */ 添加文件/** constants */export const uploadStatusMap = { 'pending': 0, 'uploading': 1, 'done': 2, 'pause': 3, 'error': 4,}export const uploadStatus = { 0: '未上传', 1: '上传中', 2: '已上传', 3: '暂停中',};/** js */const inputRef = React.useRef(null);const [fileList, setFileList] = React.useState([]);// 打开文件选择框const handleAddFile = () => { const inputEv = inputRef.current; inputEv.click();};// 上传文件后回调const handleFileSelect = async e => { const File = e.target.files[0]; // 存储文件相关信息 let filesToCurrent = { id: createUploadId(), fileName: File.name, fileType: File.type, fileSize: File.size, File, chunkCount, uploadSingleProgress: 0, currentChunk: 0, uploadStatus: uploadStatusMap.pending, }; // 表格里显示文件信息 setFileList([...fileList, filesToCurrent]);}2.2.2 生成切片这里提供了2种生成切片数量的方式,可以根据具体情况选择:1.根据默认的切片数量切割好处: 限定了http请求的数量坏处: 文件过大时有可能导致每块切片大小依然很大,失去了切片的意义2.根据默认的切片大小切割好处: 限定了切片的大小坏处: 切片数量过多容易造成http负担通过切片数量来计算每个切片大小。例如一张图片总大小为15M,切割成5份后每份切片大小为15 / 3 = 3M。因为合并切片的时候需要按切割顺序进行,所以需要记录当前切片的索引值,在上传切片时带上(本场景把 index 拼接到切片名字里)。// index从1开始计算`${File.name}-chunk-${fileChunkList.length + 1}`切片索引值除了合并切片时使用外,在读取上传进度等地方也发挥了很大作用。图9 前端生成切片信息2.2.3 上传切片需要使用post方法结合multipart/form-data头才能将文件内容填充到body中。const formData = new FormData();formData.append('name', name);formData.append('file', file);axios({ method: 'post', data: formData, header: { 'Content-type': 'multipart/form-data', }, // ...})2.2.4 展示上传进度为了演示方便,本场景里暂时使用发送所有请求的方案,会并发无序执行所有请求,直到所有的请求响应完成再发送合并切片请求。所以需要记录每个切片上传的进度,并通过其占有的进度比计算出最终的文件上传进度。例如:一个文件15M,分成5份切片,每个切片大小占比20%,发送上传请求一段时间,假设切片1返回进度是50%,其他4个切片返回进度均为25%,则文件总上传进度是多少?(0.5 * 0.2 + 0.25 * 0.8) * 100 = 30(%)图10 每个切片返回上传的进度完整代码// index.jsconst uploadPromise = uploadChunkList.map(async ({ name, file }) => { return axios({ // ... // 记录上传进度 onUploadProgress: uploadInfo => { let chunkUploadInfo = {}; // 计算当前切片上传百分比 已上传数/总共需要上传数(这里计算的是每个切片的上传进度) const chunkProgress = Number((uploadInfo.loaded / uploadInfo.total)); console.log('当前上传切片序号:', index); console.log('当前上传切片进度', `${(chunkProgress * 100).toFixed(2)}%`); chunkUploadInfo[index] = chunkProgress; currentUploadItem.isSingle = false; /** * 总的上传百分比是由 切片上传进度 * 切片分数占比 * chunkUploadInfo的格式为{[index]: progress1, [index1]: progress2, ...} index为切片索引值 */ currentUploadItem.chunkUploadInfo = { ...currentUploadItem.chunkUploadInfo, [index]: chunkProgress, }; // 切片上传进度100%时,更新当前上传切片的索引值 if (chunkProgress === 1) { currentUploadItem.currentChunk = index + 1; } setFileList([...newFileList]); }, // ... })}// utils.js 计算表格里展示的总进度export const getSliceFileUpload = (chunkUploadInfo = {}) => { let progress = 0; // chunkUploadInfo数据格式为: {0: 0, 1: 0, [切片索引值]: [切片上传进度], ...} const chunkCountArr = Object.keys(chunkUploadInfo); chunkCountArr.forEach(chunkIdx => { progress += chunkUploadInfo[chunkIdx] * (100 / chunkCountArr.length) }) return progress;}2.2.5 服务器端接收切片node层要接收解析二进制文件流。提取的实现比较麻烦,这边直接使用@koa/multer@1.0.2(版本不一样使用方式可能也不一样,具体可查看官方文档),当然还有其他非常多优秀的npm包可以选择formidable@koa/multer允许用户设定一个存放文件的位置。其实例对象提供了几种模式,为方便演示,本文案例统一使用 single。具体区别可以查看其定义。/** 流存放位置 */const chunksPath = path.join(__dirname, '../static/stream');@koa/multer允许用户通过不同方法接收上传的文件interface Instance { /** Accept a single file with the name fieldName. The single file will be stored in req.file. */ single(fieldName?: string): Koa.Middleware; /** Accept an array of files, all with the name fieldName. Optionally error out if more than maxCount files are uploaded. The array of files will be stored in req.files. */ array(fieldName: string, maxCount?: number): Koa.Middleware; /** Accept a mix of files, specified by fields. An object with arrays of files will be stored in req.files. */ fields(fields: Field[]): Koa.Middleware; /** Accepts all files that comes over the wire. An array of files will be stored in req.files. */ any(): Koa.Middleware;}// 实例router.post('/upload-chunk', koaMulterUpload.single('file'), async (ctx) => { const file = ctx.req.file;})@koa/multer会默认为接收到的文件生成如下信息:{ fieldname: 'file', originalname: 'blob', encoding: '7bit', mimetype: 'application/octet-stream', destination: '/YourLocalPath/static/stream', filename: 'ff7cd26c15305dbfd9173be5f80f9770', path: '/YourLocalPath/static/stream/ff7cd26c15305dbfd9173be5f80f9770', size: 14161959 }为了方便后续合并切片,将切片名重命名为特定的格式/** * 重命名二进制流文件 * 注意路径需要对齐 */// 从前端接收到的重命名格式,例如`${fileName}-chunk-${index}`const { name } = ctx.req.body; const file = ctx.req.file;const chunkName = `${chunksPath}/${name}`;fs.renameSync(file.path, chunkName);图11 切片存放位置以及切片合并后生成的文件完整代码const koaMulter = require('koa-multer');/** fs的封装模块 */const fs = require('fs-extra');/** 流存放位置 */const chunksPath = path.join(__dirname, '../static/stream');const koaMulterUpload = koaMulter({ dest: chunksPath });router.post('/upload-chunk', koaMulterUpload.single('file'), async (ctx) => { /** * axios方法 * ctx.req.file 文件流信息 * ctx.req.body 请求参数 */ const { name } = ctx.req.body; const file = ctx.req.file; const chunkName = `${chunksPath}/${name}`; /** * 重命名切片文件名 * 注意路径需要对齐 */ fs.renameSync(file.path, chunkName); ctx.status = 200; ctx.res.end(`upload chunk: ${name} success!`);});2.2.6 服务器端合并切片node根据约定的切片名字格式去读取已存储的切片文件,合并之后清除切片文件。完整代码// node.jsrouter.post('/merge-chunk', async (ctx) => { /** * axios.post方法 * ctx.request.body 请求参数 */ // 由前端告诉服务端生成切片数量 const { fileName = '未命名', chunkCount } = ctx.request.body || {}; // 1.创建存储文件,初始为空 const filePath = `${uploadFilePath}/${fileName}`; fs.writeFileSync(filePath, ''); console.log('chunkCount', chunkCount); // 2.读取所有chunk数据 // 3.开始写入数据 for (let idx = 1; idx [[index1,a],[index2,b]]格式,注意获取的index类型为 string。for (let [indexStr, { name, file, fileName }] of Object.entries(uploadChunkList)) { //... }图13 按顺序上传切片2.3.2 切片存放位置在切片上传成功后重命名至以该文件名为命名的文件夹里// node/index.jsconst chunksContinuePath = `${chunksPath}/${fileName}`;if (!fs.existsSync(chunksContinuePath)) { await fs.mkdirs(chunksContinuePath);}const chunkName = `${chunksPath}/${fileName}/${name}`;fs.renameSync(file.path, chunkName);2.3.3 获取已上传切片在上传切片前,客户端需要先从node里读取已上传切片数量,并依据此切割新的切片。通过文件名去读取存放文件夹下是否有对应的切片。// client/index.js// 查找文件是否已经存在上传的切片信息const existChunksList = await axios.get(`/chekck-file_chunk-upload?fileName=${File.name}`).then(({ data = [] }) => data);// 存在切片信息if (existChunksList.length) { // 状态更改为暂停 filesToCurrent.uploadStatus = uploadStatusMap.pause; // 存储切片最后的索引值 filesToCurrent.currentChunk = existChunksList.length; // 读取到的切片上传进度都设置为100% filesToCurrent.chunkUploadInfo = { ...new Array(chunkCount).fill('').map((_, index) => index { const { query: { fileName }, } = ctx; // 切片读取位置和重命名的路径要一致 const chunksContinuePath = `${chunksPath}/${fileName}`; let uploadedChunksList = []; if (fs.existsSync(chunksContinuePath)) { uploadedChunksList = fs.readdirSync(chunksContinuePath); } ctx.body = uploadedChunksList});2.3.4 重新切割前端拿到已上传切片数量后只需要从索引值位置开始切割即可,再将剩余的切片上传完成进行合并即可。const currentSize = chunkSize * currentChunk; // 计算剩余切片大小for (let current = currentSize; current { axiosSourceCancel.cancel('中断上传');// ...};因为要模拟继续上传的场景,因此不能在原取消的请求上继续发起,我们需要发起新的请求实例,简单地做一下封装,并使用每次生成的CancelToken去做取消操作。图14 断点续传三、结尾对于切片上传、断点续传等功能其实很多第三方CDN服务已经提供了成熟的方案,例如七牛云的文件上传。除了上传前置校验、断点续传等场景外,还有上传压缩、上传加密等场景各式各样。以及经常与其绑定一起的文件下载相关技术(例如有趣的HLS)都是非常值得去学习的。?拓展阅读:有赞零售中台建设方法的探索与实践有赞零售 · 跨平台订单优惠计算CAP 一致性协议及应用解析有赞客户运营系统的演进领域驱动设计(DDD)在有赞教育线索资源管理的实践有赞百亿级日志系统架构设计?Vol.365?????
回复

使用道具 举报

您需要登录后才可以回帖 登录 | 会员注册

本版积分规则

QQ|手机版|心飞设计-版权所有:微度网络信息技术服务中心 ( 鲁ICP备17032091号-12 )|网站地图

GMT+8, 2025-1-10 22:13 , Processed in 0.466829 second(s), 25 queries .

Powered by Discuz! X3.5

© 2001-2025 Discuz! Team.

快速回复 返回顶部 返回列表