65.9K
CodeProject 正在变化。 阅读更多。
Home

在 Spring Boot Web 应用程序中流式传输媒体文件

starIconstarIconstarIconemptyStarIconemptyStarIcon

3.00/5 (1投票)

2022年9月12日

MIT

18分钟阅读

viewsIcon

43721

downloadIcon

444

如何在 Spring Boot 中流式传输大文件并支持字节范围查找

引言

这是一个技术问题——设想我有一个 Web 应用程序,我想向客户端提供一个大型音频或视频文件。我所知道的唯一方法是在服务器端打开文件,将整个文件放入一个缓冲区流,然后将其附加到 HTTP 响应中。这种方法的问​​题在于,当浏览器播放媒体文件时,用户无法使用进度条来随机重新定位播放进度。当用户尝试这样做时,它总是会重置到媒体文件的开头。这显然是一个 bug,我想修复它,并且我非常需要这个答案。我计划这个教程已经很长时间了。我之前没有写是因为我不知道解决方案是如何工作的。现在我知道了。我很荣幸能与您分享这个解决方案。

我为此寻找答案已经一年了。每当我记起我有一个无法解决的问题,而我又只有 15 分钟的时间时,我就会上网搜索,希望能找到答案。我遇到的问题是我不知道这种随机跳转播放进度的行为叫做什么。前几次搜索的结果都与真正的解决方案相去甚远。我感到非常沮丧。大多数时候,关键在于知道正确的关键词并将其输入搜索引擎。结果发现,这个技术问题的正确关键词叫做“字节范围”(byte range)。在 HTTP 请求响应方面,有一种请求类型称为字节范围请求/响应,其 HTTP 状态码为 206。一旦我明白了这一点,找到答案就变得非常容易。本教程将使任何需要类似解决方案的读者更容易上手。

示例应用程序的架构

请观看上面的示例应用程序实际运行的视频。

请求和响应的工作原理如下。当用户使用浏览器发起视频流时,第一个请求将是一个没有字节范围数据的 GET 请求。在这种情况下,响应应该是服务器端打开视频文件,定位到文件开头,将整个文件加载到输出流中,然后将其返回给用户。然后,如果用户通过进度条更改播放位置,或使用左右键向前或向后跳转 10 秒(例如)。在第二种情况下,浏览器会关闭先前的连接,并向服务器发送一个带有字节范围数据(视频文件的起始字节位置和结束位置)的新请求。服务器将解析字节范围信息并打开视频文件,然后使用随机访问功能跳转到请求中的起始位置,并读取到请求中的结束位置的所有字节。然后将所有内容以流的形式发送到响应中。在客户端,媒体播放器将从用户请求的新位置开始播放。一旦完成了这样的第一次重新定位请求,所有后续的重新定位请求都将是基于字节范围的请求。只有当您刷新浏览器页面时,才能重置为非字节范围请求。

我创建的示例应用程序是一个 RESTful Web 应用程序。它提供了两个 URL。这两个 URL 都做同样的事情,只是实现方式不同。用户可以使用浏览器测试这两个 URL 并播放大文件视频。当然,您需要提供文件。用户应该能够将媒体播放器的光标 reposition 到进度条上的任何位置,并且媒体播放应该从该位置开始播放。

该应用程序是用 Spring Boot 编写的,支持 MVC/RESTful。这两个 URL 处理 HTTP GET 请求。并没有太多东西。最大的挑战是如何实现处理此类请求的机制。在这种情况下,我采取了捷径。已经存在解决方案。我发现了一个足够清晰的解决方案来解释它的工作原理。在下一节中,我将使用这个解决方案来解释该机制的工作原理。我将提供一个增强的实现。

我将在下一节从我找到的实现开始。阅读完之后,您就会明白解决本教程开头描述的问题有多么容易。

粗糙的实现

我称此实现为“粗糙”,因为它非常简单,缺乏一些额外的错误检查和处理,但它确实有效。当我第一次找到它时,我非常兴奋。它为我节省了大量独自摸索的时间。嘿!当有人有一个解决方案,而我们可以直接拿来,然后将其改进时,真是太棒了。它节省了时间和精力,以便将它们花费在更好的地方。无论如何,让我们回到教程。

这是 action 方法的完整源代码

@GetMapping(value = "/play/media/v01/{vid_id}")
@ResponseBody
public ResponseEntity<StreamingResponseBody> playMediaV01(
   @PathVariable("vid_id")
   String video_id,
   @RequestHeader(value = "Range", required = false)
   String rangeHeader)
{
   try
   {
      StreamingResponseBody responseStream;
      String filePathString = "<Place your MP4 file full path here.>";
      Path filePath = Paths.get(filePathString);
      Long fileSize = Files.size(filePath);
      byte[] buffer = new byte[1024];      
      final HttpHeaders responseHeaders = new HttpHeaders();

      if (rangeHeader == null)
      {
         responseHeaders.add("Content-Type", "video/mp4");
         responseHeaders.add("Content-Length", fileSize.toString());
         responseStream = os -> {
            RandomAccessFile file = new RandomAccessFile(filePathString, "r");
            try (file)
            {
               long pos = 0;
               file.seek(pos);
               while (pos < fileSize - 1)
               {                            
                  file.read(buffer);
                  os.write(buffer);
                  pos += buffer.length;
               }
               os.flush();
            } catch (Exception e) {}
         };
         
         return new ResponseEntity<StreamingResponseBody>
                (responseStream, responseHeaders, HttpStatus.OK);
      }

      String[] ranges = rangeHeader.split("-");
      Long rangeStart = Long.parseLong(ranges[0].substring(6));
      Long rangeEnd;
      if (ranges.length > 1)
      {
         rangeEnd = Long.parseLong(ranges[1]);
      }
      else
      {
         rangeEnd = fileSize - 1;
      }
         
      if (fileSize < rangeEnd)
      {
         rangeEnd = fileSize - 1;
      }

      String contentLength = String.valueOf((rangeEnd - rangeStart) + 1);
      responseHeaders.add("Content-Type", "video/mp4");
      responseHeaders.add("Content-Length", contentLength);
      responseHeaders.add("Accept-Ranges", "bytes");
      responseHeaders.add("Content-Range", "bytes" + " " + 
                           rangeStart + "-" + rangeEnd + "/" + fileSize);
      final Long _rangeEnd = rangeEnd;
      responseStream = os -> {
         RandomAccessFile file = new RandomAccessFile(filePathString, "r");
         try (file)
         {
            long pos = rangeStart;
            file.seek(pos);
            while (pos < _rangeEnd)
            {                        
               file.read(buffer);
               os.write(buffer);
               pos += buffer.length;
            }
            os.flush();
         }
         catch (Exception e) {}
      };
         
      return new ResponseEntity<StreamingResponseBody>
             (responseStream, responseHeaders, HttpStatus.PARTIAL_CONTENT);
   }
   catch (FileNotFoundException e)
   {
      return new ResponseEntity<>(HttpStatus.NOT_FOUND);
   }
   catch (IOException e)
   {
      return new ResponseEntity<>(HttpStatus.INTERNAL_SERVER_ERROR);
   }
}

看起来很吓人。但整个事情都很容易理解。让我们从该方法的第一部分开始,即方法声明。这是

@GetMapping(value = "/play/media/v01/{vid_id}")
@ResponseBody
public ResponseEntity<StreamingResponseBody> playMediaV01(
   @PathVariable("vid_id")
   String video_id,
   @RequestHeader(value = "Range", required = false)
   String rangeHeader)
{
...
}

方法上的注解意味着该方法将处理 HTTP GET 请求,并且将有一个响应体。该方法将返回一个 ResponseEntity<StreamingResponseBody> 类型的对象。StreamingResponseBody 类型表示一种特殊的响应体。其思想是,我们不想将大型文件数据卡在 HTTP 响应中,而是希望返回一个流对象,以便客户端可以使用该流对象来获取数据。通常,不要试图将大量字节数据塞入响应中。这不是一个好的做法。当数据对象太大时,使用流是最佳选择。

我重点介绍了重要部分,即注入的唯一请求头,键名为“Range”。这是一个可选参数。这意味着如果请求中不存在此头部,则此参数的值将为 null

如果不存在“Range”头部参数,则表示响应应将文件指针设置在媒体文件的开头。流对象应返回整个文件。这通过这段代码完成

...
if (rangeHeader == null)
{
   responseHeaders.add("Content-Type", "video/mp4");
   responseHeaders.add("Content-Length", fileSize.toString());
   responseStream = os -> {
      RandomAccessFile file = new RandomAccessFile(filePathString, "r");
      try (file)
      {
         long pos = 0;
         file.seek(pos);
         while (pos < fileSize - 1)
         {                            
            file.read(buffer);
            os.write(buffer);
            pos += buffer.length;
         }
         os.flush();
      } catch (Exception e) {}
   };
   
   return new ResponseEntity<StreamingResponseBody>
          (responseStream, responseHeaders, HttpStatus.OK);
}
...

这段代码将添加两个头部键值。一个是 Content-Type。它被硬编码为“video/mp4”。这不是一个好主意。我们稍后会讨论。另一个头部键值是 Content-Length。这将是文件的完整长度。在响应中包含此项是一个好主意,这样客户端就能知道文件的大小。但它不是必需的。对于范围位置查找,可以从起始位置到结束位置计算大小。

responseStream 对象设置为一个匿名流对象,其中的方法实现了流对象如何取回文件数据的行为。在这种情况下,我使用 RandomAccessFile 并从字节 0 读取文件一直到其末尾。最后,它以 200 的响应状态返回 stream 对象。这就是从开头播放的工作方式。

当请求包含请求头中的字节范围信息时,我们需要以不同的方式处理请求。这是处理浏览器中播放的范围查找的关键部分。这是执行此操作的代码块

String[] ranges = rangeHeader.split("-");
Long rangeStart = Long.parseLong(ranges[0].substring(6));
Long rangeEnd;
if (ranges.length > 1)
{
   rangeEnd = Long.parseLong(ranges[1]);
}
else
{
   rangeEnd = fileSize - 1;
}
   
if (fileSize < rangeEnd)
{
   rangeEnd = fileSize - 1;
}

String contentLength = String.valueOf((rangeEnd - rangeStart) + 1);
responseHeaders.add("Content-Type", "video/mp4");
responseHeaders.add("Content-Length", contentLength);
responseHeaders.add("Accept-Ranges", "bytes");
responseHeaders.add("Content-Range", "bytes" + " " + rangeStart + 
                    "-" + rangeEnd + "/" + fileSize);
final Long _rangeEnd = rangeEnd;
responseStream = os -> {
   RandomAccessFile file = new RandomAccessFile(filePathString, "r");
   try (file)
   {
      long pos = rangeStart;
      file.seek(pos);
      while (pos < _rangeEnd)
      {                        
         file.read(buffer);
         os.write(buffer);
         pos += buffer.length;
      }
      os.flush();
   }
   catch (Exception e) {}
};
   
return new ResponseEntity<StreamingResponseBody>
       (responseStream, responseHeaders, HttpStatus.PARTIAL_CONTENT);

由于原始代码将整个过程都塞在同一个方法中,因此上面的部分有点复杂。其中的第一部分是解析请求中的字节范围、起始位置和结束位置,然后确保起始位置和结束位置是正确的

String[] ranges = rangeHeader.split("-");
Long rangeStart = Long.parseLong(ranges[0].substring(6));
Long rangeEnd;
if (ranges.length > 1)
{
   rangeEnd = Long.parseLong(ranges[1]);
}
else
{
   rangeEnd = fileSize - 1;
}
   
if (fileSize < rangeEnd)
{
   rangeEnd = fileSize - 1;
}

正如您所见,代码在未检查时做了一些假设,可能会导致方法崩溃。同样,我们暂时保留它,稍后我将在后续章节中进行修复。

一旦范围值成功解析,就可以创建数据流并返回了

String contentLength = String.valueOf((rangeEnd - rangeStart) + 1);
responseHeaders.add("Content-Type", "video/mp4");
responseHeaders.add("Content-Length", contentLength);
responseHeaders.add("Accept-Ranges", "bytes");
responseHeaders.add("Content-Range", "bytes" + " " + rangeStart + 
                    "-" + rangeEnd + "/" + fileSize);
final Long _rangeEnd = rangeEnd;
responseStream = os -> {
   RandomAccessFile file = new RandomAccessFile(filePathString, "r");
   try (file)
   {
      long pos = rangeStart;
      file.seek(pos);
      while (pos < _rangeEnd)
      {                        
         file.read(buffer);
         os.write(buffer);
         pos += buffer.length;
      }
      os.flush();
   }
   catch (Exception e) {}
};
   
return new ResponseEntity<StreamingResponseBody>
       (responseStream, responseHeaders, HttpStatus.PARTIAL_CONTENT);

在浏览器播放期间进行范围查找的情况下,我们需要返回四个 HTTP 头部,它们指定内容类型、基于起始和结束位置差异的内容长度;以及最小范围单位“bytes”,最后是范围信息:起始位置、结束位置和实际内容长度。

流对象将使用 RandomFileAccess 将范围内的文件数据加载到对象中。然后将其作为 ResponseEntity 对象返回。HTTP 响应状态码应为 206 (PARTIAL_CONTENT)。

这就是关于该方法的所有内容。正如我所指出的,这是解决方案实现的粗糙版本。我创建了另一个实现,已将其添加到我的 REST 控制器中,它将比原始实现设计得更好。

第二个实现

使上述实现生效非常容易。而且我可以看到,这个实现有很多可以改进的地方,例如在一定程度上使实现可重用,以及更好地处理错误输入(或使其更难被错误破坏)。

这是处理用于播放获取视频数据请求的新方法

...

@RestController
public class MediaPlayController
{
   private MediaStreamLoader mediaLoaderService;
   
   public MediaPlayController(MediaStreamLoader mediaLoaderService)
   {
      this.mediaLoaderService = mediaLoaderService;
   }
   
   ...
   @GetMapping(value = "/play/media/v02/{vid_id}")
   @ResponseBody
   public ResponseEntity<StreamingResponseBody> playMediaV02(
      @PathVariable("vid_id")
      String video_id,
      @RequestHeader(value = "Range", required = false)
      String rangeHeader)
   {        
      try
      {
         String filePathString = "<Place full path to your video file here.>";
         ResponseEntity<StreamingResponseBody> retVal = 
           mediaLoaderService.loadPartialMediaFile(filePathString, rangeHeader);
         
         return retVal;
      }
      catch (FileNotFoundException e)
      {
         return new ResponseEntity<>(HttpStatus.NOT_FOUND);
      }
      catch (IOException e)
      {
         return new ResponseEntity<>(HttpStatus.INTERNAL_SERVER_ERROR);
      }
   }
}

正如您所见,该方法得到了极大的简化。所有数据加载的职责都已传递给名为 mediaLoaderService 的服务对象。该对象由控制器类的构造函数初始化(也称为依赖注入)

...

@RestController
public class MediaPlayController
{
   private MediaStreamLoader mediaLoaderService;
   
   public MediaPlayController(MediaStreamLoader mediaLoaderService)
   {
      this.mediaLoaderService = mediaLoaderService;
   }
   ...
}

我们在上一节中看到的那些代码不一定都属于控制器中的一个方法。服务对象更合适。这是我定义我的服务对象接口的方式

package org.hanbo.boot.rest.services;

import java.io.IOException;

import org.springframework.http.ResponseEntity;
import org.springframework.web.servlet.mvc.method.annotation.StreamingResponseBody;

public interface MediaStreamLoader
{
   ResponseEntity<StreamingResponseBody> 
           loadEntireMediaFile(String localMediaFilePath) throws IOException;

   ResponseEntity<StreamingResponseBody> loadPartialMediaFile
   (String localMediaFilePath, String rangeValues) throws IOException;

   ResponseEntity<StreamingResponseBody> loadPartialMediaFile
   (String localMediaFilePath, long fileStartPos, long fileEndPos) throws IOException;
}

有三个方法。第一个方法将获取整个文件内容并将其包装在流中,然后作为 ResponseEntity 对象返回。第二个方法是控制器方法调用的方法,它接收来自客户端的查找范围的字符串表示,并决定该做什么。我们很快就会看到这个方法是如何工作的。最后一个方法使用查找的起始位置和结束位置来获取数据,将其包装在流中,然后作为 ResponseEntity 对象返回。名为 MediaStreamLoaderImpl 的类实现了这个接口。

这是接口中第二个方法的完整源代码,它是控制器中方法调用的方法

@Override
public ResponseEntity<StreamingResponseBody> loadPartialMediaFile
                     (String localMediaFilePath, String rangeValues)
      throws IOException
{
   if (!StringUtils.hasText(rangeValues))
   {
      System.out.println("Read all media file content.");
      return loadEntireMediaFile(localMediaFilePath);
   }
   else
   {
      long rangeStart = 0L;
      long rangeEnd = 0L;
      
      if (!StringUtils.hasText(localMediaFilePath))
      {
         throw new IllegalArgumentException
               ("The full path to the media file is NULL or empty.");
      }
         
      Path filePath = Paths.get(localMediaFilePath);      
      if (!filePath.toFile().exists())
      {
         throw new FileNotFoundException("The media file does not exist.");
      }

      long fileSize = Files.size(filePath);

      System.out.println("Read rang seeking value.");
      System.out.println("Rang values: [" + rangeValues + "]");
      
      int dashPos = rangeValues.indexOf("-");
      if (dashPos > 0 && dashPos <= (rangeValues.length() - 1))
      {
         String[] rangesArr = rangeValues.split("-");
         
         if (rangesArr != null && rangesArr.length > 0)
         {
            System.out.println("ArraySize: " + rangesArr.length);
            if (StringUtils.hasText(rangesArr[0]))
            {
               System.out.println("Rang values[0]: [" + rangesArr[0] + "]");
               String valToParse = numericStringValue(rangesArr[0]);
               rangeStart = safeParseStringValuetoLong(valToParse, 0L);
            }
            else
            {
               rangeStart = 0L;
            }
            
            if (rangesArr.length > 1)
            {
               System.out.println("Rang values[1]: [" + rangesArr[1] + "]");
               String valToParse = numericStringValue(rangesArr[1]);
               rangeEnd = safeParseStringValuetoLong(valToParse, 0L);
            }
            else
            {
               if (fileSize > 0)
               {
                  rangeEnd = fileSize - 1L;
               }
               else
               {
                  rangeEnd = 0L;
               }
            }
         }
      }

      if (rangeEnd == 0L && fileSize > 0L)
      {
         rangeEnd = fileSize - 1;
      }
      if (fileSize < rangeEnd)
      {
         rangeEnd = fileSize - 1;
      }
      
      System.out.println(String.format("Parsed Range Values: [%d] - [%d]", 
                         rangeStart, rangeEnd));
      
      return loadPartialMediaFile(localMediaFilePath, rangeStart, rangeEnd);
   }
}

该方法的要点是,如果输入参数 rangeValues 有任何值。如果没有任何值,则此方法将媒体文件加载委托给接口中的第一个方法,该方法加载整个文件并将数据包装为 HTTP 的 stream 对象。如果参数有值,则该方法将从字符串输入参数中解析字节范围,并使用该参数来加载文件,而这会使用接口中的第三个方法。在介绍这两个方法之前,让我更详细地解释该方法的第二部分。我指的是 else 关键字下的代码。这些代码实现了对字节范围值的解析和部分加载媒体文件。

我首先要做的事情(在所有情况下)是检查输入参数。在这种情况下,我必须检查文件名是否为空。然后我必须检查文件是否存在。这些行执行了所有检查

...
if (!StringUtils.hasText(localMediaFilePath))
{
   throw new IllegalArgumentException
         ("The full path to the media file is NULL or empty.");
}
   
Path filePath = Paths.get(localMediaFilePath);      
if (!filePath.toFile().exists())
{
   throw new FileNotFoundException("The media file does not exist.");
}
...

检查成功后,该代码块的其余部分将尝试解析字节范围。字节范围字符串如下所示:bytes=<起始位置字节>-[<结束位置字节>]。请注意,结束位置字节值是可选的。当我测试时,我看到字符串值如下所示:bytes=202932224-。这之所以有意义,是因为当媒体在浏览器中播放时,我只能将进度条光标拖到一个新的起始点。我无法设置结束位置。唯一的方法是通过 HTML 页面的音频/视频元素进行编程并设置开始和结束点。也许可以通过用户手动设置结束点的方法,我没有深入研究这个问题。我所知道的是,在我进行的所有测试中,我只看到了带有起始点而没有结束点的字节范围文本值。在这种情况下,我可以假设新的播放请求是从当前起始点一直到文件末尾。

当我解析值时,我需要假设两个值都可用。这与原始代码的做法相同。该方法是通过破折号字符的位置将字符串分成两部分。然后我可以清理这两个子字符串并从中提取整数值。要做到这一点,我必须谨慎。所以我为代码添加了一些额外的检查。首先,我必须检查破折号字符的位置。如果它不存在,那么我将返回起始位置 0 和文件末尾作为范围的结束。如果它存在,那么我将继续解析和转换。一旦我用破折号分割了字符串,可能会有 1 个或 2 个子字符串。从那里,我可以解析起始位置,然后尝试解析结束位置的值。这是它们

...
System.out.println("Read rang seeking value.");
System.out.println("Rang values: [" + rangeValues + "]");

int dashPos = rangeValues.indexOf("-");
if (dashPos > 0 && dashPos <= (rangeValues.length() - 1))
{
   String[] rangesArr = rangeValues.split("-");
   
   if (rangesArr != null && rangesArr.length > 0)
   {
      ...
   }
}

if (rangeEnd == 0L && fileSize > 0L)
{
   rangeEnd = fileSize - 1;
}
if (fileSize < rangeEnd)
{
   rangeEnd = fileSize - 1;
}
...

一旦我得到数组中的子字符串,我将检查数组的有效性并尝试将第一个值解析为长整型。这是一个我尝试过的技巧,如果子字符串包含文本,我将使用 RegEx 将非数字字符替换为空字符串。这将把字符串重新格式化为仅包含数字字符的字符串。然后我就可以安全地解析它。这是它们

...
System.out.println("ArraySize: " + rangesArr.length);
if (StringUtils.hasText(rangesArr[0]))
{
   System.out.println("Rang values[0]: [" + rangesArr[0] + "]");
   String valToParse = numericStringValue(rangesArr[0]);
   rangeStart = safeParseStringValuetoLong(valToParse, 0L);
}
else
{
   rangeStart = 0L;
}
...

这是我用于剥离所有非数字字符的辅助方法

private String numericStringValue(String origVal)
{
   String retVal = "";
   if (StringUtils.hasText(origVal))
   {
      retVal = origVal.replaceAll("[^0-9]", "");
      System.out.println("Parsed Long Int Value: [" + retVal + "]");
   }
   
   return retVal;
}

我还编写了一个安全地从 string 解析长值的函数

private long safeParseStringValuetoLong(String valToParse, long defaultVal)
{
   long retVal = defaultVal;
   if (StringUtils.hasText(valToParse))
   {
      try
      {
         retVal = Long.parseLong(valToParse);
      }
      catch (NumberFormatException ex)
      {
         // TODO: log the invalid long int val in text format.
         retVal = defaultVal;
      }
   }
   
   return retVal;
}

对于结束位置,我必须检查它是否存在。如果它不存在,我将返回文件长度减去一个字节或 0。如果它存在,我也这样做,首先剥离所有非数字字符,以便最终的文本值全为数字。然后,我将解析结束位置的值。以下是解析结束位置值的代码

...
if (rangesArr.length > 1)
{
   System.out.println("Rang values[1]: [" + rangesArr[1] + "]");
   String valToParse = numericStringValue(rangesArr[1]);
   rangeEnd = safeParseStringValuetoLong(valToParse, 0L);
}
else
{
   if (fileSize > 0)
   {
      rangeEnd = fileSize - 1L;
   }
   else
   {
      rangeEnd = 0L;
   }
}
...

假设所有这些工作都成功了,我们就有起始位置和结束位置。我可以将它们传递给我在接口中定义的第三个方法。它将加载部分内容并将其包装在流中,然后返回给客户端。现在您知道了接口中的第二个方法是如何工作的,我将向您展示第一个方法的定义,然后是第三个方法。

方法加载整个文件

加载整个文件的方法非常简单。这是

@Override
public ResponseEntity&glt;StreamingResponseBody> 
       loadEntireMediaFile(String localMediaFilePath)
      throws IOException
{
   Path filePath = Paths.get(localMediaFilePath);      
   if (!filePath.toFile().exists())
   {
      throw new FileNotFoundException("The media file does not exist.");
   }

   long fileSize = Files.size(filePath);
   long endPos = fileSize;
   if (fileSize > 0L)
   {
      endPos = fileSize - 1;
   }
   else
   {
      endPos = 0L;
   }
   
   ResponseEntity&glt;StreamingResponseBody> retVal = 
                      loadPartialMediaFile(localMediaFilePath, 0, endPos);
   
   return retVal;
}

看起来很简单,不是吗?我只是应用了 DRY 原则,而不是用几乎相同的方式加载文件两次,我将重用我已有的东西。加载整个文件与加载部分内容相同——从字节 0 加载到文件大小减 1 的点。

在方法中间,我放了一些东西来检查结束位置,以便结束位置正好是文件的末尾。如果文件大小为 0,则值为 0。防御性编程的典范。

接下来,我将向您展示接口中的第三个方法。它将加载部分内容而不是整个文件内容。

方法加载部分文件内容

加载部分文件内容的方法有点复杂。这​​是我们以前见过的一些东西。这是

@Override
public ResponseEntity<StreamingResponseBody> 
  loadPartialMediaFile(String localMediaFilePath, long fileStartPos, long fileEndPos)
      throws IOException
{
   StreamingResponseBody responseStream;
   Path filePath = Paths.get(localMediaFilePath); 
   if (!filePath.toFile().exists())
   {
      throw new FileNotFoundException("The media file does not exist.");
   }
   
   long fileSize = Files.size(filePath);
   if (fileStartPos < 0L)
   {
      fileStartPos = 0L;
   }
   
   if (fileSize > 0L)
   {
      if (fileStartPos >= fileSize)
      {
         fileStartPos = fileSize - 1L;
      }
      
      if (fileEndPos >= fileSize)
      {
         fileEndPos = fileSize - 1L;
      }
   }
   else
   {
      fileStartPos = 0L;
      fileEndPos = 0L;
   }
   
   byte[] buffer = new byte[1024];
   String mimeType = Files.probeContentType(filePath);

   final HttpHeaders responseHeaders = new HttpHeaders();
   String contentLength = String.valueOf((fileEndPos - fileStartPos) + 1);
   responseHeaders.add("Content-Type", mimeType);
   responseHeaders.add("Content-Length", contentLength);
   responseHeaders.add("Accept-Ranges", "bytes");
   responseHeaders.add("Content-Range", 
           String.format("bytes %d-%d/%d", fileStartPos, fileEndPos, fileSize));

   final long fileStartPos2 = fileStartPos;
   final long fileEndPos2 = fileEndPos;
   responseStream = os -> {
      RandomAccessFile file = new RandomAccessFile(localMediaFilePath, "r");
      try (file)
      {
         long pos = fileStartPos2;
         file.seek(pos);
         while (pos < fileEndPos2)
         {
            file.read(buffer);
            os.write(buffer);
            pos += buffer.length;
         }
         os.flush();
      }
      catch (Exception e) {}
   };
      
   return new ResponseEntity<StreamingResponseBody>
          (responseStream, responseHeaders, HttpStatus.PARTIAL_CONTENT);
}

这个方法现在应该很明显了。第一步是检查文件是否存在。下一步是我必须确保起始和结束位置在 0 到文件大小的范围内。然后,下一步我将填充响应头部值。最后,我将附加读取媒体文件部分内容的匿名对象。在最后,我将流内容作为响应返回。

我创建了一个示例索引页面,展示了页面上的媒体播放器如何与后端代码配合使用。

示例页面上的媒体播放器

使用我创建的 RESTful 控制器播放视频不是必需的。不幸的是,没有它,我无法在 Firefox 中测试我的代码。这是示例页面的源代码

<!doctype html>
<html>
    <head>
        <meta charset="utf-8">
        <title>Han Media Play - Partial Content</title>
        </head>
        <style>
           .video-div {
              width: 640px;
              height: 364px;
              max-width: 640px;
              max-height: 364px;
              min-width: 640px;
              min-height: 364px;
           }
        </style>
    <body> 
        <h3>This is V01 of the media streaming</h3>
        <div class="video-div">
        <video muted playsInline loop style="width: 100%; height: auto;"
               controls src="/play/media/v01/888">
        </video>
        </div>
        <h3>This is V02 of the media streaming</h3>
        <div class="video-div">
        <video muted playsInline loop style="width: 100%; height: auto;"
               controls src="/play/media/v02/888">
        </video>
        </div>
    </body>
</html>

在此页面上,我将显示两个播放器。顶部的播放器将使用我复制了所有代码的方法播放视频。底部的播放器将使用我重构代码的方法播放视频。源链接通过值“v01”和“v02”进行区分。正如您所见,我在方法中插入了一些输出语句来显示范围值的计算方式。现在是时候展示示例应用程序是如何工作的了。

运行示例应用程序

下载并解压示例应用程序后,您必须先构建它。此应用程序可使用 Java 17 进行编译。如果需要,您可以修改 POM 文件以使用 Java 11 进行编译。在编译代码之前,请在控制器中的两个方法中填写要播放的媒体文件名。

要构建示例应用程序,请转到项目根文件夹(pom.xml 所在的位置),然后运行以下命令

mvn clean install

要本地运行应用程序,请运行此命令

java -jar target/hanbo-springboot-media-play-1.0.1.jar 

应用程序启动后,您可以使用浏览器导航到以下 URL 并运行测试

https://:8080/index.html 

这是索引页面的屏幕截图

在页面上,您可以看到 v01 和 v02 的两个视频区域。您可以播放它们,然后向前跳转或向后跳转到早期点。如果您尝试“v02”播放器,您将在控制台输出中看到字节范围和计算。

我在 Linux Mint 21 上使用以下浏览器进行了测试,所有浏览器均正常工作

  • Chrome
  • 火狐
  • Microsoft Edge(基于 Chromium)
  • Brave 浏览器
  • Vivaldi 浏览器

我还使用 VLC 媒体播放器测试了媒体播放,它能够正确地使用 URL 播放媒体。如果您想尝试一下,请在 VLC 媒体播放器的“打开网络串流”弹出窗口中复制并粘贴 URL:https://:8080/play/media/v02/888。

这是 VLC 媒体播放器直接从网络 URL 流式传输的屏幕截图

这是控制台输出的范围计算和中断异常的屏幕截图,当播放暂停一段时间后,这是预期的

我还想指出,我曾尝试将 Tomcat 作为应用程序服务器。但它在媒体播放过程中抛出了很多异常。进一步的挖掘表明异常是在应用程序服务器本身内部抛出的。这些异常无法捕获。切换到 Jetty 后,问题消失了。如果您在 Tomcat 中看到类似的问题,请尝试切换到不同的应用程序服务器。Maven POM 文件显示了如何操作。

摘要

当我第一次尝试在一个虚拟站点上托管 MP3 文件时,我发现了流媒体问题。查找总是默认回到文件开头。如果我想为支持媒体流媒体的产品提供良好的用户体验,这不是一个可接受的解决方案。最后,它非常简单。

了解如何正确提供流媒体为我创造了一些很好的机会。例如,如果我想设计一个简单的媒体内容分发服务,我知道如何做到。如果我想用不同的语言编写媒体流媒体功能,这可以做到,但需要一些努力。毕竟,本教程中讨论的方法不特定于 Java 或 Spring Boot。它可以被其他语言或技术复制。

本教程中有一件事我没有解决。如果流媒体必须受到身份验证和授权的保护,则播放应包含身份验证和授权 cookie 或 HTTP 请求头。这对于身份验证 cookie 很容易,但为播放请求添加额外的头部有点棘手,但可以做到。这可能需要一些研究。

写这篇教程很有趣。很高兴看到一些简单的代码可以进一步改进,一个简单的机制可以用来提供如此出色的功能。希望您在阅读本文时也感到有趣,并且它对您正在进行的项目有所帮助。祝您好运!

历史

  • 2022 年 9 月 11 日 - 初稿
© . All rights reserved.