整體架構設計
- 連接資源伺服器:取得檔案總長度(
Content-Length),建立本地檔案預分配空間。
- 切分資源、多執行緒下載:將總長度均分 N 段,每段啟動一執行緒,透過 HTTP
Range 標頭請求對應位元組區間。
- 斷點續傳:即時記錄每執行緒已下載位元組數(
curPos),序列化整個任務物件至磁碟;重啟時反序列化恢復,續傳邏輯僅將 startPos 替換為 curPos。
- 進度監聽與速率統計:獨立監聽執行緒定期序列化任務、計算並輸出平均下載速率。
核心類別設計
1. 下載任務物件(可序列化)
public class DLTask implements Serializable {
private static final long serialVersionUID = 1L;
private URL url;
private File file; // 臨時檔 (.tmp)
private String filename; // 最終檔名
private int threadCount = 10; // 執行緒數
private int contentLength; // 總長度
private long completedTotal; // 已完成總位元組
private boolean isNewTask = true; // 是否全新任務
private DLThread[] dlThreads; // 子執行緒陣列
private transient DLListener listener; // 監聽器(不序列化)
// getter/setter 略
}
2. 下載執行緒
public class DLThread implements Runnable {
private DLTask task;
private int id;
private long startPos, endPos, curPos;
private long readBytes;
private boolean finished;
private static final int BUFFER_SIZE = 4096;
public DLThread(DLTask task, int id, long startPos, long endPos) { ... }
@Override
public void run() {
try (RandomAccessFile raf = new RandomAccessFile(task.getFile(), "rw")) {
URLConnection conn = task.getUrl().openConnection();
conn.setAllowUserInteraction(true);
// 斷點續傳關鍵:isNewTask 決定起始位置
if (task.isNewTask()) {
conn.setRequestProperty("Range", "bytes=" + startPos + "-" + endPos);
raf.seek(startPos);
curPos = startPos;
} else {
conn.setRequestProperty("Range", "bytes=" + curPos + "-" + endPos);
raf.seek(curPos);
}
try (BufferedInputStream bis = new BufferedInputStream(conn.getInputStream())) {
byte[] buf = new byte[BUFFER_SIZE];
int len;
while (curPos < endPos && (len = bis.read(buf)) != -1) {
raf.write(buf, 0, len);
curPos += len;
readBytes += len;
}
}
finished = true;
} catch (IOException e) {
throw new RuntimeException(e);
}
}
}
3. 監聽執行緒:持久化 + 速率統計
public class DLListener implements Runnable {
private DLTask task;
private long startTime;
public DLListener(DLTask task) {
this.task = task;
this.startTime = System.currentTimeMillis();
}
@Override
public void run() {
while (!task.isComplete()) {
long completed = task.getCompletedTotal();
long elapsedSec = (System.currentTimeMillis() - startTime) / 1000;
double speedKB = elapsedSec > 0 ? (completed / 1024.0) / elapsedSec : 0;
System.out.printf("Speed: %.1f KB/s %.1f%%%n", speedKB, task.getCurPercent());
persistTask(); // 序列化任務到 .tsk 檔
try { Thread.sleep(3000); } catch (InterruptedException ignored) {}
}
// 完成:重命名 .tmp → 正式檔名
task.getFile().renameTo(new File(task.getFilename()));
System.out.println("下載完成,耗時 " + formatTime(task.getCostTime()));
}
private void persistTask() {
try (ObjectOutputStream oos = new ObjectOutputStream(
new FileOutputStream(task.getFilename() + ".tsk"))) {
oos.writeObject(task);
} catch (IOException e) {
e.printStackTrace();
}
}
}
4. 任務恢復入口
private void resumeTask() {
// 反序列化 .tsk 檔還原 DLTask 物件
try (ObjectInputStream ois = new ObjectInputStream(
new FileInputStream(filename + ".tsk"))) {
task = (DLTask) ois.readObject();
} catch (IOException | ClassNotFoundException e) {
throw new RuntimeException(e);
}
task.setNewTask(false); // 標記為續傳
task.setListener(new DLListener(task));
// 重新啟動所有執行緒
for (DLThread t : task.getDlThreads()) {
t.setTask(task);
threadPool.execute(t);
}
threadPool.execute(task.getListener());
}
關鍵技術點總結
| 技術點 | 實現方式 |
| HTTP 斷點續傳 | Range: bytes=start-end 請求標頭(RFC 7233) |
| 隨機寫入檔案 | RandomAccessFile.seek(pos) 定位寫入位置 |
| 任務狀態持久化 | Java 序列化 ObjectOutputStream/InputStream 整個 DLTask 物件圖 |
| 執行緒管理 | 線程池 ExecutorService 統一調度 |
| 臨時檔命名 | 下載中用 .tmp,完成後 renameTo() 原子改名 |
生產級擴充建議
- 校驗完整性:下載完成後驗證 MD5/SHA-256。
- 限速控制:Token Bucket 或
RateLimiter(Guava)於寫入迴圈限流。
- 多來源下載:支援多鏡像 URL,分段並行下載合併。
- 介面解耦:定義
DownloadListener 回調介面,支援 Swing/JavaFX/Web 前端進度顯示。
- 錯誤重試:指數退避重試、失效分片標記重新調度。
- 序列化替代:JSON (Jackson/Gson) 或 Protobuf 替代 Java 序列化,跨語言、版本演進更安全。