由于项目需求,在公司系统中需要播放录音。前端我就采用了H5的 <audio>
标签,而后端就是常规 的输出流操作,即通过response的outPutStream输出文件,对于前端来说就是文件下载。开始测试的时候因为使用的都是一些小文件音频(每个音频文件大概就几M吧)都很正常,但是当测到一些较大的音频文件时,就出现了问题,七分钟的音频(十多M的文件)只能播放出三四分钟,通过F12发现,那些小音频文件,一次请求就加载完成了,而对于七分钟的大音频,audio标签没有一次加载完,而且在播放一段时间后,会再次请求服务加载后面的音频。也就是说它是分段加载的,刚开始分段的请求正常,在播放了一半后,后面的分段请求会失败,导致后面的音频不能完整播放。也是由于没有流媒体经验,解决这个问题费了些周折,故在此记录一下。
文件输出流
我先说一下,常规的输出流方式会出现的问题。在公司时我使用的是<audio>
播放音频,于是回家后,我又用<video>
标签测试了一下,同样会出现视频卡主的现象,F12后同样是伴随分段视频流下载失败的异常。前端代码如下
<div>
<video src="http://127.0.0.1:8090/video/player"
style="width: 500px;height: 300px"
controls="controls"> </video>
</div>
后端 /video/player
接口代码如下
@GetMapping("/video/player")
public void videoPlayer(
HttpServletRequest request,
HttpServletResponse response) throws IOException {
String range = request.getHeader("Range");
if(range == null)
range = "bytes=0-";
String[] rs = range.split("\\=");
range = rs[1].split("\\-")[0];
File file = new File("F:\\test\\异界.1080p.HD中字.mp4");
if(!file.exists())
throw new RuntimeException("视频文件不存在 --> 404");
OutputStream os = response.getOutputStream();
FileInputStream fis = new FileInputStream(file);
long length = file.length();
System.out.println("file length : " + length);
// 播放进度
int count = 0;
// 播放百分比
int percent = (int)(length * 0.4);
int irange = Integer.parseInt(range);
length = length - irange;
response.addHeader("Accept-Ranges", "bytes");
response.addHeader("Content-Length", length + "");
response.addHeader("Content-Range", "bytes " + range + "-" + length + "/" + length);
response.addHeader("Content-Type", "video/mp4;charset=UTF-8");
int len = 0;
byte[] b = new byte[1024];
while ((len = fis.read(b)) != -1) {
os.write(b, 0, len);
count += len;
if(count >= percent){
break;
}
}
System.out.println("count: " + count + ", percent: " + percent);
fis.close();
os.close();
}
这里我播放的电影有1.4G,肯定会出现分段请求。在开始的时候,一切看起来很正常,视频可以正常播放,在播放一段时间后,在分段请求时就会出现如下异常
可以看到,有几个请求出现了failed,视频也卡主了,一直在转圈加载。查看控制台异常信息如下
而我在公司播放音频时碰到异常如下
两个异常不太相同,分别是 ERR_CONTENT_LENGTH_MISMATCH
和 ERR_CONNECTION_RESET 200
。但其实都是由于前端播放流文件时,响应头不对的原因,对于前端的分段请求,后端必须要支持断点续传才行。下面就来看一下这种方式后端应该怎么写代码
断点续传流
同样拿刚刚的H5视频做实验,前端代码不变,后端我新加了一个接口用于支持断点续传的方式,前端代码如下
<div>
<video src="http://127.0.0.1:8090/video/player2"
style="width: 500px;height: 300px"
controls="controls"> </video>
</div>
后端 /video/player2
接口代码如下
@GetMapping("/video/player2")
public void videoPlayer2(
HttpServletRequest request,
HttpServletResponse response) throws IOException {
File downloadFile = new File("F:\\test\\异界.1080p.HD中字.mp4");
if (!downloadFile.exists()) {
response.sendError(HttpServletResponse.SC_NOT_FOUND);
return;
}
long fileLength = downloadFile.length();// 记录文件大小
long pastLength = 0;// 记录已下载文件大小
// 0:从头开始的全文下载;
// 1:从某字节开始的下载(bytes=27000-);
// 2:从某字节开始到某字节结束的下载(bytes=27000-39000)
int rangeSwitch = 0;
long contentLength = 0;// 客户端请求的字节总量
String rangeBytes = "";// 记录客户端传来的形如“bytes=27000-”或者“bytes=27000-39000”的内容
RandomAccessFile raf = null;// 负责读取数据
OutputStream os = null;// 写出数据
OutputStream out = null;// 缓冲
int bsize = 1024;// 缓冲区大小
byte b[] = new byte[bsize];// 暂存容器
String range = request.getHeader("Range");
int responseStatus = 200;
// 客户端请求的下载的文件块的开始字节
if (range != null && range.trim().length() > 0 && !"null".equals(range)) {
responseStatus = javax.servlet.http.HttpServletResponse.SC_PARTIAL_CONTENT;
System.out.println("request.getHeader Range=" + range);
rangeBytes = range.replaceAll("bytes=", "");
if (rangeBytes.endsWith("-")) {
rangeSwitch = 1;
rangeBytes = rangeBytes.substring(0, rangeBytes.indexOf('-'));
pastLength = Long.parseLong(rangeBytes.trim());
contentLength = fileLength - pastLength;
} else {
rangeSwitch = 2;
String temp0 = rangeBytes.substring(0, rangeBytes.indexOf('-'));
String temp2 = rangeBytes.substring(rangeBytes.indexOf('-') + 1, rangeBytes.length());
pastLength = Long.parseLong(temp0.trim());
}
}
// 客户端要求全文下载
else {
contentLength = fileLength;
}
// 清除首部的空白行
response.reset();
// 告诉客户端允许断点续传多线程连接下载,响应的格式是:Accept-Ranges: bytes
response.setHeader("Accept-Ranges", "bytes");
// 如果是第一次下,还没有断点续传,状态是默认的 200,无需显式设置;响应的格式是:HTTP/1.1
if (rangeSwitch != 0) {
response.setStatus(responseStatus);
// 不是从最开始下载,断点下载响应号为206
// 响应的格式是:
// Content-Range: bytes [文件块的开始字节]-[文件的总大小 - 1]/[文件的总大小]
switch (rangeSwitch) {
case 1: {
String contentRange = "bytes " + pastLength + "-" + (fileLength-1) + "/" + fileLength;
response.setHeader("Content-Range", contentRange);
break;
}
case 2: {
String contentRange = range.replace("=", " ") + "/" + fileLength;
response.setHeader("Content-Range", contentRange);
break;
}
default: {
break;
}
}
}else{
String contentRange = "bytes " + "0-" + (fileLength - 1) + "/" + fileLength;
response.setHeader("Content-Range", contentRange);
}
try {
response.setContentType("video/mp4;charset=UTF-8");
response.setHeader("Content-Length", String.valueOf(contentLength));
os = response.getOutputStream();
out = new BufferedOutputStream(os);
raf = new RandomAccessFile(downloadFile, "r");
try {
long outLength = 0;// 实际输出字节数
switch (rangeSwitch) {
case 0: {
}
case 1: {
raf.seek(pastLength);
int n = 0;
while ((n = raf.read(b)) != -1) {
out.write(b, 0, n);
outLength += n;
}
break;
}
case 2: {
raf.seek(pastLength);
int n = 0;
long readLength = 0;// 记录已读字节数
// 大部分字节在这里读取
while (readLength <= contentLength - bsize) {
n = raf.read(b);
readLength += n;
out.write(b, 0, n);
outLength += n;
}
// 余下的不足 1024 个字节在这里读取
if (readLength <= contentLength) {
n = raf.read(b, 0, (int) (contentLength - readLength));
out.write(b, 0, n);
outLength += n;
}
break;
}
default: {
break;
}
}
System.out.println("Content-Length为:" + contentLength + ";实际输出字节数:" + outLength);
out.flush();
} catch (IOException ie) {
// ignore
}
} catch (Exception e) {
e.printStackTrace();
} finally{
if (out != null) {
try {
out.close();
} catch (IOException e) {
e.printStackTrace();
}
}
if (raf != null) {
try {
raf.close();
} catch (IOException e) {
e.printStackTrace();
}
}
}
}
前端请求 player2 就可以正常播放了,快进快退也很顺畅丝滑。通过F12观察一段时间后,也没有出现请求 failed 的情况
查看每个请求的响应头可以发现,每个请求是只请求了视频的一部分内容,不是像第一种方式那样把整个文件都输出给前端了,从后端代码也可以看出这点,因为后端文件读取使用了RandomAccessFile
,这个是用于文件随机读取的类。前端每次请求头中会带有 Range 属性,告诉后端要读取哪部分文件内容。
每次请求到的文件长度不一样
总结
第一种方式其实也是可以的,只不过接口不支持流媒体,前端还是需要等待整个资源下载下来之后,才能做进度拖拽,播放等功能。因此在对于大文件来说肯定不合适。第二种方式是流媒体方式,即边下载边播放。需要HTTP支持断点续传。
HTTP的断点续传
文件上传下载时,记录上一次上传下载的位置,再从标记位置继续传输,或者多线程下载,根据标记可按需获取。在以前版本的 HTTP 协议是不支持断点的,HTTP/1.1 开始就支持了。断点下载时需要用到 Range 和 Content-Range 实体头。
请求头 Range
请求资源的部分内容(不包括响应头的大小),单位是byte,即字节,从0开始,如果服务器能够正常响应的话,服务器会返回 206 Partial Content 的状态码及说明,如果不能处理这种Range的话,就会返回整个资源以及响应状态码为 200 OK,(这个要注意,要分段下载时,后端代码要先判断这个)用于请求头中,指定第一个字节的位置和最后一个字节的位置,一般格式:Range: bytes=start-end
Range: bytes=10-
:第10个字节及最后个字节的数据Range: bytes=40-100
:第40个字节到第100个字节之间的数据
响应头 Content-Range
Content-Range: bytes 0-100/5000
:表示相应了0到100个字节的数据,共有5000个字节大小Content-Length: 5000
:表示总文件大小