分片上传与播放的一些学习过程

89 阅读6分钟

分片上传与播放

在我们的一些项目中难免会遇到一些上传和下载的一些操作,最近的学习过程中也了解到了关于分片上传与播放的一些操作,在过程中也看到了许许多多的实现过程,在此我做一些学习过程的总结。

起因

事情的起因是这样的,最近在着手坐我的毕设,敲定做一个视频网站,于是我打算先写一个关于上传视频和播放视频的demo,在操作上传视频的时候出现了一个问题,后端报错

 org.springframework.web.multipart.MaxUploadSizeExceededException: Maximum upload size exceeded; nested exception is java.lang.IllegalStateException: org.apache.tomcat.util.http.fileupload.impl.SizeLimitExceededException: the request was rejected because its size (20972024) exceeds the configured maximum (10485760)

一看原来是视频太大了(由此可知我们springboot的tomcat上传文件最大是1M,默认最大请求是10M),超过了一次能上传的限制,我看了一下需要改一下springboot的配置即可

   servlet:
     multipart:
       max-request-size: 100MB
       max-file-size: 30MB

但是这样修改也不是长久之计,接下来我肯定是要上传比现在还要大的好几个G的视频我总不能直接将他设置的这么大,可能服务器扛不住呢?

最终我发现了可以做一些分片上传的操作,于是也顺便了解一下分片播放。

上传-前端操作

首先先确认一点,前端往后端无论是传图片还是视频,我们都是字节流来传递的,所以我们势必要将这个流进行切割分片,依次传给后端,在此期间我接触到了对于我来说新的知识,就是Blob,他是所有文件的父类,他有一个自带的分片方式

 slice(start?: number, end?: number, contentType?: string): Blob;

我们需要放入起始值与最终的值,就是这个流的范围,最后一个就是,最终返回的是一个新的Blob,(最后一个值好像也不怎么用,可以不传)也就成功将其分片了。

那么方法有了该怎么操作呢?

答:既然这个流的大小我们是无法控制的,那么我们可以控制的就是每次上传的大小,也就是每次上传的大小是多少我们可以将总的流/每次分片的数据大小,就可以确定我们可以穿几次,每次传多少。

我们就可以确定以下参数

 chunkSize:每次分片大小(可以用他来与总数据量大小相除,获得总共传递次数)
 totalChunks:总的传递次数(由上面参数与总数据量大小相除得到)
 fileName:文件名字,方便给后端处理
 file:被传的blob(每次被分片的流)

最终代码如下

页面:

 <template>
   <a-upload-dragger
       name="file"
       :multiple="true"
       @change="handleChange"
       :customRequest="beforeUpload"
   >
     <p class="ant-upload-drag-icon">
       <a-icon type="inbox" />
     </p>
     <p class="ant-upload-text">
       Click or drag file to this area to upload
     </p>
     <p class="ant-upload-hint">
       Support for a single or bulk upload. Strictly prohibit from uploading company data or other
       band files
     </p>
   </a-upload-dragger>
 ​

代码:

 ​
 const beforeUpload = async (files:any) => {
       const chunkSize = 20 * 1024 * 1024;
       const file : Blob = files.file
       // console.log('完整的-->',file)
       //能被分成几次上传
       const totalChunks = Math.ceil(file.size / chunkSize);
       for (let i = 0; i < totalChunks; i++) {
         //每次的起始下标
          const start = i * chunkSize;
          //每次结束的下标,每次都只取最小的,最后一次就取最终的大小file.size,
         // 防止最后一次start + chunkSize,导致超出流的范围
          const end = Math.min(start + chunkSize,file.size);
          const chunk = file.slice(start,end);
         console.log('chunk-->',chunk)
         let fileChunk : FileChunk  = {
           chunkNumber: i,
           file : chunk,
           chunkSize: chunkSize,
           filename: file.name
         }
          await upload(fileChunk);
       }
 }

upload方法:

 const upload = async (flie:any) => {
      await postFormProgress(flie.chunkNumber,flie.chunkSize,flie.file,flie.filename)
   }

postFormProgress方法:

 export async function postFormProgress(chunkNumber:any,chunkSize:any,file:Blob,fileName:any){
     let formData = new FormData();
     formData.append('file',file);
     formData.append('chunkNumber',chunkNumber);
     formData.append('chunkSize',chunkSize);
     formData.append('fileName',fileName)
     return myAxios.post('/upload',formData, {
         headers: {
             'Content-Type': 'multipart/form-data'
         },
     });
 }

最终封装完成传给后端

上传-后端操作

接受参数

@PostMapping("/upload")
    public BaseResponse upload(@RequestParam("file") MultipartFile file,
                               @RequestParam("chunkSize")Long chunkSize,
                               @RequestParam("chunkNumber")Integer chunkNumber,
                               @RequestParam("fileName") String fileName){
        FileChunk fileChunk = new FileChunk();
        fileChunk.setFilename(fileName);
        fileChunk.setChunkSize(chunkSize);
        fileChunk.setChunkNumber(chunkNumber);
        Boolean res = null;
        try {
          res =  fileService.uploadFile(fileChunk,file);
        } catch (Exception e) {
            e.printStackTrace();
            throw new BusinessException(ErrorCode.SYSTEM_ERROR);
        }
        return ResultUtils.success(res);
    }

执行业务的方法:


    /**
     * 默认分块大小
     */
    @Value("${file.chunk-size}") //和前端设置的大小一样20 * 1024 * 1024;
    public Long defaultChunkSize;

@Override
    public Boolean uploadFile(FileChunk fileChunk, MultipartFile file) {
        String filename = fileChunk.getFilename();
        String output = UPLOADS_PATH  + filename;
        // try 自动资源管理
        try (RandomAccessFile randomAccessFile = new RandomAccessFile(output, "rw")) {
            // 分片大小必须和前端匹配,否则上传会导致文件损坏
            long chunkSize = fileChunk.getChunkSize() == 0L ? defaultChunkSize : fileChunk.getChunkSize().longValue();
            // 偏移量, 意思是我从拿一个位置开始往文件写入,每一片的大小 * 已经存的块数
            long offset = chunkSize * (fileChunk.getChunkNumber());
            // 定位到该分片的偏移量
            randomAccessFile.seek(offset);
            // 写入
            randomAccessFile.write(file.getBytes());
        } catch (IOException e) {
            log.error("文件上传失败:" + e);
            return Boolean.FALSE;
        }
        return Boolean.TRUE;
    }
}

这里采用了一个比较方便的写入方法

 public RandomAccessFile(String name, String mode)

第一个参数是写入的目标,第二个是读写模式

 public RandomAccessFile(File file, String mode)
         throws FileNotFoundException
     {
         String name = (file != null ? file.getPath() : null);
         int imode = -1;
         if (mode.equals("r"))
             imode = O_RDONLY;
         else if (mode.startsWith("rw")) {
             imode = O_RDWR;
             rw = true;
             if (mode.length() > 2) {
                 if (mode.equals("rws"))
                     imode |= O_SYNC;
                 else if (mode.equals("rwd"))
                     imode |= O_DSYNC;
                 else
                     imode = -1;
             }
         }

可以看到他根据第二个mode来确定到底是用哪种模式

这里采用 "rw":以读、写方式打开指定文件。如果该文件尚不存在,则尝试创建该文件。

最主要的是他操作起来很方便有一个seek方法,能够确定偏移量(一开始是0,慢慢移到文件的最终大小),并且write可以直接写入目标

 private native void seek0(long pos) throws IOException;

小追了一下源码发现是native,那估计可以确定这个东西读写效率很快(猜的)

具体了解请看:RandomAccessFile详解-CSDN博客

播放-前端操作

播放可以直接使用src来操作就是直接使用后端的路径来读取文件如下,videoUrl直接放入后端所存的文件位置即可播放,但是都了解了分片,不如了解清除一下,播放是怎么操作(也就是下载)。

 <template>
   <div>
           <NPlayer
               ref="player"
               :options="{ src: videoUrl }" 
               width="100%"
           />
   </div>
 </template>

首先我们要确定一点我们要分片播放其实就是分片下载。接下来如下几个问题(过程肯定是需要前后端配合)

1.那我们应该如何让浏览器知道我们后端还在传输文件没有完全传输完成呢?

答:在前后端的交互中我们有一个请求头,那就是Range,那能够让他们的交互中知道传输的是一段流并且这个Range还要指定一个流的范围,如下

 headers: {
             Range: `bytes=${流起始的位置}-${流结束的位置}`
           },

2. 既然我们需要知道起时的位置还结束的位置,那么前端肯定要知道这个流的大小,所以需要先从后端得知这个文件的大小,才能更好操作

3.那么当获得所有的流之后如何组装呢?

答:将所有的流存入一个数组中每一个获得的流都是blob,最后使用pormis.all将所有的流进行合并,如下

  // 执行所有分片请求,并在全部请求完成后开始播放视频
     Promise.all(chunkPromises).then(chunks => {
       // 将分片数据合并成完整的视频Blob
       const videoBlob = new Blob(chunks,{type:"video/mp4"});
       const combinVideoUrl = URL.createObjectURL(videoBlob);
       videoUrl.value = combinVideoUrl;
     })

最终代码:

 const playVideo  = async (i:any, val:any)=> {
   // 保存视频id
   videoId.value = val;
   myAxios.get(`/getVideoSizeById/${Number(videoId.value)}`).then(res => {
     console.log('从后端获取的总的大小--->',res);
     const totalSize = res;
     const chunkSize = Math.ceil(totalSize / 20); //已20为基准取除,看分成的份数
 ​
     // 定义分片传输的函数
     const loadVideoChunk = (startByte:number, endByte:number) => {
       return new Promise((resolve, reject) => {
         myAxios.get(`/watchVideo`, {
           headers: {
             Range: `bytes=${startByte}-${endByte}`
           },
           params:{
             videoId: videoId.value
           },
           responseType: "blob"
         }).then(response => {
           console.log('返回的数据--->',response)
           // 返回获取到的视频分片数据
           resolve(response);
         }).catch(error => {
           reject(error);
         });
       });
     };
 ​
 ​
     // 创建一个数组来保存所有分片的Promise
     const chunkPromises = [] as any;
 ​
     // 获取所有分片的Promise
     for (let i = 0; i < 20; i++) {
       const startByte = i * chunkSize;
         //减去1是因为 加入总共有5000大小的流,其实是要从0也算是一个流,所以正真的5000应该从0到4999
       const endByte = Math.min(startByte + chunkSize - 1, totalSize - 1);
         //将所有的blob都放入一个数组中
       chunkPromises.push(loadVideoChunk(startByte, endByte));
     }
 ​
 ​
     // 执行所有分片请求,并在全部请求完成后开始播放视频
     Promise.all(chunkPromises).then(chunks => {
       // alert(1)
       // 将分片数据合并成完整的视频Blob
       const videoBlob = new Blob(chunks,{type:"video/mp4"});
       console.log('合并---------->',chunks)
       const combinVideoUrl = URL.createObjectURL(videoBlob);
       videoUrl.value = combinVideoUrl;
       console.log('合并---->',videoUrl.value)
       flag.value = true;
     }).catch(error => {
       console.error('Failed to load video:', error);
     });
   })

播放-后端操作

1.首先是返回给给前端这个文件的大小

  //获得总体的文件大小 准备为分片上传做准备
     @GetMapping("/getVideoSizeById/{videoId}")
     public long getVideoSizeById(@PathVariable("videoId") Integer videoId) throws IOException {
         VideoUpload videoPathList = videoUploadMapper.selectById(videoId);
         String videoPathUrl = videoPathList.getVideourl();
         Path filePath = Paths.get(videoPathUrl);
         if (Files.exists(filePath)) {
             return Files.size(filePath);
         }
         return 0L;
     }

2.我们接受开始传输的端口的时候应该设置Range让浏览器知道我们传输的流还没有完全传完

 response.setHeader("Accept-Ranges", "bytes");

3.我们需要获取前端Range里面的数值取出来

  // 从请求头中获取请求的视频片段的范围(如果提供)
             long startByte = 0;
             long endByte = Files.size(filePath) - 1;
             String rangeHeader = request.getHeader("Range");
             if (rangeHeader != null && rangeHeader.startsWith("bytes=")) {
                 //对应前端
                 //headers: {
                 //          Range: `bytes=${流起始的位置}-${流结束的位置}`
                  //        },
                 String[] range = rangeHeader.substring(6).split("-");
                 startByte = Long.parseLong(range[0]);
                 if (range.length == 2) {
                     endByte = Long.parseLong(range[1]);
                 }
             }

4.最后使用response来获得输出流的,将其传给浏览器

  ServletOutputStream outputStream = response.getOutputStream();
             try (RandomAccessFile file = new RandomAccessFile(filePath.toFile(), "r"); FileChannel fileChannel = file.getChannel()) {
                 fileChannel.transferTo(startByte, contentLength, Channels.newChannel(outputStream));
             } finally {
                 outputStream.close();
             }

最终代码

   @GetMapping("/watchVideo")
     public void videoPreview(HttpServletRequest request, HttpServletResponse response, @RequestParam("videoId") Integer videoId) throws Exception {
         VideoUpload videoPathList = videoUploadMapper.selectById(videoId);
         String videoPathUrl = videoPathList.getVideourl();
         //这里我直接使用绝对路径D:/BS/Movie-backend/src/main/resources/static/uploads/meeting_01.mp4
         Path filePath = Paths.get(videoPathUrl);
         if (Files.exists(filePath)) {
             String mimeType = Files.probeContentType(filePath);
             if (StringUtils.hasText(mimeType)) {
                 //设置为vi
                 response.setContentType(mimeType);
             }
 ​
             // 设置支持部分请求(范围请求)的 'Accept-Ranges' 响应头
             response.setHeader("Accept-Ranges", "bytes");
 ​
             // 从请求头中获取请求的视频片段的范围(如果提供)
             long startByte = 0;
             long endByte = Files.size(filePath) - 1;
             String rangeHeader = request.getHeader("Range");
             // System.out.println("rangeHeader:" + rangeHeader);
             if (rangeHeader != null && rangeHeader.startsWith("bytes=")) {
                 String[] range = rangeHeader.substring(6).split("-");
                 startByte = Long.parseLong(range[0]);
                 if (range.length == 2) {
                     endByte = Long.parseLong(range[1]);
                 }
             }
 ​
             // System.out.println("start:" + startByte + ",end:" + endByte);
             log.info("start:" + startByte + ",end:" + endByte);
 ​
             // 设置 'Content-Length' 响应头,指示正在发送的视频片段的大小 
             // +1 是因为传的时候如果是0-499 那么其实大小是 500
             long contentLength = endByte - startByte + 1;
             response.setHeader("Content-Length", String.valueOf(contentLength));
 ​
             // 设置 'Content-Range' 响应头,指示正在发送的视频片段的范围
             response.setHeader("Content-Range", "bytes " + startByte + "-" + endByte + "/" + Files.size(filePath));
 ​
             // 设置响应状态为 '206 Partial Content'
             response.setStatus(HttpServletResponse.SC_PARTIAL_CONTENT);
 ​
             // 使用 'RangeFileChannel' 进行视频片段的传输,以高效地只读取文件的请求部分
             ServletOutputStream outputStream = response.getOutputStream();
             try (RandomAccessFile file = new RandomAccessFile(filePath.toFile(), "r"); FileChannel fileChannel = file.getChannel()) {
                 fileChannel.transferTo(startByte, contentLength, Channels.newChannel(outputStream));
             } finally {
                 outputStream.close();
             }
         }else {
             response.setStatus(HttpServletResponse.SC_NOT_FOUND);
             response.setCharacterEncoding(StandardCharsets.UTF_8.toString());
         }
     }

重要:前后端要做到统一传递流的大小

特别鸣谢:vue+springboot文件分片上传与边放边播实现哔哩哔哩bilibili,代码基本摘自这位作者