本文主要介绍一个多线程下载器的实现方法,主要应用技术如下:
- Http请求;
- 线程池-ThreadExecutorPool;
- RandomAccessFile;
- CountDownLatch;
- 原子类
本文下载器的执行流程如下:
- 找到网上一个可供下载的链接;
- 发送http请求,获取下载文件信息;
- 设置http可分片下载,使用多线程分别对各个分片下载;
- 使用countDownLatch统计各个线程是否均已下载完毕;
- 合并各个分片成一个完整的下载文件,下载流程结束!
下载链接和主启动类
本文选取qq应用程序的下载链接作为实验对象。可以去qq官网,复制一个下载链接。
主启动类如下,其中 download 是我们要调用的多线程下载方法。
public class Main {
public static void main(String[] args) {
String url = "https://dldir1.qq.com/qqfile/qq/PCQQ9.5.9/QQ9.5.9.28650.exe";
new Downloader().download(url);
}
}
下载类 Downloader
下载类主要包含如下几种操作:
- 具体的下载方法: download;
- 文件分片下载实现:split;
- 文件合并操作:merge;
- 移除生成的临时分片文件: removeTmpFile;
包括的操作对象如下:
- ThreadPoolExecutor poolExecutor: 分片下载任务的线程池;
- ScheduledExecutorService executorService:下载任务状态信息的线程池;
- CountDownLatch countDownLatch :保证合并操作之前,下载操作全部完成;
- RandomAccessFile accessFile:分片文件,同时提供流的读写方法;
- Callable< T >:为保证线程任务有返回结果,使用此种线程实现方式;
package com.example.testspring.dudemo.core;
import com.example.testspring.dudemo.constant.Constant;
import com.example.testspring.dudemo.util.FileUtils;
import com.example.testspring.dudemo.util.HttpUtils;
import java.io.*;
import java.net.HttpURLConnection;
import java.util.ArrayList;
import java.util.concurrent.*;
/**
* 下载器
* @author zjl
* @date 2022/04/20
*/
public class Downloader {
// 监听下载信息的线程池
public ScheduledExecutorService executorService = Executors.newScheduledThreadPool(1);
// 创建线程池对象
public ThreadPoolExecutor poolExecutor = new ThreadPoolExecutor(Constant.THREAD_NUM,Constant.THREAD_NUM,0,TimeUnit.SECONDS,new ArrayBlockingQueue<>(Constant.THREAD_NUM));
// CountDownLatch 保证合并之前分片都下载完毕
CountDownLatch countDownLatch = new CountDownLatch(Constant.THREAD_NUM);
/**
* 下载
* @param url url
*/
public void download(String url) {
// 获取文件名
String fileName = HttpUtils.getHttpFileName(url);
// 设置文件下载路径
fileName = Constant.PATH + fileName;
// 获取文件大小
long localFileSize = FileUtils.getFileSize(fileName);
// 下载运行信息线程类
DownloadInfoThread downloadInfoThread = null;
// HTTP连接对象
HttpURLConnection connection = null;
try{
// 建立连接
connection = HttpUtils.getConnection(url);
// 获取下载文件的总大小
int totalLength = connection.getContentLength();
// 保证本文件未下载过
if(localFileSize >= totalLength) {
System.out.println("该文件已经下载过");
return;
}
// 创建获取下载信息的任务对象
downloadInfoThread = new DownloadInfoThread(totalLength);
// 获取下载状态,创建一个每秒执行一次的线程,捕获当前下载状态(大小、速度)
executorService.scheduleAtFixedRate(downloadInfoThread,1,1, TimeUnit.SECONDS);
}catch (IOException e) {
e.printStackTrace();
}
// 保证连接存在
if(connection == null) {
System.out.println("获取连接失败");
return;
}
// 括号内代码会自动关闭
try {
// 切分任务
ArrayList<Future> list = new ArrayList<>();
split(url,list);
// 保证多个线程的分片数据下载完毕
countDownLatch.await();
// 合并文件
if(merge(fileName)) {
removeTmpFile(fileName);
}
} catch (IOException | InterruptedException e) {
e.printStackTrace();
} finally {
System.out.print("\r");
System.out.print("下载完成");
// 关闭连接
connection.disconnect();
executorService.shutdownNow();
// 关闭线程池
poolExecutor.shutdown();
}
}
/**
* 文件切分
* @param url url
* @param futureList 未来的列表
*/
public void split(String url, ArrayList<Future> futureList) throws IOException {
// 获取下载文件大小
long fileSize = HttpUtils.getFileSize(url);
// 计算切分后的文件大小
long size = fileSize / Constant.THREAD_NUM;
for (int i = 0; i < Constant.THREAD_NUM; i++) {
// 计算下载起始位置
long startPos = i * size;
long endPos;
if(i == Constant.THREAD_NUM - 1) {
// 下载的最后一块
endPos = 0;
}else {
endPos = startPos + size;
}
// 如果不是第一块,那么起始位置+1
if(startPos != 0) {
startPos++;
}
// 创建任务对象
DownloadTask downloadTask = new DownloadTask(url, startPos, endPos,i,countDownLatch);
// 提交任务到线程池
Future<Boolean> future = poolExecutor.submit(downloadTask);
// 添加到结果集合中
futureList.add(future);
}
}
/**
* 文件合并
* @param fileName 合并的文件名前缀
*/
public boolean merge(String fileName) {
System.out.print("\r");
System.out.println("开始合并文件");
byte[] buffer = new byte[Constant.BYTE_SIZE];
int len = -1;
try(RandomAccessFile accessFile = new RandomAccessFile(fileName,"rw")){
for (int i = 0; i < Constant.THREAD_NUM; i++) {
try (BufferedInputStream bis = new BufferedInputStream(new FileInputStream(fileName + ".temp" + i))){
while((len = bis.read(buffer)) != -1) {
accessFile.write(buffer,0,len);
}
}
}
}catch (Exception e) {
e.printStackTrace();
return false;
}
System.out.println("文件合并完毕!");
return true;
}
/**
* 删除临时文件
* @param fileName 文件名称
* @return boolean
*/
public boolean removeTmpFile(String fileName) {
for (int i = 0; i < Constant.THREAD_NUM; i++) {
File file = new File(fileName + ".temp" + i);
file.delete();
}
return true;
}
}
下载任务 DownloadTask实现
有个有趣的点,可能你们会知道,try(*) 的括号里面流或文件的创建后是不需要手动关闭的。
这个任务的主要功能如下:
- 发送HTTP请求,请求下载分片后的文件数据;
- 将分片结果以临时文件形式保存到本地;
package com.example.testspring.dudemo.core;
import com.example.testspring.dudemo.constant.Constant;
import com.example.testspring.dudemo.util.HttpUtils;
import java.io.BufferedInputStream;
import java.io.FileNotFoundException;
import java.io.InputStream;
import java.io.RandomAccessFile;
import java.net.HttpURLConnection;
import java.util.concurrent.Callable;
import java.util.concurrent.CountDownLatch;
/**
* 分块下载任务
*
* @author zjl
* @date 2022/04/20
*/
public class DownloadTask implements Callable<Boolean> {
private String url;
// 起始位置
private long startPos;
// 结束位置
private long endPos;
// 标识当前是哪一部分
private int part;
private CountDownLatch countDownLatch;
public DownloadTask(String url, long startPos, long endPos, int part,CountDownLatch countDownLatch) {
this.url = url;
this.startPos = startPos;
this.endPos = endPos;
this.part = part;
this.countDownLatch = countDownLatch;
}
@Override
public Boolean call() throws Exception {
// 获取文件名
String httpFileName = HttpUtils.getHttpFileName(url);
// 分块的文件名
httpFileName = httpFileName + ".temp" + part;
// 下载路径
httpFileName = Constant.PATH + httpFileName;
// 获取分块下载的链接
HttpURLConnection connection = HttpUtils.getConnection(url, startPos, endPos);
try (
InputStream input = connection.getInputStream();
BufferedInputStream bis = new BufferedInputStream(input);
RandomAccessFile accessFile = new RandomAccessFile(httpFileName,"rw");
){
byte[] bytes = new byte[Constant.BYTE_SIZE];
int len = -1;
while((len = bis.read(bytes)) != -1) {
accessFile.write(bytes,0,len);
// 1s内下载数据之和
DownloadInfoThread.downSize.add(len);
}
} catch (Exception e) {
e.printStackTrace();
return false;
} finally {
countDownLatch.countDown();
connection.disconnect();
}
return true;
}
}
下载信息任务类:DownloadInfoThread
此处要注意本次下载大小 downSize 和 finishedSize 使用原子类实现。本任务类包含如下信息:
- 文件总大小;
- 已下载的文件大小;
- 本次下载大小;
- 上一次的下载大小;
- 下载速度、剩余文件大小和剩余下载时间等。
package com.example.testspring.dudemo.core;
import com.example.testspring.dudemo.constant.Constant;
import java.util.concurrent.atomic.LongAdder;
public class DownloadInfoThread implements Runnable{
/**
* 文件总大小
*/
private long fileSize;
public DownloadInfoThread(long fileSize) {
this.fileSize = fileSize;
}
/**
* 已下载的文件大小
*/
private static LongAdder finishedSize = new LongAdder();
/**
* 本次下载大小
*/
public static volatile LongAdder downSize = new LongAdder();
/**
* 上一次下载大小
*/
private double preSize;
@Override
public void run() {
// 计算文件总大小 单位mb
String httpFileSize = String.format("%.2f",fileSize/ Constant.MB);
// 计算每秒下载速度 单位kb/s
int speed = (int) ((downSize.longValue() - preSize) / 1024d);
preSize = downSize.longValue();
// 剩余文件大小
double remainSize = fileSize - finishedSize.longValue() - downSize.longValue();
// 计算剩余时间
String remainTime = String.format("%.1f",remainSize / 1024d / speed);
if ("Infinity".equalsIgnoreCase(remainTime)) {
remainTime = "-";
}
// 计算已经下载大小
String currentFileSize = String.format("%.2f",(downSize.longValue() - finishedSize.longValue()) / Constant.MB);
String downloadInfo = String.format("已下载 %smb/%smb,速度 %skb/s,剩余时间 %ss", currentFileSize, httpFileSize, speed, remainTime);
System.out.print("\r");
System.out.print(downloadInfo);
}
}
常量类
主要记录了一些下载相关的信息
/**
* 常量类
* @author zjl
* @date 2022/04/20
*/
public class Constant {
public static final double MB = 1024d * 1024d;
public static final String PATH = "C:\\Users\\DELL\\Desktop\\";
public static final int BYTE_SIZE = 100*1024;
public static final int THREAD_NUM = 5;
}
工具类
主要获取文件信息和建立http连接信息。
HttpUtils:
package com.example.testspring.dudemo.util;
import java.io.IOException;
import java.net.HttpURLConnection;
import java.net.URL;
/**
* http工具包
* @author zjl
* @date 2022/04/20
*/
public class HttpUtils {
/**
* 获得连接
* @param url url
* @return {@link HttpURLConnection}
* @throws IOException ioexception
*/
public static HttpURLConnection getConnection(String url) throws IOException {
URL httpUrl = new URL(url);
HttpURLConnection connection = (HttpURLConnection)httpUrl.openConnection();
// 向文件所在服务器发送标识信息
connection.setRequestProperty("User-Agent","Mozilla/5.0 (Windows NT 6.1; WOW64) AppleWebKit/535.1 (KHTML, like Gecko)Chrome/14.0.835.163 Safari/535.1");
return connection;
}
/**
* 得到http文件名称
* @param url url
* @return {@link String}
*/
public static String getHttpFileName(String url) {
int index = url.lastIndexOf("/");
return url.substring(index+1);
}
/**
* 获得分片连接
* @param url url
* @param startPos 开始pos
* @param endPos 终端pos
* @return {@link HttpURLConnection}
* @throws IOException ioexception
*/
public static HttpURLConnection getConnection(String url, long startPos,long endPos) throws IOException {
HttpURLConnection connection = getConnection(url);
System.out.println("下载的分片区间是" + startPos + "-" + endPos);
if(endPos != 0) {
// bytes = 100-200
connection.setRequestProperty("RANGE","bytes="+startPos+"-"+endPos);
}else {
// 只有 - 会下载到结尾的所有数据
connection.setRequestProperty("RANGE","bytes="+startPos+"-");
}
return connection;
}
/**
* 获取下载文件大小
* @param url
* @return long
*/
public static long getFileSize(String url) throws IOException {
return getConnection(url).getContentLength();
}
}
FileUtils:
package com.example.testspring.dudemo.util;
import java.io.File;
public class FileUtils {
public static long getFileSize(String path) {
File file = new File(path);
return file.exists() && file.isFile() ? file.length() : 0;
}
}
运行结果
代码有很多改进和值得思考的地方,案例场景虽然并不复杂,但是胜在应用技术全面,可作为其他场景下的基础demo!
参考链接:
https://www.iqiyi.com/v_1ykiuvgozfw.html?vfrm=pcw_playpage&vfrmblk=D&vfrmrst=80521_listbox_positive#curid=7040308833907200_e5e0f6c7a8a786310017870b9526bacd