likes
comments
collection
share

大文件分片传输

作者站长头像
站长
· 阅读数 14

前言

平时开发避免不了文件上传问题,而文件上传最大的问题则是大文件上传,这种问题,无论是前端还是服务端都可能会碰到,下面就以 minio 为例,介绍较大文件传递过程中的问题

大文件传输

很多人都想,无论文件多大,一次性将文件传递完毕,那样不是很省事么,为什么会产生这么多问题呢?

这就跟文件传输手段与传输中碰到的问题有关了

大文件传输碰到问题中最常见的有两种手段,文件流传输分片传输

文件流传输是最多的,传递大文件常用手段,其就是持续分片传输的结果,只不过是分批次读取文件信息传给服务器,外面很难察觉到,也很难做更改,即时即使传递不小文件也不会什么问题,但仍然无法满足复杂多变的使用场景,例如:用户传递一个4k蓝光视频,几十GB,单个文件传递,一旦出现网络波动失败,则需要重新传递,这是很耗时的,如果能够保存有前面传递的部分资源,下次上传后,继续之前的上传则会好很多,最后在合并文件即可

分片传输 这个操作本身文件流操作中都在使用,这里介绍的不是这个,有时候客户端可能会一次性读取出文件(文本)的信息(可能只有几十几百兆,对于客户端来说不算过多),然后打算将信息直接传递给服务器,显然这个是比较占用内存的,即使客户端不在意,但是服务端同时接收多个人上传,那么对于内存来说无疑是一个灾难,因此这种情况很多人也会采用分片传输来传输给服务器,以节省内存,还有一些情况是客户端直接传递给文件服务器,因此可以直接将内容分片传递给文件服务器(实际上可以还直接转化为文件传输)

分片传输核心操作:分片、传输、合并

分片传输信息

对于文本、buffer 的分片不需多说,相信直接点类里面简单翻阅一下就知道怎么分片的了,这里主要介绍用的最多的大文件文件的分片操作,这里使用 fs 来操作,也是用的最多的 fs文档,很多组件api,都是使用的它做的一些文件操作

分片

直接通过 fs 的操作,获取指定区间的信息,其中 open 是打开文件获取文件句柄,read 则使用文件句柄读取文件信息,然后根据参数形成的重载可以找到获取指定片段的函数,我们就直接使用下面的即可

import { open, read } from 'fs';

//chunkSize 64MB
//路径、文件大小、偏移量,块大小
export function readChunkFile(path: string, size: number, offset = 0, chunkSize = 64 * 1024 * 1024): Promise<ChunkFileInfo> {
    return new Promise((resolve, reject) => {
        let buffer1 = Buffer.alloc(chunkSize); //buffer的长度,然后读取指定长度buffer
        // let fullPath = join(__dirname, `../../${path}`)
        let fullPath = join(process.cwd(), path) //这两个都行
        open(fullPath, function(err: any, fd: number) {
            if (err) {
                reject('打开文件失败')
                return
            }
            //fd文件句柄、buffer自己创建的,offset在buffer中的偏移量,chunksize块大小,position块大小
            //第3个和第5个最容易记错,第三个一般为0,第5个为读取的实际文件偏移位置
            read(fd, buffer1, 0, chunkSize, offset, function(err: NodeJS.ErrnoException, bytesRead: number, buffer: Buffer) {
                if (err) {
                    reject('获取文件失败')
                }
                console.log(bytesRead, buffer)
                resolve({
                    offset, //偏移量
                    size, //总大小
                    chunkSize, //设置块大小 
                    readSize: bytesRead, //实际读取大小
                    buffer,
                    isCompleted: offset + chunkSize >= size, //文件读取完毕(可能size传递不对,多判断一下即可)
                })
            })
        })	
    })
}

通过上面的方法我们可以直接读取文件大小,但似乎分片时多次调用用着有点怪异啰嗦,我们改装一下

//只需要代开一次文件操作即可,然后每次读取完毕文件后,都回调一次方法给外面
//且该回调返回一个 promise,方便外面上传完毕后,直接顺序往后读取
export function uploadByFileHandle(path: string, chunkCallback: (err: Error | null, info: ChunkInfo | null) => Promise<boolean>,offset?: number , chunkSize?: number) {
    // let fullPath = join(__dirname, `../../${path}`)
    let fullPath = join(process.cwd(), path) //这两个都行
    open(fullPath, async function (err: NodeJS.ErrnoException, fd: number) {
        if (err) {
            chunkCallback(err, null)
            return
        }
        const file = fstatSync(fd)
        const size = file.size
        chunkSize = chunkSize ? chunkSize : 64 * 1024 * 1024 //64MB
        function readFile(offset: number) {
            const readSize = offset + chunkSize > size ? size - offset : chunkSize
            const buffer = Buffer.alloc(readSize); //buffer的长度,然后读取指定长度buffer
            read(fd, buffer, 0, readSize, offset, async function (err: NodeJS.ErrnoException, bytesRead: number, buffer: Buffer) {
                if (err) {
                    chunkCallback(err, null)
                    return
                }
                const nextoffset = offset + chunkSize
                let isCompleted = nextoffset >= size //文件读取完毕(可能size传递不对,多判断一下即可)
                let result = await chunkCallback(null, {
                        offset,
                        totolSize: size,
                        size: bytesRead,
                        buffer,
                        isCompleted,
                })
                if (!result) return
                buffer = null
                //结束了就不继续读取了
                if (!isCompleted) {
                    readFile(nextoffset)
                }
            })
        }
        readFile(offset ? offset : 0)
    })
}

传输合并

//保存上传的结果
const filenames = []
uploadByFileHandle('public/4.mp4', async (err: Error, chunk: ChunkInfo) => {
    if (err) {
      resolve(ResponseData.ok('上传失败'))
      return false
    }
    // let str = chunk.buffer.toString('utf-8')//如果是一串文本可以打印试试
    try {
      const filename = new Date().getTime().toString()
      //上传,每次上传后,保存一下我们上传成功后的标识,这里为filename
      await this.minioService.putFile(filename, chunk.buffer)
      filenames.push(filename)
    } catch(err) {
      return false
    }
    if (chunk.isCompleted) {
      //传输完成后走这里,我们通过compostObject直接对文件进行合并操作
      const filename = new Date().getTime().toString()
      await this.minioService.compostObject(filename, filenames)
      console.log('成功了')
      resolve(ResponseData.ok('好了'))
    }
    return true
  }, 10 * 1024 * 1024)

上面传输合并均以 minio 为例,对于合并方法可能不太会用,这边直接走合并的方法即可,合并完毕后记得删除

async compostObject(filename: string, sourceList: string[]) {
    let desOptions = new CopyDestinationOptions({
        Bucket: envConfig.minioBucketName,
        Object: filename
    })
    const sources = sourceList.map( function (filename) {
        return new CopySourceOptions({
            Bucket: envConfig.minioBucketName,
            Object: filename
        })
    })
    console.log(sourceList)
    //需要注意的是,这个合并操作挺慢的,如果网络或者文件服务器效率不够高,是会合并超时的
    //需要改变文件服务器默认超时时间,至于他的超时时间我也不知道是多久
    //反正我合并十几G的文件时分片好几百个就容易失败,几十个就没事😂,一两个g的上千个也没失败
    await this.client.composeObject(desOptions, sources)
    await this.client.removeObjects(
        envConfig.minioBucketName,
        sourceList
    )
    return filename
}

处理断开续传问题

上面分片、上传、合并都有了,没有看到断开续传的处理呀

实际上已经有了,上面我们保存的 filenames 就是前面上传的结果,如果还想实现断开续传,那么除了保存filename,可以保存一下 chunksize、offset、isCompleted等信息,这样传递到了哪里都知道了(最后一个offset+size就是下一个偏移了),下次我们直接传递偏移,继续上传即可,这样就处理好续传问题了

ps:实际使用中我们可以根据情况确定是否需要合并文件,有时候文件不合并,而是分散,并不一定是坏事,我们可以保存对应的id合集就行了,实际用的时候根据id来获取指定片段即可,例如:某一个电影,若干大小,就给他分成好多视频,当获取的时候,让他根据播放时间片获取不同资源内容即可,你还可以在某一段开头或者结尾给他额外插播一段广告视频也不是不行哈

转载自:https://juejin.cn/post/7281191188144046095
评论
请登录