在 Spring Boot Web 应用程序中流式传输媒体文件
如何在 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 日 - 初稿