Java-多文件多線程下載器的設計與實現
1、多線程下載的原因和我們的目標
多線程多文件下載是一個常見的需求,一些服務器爲了負載均衡,往往會給每個請求線程設置最大的帶寬,因此線程數量有時候也成爲制約我們下載網絡的原因之一。這類限制我們其實經常遇到,比如說百度網盤就是通過限制單線程下載速度來實現限速。
我們知道,線程的創建是比較消耗系統資源的操作,頻繁的,無限制的創建線程可能會導致計算機資源被耗盡等極端情況。Java裏面則提供了JUC庫爲我們方便的實現併發操作帶來了可能性。使用線程池技術能夠很好的管理線程,因此本方案採用線程池管理下載線程
我們的最終目的是實現只需要指定下載鏈接和線程數量即可實現多線程下載,並且可以同時對多個文件進行多線程下載,同時也支持斷點續傳能力。
2、下載派發器設計
下載派發器的作用是調度每個任務,在裏面我們定義一個統一的線程池。
對該下載框架,我們需要儘量的簡潔,相信每個接觸Android開發的人都會被EventBus框架的簡潔所震撼,而實現這樣簡潔的操作,我們需要使用一些設計模式。
我所設計的ConcurrentDownloader即採用了單例模式,以此對線程進行統一的管理,很好的簡化了使用流程,降低使用門檻。
public class DownloadDispatcher {
/**
* 線程池最大運行線程數量
*/
private static int DEFAULT_THREAD_COUNT=128;
private ExecutorService executorService;
/**
* 雙檢鎖單例模式
*/
private volatile static DownloadDispatcher DEFAULT_INSTANCE;
public static DownloadDispatcher getDefault(){
if(DEFAULT_INSTANCE==null){
synchronized (DownloadDispatcher.class){
if(DEFAULT_INSTANCE==null){
DEFAULT_INSTANCE=new DownloadDispatcher();
}
}
}
return DEFAULT_INSTANCE;
}
private DownloadDispatcher() {
this(DEFAULT_THREAD_COUNT);
}
/**
* 創建一個線程池
* @param threadCount
*/
public DownloadDispatcher(int threadCount) {
executorService=new ThreadPoolExecutor(threadCount
,threadCount
,0
, TimeUnit.SECONDS
,new ArrayBlockingQueue<Runnable>(threadCount)
,new ConcurrentThreadFactory()
,new ExceedHandler());
}
/**
* 線程工廠
*/
private class ConcurrentThreadFactory implements ThreadFactory {
public Thread newThread(Runnable r) {
return new Thread(r);
}
}
/**
* 包和策略
*/
private class ExceedHandler implements RejectedExecutionHandler {
public void rejectedExecution(Runnable r, ThreadPoolExecutor executor) {
System.out.println("超過最大線程數");
}
}
/**
* 創建一個下載任務
* @param downloadLink
* @param threadNum
* @param listener
*/
public void dispatchNewTask(String downloadLink,int threadNum,DownloadListener listener){
final ConcurrentDownloader downloader=new ConcurrentDownloader(this.executorService);
downloader.startDownload(downloadLink,threadNum,listener);
}
}
3、下載接口的設計
接口設計應當體現下載過程的特色,以及我們的行爲規範,爲了方便對每個任務進行控制,每個任務都需要自己的接口,每個任務的子線程共用同一個接口,我們實現下載完成通知,以及斷點續傳功能就需要體現在接口中。
public interface ConcurrentDownloadListener {
void onSuccess(int threadId);
void onProgress(long progress);
/**
* 暫停後記錄下載長度
* @param threadId
* @param startPos
* @param downloadedLen
* @param endPos
*/
void onPause(String fileName,int threadId,long startPos,long downloadedLen,long endPos);
void onFailed(int reason);
void onCanceled(File file);
}
對下載狀態的控制我們還需要一個狀態機表示狀態切換,這一點在下一節給出代碼,本節給出下載的各種狀態
public enum DownloadStatus{
SUCCESS,
FAILED,
CANCELED,
PAUSE,
PROGRESS
}
4、單文件多線程下載的實現
在2裏面,我們對文件的下載請求進行派發處理,在這裏,我們將針對某個具體的文件的下載進行實現。
public class ConcurrentDownloader {
private static final String TAG="ConcurrentDownloader";
protected DownloadStatus downloadStatus;
private ExecutorService executorService;
public ConcurrentDownloader(ExecutorService executorService) {
this.executorService=executorService;
}
private DownloadListener mainDownloadListener;
/**
* 執行當前任務的總線程數量
*/
private int totalThreadCount;
/**
* 當前完成操作的線程
*/
private int finishedThreadCount=0;
/**
* 暫停列表
*/
private List<LocalFileInfo> pauseList=new ArrayList<>();
/**
* 已下載的文件長度
*/
private volatile long downloadedLength=0;
/**
* 同步鎖
*/
private static final String LOCK="downloadLock";
/**
* 每個線程共用這一個回調接口
*/
private ConcurrentDownloadListener partDownloadListener=new ConcurrentDownloadListener() {
public void onSuccess(int threadId) {
System.out.println("線程:"+threadId+"下載完成");
finishedThreadCount++;
if(totalThreadCount==finishedThreadCount){
mainDownloadListener.onSuccess(-1);
executorService.shutdown();
}
}
public void onProgress(long progress) {
synchronized (LOCK){
downloadedLength+=progress;
}
if(downloadedLength<1024){
Log.i(TAG,"已下載:"+downloadedLength+"B");
}else if(downloadedLength<1024*1024){
Log.i(TAG,"已下載:"+(downloadedLength>>>10)+"KB");
}else if(downloadedLength<1024*1024*1024){
Log.i(TAG,"已下載:"+(downloadedLength>>>20)+"MB");
}else {
Log.i(TAG,"已下載:"+(downloadedLength>>>30)+"GB");
}
}
/**
* 暫停下載後,講下載信息寫入本地文件,等待恢復
* @param filename
* @param threadId
* @param startPos
* @param downloadedLen
* @param endPos
*/
public synchronized void onPause(String filename,int threadId,long startPos,long downloadedLen,long endPos) {
LocalFileInfo localFileInfo=new LocalFileInfo(filename,threadId,startPos,downloadedLen,endPos);
pauseList.add(localFileInfo);
if(pauseList.size()==totalThreadCount){
SerializationHelper.writeToDisk(pauseList);
System.out.println("全部暫停,序列化進度到本地文件");
executorService.shutdown();
}
}
public void onFailed(int reason) {
executorService.shutdown();
}
public void onCanceled(File file) {
finishedThreadCount++;
if(finishedThreadCount==totalThreadCount){
file.delete();
}
executorService.shutdown();
}
};
/**
* 暴露給派發器的接口,開始下載
* @param links
* @param threadCount
* @param listener
*/
public void startDownload(String links,int threadCount,DownloadListener listener){
mainDownloadListener=listener;
downloadStatus=PROGRESS;
totalThreadCount=threadCount;
/**
* 獲取需要下載的文件長度
*/
long totalLen=getTotalLength(links);
File file=new File("D:/"+ links.substring(links.lastIndexOf("/")));
/**
* 如果本地存在文件,那就從本地恢復信息,否則新創建下載信息
*/
if(file.exists()){
if(file.length()!=totalLen){
Log.i(TAG,"本地文件與服務器文件不一致");
}else {
restartDownload(links,SerializationHelper.restoreDownloadInfo(file.getAbsolutePath()),file,partDownloadListener);
}
}else {
file=createFileByLength(file,totalLen);
requestNewDownload(links,totalLen,threadCount,file,partDownloadListener);
}
}
/**
* 本地創建一個文件
* @param file
* @param fileLen
* @return
*/
private File createFileByLength(File file,long fileLen){
try {
file.createNewFile();
RandomAccessFile raf=new RandomAccessFile(file,"rwd");
raf.setLength(fileLen);
raf.close();
} catch (IOException e) {
e.printStackTrace();
}
return file;
}
/**
* 獲取文件總長度
* @param link
* @return
*/
private long getTotalLength(String link){
long len=-1;
try {
URL url = new URL(link);
HttpURLConnection conn=(HttpURLConnection)url.openConnection();
conn.setRequestMethod("GET");
len=conn.getContentLengthLong();
conn.disconnect();
} catch (Exception e) {
e.printStackTrace();
}
return len;
}
/**
* 從本地恢復暫停的線程信息
* @param downloadLink
* @param infos
* @param file
* @param listener
*/
private void restartDownload(String downloadLink,List<LocalFileInfo> infos,File file,ConcurrentDownloadListener listener){
for (LocalFileInfo info:infos){
executorService.submit(new PartDownloadRunnable(info.getThreadId(),downloadLink,info.getStartPos()+info.getDownloadedLen(),info.getEndPos(),file,listener));
}
}
/**
* 新創建
* @param downloadLink
* @param totalLen
* @param threadCount
* @param file
* @param listener
*/
private void requestNewDownload(String downloadLink,long totalLen,int threadCount,File file,ConcurrentDownloadListener listener){
long partLen=(totalLen+threadCount)/threadCount;
long startPos;
long endPos;
System.out.println("總長度:"+totalLen+"\t塊長度:"+partLen);
for (int i=0;i<threadCount;i++){
startPos=i*partLen;
endPos=startPos+partLen-1>totalLen?totalLen-1:startPos+partLen-1;
System.out.println("線程:"+i+"\tstart:"+startPos+"\tend:"+endPos);
executorService.submit(new PartDownloadRunnable(i,downloadLink,startPos,endPos,file,listener));
}
}
/**
* 執行下載的線程實體類
*/
class PartDownloadRunnable implements Runnable{
private final int threadId;
private final String downloadLink;
private final long startPos;
private final long endPos;
private ConcurrentDownloadListener listener;
private File file;
public PartDownloadRunnable(int threadId, String downloadLink, long startPos, long endPos, File file,ConcurrentDownloadListener listener) {
this.threadId = threadId;
this.downloadLink = downloadLink;
this.startPos = startPos;
this.endPos = endPos;
this.listener=listener;
this.file = file;
}
public void run() {
long downloadedLength=0;
try {
URL url=new URL(downloadLink);
HttpURLConnection conn=(HttpURLConnection)url.openConnection();
conn.setRequestMethod("GET");
conn.setRequestProperty("Range","bytes="+startPos+"-"+endPos);
RandomAccessFile raf=new RandomAccessFile(file,"rwd");
raf.seek(startPos);
BufferedInputStream reader=new BufferedInputStream(conn.getInputStream());
byte[] b=new byte[4096];
int len;
while ((len=reader.read(b))!=-1){
if(downloadStatus==CANCELED){
raf.close();
reader.close();
conn.disconnect();
listener.onCanceled(file);
return;
}else if(downloadStatus==PAUSE){
listener.onPause(file.getAbsolutePath(),threadId,startPos,downloadedLength,endPos);
raf.close();
reader.close();
conn.disconnect();
return;
}else {
raf.write(b,0,len);
}
downloadedLength+=len;
listener.onProgress(len);
}
raf.close();
reader.close();
conn.disconnect();
listener.onSuccess(threadId);
} catch (Exception e) {
listener.onFailed(-1);
}
}
}
/**
* 取消下載,暴露給外部調用
*/
public void cancelDownload(){
downloadStatus=CANCELED;
}
/**
* 暫停下載
*/
public void pauseDownload(){
downloadStatus=PAUSE;
}
}
5、斷點續傳的實現
斷點續傳是下載器一個很有必要的功能,我們觀察百度網盤,迅雷等下載工具,可以發現,其在下載時都是生成兩個文件,一個是要下載的文件,另一個是記錄下載信息的文件,本設計也採用相同的思路,專門用一個文件來保存下載進度。
寫一個文件實現信息保存我們可以有很多方式,比如大家常用的JSON,解析JSON是一個很好的解決辦法,但我這裏並未採用,因爲JSON數據格式很容易被讀懂和篡改,我採用的方式是利用對象序列化來實現保存下載進度。
首先是我們需要保存哪些信息,在下面的類裏面已經給出。
public class LocalFileInfo implements Serializable {
private static final long serialVersionUID=0x123453245145L;
private String filename;
private int threadId;
private long startPos;
private long downloadedLen;
private long endPos;
public LocalFileInfo(String filename, int threadId, long startPos, long downloadedLen, long endPos) {
this.filename = filename;
this.threadId = threadId;
this.startPos = startPos;
this.downloadedLen = downloadedLen;
this.endPos = endPos;
}
public String getFilename() {
return filename;
}
public void setFilename(String filename) {
this.filename = filename;
}
public int getThreadId() {
return threadId;
}
public void setThreadId(int threadId) {
this.threadId = threadId;
}
public long getStartPos() {
return startPos;
}
public void setStartPos(long startPos) {
this.startPos = startPos;
}
public long getDownloadedLen() {
return downloadedLen;
}
public void setDownloadedLen(long downloadedLen) {
this.downloadedLen = downloadedLen;
}
public long getEndPos() {
return endPos;
}
public void setEndPos(long endPos) {
this.endPos = endPos;
}
@Override
public String toString() {
return "{" +
"\"filename\":\"" + filename + "\"" +
", \"threadId\":\"" + threadId + "\"" +
", \"startPos\":\"" + startPos + "\"" +
", \"downloadedLen\":\"" + downloadedLen + "\"" +
", \"endPos\":\"" + endPos + "\"" +
"}";
}
然後是如何實現序列化。經過考慮,我決定寫一個工具類實現幫我們實現序列化,一個是將對象寫入本地文件,另一個是從本地文件讀取對象。
public class SerializationHelper {
public static void writeToDisk(List<LocalFileInfo> info){
File file=new File(info.get(0).getFilename()+".download_config.json");
try {
if(file.exists()){
file.delete();
}
file.createNewFile();
LocalFileInfo[] localFileInfos=new LocalFileInfo[info.size()];
info.toArray(localFileInfos);
ObjectOutputStream oos=new ObjectOutputStream(new FileOutputStream(file));
oos.writeObject(localFileInfos);
oos.close();
} catch (IOException e) {
e.printStackTrace();
}
}
public static List<LocalFileInfo> restoreDownloadInfo(String filename){
List<LocalFileInfo> localFileInfoList=new ArrayList<LocalFileInfo>();
filename+=".download_config.json";
File file=new File(filename);
try {
ObjectInputStream ois=new ObjectInputStream(new FileInputStream(file));
LocalFileInfo[] infos=(LocalFileInfo[])ois.readObject();
localFileInfoList= Arrays.asList(infos);
ois.close();
} catch (Exception e) {
e.printStackTrace();
}
localFileInfoList.sort((o1, o2) -> o2.getThreadId()-o1.getThreadId());
return localFileInfoList;
}
}
6、如何使用
ConcurrentDownloader的使用是非常簡單的,下面給大家一個測試用例
public class Main {
public static void main(String[] args) {
String link2004="https://mirrors.tuna.tsinghua.edu.cn/mysql/downloads/MySQL-8.0/mysql-community-server-core_8.0.20-2ubuntu20.04_amd64.deb";
String link1804="https://mirrors.tuna.tsinghua.edu.cn/mysql/downloads/MySQL-8.0/mysql-community-server-core_8.0.20-1ubuntu18.04_amd64.deb";
DownloadDispatcher.getDefault().dispatchNewTask(link2004,10,listener);
DownloadDispatcher.getDefault().dispatchNewTask(link1804,10,listener);
}
private static DownloadListener listener=new DownloadListener() {
@Override
public void onSuccess(int reason) {
System.out.println("全部完成");
}
@Override
public void onPause(LocalFileInfo info) {
}
};
}
7、總結
上述代碼並非全部代碼,完整工程是一個maven工程,大家可以去我的GitHub下載:
https://github.com/bestyize/ConcurrentDownloader
(歡迎star)