- 最近发现系统中有不少功能的下载文件涉及到较大文件 当超过1G的文件下载时,直接通过浏览器下载,可能出现下载失败现象 下载失败表现为下载文件损坏,或重复重试下载 大文件的下载会因为网络波动、会话连接超时、文件IO读写错误等各种因素导致出现问题 因此考虑提供一种可以支持文件分片下载、端点续传的文件下载服务 下面看具体实现,代码注释都很丰富,具体可细参考
- 核心利用HTTP请求头中携带的Range参数确定下载的文件分片 关键要保证每个分片的完整性和不重叠性 每个分片大小具体取决与客户端 该下载服务,支持IDM、迅雷等专业下载工具分片并行下载 同时也支持网络中断后,再次重试下载时可接续下载,而非重头在开始 注意:亲测Chrome浏览器默认的文件机制为单线程下载,不支持分片
- Springboot2.x 第三方依赖见如下
核心下载服务实现org.springframework.boot spring-boot-starter-weborg.springframework.boot spring-boot-autoconfigurecommons-fileupload commons-fileupload1.3.1 org.apache.httpcomponents httpcoreorg.apache.httpcomponents httpclientcommons-io commons-io2.6 org.projectlombok lombok
@Slf4j(topic = "DownloadController")
@RestController
@RequestMapping("/file/download")
public class DownloadController {
public static final String HEADER_RANGE = "Range";
@GetMapping("/range/{fileId}")
public String downloadFileByRange(@PathVariable("fileId")String fileId,
@RequestParam(value = "delay",defaultValue = "100") Long delayFactor,
HttpServletRequest request, HttpServletResponse response) {
File file = getDownloadFile(fileId);
if(file == null) {
return "Download fail, file is not exists";
}
log.info("准备下载文件,文件名为 {}", file.getName());
InputStream is = null;
OutputStream os = null;
try {
// 获取到文件总大小
long fSize = file.length();
log.info("文件总大小为 {}", fSize);
response.setCharacterEncoding("UTF-8");
response.setContentType("application/x-download");
String fileName = URLEncoder.encode(file.getName(), "UTF-8");
response.addHeader("Content-Disposition", "attachement;fileName=" + fileName);
// 告诉浏览器,支持分片下载
response.addHeader("Accept-Range", "bytes");
// 下面两个自定义响应头,给 Java 客户端用
response.addHeader("fSize", String.valueOf(fSize));
response.addHeader("fName", fileName);
// pos 为开始位置,last最后位置,sum已下载总和
long pos = 0, last = fSize - 1, sum = 0;
// 判断客户是否支持分片下载,请求头中带有 Range 即为支持分片,Chrome浏览器不支持,需要专用工具如:IDM,迅雷,或Java客户端等
if (null != request.getHeader(HEADER_RANGE)) {
log.info("分片下载中,range信息为 {}", request.getHeader(HEADER_RANGE));
// 206 表示响应给下载客户端,服务器支持分片下载
response.setStatus(HttpServletResponse.SC_PARTIAL_CONTENT);
// 分片头 Range 的格式, http 协议约定为: Range bytes=100-1000,下面做解析,拿到分片起始与终止位置标识
String numRange = request.getHeader(HEADER_RANGE).replaceAll("bytes=", "");
String[] strRange = numRange.split(FileConstants.DASH);
if (strRange.length == 2) {
pos = Long.parseLong(strRange[0].trim());
last = Long.parseLong(strRange[1].trim());
// 下载器正常情况下不会出现该情况,此处是为了防止,增强代码可靠性
if (last > fSize - 1) {
last = fSize - 1;
}
} else {
// 某些特殊情况下,有可能传过来 range 是 bytes=129019- 这样的格式
pos = Long.parseLong(numRange.replace(FileConstants.DASH, "").trim());
}
} else {
log.warn("客户端本次进行非分片下载");
}
// 下面考虑每次读取文件的多少位置内容,返回给客户端? 假设:pos=0 last=9,则读 10 个长度
long rangeLength = last - pos + 1;
// http 分片下载 约定的格式,Http 规范,注意bytes后面有个【空格】,设置到响应头中
String contentRange = new StringBuffer("bytes ").append(pos).append(FileConstants.DASH).append(last)
.append("/").append(fSize).toString();
response.setHeader("Content-Range", contentRange);
response.setHeader("Content-Length", String.valueOf(rangeLength));
os = new BufferedOutputStream(response.getOutputStream());
// 创建整个分片文件的输入流
is = new BufferedInputStream(new FileInputStream(file));
// 很关键:跳过前面的分片段,相当于从pos开始读取文件段
is.skip(pos);
byte[] buffer = new byte[1024];
int length = 0;
int j = 0;
// 读取的分片总长度和 小于 当前文件分片长度,则继续循环读取
while (sum < rangeLength) {
// 分片总长度 - 已读长度 = 剩余需读取的长度
long readLength = rangeLength - sum;
// 剩余需读取的长度若大于缓冲区长度,则先就读 缓冲区长度,剩余部分等下次循环继续读
if (readLength > buffer.length) {
readLength = buffer.length;
}
length = is.read(buffer, 0, (int) readLength);
sum = sum + length;
os.write(buffer, 0, length);
// 延迟因子参数可故意让下载拖延时间,以便观察和调试
if (delayFactor != 0 && j % 2000 == 0) {
TimeUnit.MILLISECONDS.sleep(delayFactor);
log.info(">>>>> 文件下载中 length = {}", length);
}
j++;
}
log.info("文件下载完毕");
return "Download file success";
} catch (IOException e) {
log.warn("下载异常终止:{}", e.getMessage());
} catch (Exception e) {
e.printStackTrace();
} finally {
try {
is.close();
os.close();
} catch (IOException e) {
log.warn("关闭文件流异常:{}", e.getMessage());
}
}
return "Download fail";
}
private File getDownloadFile(String fileId) {
// 演示下载的文件对象
File file = null;
if("1".equals(fileId)) {
file = new File("E:\soft\BootCamp5.0.5033.zip");
} else if("2".equals(fileId)) {
file = new File("E:\life\movie\阳光电影www.ygdy8.com.恐怖分子的孩子.BD.720p.中英双字幕.mkv");
} else {
// 根据fileId查询数据库,得到文件信息,一般从文件服务器先下载到本地,后面逻辑进行对文件的分片响应
}
return file;
}
@GetMapping("/{fileId}")
public String downloadFile(@PathVariable("fileId")String fileId, HttpServletResponse response) {
// 这里作为演示,将要下载的文件直接写死
File file = getDownloadFile(fileId);
FileInputStream fis = null;
BufferedInputStream bis = null;
// 设置响应头等信息,注意,如果文件名称包含中文,需要使用URLEncoder转码,否则文件名将乱码
try {
String fileName = URLEncoder.encode(file.getName(), "UTF-8");
response.setContentType("application/x-download");
response.addHeader("Content-Disposition", "attachement;fileName=" + fileName);
// 设置缓冲区大小
byte[] buffer = new byte[1024];
fis = new FileInputStream(file);
bis = new BufferedInputStream(fis);
ServletOutputStream outputStream = response.getOutputStream();
int i = bis.read(buffer);
int j = 0;
while (i != -1) {
// 此处为了本地演示下载过程,故将文件下载速度放慢
if (j % 2000 == 0) {
TimeUnit.MILLISECONDS.sleep(100);
log.info(">>>>> 文件下载中 i = {}", i);
}
j++;
outputStream.write(buffer, 0, i);
i = bis.read(buffer);
}
log.info("下载完毕");
return "download success";
} catch (IOException e) {
log.warn("下载异常终止:{}", e.getMessage());
} catch (Exception e) {
e.printStackTrace();
} finally {
try {
bis.close();
fis.close();
} catch (IOException e) {
e.printStackTrace();
}
}
return "download fail";
}
}
Java下载客户端对比普通下载,分片下载的逻辑相对复杂不少,所以,编写代码过长中需细心,仔细考虑每个细节,否则将导致客户下载的文件最终合并时得不到完整的源文件;
- 为了测试,可以自己编写Java客户进行下载测试 当然,也可以用IDM、迅雷等客户端测试并发下载的效果 这里编写此客户端,仅为了测试 当然,该客户端也可以用于系统中下载外部文件时使用,加快下载大文件速度 也可以自己日常用,下载第三方大型软件等场景,当然前提是人家的下载服务器支持分片下载
@Slf4j(topic = "DownloadClient")
@RestController
@RequestMapping("/file/client")
public class DownloadClient {
private final static long PER_PAGE = 1024 * 1024 * 10L;
private final static String DOWN_PATH = "E:\fileItem";
ExecutorService pool = Executors.newFixedThreadPool(4);
@PostMapping("/download")
public String downloadFile(@RequestBody FileInfo downloadFileInfo) {
if (StringUtils.isEmpty(downloadFileInfo.getDownloadUrl())) {
// 测试地址
downloadFileInfo.setDownloadUrl("http://192.168.2.12:8080/file/download/range/2");
}
// 第一次探测,只下载【10】个字节,目的只为了拿到文件的基本信息,文件名称暂时传空,实际应用文件id,防止多文件下载出现并发问题
FileInfo fileInfo = download(downloadFileInfo.getDownloadUrl(), 0, 10, -1, null);
// 分片下载
if (fileInfo != null) {
// 拿下载文件总大小 除 分片大小,得到总分片数
long pages = fileInfo.getfSize() / PER_PAGE;
for (long i = 0; i <= pages; i++) {
// 特别注意,字节不能重复
long start = i * PER_PAGE;
long end = (i + 1) * PER_PAGE - 1;
pool.submit(new DownLoadTask(downloadFileInfo.getDownloadUrl(), start, end, i, fileInfo.getfName()));
}
}
return "success";
}
class DownLoadTask implements Runnable {
private String downloadUrl;
private long start;
private long end;
private long page;
private String fName;
@Override
public void run() {
// 执行下载动作
FileInfo download = download(downloadUrl, start, end, page, fName);
log.info("文件名 {} 分片码 {} 对应的分片内容已经下载完毕", download.getfName(), page);
}
public DownLoadTask(String downloadUrl, long start, long end, long page, String fName) {
this.start = start;
this.end = end;
this.page = page;
this.fName = fName;
this.downloadUrl = downloadUrl;
}
}
public FileInfo download(String downloadUrl, long start, long end, long page, String fName) {
if (page == -1) {
log.info("下载探测文件:{}", fName);
} else {
log.info("下载分片文件:{},分片序号 {}", fName, page);
}
// 创建一个分片文件对象
File file = new File(DOWN_PATH, page + "-" + fName);
// 两次分片下载,分片需要一致,否则重新开始下载;还要考虑分片是否损坏,page == -1 表示 是 探测下载
if (file.exists() && page != -1 && file.length() == PER_PAGE) {
// 分片文件已存在,并且是非探测下载,并且长度与分片大小一致,说明该分片在之前已经成功下载过了
log.info("此分片文件 {} 已存在,免下载", page);
return null;
}
long fSize = 0L;
try {
HttpClient client = HttpClients.createDefault();
HttpGet httpGet = new HttpGet(downloadUrl);
// 此处是关键,告诉服务端,这次下载给客户端传下载文件的哪个段范围的内容
httpGet.setHeader("Range", "bytes=" + start + "-" + end);
HttpResponse response = client.execute(httpGet);
// 获取到文件大小及文件名称
if (response.getFirstHeader("fSize") == null) {
URL url = new URL(downloadUrl);
URLConnection conn = url.openConnection();
int fileSize = conn.getContentLength();
fSize = fileSize;
} else {
fSize = Long.valueOf(response.getFirstHeader("fSize").getValue());
}
log.info("将要下载的文件大小为:{} bytes, headerFields {}", fSize);
if (response.getFirstHeader("fName") == null) {
fName = downloadUrl.substring(downloadUrl.lastIndexOf("/") + 1);
} else {
fName = URLDecoder.decode(response.getFirstHeader("fName").getValue(), "UTF-8");
}
HttpEntity entity = response.getEntity();
InputStream is = entity.getContent();
// 将分片内容写入临时存储分片文件
FileOutputStream fos = new FileOutputStream(file);
byte[] buffer = new byte[1024];
int ch;
while ((ch = is.read(buffer)) != -1) {
fos.write(buffer, 0, ch);
}
is.close();
fos.flush();
fos.close();
// 判断是否是最后一个分片
if (end - fSize >= 0) {
log.info("最后一个分片文件已下载完毕,准备合并文件");
mergeFile(fName, page);
}
} catch (IOException e) {
e.printStackTrace();
}
return new FileInfo(fSize, fName);
}
private void mergeFile(String fName, long page) {
// TODO 这里要考虑完善,如果下载的文件在目录中已经存在,是覆盖、还是重命名?
File file = new File(DOWN_PATH, fName);
try {
BufferedOutputStream os = new BufferedOutputStream(new FileOutputStream(file));
// 遍历所有的分片文件,按顺序合并
for (int i = 0; i <= page; i++) {
// 得到每个分片文件对象
File tempFile = new File(DOWN_PATH, i + "-" + fName);
// 此类逻辑与分片上传是同理
while (!tempFile.exists() || (i != page && tempFile.length() < PER_PAGE)) {
try {
Thread.sleep(100);
} catch (InterruptedException interruptedException) {
interruptedException.printStackTrace();
}
// 这里考虑异常情况导致死循环问题处理
}
log.info("所有分片文件已下载完毕,合并文件中,合并分片 {}", i);
byte[] bytes = FileUtils.readFileToByteArray(tempFile);
os.write(bytes);
os.flush();
// 合并后,就将临时分片文件删除掉
tempFile.delete();
log.info("分片 {} 已合并完成,进行清理", i);
}
log.info("文件合并结束,最后删除探测文件");
File tanceFile = new File(DOWN_PATH, "-1-null");
tanceFile.delete();
os.flush();
os.close();
} catch (IOException e) {
e.printStackTrace();
}
log.info("恭喜您,文件{}下载完成!", fName);
}
}
最后效果展示
- 简单写个UI,测试下
文件下载
- 后台日志信息:
: 下载探测文件:null
: 准备下载文件,文件名为 阳光电影www.ygdy8.com.恐怖分子的孩子.BD.720p.中英双字幕.mkv
: 文件总大小为 1353617649
: 分片下载中,range信息为 bytes=0-10
: >>>>> 文件下载中 length = 11
: 文件下载完毕
: 将要下载的文件大小为:1353617649 bytes, headerFields {}
: 下载分片文件:阳光电影www.ygdy8.com.恐怖分子的孩子.BD.720p.中英双字幕.mkv,分片序号 1
: 下载分片文件:阳光电影www.ygdy8.com.恐怖分子的孩子.BD.720p.中英双字幕.mkv,分片序号 2
: 下载分片文件:阳光电影www.ygdy8.com.恐怖分子的孩子.BD.720p.中英双字幕.mkv,分片序号 0
: 下载分片文件:阳光电影www.ygdy8.com.恐怖分子的孩子.BD.720p.中英双字幕.mkv,分片序号 3
: 准备下载文件,文件名为 阳光电影www.ygdy8.com.恐怖分子的孩子.BD.720p.中英双字幕.mkv
: 准备下载文件,文件名为 阳光电影www.ygdy8.com.恐怖分子的孩子.BD.720p.中英双字幕.mkv
: 准备下载文件,文件名为 阳光电影www.ygdy8.com.恐怖分子的孩子.BD.720p.中英双字幕.mkv
: 准备下载文件,文件名为 阳光电影www.ygdy8.com.恐怖分子的孩子.BD.720p.中英双字幕.mkv
: 文件总大小为 1353617649
2021-08-06 11:34:08.265 INFO 23228 --- [nio-8080-exec-2] DownloadController
: 文件总大小为 1353617649
: 文件总大小为 1353617649
: 分片下载中,range信息为 bytes=0-10485759
: 分片下载中,range信息为 bytes=31457280-41943039
: 分片下载中,range信息为 bytes=20971520-31457279
: 分片下载中,range信息为 bytes=10485760-2097151
……
: 所有分片文件已下载完毕,合并文件中,合并分片 128
: 分片 129 已合并完成,进行清理
: 文件合并结束,最后删除探测文件
: 恭喜您,文件阳光电影www.ygdy8.com.恐怖分子的孩子.BD.720p.中英双字幕.mkv下载完成!
: 文件名 阳光电影www.ygdy8.com.恐怖分子的孩子.BD.720p.中英双字幕.mkv 分片码 129 对应的分片内容已经下载完毕



